DanLevy.net

Перестаньте утекать память с WeakMap

Исправляем слабый код с помощью слабых ссылок!

Помните то чувство, когда меняешь одну строку кода и видишь, как потребление памяти падает на 50%? У меня был такой момент, когда я смотрел на Performance Monitor в Chrome DevTools: панель управления, которая раньше выжирала 100 МБ в час, работала чисто целый день после обеда.

Изменение в одну строку: new Map() превратилось в new WeakMap().

Вот и всё. Тот же API, тот же паттерн использования, совершенно разное поведение под капотом. Но понимание того, почему это работает, требует осознания того, о чём большинство JavaScript-разработчиков никогда не задумываются: что происходит, когда на ваши данные больше никто не смотрит.

Когда ссылки становятся якорями

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

Эта защитная инстинктивность становится проблемой, когда вы храните метаданные о временных вещах. DOM-узлы, которые будут удалены. Сессии пользователей, которые истекают. Экземпляры компонентов, которые размонтируются. Map не знает, что эти объекты больше не нужны. Он просто знает, что у него есть ссылка, поэтому он держит их живыми.

const cache = new Map();
function trackClick(element) {
cache.set(element, { clicks: 0 });
}
document.body.removeChild(element);
// Элемент исчез из DOM, но cache всё ещё держит его в памяти

Сборщик мусора не может очистить element, потому что cache всё ещё указывает на него. Это называется «сильная ссылка», и в долгоживущих Single Page App это превращается в утечку, которая в конечном итоге роняет браузер.

WeakMap меняет правила

WeakMap работает иначе. Он относится к своим ключам как к временным жильцам, а не как к постоянным резидентам. Когда вы сохраняете что-то в WeakMap, вы по сути говорите: «Я хочу связать эти данные с этим объектом, но я не хочу быть причиной того, что он остаётся в живых».

Если единственное, что удерживает объект в памяти, — это WeakMap, сборщик мусора может его забрать. Когда объект исчезает, запись в WeakMap исчезает вместе с ним. Ручная очистка не нужна.

const cache = new WeakMap();
function trackClick(element) {
cache.set(element, { clicks: 0 });
}
document.body.removeChild(element);
// Элемент уходит в сборку мусора
// Запись в кеше исчезает автоматически

Я запустил бенчмарк: создал 100 000 DOM-узлов, сохранил метаданные о каждом, а затем удалил их все. С Map браузер держал 150–200 МБ. С WeakMap — 70–80 МБ. Тот же код, та же функциональность, вдвое меньше памяти.

От чего придётся отказаться

У WeakMap есть ограничения, которые кажутся недостатками, пока вы не поймёте, что именно они и обеспечивают всю магию.

Нельзя итерировать WeakMap. Нет forEach, нет keys(), нет values(). Это логично, если подумать: сборщик мусора может удалить запись прямо во время вашего цикла. Вы действительно хотите с этим разбираться?

Нельзя узнать размер. Нет .size, нет .length. Опять же, это подвижная цель. Число может измениться между тем, как вы спросили, и тем, когда получили ответ.

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

Это не баги. Это дизайн. WeakMap создан для одной конкретной задачи: прикреплять метаданные к объектам, не мешая этим объектам быть очищенными. Если вам нужен обход, примитивные ключи или подсчёт записей, вы, скорее всего, решаете другую проблему и должны использовать обычный Map.

Где это действительно помогает

Паттерн «приватные данные» был оригинальным вариантом использования WeakMap, ещё до того, как в JavaScript появились #private поля. Библиотеки создавали WeakMap вне класса и использовали его для хранения данных, которые не должны быть доступны на экземпляре.

const privateData = new WeakMap();
class User {
constructor(name) {
privateData.set(this, { name });
}
getName() {
return privateData.get(this).name;
}
}

Когда экземпляр User уходит в сборку мусора, приватные данные уходят вместе с ним. Код для очистки не нужен.

Мемоизация — ещё одно естественное применение, особенно когда вы кешируете результаты на основе объектных аргументов, а не примитивных значений. Если ваше дорогое вычисление принимает на вход конфигурационный объект, WeakMap позволяет не беспокоиться о том, что кеш переживёт конфиги.

const cache = new WeakMap();
function expensiveCalc(obj) {
if (cache.has(obj)) return cache.get(obj);
const result = heavyMath(obj);
cache.set(obj, result);
return result;
}

Кеш живёт ровно столько, сколько живут кешируемые объекты. Как только на obj больше нет ссылок, результат и запись в кеше исчезают вместе.

Когда стоит применять

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

WeakMap особенно полезен, когда вы связываете данные с DOM-узлами, экземплярами компонентов или любыми объектами, чей жизненный цикл вы не контролируете. Если вы храните что-то на основе ссылки, и эта ссылка может исчезнуть, WeakMap значительно упрощает очистку.

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

Приятная особенность WeakMap в том, что обычно очевидно, когда он нужен. Если вы ловите себя на том, что пишете код для удаления записей из Map при уничтожении объектов — это признак. Если вы переживаете, что память растёт бесконтрольно, потому что не знаете, когда удалять данные — это ещё один признак.

Иногда лучшая функциональность — это та, которая просто работает, не заставляя вас о ней думать.

Ресурсы