Замыкания в JavaScript: объяснение на пальцах и под капотом

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Как работают замыкания в 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():

  1. Создается новое лексическое окружение для этого вызова. В нем — переменная count = 0.
  2. Создается анонимная функция. Ее [[Environment]] указывает на текущее окружение (где живет count).
  3. Функция возвращается и присваивается 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 =                

Блоги

Рекомендаций по книгам