EssayAI
Блог
Блог
Математика и алгоритмы

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

20 января 2026Время чтения: 8 минут
#javascript#замыкания#lexical scope#closure#frontend
Замыкания 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.

Способов починить - три:

  1. let вместо var. ES2015 ввёл блочную область видимости: на каждой итерации цикла let создаёт новую привязку. Каждый колбэк замыкает свою копию.
for (let i = 0; i < 3; i++) {
    setTimeout(() => console.log(i), 100); // 0, 1, 2
}
  1. IIFE внутри цикла. Способ из эпохи до ES6: оборачиваем тело в немедленно вызываемую функцию, которая принимает i параметром и создаёт свой scope.
for (var i = 0; i < 3; i++) {
    (function (j) {
        setTimeout(() => console.log(j), 100);
    })(i);
}
  1. 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.

Доверьте текст нейросети EssayAI

Открыть EssayAI

Бесплатно, на русском языке и без VPN

Читайте также