Замыкания в JavaScript: рабочие приёмы и типичные грабли
Когда переменная в цикле «залипает» на последнем значении, а обработчик события помнит уже неактуальные данные — виноваты замыкания и область видимости. Разбираем, как использовать их осознанно.

Классический баг на собеседовании и в проде: цикл for (var i = 0; i < 3; i++) с отложенным вызовом внутри печатает 3, 3, 3 вместо 0, 1, 2. Причина — одна переменная i на всю функцию и замыкание, которое хранит ссылку, а не копию. Ниже — приёмы, где замыкания решают задачу, где создают утечку памяти и как выбирать между ними и полями класса. Всё на синтаксисе ES2015+ (актуально для любого движка с поддержкой let/const: Node.js 6+ и все современные браузеры).
Что такое замыкание на практике
Замыкание — это функция вместе с ссылками на переменные из внешней области видимости, где она была объявлена. Пока живёт функция, живут и переменные, которые она захватила. Это не абстракция: именно поэтому обработчик клика, навешенный три года назад, всё ещё держит в памяти объект, который вы давно считали удалённым.
Ключевой момент — захватывается ссылка на переменную, а не её значение в момент создания функции. Отсюда и растут почти все сюрпризы.
Проблема цикла с var
// var — одна переменная на всю функцию
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// Вывод: 3, 3, 3 — все три колбэка ссылаются на один и тот же i,
// а к моменту их вызова цикл уже закончился
Замена var на let чинит это в одну букву. let в заголовке цикла создаёт новую привязку на каждой итерации:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// Вывод: 0, 1, 2 — у каждого колбэка свой i
До появления let (ES2015) ту же задачу решали через IIFE — немедленно вызываемую функцию, которая создавала новую область видимости и копировала значение в аргумент. В новом коде такой приём не нужен, но вы встретите его в легаси, поэтому важно узнавать конструкцию (function(x){ ... })(i).
Где замыкания реально полезны
Приватное состояние без класса
Фабрика, которая возвращает объект с методами, но прячет данные внутри. Снаружи до count не добраться иначе, чем через возвращённые функции:
function createCounter(start = 0) {
let count = start; // недоступно снаружи
return {
increment: () => ++count,
decrement: () => --count,
value: () => count,
};
}
const counter = createCounter(10);
counter.increment(); // 11
counter.value(); // 11
counter.count; // undefined — прямого доступа нет
С приходом приватных полей класса (#field, поддержка в Node.js 12+ и современных браузерах) у этого приёма появился конкурент. Но замыкание остаётся короче там, где класс избыточен.
Мемоизация
Функция помнит результаты предыдущих вызовов в захваченном Map. Кэш живёт ровно столько, сколько живёт сама мемоизированная функция:
function memoize(fn) {
const cache = new Map();
return function (...args) {
const key = JSON.stringify(args);
if (cache.has(key)) return cache.get(key);
const result = fn.apply(this, args);
cache.set(key, result);
return result;
};
}
const slowSquare = (n) => { /* тяжёлое вычисление */ return n * n; };
const fastSquare = memoize(slowSquare);
fastSquare(5); // считает
fastSquare(5); // берёт из cache
Цена вопроса: cache растёт бесконечно, если аргументы всё время новые. Для долгоживущей функции это утечка. Если ключей может быть много — берите Map с ручным вытеснением или библиотеку с ограничением размера, а не голое замыкание.
Замыкание удобно ровно до того момента, пока вы не забыли, что оно продолжает держать в памяти всё, что захватило. Утечки в SPA почти всегда начинаются здесь.
Где замыкания стреляют в ногу
Утечки через обработчики событий
Навесили addEventListener с колбэком, который захватывает большой объект, — и забыли снять обработчик при удалении элемента. Пока висит слушатель, живёт и колбэк, а значит, и весь объект.
function attach(node, bigData) {
const handler = () => console.log(bigData.length);
node.addEventListener('click', handler);
// Возвращаем функцию отписки — иначе bigData не соберётся сборщиком мусора
return () => node.removeEventListener('click', handler);
}
const detach = attach(button, hugeArray);
// когда элемент больше не нужен:
detach();
Правило: если функция захватывает что-то тяжёлое и живёт долго, отвечайте за её удаление явно. Анонимный колбэк в addEventListener снять нельзя — только именованную ссылку.
Захват лишнего
Замыкание держит всю область видимости, где было объявлено, а не только те переменные, которые видно в теле. В некоторых движках это приводило к удержанию объектов, которые функция вообще не использует. Практический вывод: не создавайте колбэки внутри функций с большими локальными объектами, если колбэк переживёт саму функцию. Выносите тяжёлые данные наружу или обнуляйте ссылку после использования.
Замыкание, класс или модуль — что выбрать
| Подход | Когда брать | Цена |
|---|---|---|
| Замыкание (фабрика) | Один-два экземпляра, короткий код, приватность важна | Каждый экземпляр создаёт новые копии функций — память |
Класс с #-полями | Много экземпляров, наследование, методы на прототипе | Синтаксис длиннее, приватные поля появились относительно недавно |
| Модуль (ESM) | Синглтон: одно состояние на всё приложение | Сложнее тестировать — состояние глобальное на модуль |
Критерий простой. Нужно много одинаковых объектов — класс: методы лежат на прототипе, не дублируются в памяти. Нужен один объект с приватным состоянием — замыкание или модуль. Нужна приватность и вы на старом рантайме без #-полей — замыкание.
Типичные ошибки, которые ловит ревью
- Колбэк в цикле с
var. Замечание: «Здесь все итерации ссылаются на одну переменную, замени наletв заголовке цикла». - Анонимный обработчик, который потом нужно снять. Замечание: «
removeEventListenerне сработает — нет ссылки на ту же функцию, вынеси в именованную». - Мемоизация без ограничения кэша на горячем пути. Замечание: «Ключи не повторяются,
cacheрастёт неограниченно — это утечка». - Фабрика внутри рендера, вызывается на каждый кадр. Замечание: «Новое замыкание на каждый вызов — вынеси создание за пределы горячего кода».
Как убедиться, что утечки нет
Замыкания не видно на глаз — их видно в профайлере. В DevTools Chrome снимите Heap snapshot, выполните подозрительный сценарий (открыть-закрыть компонент несколько раз), снимите второй снимок и сравните через Comparison. Растущее число Detached DOM-узлов или объектов, которые должны были умереть, — прямой признак замыкания, которое их держит.
Следующий шаг: возьмите один живой обработчик событий в своём проекте и проверьте, есть ли для него парный removeEventListener при размонтировании. Если нет — это самый частый источник утечек, начните с него.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Closures — MDN Web Docs, Private class features — MDN Web Docs
Частые вопросы
Чем область видимости отличается от замыкания?
У меня старый код на var, где всё работает — надо ли переписывать на let?
А если у меня движок без поддержки приватных полей класса?
Замыкания замедляют код?
Как понять, что замыкание держит память, если код вроде правильный?
Нужно ли обнулять переменные вручную, чтобы помочь сборщику мусора?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.