Замыкания в JavaScript: как и зачем

Содержание

О проверке кода. Примеры не запускались — в отличие от материалов по Node.js с фактическим выводом. Говорим прямо, а не ставим бейдж «проверено».

Замыкания — тема, которая пугает новичков и обязательно всплывает на собеседовании. На деле это простая идея, естественно вытекающая из того, как JavaScript ищет переменные.

Что это на самом деле

Замыкание — это функция, которая помнит, где она была создана, и продолжает видеть тамошние переменные.

function makeCounter() {
  let count = 0;              // переменная внешней функции
  return function () {
    count++;                  // внутренняя функция ВИДИТ count
    return count;
  };
}

const counter = makeCounter();
counter();   // 1
counter();   // 2
counter();   // 3

Смотрите, что произошло: makeCounter уже завершилась, её переменная count по всем правилам должна была исчезнуть. Но возвращённая функция помнит count и продолжает им пользоваться. Каждый вызов увеличивает то самое count.

Это и есть замыкание: внутренняя функция «замкнула» вокруг себя переменные из места, где родилась, и унесла их с собой.

Почему так работает

Никакой магии — это прямое следствие лексической области видимости. Когда функция объявляется, она запоминает окружение, в котором создана. Обращаясь к переменной, она ищет её сначала у себя, потом во внешней функции, потом ещё выше — по цепочке областей.

Эта цепочка не рвётся, когда внешняя функция завершается. Пока живёт внутренняя функция, живут и захваченные ею переменные — сборщик мусора их не трогает.

Как замыкание удерживает переменную
  1. 1 Внешняя функция создаёт переменнуюlet count = 0 внутри makeCounter
  2. 2 Возвращает внутреннюю функциюТа ссылается на count из внешней
  3. 3 Внешняя завершаетсяНо count НЕ исчезает — на неё ссылаются
  4. 4 Внутренняя живёт и помнит countКаждый вызов работает с той же переменной

Главное применение: приватные данные

В JavaScript до недавнего времени не было приватных полей, и замыкания их заменяли. Переменная внутри функции недоступна снаружи — достать её можно только через методы, которые вы сами открыли:

function createAccount(initial) {
  let balance = initial;              // приватно! снаружи не достать

  return {
    deposit(sum) { balance += sum; },
    withdraw(sum) { balance -= sum; },
    getBalance() { return balance; },
  };
}

const acc = createAccount(100);
acc.deposit(50);
acc.getBalance();   // 150
acc.balance;        // undefined — нет прямого доступа!

balance существует, но добраться до него можно только через deposit, withdraw, getBalance. Никто не изменит баланс напрямую в обход правил. Это инкапсуляция через замыкание — тот же принцип, что приватные поля # в классах, только старше.

Классическая ловушка: var в цикле

Самый частый способ обжечься о замыкания:

// ❌ напечатает 3, 3, 3
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i));
}

// ✅ напечатает 0, 1, 2
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i));
}

С var переменная i одна на весь цикл, и все три замыкания ссылаются на неё же. Когда таймеры срабатывают, цикл давно кончился, и i равна 3 — все печатают 3.

С let на каждой итерации создаётся новая i, и каждое замыкание захватывает своё значение. Это и есть главная практическая причина, по которой let вытеснил var.

Реальные применения

Замыкания — не академическая экзотика, они повсюду:

  • Хуки ReactuseState возвращает функцию, которая через замыкание помнит, к какому кусочку состояния относится;
  • Debounce и throttle — таймер и последнее значение хранятся в замыкании между вызовами;
  • Фабрики функцийmakeCounter, генераторы конфигураций, каррирование;
  • Обработчики событий — колбэк помнит переменные из места, где его повесили;
  • Мемоизация — кэш результатов живёт в замыкании.

Если вы пишете современный JavaScript, вы используете замыкания постоянно, даже не называя их так.

Про память

Честная оговорка. Замыкание держит захваченные переменные живыми, пока живёт само. Обычно это ровно то, что нужно. Но если замыкание случайно захватило большой объект и живёт долго (висит обработчиком, лежит в кэше), этот объект не освободится — потенциальная утечка памяти.

Практическое правило: не захватывайте больше, чем нужно. Если функции нужно одно поле большого объекта — передайте это поле, а не весь объект.


Смежные темы: область видимости и hoisting — фундамент замыканий; переменные — почему let, а не var; функции. Полный список — в уроках JavaScript.

Частые вопросы

Что такое замыкание простыми словами?
Функция, которая запомнила переменные из места, где была создана, и продолжает иметь к ним доступ даже после того, как внешняя функция завершилась. Это не отдельная конструкция, а следствие того, как в JavaScript работает область видимости.
Зачем нужны замыкания на практике?
Для приватных данных (переменную видит только замыкание, снаружи не достать), для счётчиков и фабрик функций, для запоминания состояния между вызовами. На замыканиях построены хуки React и половина функциональных приёмов.
Замыкания вызывают утечки памяти?
Могут, если замыкание держит ссылку на большой объект дольше, чем нужно. Пока живёт функция-замыкание, живут и переменные, которые она захватила. Обычно это не проблема, но за долгоживущими замыканиями стоит следить.
Почему все колбэки в цикле с var печатают одно число?
Потому что var создаёт одну переменную на весь цикл, и все замыкания ссылаются на неё же — к моменту вызова она уже равна последнему значению. Замена var на let создаёт новую переменную на каждой итерации и чинит это.
Каждая функция создаёт замыкание?
Технически да — любая функция имеет доступ к внешней области. Но термином «замыкание» обычно называют случай, когда функция переживает свою внешнюю функцию и продолжает пользоваться её переменными.