Skip to content
Иван Ткаченко Блог
EN
Замыкания в JavaScript: ещё одна классика собесов — Иван Ткаченко ← К статьям
JavaScript

Замыкания в JavaScript: ещё одна классика собесов

Продолжаю разбирать базовые вопросы для подготовки к собесам. После event loop следующая тема из обязательной программы — это замыкания, спрашивают очень часто.


Что такое замыкание

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

Давайте представим музыканта, который разучил мелодию на репетиции. Репетиция закончилась, но музыкант запомнил мелодию, он может играть её на концертах, импровизировать, немного менять. Репетиция как контекст давно в прошлом, но мелодия живёт и продолжает использоваться.

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


Простой пример

function createCounter() {
  let count = 0;

  return function () {
    count++;
    return count;
  };
}

const counter = createCounter();

console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3

Разберём, что происходит:

  1. Функция createCounter создаёт переменную count и возвращает новую функцию
  2. createCounter завершает работу, но возвращённая функция сохраняет доступ к count
  3. Каждый раз при вызове counter() функция обращается к той же переменной count и увеличивает её
  4. Переменная count живёт в памяти до тех пор, пока существует ссылка на counter

Переменная count не уничтожается после завершения createCounter, потому что внутренняя функция держит на неё ссылку.


Как это работает под капотом

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

Но если внутри функции создаётся другая функция, внутренняя функция хранит ссылку на лексическое окружение внешней. Пока внутренняя функция существует, сборщик мусора не трогает это окружение.

Проще говоря: внутренняя функция держит внешнее окружение живым столько, сколько нужно.

Функция видит не только окружение непосредственно внешней функции, но и всю цепочку выше, вплоть до глобального скоупа. Каждое вложенное окружение хранит ссылку на родительское.


Приватные переменные через замыкания

Замыкания дают возможность скрыть переменные от внешнего кода. Это один из способов реализовать инкапсуляцию в JavaScript.

function createUser(name) {
  let loginCount = 0;

  return {
    login() {
      loginCount++;
      console.log(`${name} вошёл. Входов всего: ${loginCount}`);
    },
    getCount() {
      return loginCount;
    },
  };
}

const user = createUser("Вася");
user.login(); // Вася вошёл. Входов всего: 1
user.login(); // Вася вошёл. Входов всего: 2
console.log(user.getCount()); // 2
console.log(user.loginCount); // undefined — переменная недоступна снаружи

Переменная loginCount доступна только внутри замыкания. Снаружи напрямую обратиться к ней нельзя, только через методы login и getCount.


Мемоизация

Замыкания удобны для кэширования результатов тяжёлых вычислений.

function createMemoize(fn) {
  const cache = {};

  return function (n) {
    if (cache[n] !== undefined) {
      return cache[n];
    }
    cache[n] = fn(n);
    return cache[n];
  };
}

function slowSquare(n) {
  return n * n; // представим что это долгое вычисление
}

const memoSquare = createMemoize(slowSquare);

console.log(memoSquare(4)); // вычисляет: 16
console.log(memoSquare(4)); // берёт из кэша: 16
console.log(memoSquare(5)); // вычисляет: 25

Объект cache живёт в замыкании, недоступен снаружи и сохраняется между вызовами: новые результаты добавляются в него и доступны при каждом следующем обращении.


Проблемные кейсы

Классический вопрос с собеседования

Вот задача, которая встречается почти на каждом собеседовании. Что выведет этот код?

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

Большинство ожидает 0 1 2. На самом деле выводит 3 3 3.

Разбираем, почему:

  1. var не создаёт блочную область видимости: одна переменная i на весь цикл
  2. setTimeout откладывает выполнение колбэков
  3. К моменту, когда колбэки выполняются, цикл уже завершился и i равно 3
  4. Все три колбэка замкнулись на одну и ту же переменную i, а не на её значение в момент итерации

Чтобы это исправить, есть два способа.

Если использовать let вместо var, переменная создаётся отдельно для каждой итерации:

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// 0 1 2

Или с помощью самовызывающейся функции (IIFE): в каждом шаге цикла создаётся своя переменная j со значением i на этом шаге:

for (var i = 0; i < 3; i++) {
  (function (j) {
    setTimeout(() => console.log(j), 0);
  })(i);
}
// 0 1 2

Утечка памяти

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

function processData() {
  const bigArray = new Array(1000000).fill("data");

  return function () {
    // эта функция не использует bigArray,
    // но bigArray всё равно остаётся в памяти
    return "done";
  };
}

const fn = processData();
// bigArray живёт в памяти, пока живёт fn

Функция замкнулась на всё лексическое окружение processData, включая bigArray, хотя она ей не нужна. Пока fn существует, bigArray не будет удалён из памяти.

Чтобы освободить память, можно сбросить переменную в null: fn = null. Сборщик мусора увидит, что на функцию больше нет ссылок, и удалит её вместе с bigArray. На практике это нужно, когда функция хранится долго: в глобальной переменной, в модуле или в сторе. Если fn объявлена в локальной переменной, память освободится автоматически, как только выполнение внешней функции завершится.


Почему это спрашивают на каждом собеседовании

Понимать замыкания — значит понимать, как JavaScript управляет областями видимости и памятью. Без этого сложно объяснить, почему код ведёт себя не так, как ожидается.

Вот несколько ситуаций, где незнание замыканий приводит к проблемам:

  • Классическая ловушка с var в цикле и асинхронными колбэками: все обработчики читают одну и ту же переменную вместо её значения на момент итерации
  • Непредвиденные утечки памяти: функция держит в памяти объект, который ей давно не нужен
  • Неожиданное состояние, когда функция читает переменную, которую другой код успел изменить к моменту вызова

При этом замыкания в реальном коде встречаются повсюду. Колбэки, обработчики событий, хуки в React — всё это работает благодаря замыканиям.