Замыкания в JavaScript: как работают и как не наделать ошибок

Замыкания в JavaScript: как работают и как не наделать ошибок Полезное

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

Ниже — как это устроено, четыре ошибки, которые чаще всего совершают с замыканиями (var в цикле, «устаревшее» значение, потерянный this, утечка памяти), и где замыкания реально нужны. Все примеры проверены в Node.js 24 LTS (v24.21.0) и Node.js 22; в браузере результат тот же, если не оговорено иное.

Мини-словарь: четыре термина, которые путают

Термин Что это Пример
Область видимости (scope) Правило, где в коде доступно имя переменной count виден внутри makeCounter и вложенных функций
Лексическое окружение Объект времени выполнения: переменные текущего блока или вызова + ссылка на внешнее окружение Создается заново при каждом вызове makeCounter()
Замыкание Функция + ссылка на окружение, где она создана Функция, которую вернул makeCounter()
Контекст this Объект, на котором функция вызвана; замыканием обычной функции не сохраняется Отдельная ошибка ниже

Главное различие: область видимости определяется местом в тексте программы (поэтому она «лексическая»), а окружение — это конкретный экземпляр переменных, который появляется при запуске кода.

Минимальный пример: счетчик

function makeCounter() {
  let count = 0;              // локальная переменная makeCounter
  return function () {        // внутренняя функция "запоминает" count
    count += 1;
    return count;
  };
}

const counterA = makeCounter();
const counterB = makeCounter();

console.log(counterA()); // 1
console.log(counterA()); // 2
console.log(counterB()); // 1 - у B свое окружение
console.log(counterA()); // 3

В консоли появится 1, 2, 1, 3. Вызов makeCounter() уже завершился, а count жив и меняется.

Как это работает по шагам

  1. При вызове makeCounter() движок создает новое лексическое окружение, в нем count = 0.
  2. Внутренняя функция создается внутри этого вызова. В спецификации ECMAScript у каждой функции есть внутренний слот Environment: туда записывается ссылка на окружение, где функция появилась.
  3. makeCounter возвращает функцию и заканчивает работу. Окружение не удаляется: на него ссылается возвращенная функция.
  4. При вызове counterA() движок ищет count сначала в окружении самого вызова, не находит и идет по ссылке во внешнее окружение — там count есть.
  5. Второй вызов makeCounter() создает второе, независимое окружение. Поэтому counterA и counterB не мешают друг другу.

Это модель из спецификации. Реальный движок (например, V8 в Chrome и Node.js) оптимизирует хранение и держит в окружении только те переменные, которые используют вложенные функции. У такой оптимизации есть побочный эффект, он разобран в разделе про память.

Замыкание хранит переменную, а не снимок значения

Частое заблуждение — что функция «запоминает значение на момент создания». Она запоминает саму переменную:

let greeting = "Привет";
function greet(name) {
  return () => `${greeting}, ${name}!`;
}
const sayHi = greet("Аня");
console.log(sayHi()); // Привет, Аня!
greeting = "Здравствуй";
console.log(sayHi()); // Здравствуй, Аня!

Второй вызов напечатает новое приветствие: greeting читается в момент вызова, а не создания. Из этого правила вытекают сразу две ошибки ниже.

Ошибка 1: var в цикле и отложенный вызов

Классика собеседований и реальных багов с обработчиками кликов и таймерами.

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log("var:", i), 0);
}
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log("let:", j), 0);
}

Фактический вывод:

var: 3
var: 3
var: 3
let: 0
let: 1
let: 2

Почему так. У var область видимости — функция (или глобальная), а не блок. Все три стрелочные функции замыкают одну и ту же переменную i. К моменту срабатывания таймеров цикл уже закончился, и i равно 3.

У let в заголовке цикла for спецификация создает отдельную привязку на каждую итерацию. Каждая функция замыкает свою копию j со значением 0, 1 и 2.

Исправление — заменить var на let. В старом коде без let (до ES2015) ту же задачу решали немедленно вызываемой функцией (IIFE), которая создает окружение на каждую итерацию:

var handlers = [];
for (var i = 0; i < 3; i++) {
  (function (k) {
    handlers.push(() => k);
  })(i);
}
console.log(handlers.map((h) => h())); // [ 0, 1, 2 ]

Для нового кода IIFE не нужна — это прием, который стоит узнавать в legacy-коде.

Ошибка 2: «устаревшее» значение (stale closure)

Обратная ситуация: функция замкнула переменную, которая указывает на старый объект, а вы заменили объект в другом месте.

function createLogger(settings) {
  return (msg) => console.log(`[${settings.level}] ${msg}`);
}

let settings = { level: "info" };
const log = createLogger(settings);

settings = { level: "debug" };   // заменили объект целиком
log("старт");                    // [info] старт

const live = { level: "info" };
const log2 = createLogger(live);
live.level = "debug";            // поменяли свойство того же объекта
log2("старт");                   // [debug] старт

Первый логгер напечатает [info] старт. Он замкнул параметр settings функции createLogger — это отдельная переменная, и она по-прежнему указывает на первый объект. Внешнее settings = {...} ее не трогает. Второй логгер видит изменение, потому что меняется свойство того же объекта.

Правило проверки: спросите себя, какую именно переменную замкнула функция и меняется ли эта переменная или только ее тезка снаружи. Та же ошибка часто встречается в React: обработчик внутри useEffect или setInterval видит состояние из рендера, в котором был создан. Там ее лечат зависимостями эффекта, функциональным обновлением состояния или useRef — детали зависят от версии React и задачи.

Ошибка 3: потерянный this

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

"use strict";
const timer = {
  seconds: 0,
  tickBroken() {
    [1, 2].forEach(function () {
      this.seconds += 1;      // this здесь не timer
    });
  },
  tick() {
    [1, 2].forEach(() => {
      this.seconds += 1;      // стрелка берет this из tick()
    });
  },
};

timer.tick();
console.log(timer.seconds); // 2
try {
  timer.tickBroken();
} catch (e) {
  console.log(e.constructor.name + ": " + e.message);
}

Вывод в Node.js 24:

2
TypeError: Cannot read properties of undefined (reading 'seconds')

В строгом режиме (он включен и во всех ES-модулях) this у обычного колбэка равен undefined, отсюда TypeError. В нестрогом режиме (файл CommonJS без "use strict") this будет глобальным объектом: ошибки не будет, timer.seconds молча не изменится, а в глобальной области появится seconds со значением NaN (проверено в Node.js 22 и 24). Такую ошибку найти труднее. Текст сообщения здесь от движка V8, в Firefox и Safari формулировка другая.

Исправление: стрелочная функция, fn.bind(this) или второй аргумент forEach(callback, thisArg).

Ошибка 4: замыкание удерживает память

Пока замыкание доступно из программы, доступно и его окружение со всеми нужными ему переменными. Сборщик мусора их не освободит. Сам по себе это не баг, но если замыкание живет долго (обработчик события, таймер, кеш), вместе с ним живут и большие данные.

Замер в Node.js 24, 20 замыканий, каждое создано рядом с массивом из миллиона чисел:

// Запуск: node --expose-gc m1.js
const keep = [];

function withBig() {
  const big = new Array(1_000_000).fill(1);   // ~8 МБ в куче
  return () => big.length;                    // держит весь big
}

function withLength() {
  const big = new Array(1_000_000).fill(1);
  const n = big.length;                       // берем только нужное
  return () => n;                             // big можно собрать
}

function sharedContext() {
  const big = new Array(1_000_000).fill(1);
  const debug = () => big.length;             // big нужен только этой функции
  const n = big.length;
  return () => n;                             // но возвращаем другую
}

function heapMB() {
  global.gc();
  return Math.round(process.memoryUsage().heapUsed / 1024 / 1024);
}

for (const factory of [withBig, withLength, sharedContext]) {
  keep.length = 0;
  const before = heapMB();
  for (let i = 0; i < 20; i++) keep.push(factory());
  console.log(`${factory.name}: +${heapMB() - before} МБ`);
}

Результат прогона:

withBig: +152 МБ
withLength: +0 МБ
sharedContext: +152 МБ

Первые две строки ожидаемы: если функция использует big, массив живет. Третья неочевидна. В V8 все функции, созданные в одном вызове, делят один объект-окружение. debug потребовала положить туда big, и возвращенная функция держит его, хотя сама big не трогает. Это деталь реализации V8, а не требование стандарта: в других движках цифры могут отличаться, но полагаться на обратное не стоит.

Практические меры:

  • Выносить из больших данных только нужное (n вместо big), как в withLength.
  • Снимать обработчики и таймеры, когда они больше не нужны: removeEventListener, clearInterval, clearTimeout.
  • Для группы обработчиков удобно передавать signal от AbortController: один вызов abort() снимает их все.
const bus = new EventTarget();          // в браузере это может быть window или кнопка
const controller = new AbortController();

function subscribe(report) {
  bus.addEventListener(
    "refresh",
    () => console.log("строк в отчете:", report.length),
    { signal: controller.signal }       // обработчик привязан к сигналу
  );
}

subscribe(new Array(1000).fill("строка"));
bus.dispatchEvent(new Event("refresh")); // строк в отчете: 1000

controller.abort();                      // снимает обработчик
bus.dispatchEvent(new Event("refresh")); // тишина: обработчика больше нет
console.log("готово");

Вывод — две строки: строк в отчете: 1000 и готово. После abort() обработчик снят, и массив report больше ничем не удерживается. Искать такие утечки удобно в Chrome DevTools: вкладка Memory, снимок кучи до и после действия, а в панели Sources при остановке на точке останова видна секция Closure с замкнутыми переменными.

Сводка ошибок

Ошибка Симптом Исправление
var в цикле + колбэк Все колбэки видят последнее значение let в заголовке for; в legacy — IIFE
Устаревшее значение Функция видит старый объект или состояние Понять, какую переменную замкнули; менять ее, а не тезку; в React — зависимости эффекта или useRef
Потерянный this TypeError в строгом режиме или тихо не меняется поле Стрелочная функция, bind, thisArg
Удержание памяти Куча растет после повторных действий Замыкать только нужное, снимать обработчики и таймеры, AbortController

Где замыкания реально нужны

Скрытое состояние. Переменная доступна только методам, созданным рядом с ней:

function createWallet(start) {
  let balance = start;                  // снаружи недоступно
  return {
    deposit(sum) {
      if (!(sum > 0)) throw new RangeError("сумма должна быть > 0");
      balance += sum;
      return balance;
    },
    getBalance: () => balance,
  };
}

const w = createWallet(100);
w.deposit(50);
console.log(w.getBalance(), w.balance); // 150 undefined
try { w.deposit(-10); } catch (e) { console.log(e.name + ": " + e.message); }

Вывод: 150 undefined, затем RangeError: сумма должна быть > 0. Свойства balance у объекта нет, изменить баланс в обход проверки нельзя. В классах ту же задачу решают приватные поля #balance (стандарт ES2022) — для объектов с многими методами это обычно понятнее.

Функции-фабрики и частичное применение. multiplier(2) возвращает функцию, которая помнит множитель:

const multiplier = (k) => (x) => x * k;
const double = multiplier(2);
console.log([1, 2, 3].map(double)); // [ 2, 4, 6 ]

Однократный вызов, мемоизация, debounce и throttle тоже построены на замыкании: служебный флаг, кеш или идентификатор таймера живут между вызовами, но не торчат в глобальной области.

function once(fn) {
  let done = false;
  let result;
  return function (...args) {
    if (!done) {
      done = true;
      result = fn.apply(this, args);
    }
    return result;
  };
}

const init = once(() => {
  console.log("инициализация");
  return 42;
});
console.log(init()); // инициализация, затем 42
console.log(init()); // 42 без повторной инициализации

Слово «инициализация» появится в консоли один раз, 42 — дважды. Обратите внимание на fn.apply(this, args): обертка передает this дальше, чтобы не повторить ошибку 3. Граница примера: флаг ставится до вызова, поэтому если fn бросит исключение, следующие вызовы молча вернут undefined без повторной попытки. Для инициализации, которая может упасть, ставьте done = true только после успешного вызова.

Выводы

  • Замыкание — функция плюс ссылка на окружение, где она создана; поэтому она видит переменные внешней функции после ее завершения.
  • Каждый вызов внешней функции создает новое окружение: два счетчика из одной фабрики независимы.
  • Замыкается переменная, а не ее значение: отсюда и 3 3 3 с var в цикле, и «устаревшие» значения.
  • this обычной функции замыканием не сохраняется; стрелочная функция берет его снаружи.
  • Долгоживущие замыкания удерживают данные в памяти; в V8 даже те, что использует соседняя функция из того же вызова.

Где применяется / связь с практикой

Освойте тему на практике

Замыкания лежат в основе обработчиков событий, хуков React, промисов и таймеров, модулей и middleware в Node.js. Вопросы про var в цикле и this — постоянная часть собеседований на фронтенд и Node.js, а утечки через обработчики — частая причина медленных SPA. Разобрать функции, области видимости, асинхронность и профилирование памяти на практике можно на курсе «JavaScript-разработчик. Продвинутый уровень». Отдельные темы удобно попробовать на бесплатных открытых уроках.

Смежная тема: задачи по JS на собеседованиях.

FAQ

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

Замыкание — это то же самое, что callback?
Нет. Callback — роль функции (ее передают, чтобы вызвали позже), замыкание — механизм доступа к внешним переменным. Колбэк часто бывает замыканием, но может им и не пользоваться.

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

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)