Замыкания и лексическое окружение
Замыкание — это функция, которая «помнит» переменные из лексического окружения, в котором была создана, даже когда выполняется в другом месте. Это одна из самых важных концепций JavaScript: она объясняет работу хуков React, модулей, каррирования, фабрик и приватных переменных.
Содержание
- Что такое замыкание
- Почему это работает
- Приватные переменные
- Каррирование и фабрики функций
- Классическая ошибка с
varв цикле - Модули как замыкания
- Замыкания и React-хуки
- Stale closures
- Ключевые тезисы для интервью
- Заключение
- Полезные ссылки
Что такое замыкание
Замыкание возникает, когда функция сохраняет доступ к переменным внешней функции после того, как внешняя функция завершила выполнение.
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) — внутреннюю структуру, которая хранит переменные и связи между областями видимости. У окружения две части:
- Environment Record — хранилище биндингов: локальные переменные и параметры живут именно здесь.
- Ссылка на внешнее окружение (
[[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();
Разбор по шагам:
- При вызове
createCounter()движок создаёт окружение сcount = 0. - Внутренняя функция при создании получает
[[Environment]], указывающий на это окружение. - Функция возвращается наружу — и вместе с ней «уходит» ссылка на окружение
createCounter. - После завершения
createCounterеё окружение не удаляется: на него всё ещё ссылается[[Environment]]возвращённой функции. - Каждый вызов
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 в цикле и фабрики функций, чтобы закрепить тему.