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

Классический баг: вешаете обработчик клика на пять кнопок в цикле, а при клике на любую из них в консоль печатается 5. Или пишете фабрику функций, а все они возвращают одно и то же значение. Причина одна — вы думаете, что замыкание запомнило значение переменной, а оно запомнило саму переменную. В этой статье разберём, где очевидное решение даёт сбой, какие подходы это лечат и чем каждый из них платит. Материал актуален для любого современного окружения с поддержкой ES2015 и выше (Node.js 18+, актуальные браузеры).
Что такое замыкание на практике
Замыкание — это функция вместе с ссылками на переменные из той области, где она была объявлена. Функция «помнит» окружение, даже когда внешняя функция уже завершилась.
function createCounter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
Переменная count живёт после того, как createCounter отработала, потому что на неё держит ссылку возвращённая функция. Пока эта функция достижима — переменная не будет собрана сборщиком мусора. Это одновременно и суть замыканий, и источник утечек памяти, о которых ниже.
Ошибка номер один: var в цикле
Самый частый баг у тех, кто пришёл из кода на var. Вот воспроизведение:
const handlers = [];
for (var i = 0; i < 3; i++) {
handlers.push(function () {
return i;
});
}
console.log(handlers[0]()); // 3
console.log(handlers[1]()); // 3
console.log(handlers[2]()); // 3
Ожидаешь 0, 1, 2, а получаешь 3, 3, 3. Причина: var имеет функциональную область видимости. На весь цикл существует одна переменная i, и все три функции ссылаются на неё же. К моменту вызова цикл уже завершился, и в i лежит 3.
Решение 1: let вместо var
В let область видимости блочная, и на каждой итерации цикла создаётся новая привязка i. Каждое замыкание захватывает свою.
const handlers = [];
for (let i = 0; i < 3; i++) {
handlers.push(function () {
return i;
});
}
console.log(handlers[0]()); // 0
console.log(handlers[1]()); // 1
console.log(handlers[2]()); // 2
Это поведение let в циклах for закреплено в стандарте ES2015 и работает во всех актуальных движках. В 90% случаев замена var на let — весь фикс, который нужен.
Решение 2: IIFE, если let недоступен
Немедленно вызываемая функция создаёт новую область на каждой итерации и захватывает значение через параметр. Приём из эпохи до ES2015, но полезно знать, встретив в старом коде:
var handlers = [];
for (var i = 0; i < 3; i++) {
handlers.push((function (fixedIndex) {
return function () {
return fixedIndex;
};
})(i));
}
Здесь i передаётся аргументом, и fixedIndex — уже отдельная переменная в отдельной области. В новом коде это писать не нужно: let делает то же самое читаемее.
Замыкание захватывает переменную, а не её значение. Если хотите зафиксировать значение — создайте новую переменную. let в цикле делает это за вас, IIFE — вручную.
Захват по ссылке: где это кусается ещё
Проблема не ограничивается циклами. Любое замыкание видит переменную в её текущем состоянии на момент вызова, а не на момент создания.
let message = "привет";
const show = function () {
console.log(message);
};
message = "пока";
show(); // "пока", а не "привет"
Если нужно зафиксировать значение в момент создания функции — скопируйте его в отдельную константу внутри блока или передайте параметром. Для примитивов копия делается присваиванием, для объектов помните, что копируется ссылка, а не содержимое.
Область видимости: три уровня
Чтобы не гадать, откуда функция берёт переменную, держите в голове порядок поиска. JavaScript ищет переменную по цепочке областей снизу вверх:
- Локальная — объявленные внутри самой функции параметры и переменные.
- Область родительских функций — то, что образует замыкание.
- Глобальная — переменные модуля или объекта
globalThis.
Как только имя найдено на ближайшем уровне, поиск останавливается. Отсюда перекрытие имён: локальная переменная user закроет собой одноимённую из внешней области, и это частый источник путаницы при рефакторинге.
Сравнение способов объявления
| Способ | Область видимости | Hoisting | Повторное объявление |
|---|---|---|---|
var | функциональная | поднимается, значение undefined | разрешено |
let | блочная | поднимается, но в «мёртвой зоне»* | ошибка |
const | блочная | поднимается, но в «мёртвой зоне» | ошибка |
* Temporal Dead Zone (TDZ) — интервал между началом блока и строкой объявления, в котором обращение к let/const кидает ReferenceError. Защищает от чтения переменной до инициализации.
Приватные поля через замыкание
Замыкания дают инкапсуляцию без классов: переменная недоступна снаружи, но доступна методам.
function createAccount(initial) {
let balance = initial; // снаружи не достать
return {
deposit(amount) {
balance += amount;
return balance;
},
getBalance() {
return balance;
},
};
}
const account = createAccount(100);
account.deposit(50); // 150
account.getBalance(); // 150
// account.balance — undefined, поля нет в объекте
Цена: у каждого экземпляра свои копии методов, что при тысячах объектов заметно в памяти. Если объектов много и важна экономия — используйте классы с приватными полями через # (поддерживаются в Node.js 12+ и актуальных браузерах): методы окажутся в прототипе в единственном экземпляре.
Когда что выбирать
- Фиксация значения в цикле — всегда
let. IIFE только при работе со старым кодом, гдеletзапрещён линтером или стилем проекта. - Инкапсуляция в единичных объектах (фабрики, синглтоны) — замыкание удобнее и читается лучше классов.
- Множество однотипных объектов — класс с
#-полями: методы в прототипе экономят память. - Просто локальная переменная, которую не переприсваиваете —
constпо умолчанию,letтолько когда значение реально меняется.
Что ломается: утечки памяти
Замыкание держит ссылку на всю свою область, а не только на используемые переменные — в большинстве движков захватывается лексическое окружение целиком. Если замыкание живёт долго (обработчик события, таймер, подписка), оно не даёт собрать сборщику мусора всё, до чего дотягивается.
function attach(node) {
const bigData = new Array(1_000_000).fill(0); // тяжёлые данные
node.addEventListener("click", function () {
console.log("клик"); // bigData здесь не нужен,
}); // но замыкание держит область
}
Пока слушатель висит на элементе, bigData не освободится, даже если обработчик его не использует. Лечится так:
- снимать ненужные слушатели через
removeEventListener; - не объявлять тяжёлые данные в области, которую захватывает долгоживущее замыкание;
- обнулять внешние ссылки, когда объект больше не нужен.
Детали того, какие именно переменные движок оптимизирует и выбрасывает из захвата, отличаются между V8 и другими движками и меняются между версиями — на это опираться не стоит. Профилируйте память в DevTools на вкладке Memory, если подозреваете утечку.
Что делать дальше
Включите в линтере правило no-var (в ESLint) — оно отрубит целый класс багов с областью видимости на этапе написания. Прогоните код через профайлер памяти, если у вас есть долгоживущие подписки и таймеры. А чтобы окончательно закрыть тему областей видимости, прочитайте про TDZ и hoisting в спецификации — понимание порядка инициализации избавит от ReferenceError, которые кажутся необъяснимыми.
Дефектный акт по фото: Осмотр по фотографиям: классификация дефектов, ссылки на нормы, требования к устранению. Готовый акт в Word.
Спросить за 80 ₽Источники: Closures — MDN Web Docs, let — MDN Web Docs
Частые вопросы
Работает ли let в цикле for так же во всех браузерах?
А если у меня старый код на var и переписать всё нельзя?
Замыкание всегда захватывает всю область или только нужные переменные?
Чем замыкание для приватных полей отличается от # в классах?
Почему мой счётчик на замыкании сбрасывается при каждом вызове?
Как понять, что замыкание вызывает утечку памяти?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.