Как работают замыкания в JavaScript: разбор на примере из Stack Overflow
Если вы когда-нибудь гуглили «JavaScript closures» или читали обсуждения на Stack Overflow, то наверняка видели знаменитый вопрос: «How do JavaScript closures work?». Этот тред собрал тысячи просмотров и сотни ответов — от простых объяснений до глубокого погружения в движок V8. Почему? Потому что замыкания — одна из тех концепций, которые сначала кажутся магией, а потом становятся вашим супероружием.
В этой статье мы не просто перескажем теорию. Мы заглянем под капот JavaScript, разберем, что происходит в памяти, и наконец-то поймем, почему этот код работает именно так, а не иначе. Готовы? Поехали.
Что такое замыкание? Определение без воды
Начнем с самого сока. Замыкание (closure) — это функция, которая «запоминает» переменные из своей внешней области видимости, даже после того, как эта внешняя функция уже завершила свою работу.
Звучит как заклинание? Давайте сразу к коду. Вот классический пример, который вы найдете на Stack Overflow:
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 продолжает жить и меняться при каждом вызове counter(). Вот это и есть замыкание.
Под капотом: лексическое окружение
Чтобы понять, как это работает, нужно заглянуть внутрь движка JavaScript. У каждой функции при создании есть скрытое свойство [[Environment]]. Оно хранит ссылку на лексическое окружение (Lexical Environment), в котором функция была создана.
Лексическое окружение — это просто объект, который содержит:
- Запись окружения (Environment Record) — все переменные и функции, объявленные в этой области видимости.
- Ссылку на внешнее окружение (outer) — родительскую область видимости.
Когда мы вызываем функцию, создается новое лексическое окружение для этого вызова. Внутренняя функция получает доступ к переменным внешней функции через цепочку областей видимости (scope chain).
Вернемся к нашему примеру. Когда мы объявляем createCounter, создается глобальное лексическое окружение. Внутри него — переменная createCounter (функция) и counter (пока undefined).
Когда мы вызываем createCounter():
- Создается новое лексическое окружение для этого вызова. В нем — переменная
count = 0. - Создается анонимная функция. Ее
[[Environment]]указывает на текущее окружение (где живетcount). - Функция возвращается и присваивается
counter.
И вот тут самое интересное: createCounter завершилась, но ее лексическое окружение не удаляется сборщиком мусора! Потому что на него есть ссылка — [[Environment]] внутренней функции. Окружение «висит» в памяти, пока существует сама функция counter.
Теперь каждый раз, когда мы вызываем counter(), создается новое окружение для этого вызова, которое через цепочку видит то самое старое окружение с count. Так мы и получаем эффект «запоминания».
Почему это важно? Реальные сценарии
Замыкания — не просто академическая концепция. Они используются повсеместно. Давайте разберем несколько живых примеров.
1. Приватные переменные (инкапсуляция)
В JavaScript нет настоящих приватных полей (до ES2020 с #), но замыкания отлично их эмулируют:
function createUser(name) {
let _name = name; // "приватная" переменная
return {
getName: function() {
return _name;
},
setName: function(newName) {
_name = newName;
}
};
}
const user = createUser('Иван');
console.log(user.getName()); // Иван
user._name = 'Петр'; // не сработает, _name недоступна снаружи
console.log(user.getName()); // Иван
Только методы getName и setName имеют доступ к _name. Это классический паттерн «модуль» (Module Pattern), который вы найдете в любой серьезной кодовой базе.
2. Обработчики событий и асинхронность
Замыкания спасают, когда нужно «запомнить» состояние для асинхронных операций:
function createButtonHandler(buttonId) {
return function() {
console.log('Клик по кнопке #' + buttonId);
};
}
// Где-то в цикле
for (let i = 0; i < 5; i++) {
const button = document.getElementById('btn-' + i);
button.addEventListener('click', createButtonHandler(i));
}
Каждый обработчик «запоминает» свой buttonId. Без замыканий все обработчики показывали бы одно и то же значение (последнее i из цикла).
3. Функциональное программирование (каррирование)
Замыкания — основа для каррирования (currying):
function multiply(a) {
return function(b) {
return a * b;
};
}
const double = multiply(2);
console.log(double(5)); // 10
const triple = multiply(3);
console.log(triple(5)); // 15
Функция multiply возвращает новую функцию, которая «помнит» первый аргумент. Красиво и лаконично.
Частые ошибки и подводные камни
Даже опытные разработчики иногда спотыкаются о замыкания. Вот два классических грабля.
Ошибка 1: Замыкание в цикле с var
Помните старую добрую проблему с var и циклом?
for (var i = 0; i < 5; i++) {
setTimeout(function() {
console.log(i); // 5, 5, 5, 5, 5
}, 100);
}
Почему? var имеет функциональную область видимости, а не блочную. Все пять функций замыкаются на одну и ту же переменную i. К моменту выполнения setTimeout цикл уже завершился, и i равна 5.
Решение: использовать let (создает новую переменную на каждой итерации) или обернуть в немедленно вызываемую функцию (IIFE):
for (var i =