Замыкания в деталях
В прошлой главе мы разобрали ментальную модель: у каждого блока кода есть лексическое окружение, а каждое окружение хранит ссылку на внешнее. Замыкание (closure) — это функция вместе с этой сохранённой ссылкой. Когда функция возвращается из другой функции, она уносит с собой доступ к окружению места, где была создана. Движок не «копирует» переменные — он держит окружение живым, пока живёт хотя бы одна функция, которая на него ссылается.
Это определение из спецификации звучит сухо, но на практике замыкания — рабочая лошадка JavaScript: от дебаунса в поисковой строке до архитектуры модулей. В краткой версии ты видел замыкания на примерах. Здесь — механика, приватность, фабрики, утечки и разбор самого известного собеседовательного бага с циклом var.
Механика: что именно «замыкается»
Заголовок раздела «Механика: что именно «замыкается»»Создадим функцию внутри функции и вернём наружу:
function outer() { const secret = 'секрет из outer';
return function inner() { // inner хранит ссылку Outer → окружение outer, // даже после того как outer завершилась return secret; };}
const fn = outer(); // outer отработала и вышла из стекаconsole.log(fn()); // «секрет из outer» — окружение живо!console.log(secret); // ReferenceError — снаружи переменной нетКлючевой момент: после выхода outer её кадр удалён из стека вызовов, но её лексическое окружение не удалено сборщиком мусора — на него ссылается Outer внутри fn. Движок применяет reachability (достижимость): пока fn жива, всё, до чего она может дотянуться по цепочке окружений, считается живым.
Каждый вызов outer создаёт новое окружение, поэтому замыкания независимы:
function makeAdder(x) { return function (y) { return x + y; // x — из окружения этого конкретного вызова };}
const add5 = makeAdder(5);const add10 = makeAdder(10);
console.log(add5(2)); // 7console.log(add10(2)); // 12console.log(add5(2)); // 7 — окружение add5 не тронуто вызовами add10Частное применение и карринг
Заголовок раздела «Частное применение и карринг»Замыкания естественно выражают частное применение (partial application): зафиксировать часть аргументов, остальные передать потом. Это рабочая лошадка функционального стиля:
function multiply(a) { return function (b) { return a * b; };}
const double = multiply(2);const triple = multiply(3);
console.log(double(21)); // 42console.log(triple(7)); // 21Типичный продакшен-сценарий — конфигурируемые валидаторы и преобразователи:
function createValidator(rules) { // rules захвачены в замыкании — валидатор самодостаточен return function validate(data) { const errors = []; for (const [field, check] of Object.entries(rules)) { const error = check(data[field]); if (error) errors.push(`${field}: ${error}`); } return errors; };}
const validateUser = createValidator({ name: (v) => (v?.length >= 2 ? null : 'минимум 2 символа'), age: (v) => (v >= 18 ? null : 'только 18+'),});
console.log(validateUser({ name: 'Влад', age: 20 })); // []console.log(validateUser({ name: 'В', age: 16 }));// ['name: минимум 2 символа', 'age: только 18+']Разница с каррингом (как формализмом): частное применение фиксирует произвольное число аргументов слева, карринг превращает f(a, b, c) в цепочку f(a)(b)(c). В прикладном коде нужен почти всегда первый.
Приватное состояние без классов
Заголовок раздела «Приватное состояние без классов»До приватных полей # (глава про прототипы) замыкания были единственным способом спрятать состояние в JS. До сих пор — самый гибкий: можно прятать не только поля, но и любые структуры и инварианты.
function createBankAccount(initialBalance) { let balance = initialBalance; // недоступна снаружи никак
return { deposit(amount) { if (amount <= 0) throw new Error('Сумма должна быть положительной'); balance += amount; return balance; }, withdraw(amount) { if (amount > balance) throw new Error('Недостаточно средств'); balance -= amount; return balance; }, get balance() { return balance; }, // только чтение };}
const account = createBankAccount(100);account.deposit(50);console.log(account.balance); // 150// account.balance = 1_000_000 — молча не сработает (setter нет,// в строгом режиме бросит). Прямого доступа к balance нет.В отличие от приватного поля #balance, здесь спрятана даже сама переменная: в DevTools она видна только при отладке замыкания конкретной функции.
Фабрики и модули через замыкания
Заголовок раздела «Фабрики и модули через замыкания»Фабрика — функция, возвращающая настроенный объект или функцию. Фабрики удобнее классов, когда нужно несколько экземпляров с общим, но изменяемым состоянием, или когда создание объекта требует сложной логики:
function createLogger(prefix) { let count = 0; // общий для всех методов этого логгера
return { log: (msg) => { count += 1; console.log(`[${prefix}] #${count}: ${msg}`); }, getCount: () => count, };}
const apiLogger = createLogger('api');apiLogger.log('запрос отправлен'); // [api] #1: запрос отправленapiLogger.log('ответ получен'); // [api] #2: ответ полученПаттерн «модуль» — IIFE, которая возвращает публичный API, а внутренности прячет в замыкании. До ES-модулей это был стандарт организации кода (jQuery, Backbone):
const cart = (function () { // Приватная зона модуля const items = []; const listeners = new Set();
function notify() { for (const fn of listeners) fn(items); }
// Публичный API return { add(item) { items.push(item); notify(); }, remove(id) { const idx = items.findIndex((i) => i.id === id); if (idx >= 0) items.splice(idx, 1); notify(); }, subscribe(fn) { listeners.add(fn); return () => listeners.delete(fn); // отписка — иначе утечка }, };})();
cart.subscribe((items) => console.log('Корзина изменилась:', items.length));cart.add({ id: 1, title: 'Книга по JS' });Современные ES-модули решают ту же задачу (всё неэкспортированное — приватно), но паттерн «модуль через IIFE» живёт в тестах, сниппетах и старом коде, который ты будешь читать годами.
Классика: цикл var и замыкания
Заголовок раздела «Классика: цикл var и замыкания»Самый известный вопрос на собеседованиях по JS. Что выведет этот код?
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0);}// Ожидание: 0, 1, 2// Реальность: 3, 3, 3Почему так, мы знаем из главы про контекст выполнения: var игнорирует блочную область видимости, в цикле существует одна переменная i. К моменту срабатывания таймеров цикл закончился, i === 3, и все три замыкания читают одну и ту же живую переменную.
С let каждая итерация получает своё окружение, и каждый setTimeout замыкается на «свой» i:
for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0);}// 0, 1, 2 — каждая итерация — новое лексическое окружениеЕсли по какой-то причине нужен var (старый код), есть три классических исправления:
// 1. IIFE — создать своё окружение на каждой итерацииfor (var i = 0; i < 3; i++) { (function (j) { setTimeout(() => console.log(j), 0); })(i);}
// 2. bind — то же через явную привязку аргументаfor (var i = 0; i < 3; i++) { setTimeout(function (j) { console.log(j); }.bind(null, i), 0);}
// 3. setTimeout с дополнительным аргументом (чистый API)for (var i = 0; i < 3; i++) { setTimeout((j) => console.log(j), 0, i);}Замыкания в замыкании: вложенные фабрики
Заголовок раздела «Замыкания в замыкании: вложенные фабрики»Фабрики можно вкладывать друг в друга — каждый уровень получает свой слой приватности. Это основа архитектур, где настройка разбита на этапы (конфиг → сервис → обработчик):
function createApiClient(baseUrl) { // Слой 1: конфигурация клиента — видна всем запросам этого клиента const defaults = { headers: { 'X-Client': 'web' } }; let requestCount = 0; // статистика на уровне клиента
return function createEndpoint(path) { // Слой 2: конкретный эндпоинт — знает baseUrl и path const url = `${baseUrl}${path}`;
return async function request(params) { // Слой 3: конкретный запрос — видит всё выше + свои аргументы requestCount += 1; // инкремент общего счётчика клиента const response = await fetch(url, { ...defaults, body: JSON.stringify(params), }); return response.json(); }; };}
const api = createApiClient('https://api.example.com');const getUser = api('/users'); // эндпоинт с зафиксированным pathconst getOrders = api('/orders');
// getUser и getOrders делят baseUrl и счётчик requestCount,// но не делят path — каждый замкнул своё значениеТри уровня — три цепочки окружений. При вызове getUser({ id: 1 }) поиск идёт: окружение request → окружение createEndpoint → окружение createApiClient → глобальное. Каждый слой отвечает за свои данные; внутренние слои не знают о внешних деталях реализации — это небольшой, но настоящий принцип инкапсуляции.
Такая конструкция встречается в HTTP-клиентах (axios-инстансы с дефолтами), в логгерах (контекст + префикс + уровень), в DI-контейнерах (регистрация → резолвинг → инстанцирование). Узнаешь паттерн — читаешь чужую библиотеку как свою.
Замыкания и асинхронность: состояние между await
Заголовок раздела «Замыкания и асинхронность: состояние между await»Замыкания отлично работают с асинхронным кодом: локальные переменные окружения живут между await-точками, не требуя классов или глобальных переменных:
async function pollJobStatus(jobId, { interval = 1000, timeout = 60_000 } = {}) { const startedAt = Date.now(); // окружение живёт всё время поллинга const deadline = startedAt + timeout;
while (Date.now() < deadline) { const status = await fetch(`/api/jobs/${jobId}`).then((r) => r.json()); if (status.done) { return { ...status, waitedMs: Date.now() - startedAt }; } await new Promise((resolve) => setTimeout(resolve, interval)); } throw new Error(`Таймаут ожидания задачи ${jobId}`);}startedAt и deadline — локальные переменные одного вызова pollJobStatus, доступные на каждой итерации цикла без объявления их «снаружи». Два параллельных вызова pollJobStatus получат два независимых набора — как и положено по механике окружений.
Мемоизация: замыкания как кэш
Заголовок раздела «Мемоизация: замыкания как кэш»Разбор классического применения замыканий — кэширование результатов дорогих вычислений. Мемоизатор хранит кэш в замыкании, невидимый снаружи, с контролируемым доступом:
function memoize(fn, getKey = (...args) => JSON.stringify(args)) { const cache = new Map(); // живёт в замыкании, недоступен снаружи let hits = 0; let misses = 0;
function memoized(...args) { const key = getKey(...args); if (cache.has(key)) { hits += 1; return cache.get(key); } misses += 1; const result = fn.apply(this, args); // this пробрасываем! cache.set(key, result); return result; }
// Диагностический API — часть контракта, а не «взлом» memoized.stats = () => ({ hits, misses, size: cache.size }); memoized.clear = () => cache.clear();
return memoized;}
// Дорогое вычисление: факториал с кучей арифметикиconst factorial = memoize((n) => (n <= 1 ? 1 : n * factorial(n - 1)));
console.log(factorial(20)); // считаетconsole.log(factorial(20)); // из кэша — мгновенноconsole.log(factorial.stats()); // { hits: 2, misses: 21, size: 21 }Заметь детали, которые делают мемоизатор продакшен-качества: проброс this (иначе сломаешь методы объектов), пользовательская функция ключа (JSON не всегда подходит: порядок ключей, функции в аргументах), статистика для наблюдаемости. Простейший мемоизатор из трёх строк — учебный; этот — готов к использованию.
Ограничения, о которых стоит помнить: кэш растёт бесконечно (см. главу про память — нужен лимит или WeakMap при объектных ключах); функция должна быть чистой относительно аргументов; время вычисления должно превышать накладные расходы на сериализацию ключа.
Тестирование замыканий
Заголовок раздела «Тестирование замыканий»Замыкания тестируют через публичный API — это и есть суть чёрного ящика. Не пытайся «залезть внутрь» окружения; проверяй поведение:
// createCounter из примера вышеdescribe('createCounter', () => { it('независимые экземпляры', () => { const a = createCounter(); const b = createCounter(); a(); a(); expect(a()).toBe(3); expect(b()).toBe(1); // b не пострадал от вызовов a });
it('состояние скрыто', () => { const c = createCounter(); expect(c.count).toBeUndefined(); // нет доступа к внутренностям expect(Object.keys(c)).toEqual([]); // и через перечисление тоже });});Если тесту «очень нужно» добраться до внутреннего состояния — это сигнал, что фабрика слишком монолитна. Разбей её (вынеси состояние в явный параметр-объект) или дай диагностический метод (debugState()), который не считается частью API.
Утечки через замыкания
Заголовок раздела «Утечки через замыкания»Замыкание + долгоживущий объект = классическая формула утечки памяти в SPA:
// ПЛОХО: обработчик держит тяжёлый контекст навсегдаfunction setupHandler() { const hugeData = loadMegabytesOfData(); // 50 МБ объект
document.querySelector('#btn').addEventListener('click', () => { // Даже если hugeData здесь не используется, // замыкание держит ВСЁ окружение setupHandler живым console.log('клик'); });}Кнопка живёт, пока жив документ; замыкание живёт, пока жива кнопка; окружение живёт, пока живо замыкание; hugeData жива, пока живо окружение. Цепочка достижимости не даёт сборщику мусора удалить 50 МБ.
Исправления: минимизировать захваченное окружение (вынести нужное в локальные переменные до создания колбэка), снимать обработчики при размонтировании, использовать AbortSignal:
// ХОРОШО: снимаем слушатель вместе с компонентомfunction setupHandler(signal) { const hugeData = loadMegabytesOfData(); const handler = () => console.log('клик, данные:', hugeData.length);
document.querySelector('#btn').addEventListener('click', handler, { signal }); // signal.abort() где-то при размонтировании — слушатель удалён, // замыкание освобождено, hugeData станет unreachable}Связь с соседними темами
Заголовок раздела «Связь с соседними темами»- Контекст выполнения — замыкания невозможны без
Outer-ссылок лексических окружений. - Память и GC — замыкание — механизм удержания; глава про утечки дополняет эту.
- Прототипы — приватные поля
#решают ту же задачу приватности через другой механизм.
Типичные ошибки и грабли
Заголовок раздела «Типичные ошибки и грабли»-
Ожидать «копию» значения в замыкании. Замыкание видит живую переменную. Если внешний код меняет её — замыкание увидит новое значение. Нужна копия — создавай отдельное окружение (IIFE, фабрика).
-
Замыкание в цикле с
var— разобрано выше. Лечитсяlet, IIFE или доп. аргументом колбэка. -
Создавать функции в цикле «для каждого элемента» без нужды. Десять тысяч итераций — десять тысяч окружений. Если замыкание не требуется, вынеси функцию наружу цикла.
-
Забыть про
thisвнутри замыкания. Замыкание захватывает окружение, но неthis. Внутри метода объектаthisпотеряется во вложенной обычной функции (см. главу проthis). -
Держать ссылку на DOM-ноду «на всякий случай».
elementв замыкании удерживает всё поддерево. После удаления элемента из документа обнуляй ссылки.
Вопросы на собеседовании
Заголовок раздела «Вопросы на собеседовании»- Что такое замыкание? Функция вместе со ссылкой на лексическое окружение места своего создания. Позволяет функции обращаться к переменным внешней области после её завершения.
- Почему
varв цикле с таймерами печатает одинаковые значения?var— одна переменная на все итерации; все замыкания ссылаются на неё. К моменту выполнения колбэков цикл завершён, значение — конечное. - Как сделать состояние приватным без классов? Фабрика, возвращающая объект с методами; состояние — локальные переменные функции-фабрики, доступные только через замыкание.
- Замыкание — копия переменной или ссылка? Ссылка на окружение: видно актуальные значения, а не снимок на момент создания.
- Может ли замыкание привести к утечке памяти? Как? Да: если долгоживущий объект (слушатель, таймер, глобальный кэш) хранит функцию, замыкающую тяжёлое окружение. Лечится снятием обработчиков, AbortSignal, минимизацией захваченного.
- Чем приватность через замыкание отличается от приватных полей
#?#— синтаксическая защита на уровне класса, поле видно в DevTools; замыкание — полное скрытие переменной вне объекта. Замыкание гибче (прячет произвольные структуры),#дешевле и интегрируется с наследованием.
Практика
Заголовок раздела «Практика»- Напиши
once(fn): обёртку, которая вызываетfnтолько при первом вызове, возвращает сохранённый результат при повторных. Критерий: исходная функция вызывается ровно один раз при любых обращениях. - Реализуй
debounce(fn, delay)с замыканием наtimerId, плюс метод.cancel()для отмены отложенного вызова. - Создай
createCache(fn): фабрика, возвращающая функцию с кэшем результатов (ключ — первый аргумент). Критерий: повторный вызов с тем же аргументом не выполняетfn, но возвращает закэшированное значение; добавь метод.clear(). - Продемонстрируй утечку: код создаёт 10 тысяч слушателей на элементе, каждый замыкается на большой массив. Объясни цепочку достижимости и перепиши так, чтобы массив не удерживался.
- Реализуй модуль
eventBusчерез IIFE: методыon(event, fn),off(event, fn),emit(event, ...args). Подписки хранятся в замыкании; критерий — послеoffобработчик не вызывается, повторныйonтого жеfnне дублирует его.