Замыкания JavaScript: как функция помнит свой контекст

Замыкания JavaScript разработчики используют каждый день, не задумываясь: любой обработчик клика, setTimeout, колбэк в Array.prototype.map - это замыкания. Проблемы начинаются, когда нужно объяснить их на собеседовании или когда странный баг с var в цикле ломает логику. Разберём, как они устроены внутри, зачем нужны и где на них спотыкаются.
Что такое замыкание
Замыкание - это функция вместе со ссылками на переменные из области видимости, в которой она создана. Функция «помнит» окружение, где её определили, и продолжает видеть эти переменные даже после того, как внешняя функция вернулась.
В терминах спецификации JavaScript создаёт Lexical Environment - структуру, хранящую переменные текущего scope и ссылку на родительский environment. У каждой функции есть скрытое свойство [[Environment]], указывающее на тот lexical environment, где функция была объявлена. При вызове движок создаёт новый environment для локальных переменных и ставит его «поверх» сохранённого [[Environment]].
Слово lexical означает «по тексту программы»: не важно, откуда функция вызвана - важно, где она написана. Это отличает JavaScript от языков с динамическим scope.
Минимальный пример
Канонический пример замыкания - счётчик:
function makeCounter() {
let count = 0;
return function () {
count += 1;
return count;
};
}
const counter = makeCounter();
counter(); // 1
counter(); // 2
counter(); // 3
После return фрейм makeCounter должен был бы исчезнуть. Но count живёт дальше - почему?
Возвращённая внутренняя функция держит в [[Environment]] ссылку на lexical environment makeCounter. Пока ссылка на саму функцию жива (она в counter), сборщик мусора не имеет права удалить environment, в котором лежит count. Каждый вызов counter() создаёт свой локальный scope, но обращение к count проходит вверх по цепочке и попадает в тот самый сохранённый environment.
Каждый новый вызов makeCounter() создаёт независимый замкнутый count. Это удобный способ инкапсулировать состояние без классов.
Замыкания - это не один паттерн, а семейство ситуаций. Выбери типовой паттерн ниже или вставь свой код - собранный запрос откроет чат с разбором: какие переменные захвачены, что переживает возврат, есть ли утечки и как переписать на ES6+.
Где реально применяются
Замыкания - это не учебная конструкция, а рабочая лошадка половины кода на JS.
- Module pattern. До появления ES-модулей разработчики прятали приватное состояние в IIFE:
const api = (function () { let secret = 42; return { get: () => secret }; })();. Снаружиsecretнедоступен, обращение возможно только через возвращённый объект. - Обработчики событий. Когда
button.addEventListener('click', () => log(id))создаёт колбэк, тот замыкает переменнуюidиз окружающей функции. Через минуту, через час - клик сработает, иidвсё ещё будет тем же. - Currying.
const add = a => b => a + b;Внешняя стрелка возвращает функцию, которая помнитa. Вызовadd(2)(3)даст5, потому что внутренняя стрелка замкнулаa = 2. - Debounce и throttle. Эти обёртки хранят таймер или последнее время вызова в переменной из замыкания. Каждый вызов читает и обновляет одно и то же значение, скрытое от внешнего кода.
- Мемоизация. Кэш результатов лежит в Map внутри замыкания:
const memo = (fn) => { const cache = new Map(); return (x) => cache.has(x) ? cache.get(x) : (cache.set(x, fn(x)), cache.get(x)); };. Кэш недоступен снаружи, его нельзя случайно сломать.
Альтернатива во всех случаях - класс или глобальная переменная. Замыкание короче и не засоряет namespace, а заодно снижает цикломатическую сложность окружающего кода: меньше явных ветвлений ради хранения состояния.
Классическая ловушка с var в цикле
Самая известная JS-«загадка» на собеседованиях:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
// Выведет: 3, 3, 3
Почему не 0, 1, 2? var имеет функциональную область видимости - i одна на весь цикл. Все три колбэка замыкают одну и ту же переменную. Когда таймеры срабатывают, цикл уже завершился, и i равно 3.
Способов починить - три:
letвместоvar. ES2015 ввёл блочную область видимости: на каждой итерации циклаletсоздаёт новую привязку. Каждый колбэк замыкает свою копию.
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100); // 0, 1, 2
}
- IIFE внутри цикла. Способ из эпохи до ES6: оборачиваем тело в немедленно вызываемую функцию, которая принимает
iпараметром и создаёт свой scope.
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(() => console.log(j), 100);
})(i);
}
Function.prototype.bind. Привязываемiкак аргумент первым параметром.
for (var i = 0; i < 3; i++) {
setTimeout(console.log.bind(null, i), 100);
}
В современном коде ответ - let. Историю с var спрашивают, потому что она показывает: захват идёт по ссылке на привязку, а не по значению.
Утечки памяти из-за замыканий
Замыкание держит весь свой environment, пока существует ссылка на функцию. Иногда это ловушка.
Забытые обработчики DOM. Вешаем button.addEventListener('click', handler), где handler замыкает большой data. Удаляем кнопку из DOM, но не вызываем removeEventListener. Если на кнопку где-то остаётся ссылка, handler и data живут дальше. В SPA с тысячами компонентов - плавная деградация.
Циклы между DOM и замыканием. Сами по себе циклы Mark-and-Sweep разрулит. Но если функция дополнительно зарегистрирована в глобальной таблице (свой event bus), цикл становится якорем для всего поддерева DOM.
«Жирный» замкнутый scope. Вспомогательная функция замыкает огромный объект ради одного поля:
function init(bigPayload) {
const id = bigPayload.id;
return () => console.log(id); // помнит ВЕСЬ bigPayload
}
Формально нужен только id, но движок не делает escape-анализа и держит весь bigPayload, пока возвращённая функция жива. Лечится копированием поля в локальную переменную и явным bigPayload = null.
В DevTools такие утечки видны через Memory → Heap snapshot: смотрим «Retainers» подозрительного объекта.
Замыкания и async/await
async-функции - обычные функции, просто возвращают Promise. Это значит, что они тоже создают замыкания и сохраняют lexical environment между await-ами.
async function load(userId) {
const start = Date.now();
const user = await fetchUser(userId);
const posts = await fetchPosts(user.id);
console.log(`Загружено за ${Date.now() - start} мс`);
return posts;
}
После каждого await функция приостанавливается, но локальный scope - start, userId, user - сохраняется в замыкании, привязанном к продолжению промиса. Когда await резолвится, выполнение возобновляется, и все переменные на месте.
То же с генераторами: function* использует тот же механизм сохранения environment между yield. Async/await под капотом - сахар над генераторами и промисами.
Тонкость: большая структура в локальной переменной до самого return занимает память всё время ожидания. Иногда выгоднее bigArray = null сразу после использования.
Частые ошибки
- Путают замыкание с
this.thisопределяется вызовом функции, а замыкание - определением. Стрелочные функции не имеют собственногоthis, но это проthis, а не про замыкания - замыкают они так же, как обычные. - Думают, что
letкопирует переменную в замыкании. Нет,letсоздаёт новую привязку на каждой итерации цикла, но если вы изменяете переменную после захвата (let x = 1; const f = () => x; x = 2; f();→ вернёт2), захват остаётся «по ссылке на привязку». - Боятся утечек памяти и избегают замыканий. В 99% случаев GC справляется. Стоит копать память, только когда DevTools показывает реальную деградацию.
- Считают, что
varв цикле - это «баг JavaScript». Это не баг, а следствие функциональной области видимости. Достаточно знать проlet. - Не понимают, что IIFE с параметром - это замыкание. Любая немедленно вызываемая функция создаёт scope, в котором её аргументы становятся локальными переменными, замкнутыми всем внутри.
FAQ
Замыкания есть только в JavaScript? Нет, замыкания есть в любом языке с first-class функциями: Python, Ruby, Swift, Kotlin, Rust, даже в современном C++ (лямбды с захватом). В JavaScript они просто более заметны из-за вездесущих колбэков и асинхронности.
Замыкание создаётся всегда, когда я объявляю функцию?
Формально - да, каждая функция держит [[Environment]] и потенциально может замкнуть переменные. Практически: если внутренняя функция не обращается ни к каким внешним переменным, оптимизатор движка может выкинуть environment. Замыкание «существенно» только когда оно реально что-то захватывает.
В чём разница между замыканием и областью видимости? Область видимости (scope) - это правила, по которым переменные находятся при обращении. Замыкание - это конкретный объект-окружение, который функция «носит с собой». Scope - концепция, замыкание - её материальное воплощение.
Коротко
Замыкание в JavaScript - это пара «функция + сохранённое lexical environment». Благодаря замыканиям работают счётчики, обработчики событий, currying, debounce, мемоизация и module pattern. Главная ловушка - var в цикле, лечится переходом на let. Утечки памяти редки, но случаются в больших SPA с DOM-обработчиками; в современных движках Mark-and-Sweep справляется почти со всем. async/await - тоже замыкание: локальные переменные переживают каждый await.
Читайте также

Алгоритм Рабина-Карпа: поиск подстроки за O(n+m)
Разбираем алгоритм Рабина-Карпа: как полиномиальный хеш и скользящее окно ускоряют поиск подстроки до O(n+m) в среднем, почему бывают ложные совпадения и при чём тут плагиат.

Распределение Фишера критические значения: как искать F-квантили
Распределение Фишера и его критические значения: что такое F-распределение, как читать таблицу критических значений по двум степеням свободы, как применять F-квантили в F-тесте на равенство дисперсий и в дисперсионном анализе.

Модель Гордона: рост дивидендов и цена акции
Модель Гордона (Gordon Growth Model) оценивает справедливую стоимость акции через дивиденды с постоянным темпом роста. Формула, вывод, расчёт, ставка дисконтирования и ошибки.