Открой современный одностраничный сайт (SPA — страница загружается один раз, а дальше всё подгружается на лету без перезагрузки), поработай в нём несколько часов и посмотри, сколько он потребляет памяти. Часто видно, как число медленно ползёт вверх: 200 МБ, потом 400, иногда 800. На десктопе ещё ничего, а на телефоне такая вкладка через час просто закрывается системой. И это при том, что в JavaScript про память «заботится сам движок» — он сам решает, что можно выкинуть.

Разберёмся, как именно движок это решает, в каких случаях промахивается и какие инструменты сам язык даёт, чтобы держать память под контролем.

Где живёт память: стек и куча

Когда движок встречает в коде число, строку или булевое значение, он заносит его в специальную память, которую называют стеком (stack). Вес подобных значений известен заранее, и работать с ними для памяти не затратно.

С объектами, массивами и функциями всё иначе: они могут расти и менять структуру. Поэтому движок кладёт их в общий «склад» — кучу (heap). А в стек записывает только ссылку: короткий адрес, по которому можно найти сам объект.

let count = 42;              // примитив, лежит в стеке
let user = { name: "Anna" }; // объект в куче, в стеке только ссылка
let copy = user;             // в стеке вторая ссылка, объект тот же

Это разделение объясняет одну неочевидную деталь: копирование переменной для примитива и для объекта работает по-разному. У count копируется само число, а user и copy делят один объект на двоих, и любая правка через одну переменную видна через другую.

Любой объект в куче проходит через три стадии:

  • создание — когда движок встречает литерал {}, вызов new, конкатенацию строк или появление замыкания;
  • использование — пока на объект есть хотя бы одна доступная ссылка;
  • освобождение — когда движок убеждается, что добраться до объекта больше неоткуда, и переиспользует его место под что-то новое.

Первые две стадии видно прямо в коде. Третья происходит за кулисами, и за неё отвечает специальный сборщик мусора (garbage collector, GC).

Кто решает, что объект больше не нужен

Правило простое: объект жив, пока до него можно добраться по цепочке ссылок. Если такой цепочки нет — память можно вернуть.

Если на пальцах: представь все объекты как точки, а ссылки между ними как стрелки. Есть несколько корней — точек, с которых сборщик стартует обход. В браузере это глобальный объект window, текущие стеки вызовов и пара встроенных структур самого движка. Сборщик идёт по стрелкам от корней и метит всё, до чего добрался. Что не пометил — то и есть мусор. Этот общий принцип называется достижимостью (reachability).

Базовая реализация называется mark-and-sweep и работает в два прохода:

  1. Mark. Сборщик стартует от корней и метит все объекты, до которых может добраться.
  2. Sweep. Всё непомеченное считается мусором, его память возвращается в общий пул.

В реальных движках алгоритм хитрее. V8 (Chrome, Edge, Node.js) делит кучу на поколения: молодые, только что созданные объекты собирает часто и быстро, долгоживущие — реже и тщательнее. Идея в том, что большинство объектов умирают молодыми — это какие-нибудь промежуточные переменные внутри функций, — и нет смысла каждый раз проверять всю кучу целиком.

Сборка идёт ещё и инкрементально: вместо одной долгой паузы движок откусывает работу маленькими порциями между задачами браузера, чтобы анимации не дёргались. Похожий подход у SpiderMonkey в Firefox и у JavaScriptCore в Safari.

Для пишущего на JS важна одна мысль: момент сборки не контролируется из кода. Принудительно вызвать сборщик в браузере нельзя — функции вроде window.gc() в продакшене закрыты, и трогать их не получится.

Что такое утечка памяти в JavaScript

Утечка — это объект, до которого формально можно дойти от корня, но который по сути уже никому не нужен. С точки зрения движка такой объект «живой», поэтому он его не трогает. С точки зрения приложения — это мусор, который зачем-то занимает место.

Чем дольше открыта вкладка, тем больше растёт количество ненужных, но всё ещё достижимых объектов, и как следствие: память съедается, сборка замедляется, страница начинает подтормаживать, а на мобилке в какой-то момент просто падает.

Представьте ресторан (память браузера). Каждый посетитель — это объект JavaScript. Когда клиент поел и ушёл, официант должен полностью убрать со стола (удалить все ссылки), чтобы стол освободился и пришёл новый гость. При утечке памяти официант забывает убрать одну мелочь — например, оставляет на столе салфетку с именем клиента или не стирает его заказ из блокнота. Из-за этой одной «ссылки» стол считается занятым. В итоге свободных столов становится всё меньше, новые посетители ждут дольше, обслуживание замедляется, а в какой-то момент ресторан просто переполняется и «падает» — посетители уходят недовольные.

Ниже — четыре сценария, в которых легко случайно создать такие «ложно живые» ссылки на объекты.

Четыре типичных утечки

Незакрытые таймеры

Самый очевидный случай — setInterval, который никто не отменил. Пока интервал жив, движок держит ссылку на функцию-колбэк. А колбэк, как правило, читает какие-то переменные и DOM-узлы из окружения, и тоже их удерживает. В результате не освобождается ни колбэк, ни всё, на что он смотрит.

function startPolling(node) {
  const data = new Array(1_000_000);
  setInterval(() => {
    node.textContent = data.length;
  }, 1000);
}

Если такая функция вызвана и забыта, миллион ячеек массива и DOM-узел будут жить до конца сессии. Лечится сохранением идентификатора и явной очисткой:

const id = setInterval(tick, 1000);
// когда стало не нужно
clearInterval(id);

В компонентных фреймворках для этой очистки есть стандартные места: onUnmounted/beforeUnmount во Vue, функция возврата из useEffect в React, ngOnDestroy в Angular. Если интервал создан в компоненте, ему обязан соответствовать clearInterval в момент демонтажа.

Слушатели на оторванных узлах

Слушатель события не даёт браузеру выкинуть из памяти ни сам обработчик, ни узел, к которому он привязан. В SPA это особенно неприятно: компонент исчез из дерева, DOM-узел уже не виден на экране, но из колбэка к нему ведёт цепочка ссылок — и узел вместе со всем своим поддеревом превращается в detached DOM (отсоединённый от документа, но удерживаемый JS-кодом). Никто его не показывает, а память он занимает.

const button = document.querySelector("#submit");
function onClick() {
  console.log("clicked");
}
button.addEventListener("click", onClick);

Если в какой-то момент кнопку заменили на новую, а слушатель не сняли, старый узел остаётся жить. Правило: на каждый addEventListener в долгоживущем коде должен быть парный removeEventListener с той же функцией. Анонимная стрелка не снимется — чтобы её можно было удалить, обработчик должен быть именованной функцией или храниться в переменной.

Когда слушателей много, удобнее AbortController: один контроллер можно повесить на любое количество подписок и отозвать всех разом:

const ctrl = new AbortController();

button.addEventListener("click", onClick, { signal: ctrl.signal });
input.addEventListener("input", onType, { signal: ctrl.signal });

// при размонтировании
ctrl.abort();

Замыкания, которые держат слишком много

Замыкание — это функция, которая помнит, в каком окружении была создана, и сохраняет доступ к его переменным. Полезный механизм для приватного состояния и удобных API (подробнее в отдельной статье про область видимости и замыкания). Но та же механика легко удерживает в памяти объекты, которые читателю кажутся «уже отпущенными».

function attachLogger(node) {
  const heavyCache = new Array(500_000).fill("...");

  return function onResize() {
    console.log("resized", node.tagName);
  };
}

window.addEventListener("resize", attachLogger(document.body));

Возвращаемая функция использует только node, но удерживает всё окружение целиком, включая heavyCache. Пока подписка висит на window, кэш в полмиллиона строк живёт вместе с ней.

Лечится тем, чтобы не таскать в замыкание лишнее. Если кэш нужен только в момент настройки, его можно использовать снаружи, а внутрь не передавать:

function attachLogger(node) {
  return function onResize() {
    console.log("resized", node.tagName);
  };
}

Случайные глобальные переменные

Самый тихий случай. В нестрогом режиме (как код по умолчанию выполнялся в старых браузерах) присваивание необъявленной переменной создаёт свойство на глобальном объекте. А глобальный объект — корень для сборщика, и его свойства не умирают никогда.

function load() {
  results = await fetch("/api").then(r => r.json()); // забыли let
}

После одного вызова в window.results навсегда осядет ответ сервера. Похожий сюрприз — this внутри обычной функции, вызванной без контекста: в нестрогом режиме он указывает на window, и любая запись через такой this уходит в глобальную область.

Лекарство простое: "use strict" и модули (ES-модули включают строгий режим по умолчанию). Плюс корректные ключевые слова при объявлении переменных — let для изменяемых, const для всего остального. Разницу между ними хорошо разбирает статья про var, let и const.

Слабые ссылки: WeakMap, WeakSet, WeakRef

Бывает и обратная задача: хочется навешать на объект какие-то метаданные, но не мешать сборщику его собрать. Обычная Map для этого не подойдёт — пока запись в карте жива, жив и её ключ. А ключом часто бывает как раз тот объект, который мы хотели бы «отпустить».

Для таких случаев есть слабый родственник — WeakMap. Ключом в нём может быть только объект, и ссылка на него слабая: как только ключ становится недостижимым из других мест, запись из карты исчезает автоматически.

const meta = new WeakMap();
let element = document.createElement("div");

meta.set(element, { createdAt: Date.now() });

element = null;
// запись из WeakMap уйдёт при следующей сборке мусора

Типичные применения: приватные данные класса, кэширование результатов по объекту-ключу, навешивание метаинформации на DOM-узлы без риска вечно их удерживать. У WeakSet та же логика, но без значений — чтобы помечать объекты как «посещённые» или «обработанные».

За такую гибкость приходится платить: слабые контейнеры нельзя перебирать. У них нет .size, .keys(), .forEach. Это сознательное ограничение: иначе ссылка из итератора оказалась бы сильной, и весь смысл «слабости» пропал бы.

Поддержка браузерами
chrome
Chrome
36
firefox
Firefox
6
edge
Edge
12
safari
Safari
8
opera
Opera
23

В ECMAScript 2021 добавили ещё два инструмента — WeakRef и FinalizationRegistry. Первый позволяет хранить слабую ссылку на любой объект (а не только ключ карты, как у WeakMap). Второй — узнать, что какой-то объект был собран, и подчистить связанные с ним ресурсы.

const ref = new WeakRef(bigImage);

// позже
const image = ref.deref();
if (image) {
  drawTo(canvas, image);
} else {
  // объект уже собран, надо подгрузить заново
}

В спецификации прямо предупреждают: на WeakRef и FinalizationRegistry не стоит опираться как на «деструкторы объекта» — момент сборки непредсказуем. Их разумная зона — кэши, потерять которые не страшно, и связки с внешними ресурсами (WebGL-текстуры, файлы, сокеты).

Поддержка браузерами
chrome
Chrome
84
firefox
Firefox
79
edge
Edge
84
safari
Safari
14.1
opera
Opera
70

Как увидеть утечку глазами: Memory-таб DevTools

Догадки про память почти всегда ошибочны. Достоверный способ — снять снимок кучи и посмотреть на цифры.

В Chrome и Edge для этого нужна вкладка Memory в DevTools. Сценарий проверки выглядит так:

  1. Открыть приложение, выйти на «холодный» экран (сразу после загрузки).

    холодное состояние страницы
  2. Нажать Take heap snapshot (сделается полный снимок всей памяти JavaScript (heap) в текущий момент).

    Take heap snapshot
  3. Выполнить действие, которое подозревается в утечке: открыть и закрыть модалку, перейти на другую страницу и обратно, отправить форму.
  4. Повторить раз пять-десять, чтобы накопилась разница.
  5. Снять второй снимок (иконка записи в левом верхнем углу).

    Take heap snapshot
  6. и в выпадающем меню сверху переключиться на режим Comparison

В сравнении видны объекты, которых стало больше после повторных действий. Особенно полезен фильтр Detached: он сразу показывает DOM-узлы, отвязанные от документа, но удерживаемые JavaScript. Это самый частый класс утечек в SPA.

Snapshots comparison

В Firefox похожий инструмент называется Memory и работает по тому же принципу. В Safari нужный таб — Timelines → JavaScript Allocations: разница в названиях, но смысл тот же — записать активность и посмотреть на распределение объектов.

Когда об этом стоит думать, а когда — нет

Заниматься памятью раньше времени — такой же антипаттерн, как раньше времени заниматься скоростью. Для статического лендинга, серверного блога и страниц с коротким временем жизни в табе разговор про утечки почти не имеет смысла: пользователь закроет вкладку прежде, чем сборщик найдёт что-нибудь интересное.

Стоит насторожиться, если совпадает несколько условий: приложение — SPA, сессия длится часами, страница активно создаёт и убирает компоненты с DOM-узлами, в проекте есть подписки на глобальные события (window, document, WebSocket, BroadcastChannel). В таких сценариях ревизия точек, где компонент чистит свои ресурсы при размонтировании, обычно даёт больше, чем любая «оптимизация на всякий случай».

Итог

Движок JavaScript умеет освобождать память сам, но судит он не о смысле объекта, а о том, можно ли до него добраться по ссылкам. Поэтому утечка в JS — это не «забыли вернуть память», как в более низкоуровневых языках, а «забыли убрать ссылку». Четыре главных источника таких ссылок — неубранные таймеры, неснятые слушатели, прожорливые замыкания и случайные глобалки — закрываются дисциплиной в коде.