Навигация

Замыкания и лексическое окружение

Замыкание — это функция, которая «помнит» переменные из лексического окружения, в котором была создана, даже когда выполняется в другом месте. Это одна из самых важных концепций JavaScript: она объясняет работу хуков React, модулей, каррирования, фабрик и приватных переменных.

Содержание

  1. Что такое замыкание
  2. Почему это работает
  3. Приватные переменные
  4. Каррирование и фабрики функций
  5. Классическая ошибка с var в цикле
  6. Модули как замыкания
  7. Замыкания и React-хуки
  8. Stale closures
  9. Ключевые тезисы для интервью
  10. Заключение
  11. Полезные ссылки

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

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

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

Функция, возвращённая из createCounter, «замыкает» переменную count. Даже после завершения createCounter внутренняя функция имеет доступ к count и может её изменять.

Говоря коротко: замыкание — это функция вместе со всеми внешними переменными, к которым она обращается.


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

Анатомия лексического окружения

Когда выполняется блок, функция или скрипт, JavaScript создаёт лексическое окружение (Lexical Environment) — внутреннюю структуру, которая хранит переменные и связи между областями видимости. У окружения две части:

  1. Environment Record — хранилище биндингов: локальные переменные и параметры живут именно здесь.
  2. Ссылка на внешнее окружение ([[OuterEnv]]) — указатель на родительское окружение, в котором создано текущее.

Окружения выстраиваются в цепочку: глобальное → внешнее → вложенное. При обращении к переменной движок ищет её в текущем Environment Record, а если не находит — переходит по ссылке на внешнее окружение, и так до глобального. Эту цепочку поиска называют цепочкой областей видимости (scope chain).

function outer() {
  const a = 1; // биндинг a попадает в Environment Record окружения outer

  function inner() {
    const b = 2; // биндинг b попадает в Environment Record окружения inner
    return a + b; // b — своя, a движок ищет по цепочке во внешнем окружении
  }

  return inner;
}

У inner нет собственной a, поэтому движок поднимается по ссылке на окружение outer и находит a там.

Слот [[Environment]]: откуда функция знает своё окружение

Каждая функция при создании получает скрытое свойство [[Environment]] — ссылку на лексическое окружение, в котором она была объявлена. Это и есть корень замыканий: функция буквально носит с собой своё окружение.

function createCounter() {
  let count = 0;

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

const counter = createCounter();

Разбор по шагам:

  1. При вызове createCounter() движок создаёт окружение с count = 0.
  2. Внутренняя функция при создании получает [[Environment]], указывающий на это окружение.
  3. Функция возвращается наружу — и вместе с ней «уходит» ссылка на окружение createCounter.
  4. После завершения createCounter её окружение не удаляется: на него всё ещё ссылается [[Environment]] возвращённой функции.
  5. Каждый вызов counter() создаёт временное окружение вызова, чей [[OuterEnv]] указывает на сохранённое окружение createCounter, поэтому count находится по цепочке.

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

Жизненный цикл окружения и сборка мусора

Окружение живёт, пока на него есть хотя бы одна ссылка. Пока counter доступен, окружение createCounter с count удерживается через его [[Environment]] и не удаляется. Как только замыкание становится недостижимым, ссылка исчезает — и окружение вместе с count освобождается сборщиком мусора:

let counter = createCounter();
counter(); // 1

counter = null; // на замыкание нет ссылок — окружение и count удаляются GC

Независимые замыкания

Если вызвать createCounter() несколько раз, каждому вызову соответствует собственное окружение и собственное замыкание:

const counterA = createCounter();
const counterB = createCounter();

console.log(counterA()); // 1
console.log(counterA()); // 2
console.log(counterB()); // 1

Каждый счётчик хранит собственный count — внутренняя функция ссылается только на окружение «своего» вызова, поэтому счётчики не влияют друг на друга.


Приватные переменные

До появления приватных полей # в классах замыкания были основным способом сделать данные приватными:

function createBankAccount(initialBalance) {
  let balance = initialBalance;

  return {
    deposit(amount) {
      balance += amount;
      return balance;
    },
    withdraw(amount) {
      if (amount > balance) throw new Error('Insufficient funds');
      balance -= amount;
      return balance;
    },
    getBalance() {
      return balance;
    },
  };
}

const account = createBankAccount(1000);
account.deposit(500); // 1500
console.log(account.balance); // undefined — переменная скрыта

Переменная balance доступна только методам, созданным внутри createBankAccount. Снаружи её прочитать или изменить напрямую нельзя.


Каррирование и фабрики функций

Каррирование

Каррирование превращает функцию от нескольких аргументов в цепочку функций от одного аргумента. Каждая промежуточная функция замыкает предыдущий аргумент:

function multiply(a) {
  return function (b) {
    return a * b;
  };
}

const double = multiply(2);
console.log(double(5));  // 10
console.log(double(10)); // 20

Функция multiply(2) возвращает новую функцию, которая «помнит», что a = 2.

Фабрики функций

Фабрика создаёт функции с заранее заданным состоянием:

function createLogger(prefix) {
  return function (message) {
    console.log(`[${prefix}] ${message}`);
  };
}

const infoLog = createLogger('INFO');
const errorLog = createLogger('ERROR');

infoLog('Server started');  // [INFO] Server started
errorLog('Connection failed'); // [ERROR] Connection failed

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


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

На собеседованиях часто дают такой код:

for (var i = 0; i < 5; i++) {
  setTimeout(function () {
    console.log(i);
  }, 1000);
}

Ответ: пять раз 5, а не 0, 1, 2, 3, 4.

Почему? var имеет функциональную область видимости. Все пять функций setTimeout замыкают одну и ту же переменную i. К моменту выполнения таймеров цикл уже завершился, и i = 5.

Решение 1 — let:

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

let создаёт новую переменную для каждой итерации цикла.

Решение 2 — IIFE:

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

IIFE (Immediately Invoked Function Expression) создаёт новое лексическое окружение на каждой итерации, и функция setTimeout замыкает j, а не i.


Модули как замыкания

Модули ES (import/export) и старые модули CommonJS (require/module.exports) основаны на замыканиях. Каждый модуль выполняется в собственном лексическом окружении, поэтому переменные верхнего уровня модуля не попадают в глобальную область.

// counter.js
let count = 0;

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

export function decrement() {
  return --count;
}

Переменная count скрыта внутри модуля. Все экспортированные функции разделяют одно и то же замыкание.

// main.js
import { increment, decrement } from './counter.js';

console.log(increment()); // 1
console.log(increment()); // 2
console.log(decrement()); // 1

Это и есть приватное состояние модуля: оно доступно только через явно экспортированный интерфейс.


Замыкания и React-хуки

В React вы используете замыкания каждый день, даже если не задумываетесь об этом:

function Counter() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const timer = setInterval(() => {
      setCount(count + 1);
    }, 1000);

    return () => clearInterval(timer);
  }, [count]);

  return <div>{count}</div>;
}

Функция внутри setInterval замыкает count из текущего рендера Counter. Это работает, но эффект пересоздаётся при каждом изменении count, потому что count указан в зависимостях.

Понимание замыканий помогает объяснить:

  • почему массив зависимостей в useEffect важен;
  • зачем нужны useCallback и useMemo;
  • почему устаревшие замыкания (stale closures) — частый источник багов.

Stale closures

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

Пример с useEffect

function Timer() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // count всегда равен 0 в этом замыкании
    }, 1000);

    return () => clearInterval(id);
  }, []); // эффект создаётся один раз

  return <div>{count}</div>;
}

Если убрать count из зависимостей, эффект создаётся один раз и замыкает count = 0. В итоге setCount(count + 1) всегда устанавливает 1, а счётчик застрянет.

Как исправить

Вариант 1 — добавить зависимость:

useEffect(() => {
  const id = setInterval(() => {
    setCount(count + 1);
  }, 1000);

  return () => clearInterval(id);
}, [count]);

Эффект будет пересоздаваться при каждом изменении count.

Вариант 2 — использовать функциональное обновление:

useEffect(() => {
  const id = setInterval(() => {
    setCount((prev) => prev + 1);
  }, 1000);

  return () => clearInterval(id);
}, []);

Функциональная форма setCount берёт актуальное значение из React, а не из замыкания, поэтому зависимость от count больше не нужна.

Пример с useCallback

function Search({ query }) {
  const handleSearch = useCallback(() => {
    fetch(`/api/search?q=${query}`);
  }, []); // stale closure: query устареет

  useEffect(() => {
    handleSearch();
  }, [handleSearch]);
}

Если query изменится, handleSearch всё равно будет использовать старое значение. Правильно:

const handleSearch = useCallback(() => {
  fetch(`/api/search?q=${query}`);
}, [query]);

Важно указывать в зависимостях все значения, которые используются внутри замыкания.


Ключевые тезисы для интервью

  • Замыкание — это функция вместе со ссылкой на внешнее лексическое окружение, в котором она была создана.
  • Внутренняя функция сохраняет доступ к переменным внешней функции, потому что хранит ссылку на её лексическое окружение.
  • Лексическое окружение состоит из Environment Record (хранилища биндингов) и ссылки на внешнее окружение ([[OuterEnv]]); цепочка таких ссылок образует scope chain.
  • Каждая функция при создании получает скрытое свойство [[Environment]] — ссылку на окружение, где она была объявлена; именно эта ссылка удерживает окружение от сборки мусора, пока живо замыкание.
  • Приватные переменные создаются через замыкание: данные доступны только методам, возвращённым из внешней функции.
  • Каррирование отличается от обычной функции тем, что каждая промежуточная функция замыкает ранее переданный аргумент.
  • Проблема setTimeout в цикле с var решается заменой var на let или использованием IIFE для создания отдельного замыкания на каждой итерации.
  • Модули ES — это замыкания: переменные верхнего уровня модуля скрыты и доступны только через экспортированный интерфейс.
  • Каждый хук React замыкает переменные текущего рендера; массив зависимостей useEffect управляет актуальностью этих замыканий.
  • Stale closure возникает, когда функция использует устаревшее значение из замыкания; исправляется добавлением зависимостей или функциональным обновлением состояния.

Заключение

Замыкания — одна из ключевых концепций JavaScript. Функция сохраняет доступ к переменным внешнего лексического окружения даже после завершения внешней функции. Это объясняет работу приватных переменных, каррирования, модулей и React-хуков. Понимание замыканий необходимо для избежания stale closures и написания предсказуемого кода. Разберите классические задачи с var в цикле и фабрики функций, чтобы закрепить тему.

Полезные ссылки