DanLevy.net

Перестаньте течь по памяти с WeakMap

Исправляем слабый код слабыми ссылками!

Знаете это чувство, когда меняешь одну строчку кода и видишь, как потребление памяти падает на 50%? У меня так было: я смотрел на Chrome DevTools Performance Monitor, пока приложение-дашборд перестало терять по 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 Apps она превращается в утечку, которая в итоге крашит браузер.

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 — обычно очевидно, когда он нужен. Если вы ловите себя на том, что пишете код очистки для удаления записей из мапы при уничтожении объектов — это знак. Если вы переживаете о том, что память растёт безгранично, потому что не уверены, когда удалять вещи — это тоже знак.

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

Ресурсы