Перейти к содержимому

Замыкания в деталях

В прошлой главе мы разобрали ментальную модель: у каждого блока кода есть лексическое окружение, а каждое окружение хранит ссылку на внешнее. Замыкание (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)); // 7
console.log(add10(2)); // 12
console.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)); // 42
console.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» живёт в тестах, сниппетах и старом коде, который ты будешь читать годами.

Самый известный вопрос на собеседованиях по 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'); // эндпоинт с зафиксированным path
const 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 — замыкание — механизм удержания; глава про утечки дополняет эту.
  • Прототипы — приватные поля # решают ту же задачу приватности через другой механизм.
  1. Ожидать «копию» значения в замыкании. Замыкание видит живую переменную. Если внешний код меняет её — замыкание увидит новое значение. Нужна копия — создавай отдельное окружение (IIFE, фабрика).

  2. Замыкание в цикле с var — разобрано выше. Лечится let, IIFE или доп. аргументом колбэка.

  3. Создавать функции в цикле «для каждого элемента» без нужды. Десять тысяч итераций — десять тысяч окружений. Если замыкание не требуется, вынеси функцию наружу цикла.

  4. Забыть про this внутри замыкания. Замыкание захватывает окружение, но не this. Внутри метода объекта this потеряется во вложенной обычной функции (см. главу про this).

  5. Держать ссылку на DOM-ноду «на всякий случай». element в замыкании удерживает всё поддерево. После удаления элемента из документа обнуляй ссылки.

  1. Что такое замыкание? Функция вместе со ссылкой на лексическое окружение места своего создания. Позволяет функции обращаться к переменным внешней области после её завершения.
  2. Почему var в цикле с таймерами печатает одинаковые значения? var — одна переменная на все итерации; все замыкания ссылаются на неё. К моменту выполнения колбэков цикл завершён, значение — конечное.
  3. Как сделать состояние приватным без классов? Фабрика, возвращающая объект с методами; состояние — локальные переменные функции-фабрики, доступные только через замыкание.
  4. Замыкание — копия переменной или ссылка? Ссылка на окружение: видно актуальные значения, а не снимок на момент создания.
  5. Может ли замыкание привести к утечке памяти? Как? Да: если долгоживущий объект (слушатель, таймер, глобальный кэш) хранит функцию, замыкающую тяжёлое окружение. Лечится снятием обработчиков, AbortSignal, минимизацией захваченного.
  6. Чем приватность через замыкание отличается от приватных полей #? # — синтаксическая защита на уровне класса, поле видно в DevTools; замыкание — полное скрытие переменной вне объекта. Замыкание гибче (прячет произвольные структуры), # дешевле и интегрируется с наследованием.
  1. Напиши once(fn): обёртку, которая вызывает fn только при первом вызове, возвращает сохранённый результат при повторных. Критерий: исходная функция вызывается ровно один раз при любых обращениях.
  2. Реализуй debounce(fn, delay) с замыканием на timerId, плюс метод .cancel() для отмены отложенного вызова.
  3. Создай createCache(fn): фабрика, возвращающая функцию с кэшем результатов (ключ — первый аргумент). Критерий: повторный вызов с тем же аргументом не выполняет fn, но возвращает закэшированное значение; добавь метод .clear().
  4. Продемонстрируй утечку: код создаёт 10 тысяч слушателей на элементе, каждый замыкается на большой массив. Объясни цепочку достижимости и перепиши так, чтобы массив не удерживался.
  5. Реализуй модуль eventBus через IIFE: методы on(event, fn), off(event, fn), emit(event, ...args). Подписки хранятся в замыкании; критерий — после off обработчик не вызывается, повторный on того же fn не дублирует его.