Замыкание в JavaScript — это функция вместе с лексическим окружением, в котором она была создана. Благодаря этому функция продолжает видеть переменные внешней функции даже после того, как та завершилась. Формально замыкание образует любая функция в JS, но на практике так говорят, когда внутренняя функция живет дольше внешней и пользуется ее переменными.
Содержание
- Мини-словарь: четыре термина, которые путают
- Минимальный пример: счетчик
- Как это работает по шагам
- Ошибка 1: var в цикле и отложенный вызов
- Ошибка 2: «устаревшее» значение (stale closure)
- Ошибка 3: потерянный this
- Ошибка 4: замыкание удерживает память
- Сводка ошибок
- Где замыкания реально нужны
- Выводы
- Где применяется / связь с практикой
- FAQ
Ниже — как это устроено, четыре ошибки, которые чаще всего совершают с замыканиями (var в цикле, «устаревшее» значение, потерянный this, утечка памяти), и где замыкания реально нужны. Все примеры проверены в Node.js 24 LTS (v24.21.0) и Node.js 22; в браузере результат тот же, если не оговорено иное.
Мини-словарь: четыре термина, которые путают
| Термин | Что это | Пример |
|---|---|---|
| Область видимости (scope) | Правило, где в коде доступно имя переменной | count виден внутри makeCounter и вложенных функций |
| Лексическое окружение | Объект времени выполнения: переменные текущего блока или вызова + ссылка на внешнее окружение | Создается заново при каждом вызове makeCounter() |
| Замыкание | Функция + ссылка на окружение, где она создана | Функция, которую вернул makeCounter() |
Контекст this |
Объект, на котором функция вызвана; замыканием обычной функции не сохраняется | Отдельная ошибка ниже |
Главное различие: область видимости определяется местом в тексте программы (поэтому она «лексическая»), а окружение — это конкретный экземпляр переменных, который появляется при запуске кода.
Минимальный пример: счетчик
function makeCounter() {
let count = 0; // локальная переменная makeCounter
return function () { // внутренняя функция "запоминает" count
count += 1;
return count;
};
}
const counterA = makeCounter();
const counterB = makeCounter();
console.log(counterA()); // 1
console.log(counterA()); // 2
console.log(counterB()); // 1 - у B свое окружение
console.log(counterA()); // 3
В консоли появится 1, 2, 1, 3. Вызов makeCounter() уже завершился, а count жив и меняется.
Как это работает по шагам
- При вызове
makeCounter()движок создает новое лексическое окружение, в немcount = 0. - Внутренняя функция создается внутри этого вызова. В спецификации ECMAScript у каждой функции есть внутренний слот Environment: туда записывается ссылка на окружение, где функция появилась.
makeCounterвозвращает функцию и заканчивает работу. Окружение не удаляется: на него ссылается возвращенная функция.- При вызове
counterA()движок ищетcountсначала в окружении самого вызова, не находит и идет по ссылке во внешнее окружение — тамcountесть. - Второй вызов
makeCounter()создает второе, независимое окружение. ПоэтомуcounterAиcounterBне мешают друг другу.
Это модель из спецификации. Реальный движок (например, V8 в Chrome и Node.js) оптимизирует хранение и держит в окружении только те переменные, которые используют вложенные функции. У такой оптимизации есть побочный эффект, он разобран в разделе про память.
Замыкание хранит переменную, а не снимок значения
Частое заблуждение — что функция «запоминает значение на момент создания». Она запоминает саму переменную:
let greeting = "Привет";
function greet(name) {
return () => `${greeting}, ${name}!`;
}
const sayHi = greet("Аня");
console.log(sayHi()); // Привет, Аня!
greeting = "Здравствуй";
console.log(sayHi()); // Здравствуй, Аня!
Второй вызов напечатает новое приветствие: greeting читается в момент вызова, а не создания. Из этого правила вытекают сразу две ошибки ниже.
Ошибка 1: var в цикле и отложенный вызов
Классика собеседований и реальных багов с обработчиками кликов и таймерами.
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log("var:", i), 0);
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log("let:", j), 0);
}
Фактический вывод:
var: 3
var: 3
var: 3
let: 0
let: 1
let: 2
Почему так. У var область видимости — функция (или глобальная), а не блок. Все три стрелочные функции замыкают одну и ту же переменную i. К моменту срабатывания таймеров цикл уже закончился, и i равно 3.
У let в заголовке цикла for спецификация создает отдельную привязку на каждую итерацию. Каждая функция замыкает свою копию j со значением 0, 1 и 2.
Исправление — заменить var на let. В старом коде без let (до ES2015) ту же задачу решали немедленно вызываемой функцией (IIFE), которая создает окружение на каждую итерацию:
var handlers = [];
for (var i = 0; i < 3; i++) {
(function (k) {
handlers.push(() => k);
})(i);
}
console.log(handlers.map((h) => h())); // [ 0, 1, 2 ]
Для нового кода IIFE не нужна — это прием, который стоит узнавать в legacy-коде.
Ошибка 2: «устаревшее» значение (stale closure)
Обратная ситуация: функция замкнула переменную, которая указывает на старый объект, а вы заменили объект в другом месте.
function createLogger(settings) {
return (msg) => console.log(`[${settings.level}] ${msg}`);
}
let settings = { level: "info" };
const log = createLogger(settings);
settings = { level: "debug" }; // заменили объект целиком
log("старт"); // [info] старт
const live = { level: "info" };
const log2 = createLogger(live);
live.level = "debug"; // поменяли свойство того же объекта
log2("старт"); // [debug] старт
Первый логгер напечатает [info] старт. Он замкнул параметр settings функции createLogger — это отдельная переменная, и она по-прежнему указывает на первый объект. Внешнее settings = {...} ее не трогает. Второй логгер видит изменение, потому что меняется свойство того же объекта.
Правило проверки: спросите себя, какую именно переменную замкнула функция и меняется ли эта переменная или только ее тезка снаружи. Та же ошибка часто встречается в React: обработчик внутри useEffect или setInterval видит состояние из рендера, в котором был создан. Там ее лечат зависимостями эффекта, функциональным обновлением состояния или useRef — детали зависят от версии React и задачи.
Ошибка 3: потерянный this
Замыкание сохраняет переменные, но не this. У обычной функции this определяется тем, как ее вызвали. Стрелочная функция своего this не имеет и берет его из внешнего окружения — то есть ведет себя как замыкание.
"use strict";
const timer = {
seconds: 0,
tickBroken() {
[1, 2].forEach(function () {
this.seconds += 1; // this здесь не timer
});
},
tick() {
[1, 2].forEach(() => {
this.seconds += 1; // стрелка берет this из tick()
});
},
};
timer.tick();
console.log(timer.seconds); // 2
try {
timer.tickBroken();
} catch (e) {
console.log(e.constructor.name + ": " + e.message);
}
Вывод в Node.js 24:
2
TypeError: Cannot read properties of undefined (reading 'seconds')
В строгом режиме (он включен и во всех ES-модулях) this у обычного колбэка равен undefined, отсюда TypeError. В нестрогом режиме (файл CommonJS без "use strict") this будет глобальным объектом: ошибки не будет, timer.seconds молча не изменится, а в глобальной области появится seconds со значением NaN (проверено в Node.js 22 и 24). Такую ошибку найти труднее. Текст сообщения здесь от движка V8, в Firefox и Safari формулировка другая.
Исправление: стрелочная функция, fn.bind(this) или второй аргумент forEach(callback, thisArg).
Ошибка 4: замыкание удерживает память
Пока замыкание доступно из программы, доступно и его окружение со всеми нужными ему переменными. Сборщик мусора их не освободит. Сам по себе это не баг, но если замыкание живет долго (обработчик события, таймер, кеш), вместе с ним живут и большие данные.
Замер в Node.js 24, 20 замыканий, каждое создано рядом с массивом из миллиона чисел:
// Запуск: node --expose-gc m1.js
const keep = [];
function withBig() {
const big = new Array(1_000_000).fill(1); // ~8 МБ в куче
return () => big.length; // держит весь big
}
function withLength() {
const big = new Array(1_000_000).fill(1);
const n = big.length; // берем только нужное
return () => n; // big можно собрать
}
function sharedContext() {
const big = new Array(1_000_000).fill(1);
const debug = () => big.length; // big нужен только этой функции
const n = big.length;
return () => n; // но возвращаем другую
}
function heapMB() {
global.gc();
return Math.round(process.memoryUsage().heapUsed / 1024 / 1024);
}
for (const factory of [withBig, withLength, sharedContext]) {
keep.length = 0;
const before = heapMB();
for (let i = 0; i < 20; i++) keep.push(factory());
console.log(`${factory.name}: +${heapMB() - before} МБ`);
}
Результат прогона:
withBig: +152 МБ
withLength: +0 МБ
sharedContext: +152 МБ
Первые две строки ожидаемы: если функция использует big, массив живет. Третья неочевидна. В V8 все функции, созданные в одном вызове, делят один объект-окружение. debug потребовала положить туда big, и возвращенная функция держит его, хотя сама big не трогает. Это деталь реализации V8, а не требование стандарта: в других движках цифры могут отличаться, но полагаться на обратное не стоит.
Практические меры:
- Выносить из больших данных только нужное (
nвместоbig), как вwithLength. - Снимать обработчики и таймеры, когда они больше не нужны:
removeEventListener,clearInterval,clearTimeout. - Для группы обработчиков удобно передавать
signalотAbortController: один вызовabort()снимает их все.
const bus = new EventTarget(); // в браузере это может быть window или кнопка
const controller = new AbortController();
function subscribe(report) {
bus.addEventListener(
"refresh",
() => console.log("строк в отчете:", report.length),
{ signal: controller.signal } // обработчик привязан к сигналу
);
}
subscribe(new Array(1000).fill("строка"));
bus.dispatchEvent(new Event("refresh")); // строк в отчете: 1000
controller.abort(); // снимает обработчик
bus.dispatchEvent(new Event("refresh")); // тишина: обработчика больше нет
console.log("готово");
Вывод — две строки: строк в отчете: 1000 и готово. После abort() обработчик снят, и массив report больше ничем не удерживается. Искать такие утечки удобно в Chrome DevTools: вкладка Memory, снимок кучи до и после действия, а в панели Sources при остановке на точке останова видна секция Closure с замкнутыми переменными.
Сводка ошибок
| Ошибка | Симптом | Исправление |
|---|---|---|
var в цикле + колбэк |
Все колбэки видят последнее значение | let в заголовке for; в legacy — IIFE |
| Устаревшее значение | Функция видит старый объект или состояние | Понять, какую переменную замкнули; менять ее, а не тезку; в React — зависимости эффекта или useRef |
Потерянный this |
TypeError в строгом режиме или тихо не меняется поле |
Стрелочная функция, bind, thisArg |
| Удержание памяти | Куча растет после повторных действий | Замыкать только нужное, снимать обработчики и таймеры, AbortController |
Где замыкания реально нужны
Скрытое состояние. Переменная доступна только методам, созданным рядом с ней:
function createWallet(start) {
let balance = start; // снаружи недоступно
return {
deposit(sum) {
if (!(sum > 0)) throw new RangeError("сумма должна быть > 0");
balance += sum;
return balance;
},
getBalance: () => balance,
};
}
const w = createWallet(100);
w.deposit(50);
console.log(w.getBalance(), w.balance); // 150 undefined
try { w.deposit(-10); } catch (e) { console.log(e.name + ": " + e.message); }
Вывод: 150 undefined, затем RangeError: сумма должна быть > 0. Свойства balance у объекта нет, изменить баланс в обход проверки нельзя. В классах ту же задачу решают приватные поля #balance (стандарт ES2022) — для объектов с многими методами это обычно понятнее.
Функции-фабрики и частичное применение. multiplier(2) возвращает функцию, которая помнит множитель:
const multiplier = (k) => (x) => x * k;
const double = multiplier(2);
console.log([1, 2, 3].map(double)); // [ 2, 4, 6 ]
Однократный вызов, мемоизация, debounce и throttle тоже построены на замыкании: служебный флаг, кеш или идентификатор таймера живут между вызовами, но не торчат в глобальной области.
function once(fn) {
let done = false;
let result;
return function (...args) {
if (!done) {
done = true;
result = fn.apply(this, args);
}
return result;
};
}
const init = once(() => {
console.log("инициализация");
return 42;
});
console.log(init()); // инициализация, затем 42
console.log(init()); // 42 без повторной инициализации
Слово «инициализация» появится в консоли один раз, 42 — дважды. Обратите внимание на fn.apply(this, args): обертка передает this дальше, чтобы не повторить ошибку 3. Граница примера: флаг ставится до вызова, поэтому если fn бросит исключение, следующие вызовы молча вернут undefined без повторной попытки. Для инициализации, которая может упасть, ставьте done = true только после успешного вызова.
Выводы
- Замыкание — функция плюс ссылка на окружение, где она создана; поэтому она видит переменные внешней функции после ее завершения.
- Каждый вызов внешней функции создает новое окружение: два счетчика из одной фабрики независимы.
- Замыкается переменная, а не ее значение: отсюда и
3 3 3сvarв цикле, и «устаревшие» значения. thisобычной функции замыканием не сохраняется; стрелочная функция берет его снаружи.- Долгоживущие замыкания удерживают данные в памяти; в V8 даже те, что использует соседняя функция из того же вызова.
Где применяется / связь с практикой
Освойте тему на практике
Замыкания лежат в основе обработчиков событий, хуков React, промисов и таймеров, модулей и middleware в Node.js. Вопросы про var в цикле и this — постоянная часть собеседований на фронтенд и Node.js, а утечки через обработчики — частая причина медленных SPA. Разобрать функции, области видимости, асинхронность и профилирование памяти на практике можно на курсе «JavaScript-разработчик. Продвинутый уровень». Отдельные темы удобно попробовать на бесплатных открытых уроках.
Смежная тема: задачи по JS на собеседованиях.
FAQ
Создает ли замыкание любая функция в JavaScript?
По спецификации да: любая функция хранит ссылку на окружение, где создана. Практический смысл это имеет, когда функция использует внешние переменные и живет дольше вызова, в котором появилась.
Замыкание — это то же самое, что callback?
Нет. Callback — роль функции (ее передают, чтобы вызвали позже), замыкание — механизм доступа к внешним переменным. Колбэк часто бывает замыканием, но может им и не пользоваться.
Замыкания медленнее обычных функций?
В современных движках накладные расходы на доступ к замкнутой переменной обычно малы. Заметная цена — память: окружение живет, пока живет функция, поэтому важнее следить за долгоживущими обработчиками, чем избегать замыканий.



