Замыкания в цикле: как не сойти с ума при захвате переменных

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

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

Начать курс

Почему ваш код на JavaScript ведёт себя как испорченный телефон?

Представьте: вы пишете простой цикл, добавляете обработчики событий на кнопки, и вдруг — бам! — все кнопки показывают одно и то же число. Знакомо? Если вы хоть раз сталкивались с замыканиями в цикле JavaScript, то знаете этот момент когнитивного диссонанса. Вы уверены, что код правильный, но браузер упрямо твердит: "Нет, дружок, ты что-то напутал".

На Stack Overflow этот вопрос собрал сотни тысяч просмотров. И неудивительно: захват переменных цикла в замыкании — одна из самых частых ловушек для новичков и даже опытных разработчиков. Давайте разберёмся, почему так происходит и, главное, как это исправить с учётом производительности.

Классическая проблема: var и асинхронность

Взгляните на этот безобидный код:

for (var i = 0; i < 5; i++) {
    setTimeout(function() {
        console.log(i); // Что выведет?
    }, 100);
}

Если вы ответили "0, 1, 2, 3, 4" — поздравляю, вы оптимист. Реальность жестока: консоль покажет 5, 5, 5, 5, 5. Почему? Потому что var имеет функциональную область видимости, а не блочную. К моменту, когда setTimeout выполнится, цикл уже завершится, и i будет равно 5. Все пять замыканий ссылаются на одну и ту же переменную.

Это классический пример проблемы замыканий в цикле JavaScript. И решение лежит на поверхности: нужно создать новую область видимости для каждой итерации.

Решение №1: IIFE — старый добрый костыль

До появления ES6 разработчики использовали немедленно вызываемые функции (IIFE):

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

Здесь j — это копия i на момент вызова IIFE. Каждое замыкание захватывает своё значение. Работает? Да. Красиво? Не очень. С точки зрения оптимизации замыканий это создаёт лишнюю функцию на каждой итерации, что может ударить по памяти, если цикл огромный.

Решение №2: let — спаситель из ES6

С появлением let проблема решилась элегантно:

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

let создаёт новую привязку для каждой итерации цикла. Это встроенное поведение, закреплённое в спецификации ECMAScript. Каждое замыкание получает свою копию i. Производительность? Отличная — движок оптимизирует это на уровне компиляции.

Однако есть нюанс: если вы используете let внутри цикла, но вне блока for, поведение может отличаться. Всегда держите let внутри заголовка цикла.

Решение №3: bind — функциональный подход

Метод bind позволяет зафиксировать аргументы:

for (var i = 0; i < 5; i++) {
    setTimeout(function(j) {
        console.log(j);
    }.bind(null, i), 100);
}

Здесь bind создаёт новую функцию с предустановленным аргументом. Это работает, но создаёт дополнительную функцию на каждую итерацию. По производительности bind немного медленнее, чем IIFE, из-за внутренних проверок.

Решение №4: forEach и другие методы массивов

Иногда лучший способ избежать проблемы — не использовать for:

[0, 1, 2, 3, 4].forEach(function(i) {
    setTimeout(function() {
        console.log(i);
    }, 100);
});

forEach создаёт новую область видимости для каждой итерации через callback. Это не только решает проблему, но и делает код более декларативным. Минус: нельзя использовать break или continue.

Производительность: что выбрать?

Теперь самое интересное — оптимизация и производительность замыканий. Давайте сравним подходы:

  • let — самый быстрый и современный вариант. Движки V8, SpiderMonkey, JavaScriptCore оптимизируют его на уровне байт-кода. Используйте его по умолчанию.
  • IIFE — создаёт новую функцию на каждой итерации. Для 1000 итераций это 1000 функций в памяти. Если цикл небольшой (до 100), разница незаметна. Для больших циклов — тормоза.
  • bind — чуть медленнее IIFE из-за дополнительных проверок (проверка this, обработка прототипа). Не рекомендуется для горячих циклов.
  • forEach — нативный метод, оптимизированный движком. По скорости близок к let, но создаёт callback-функцию для каждого элемента. Для массивов до 10 000 элементов — отлично.

Важный момент: производительность замыканий в JavaScript часто упирается не в сам захват переменной, а в количество создаваемых объектов. Каждое замыкание — это объект в куче. Если вы создаёте миллион замыканий, GC (сборщик мусора) будет работать активнее.

Практический пример: обработчики событий

Представьте, что вы генерируете кнопки динамически:

// Плохо: все кнопки покажут последний элемент
for (var i = 0; i < buttons.length; i++) {
    buttons[i].addEventListener('click', function() {
        console.log('Нажата кнопка ' + i);
    });
}

// Хорошо: каждая кнопка запоминает свой индекс
for (let i = 0; i < buttons.length; i++) {
    buttons[i].addEventListener('click', function() {
        console.log('Нажата кнопка ' + i);
    });
}

Второй вариант не только короче, но и производительнее, чем создание IIFE для каждой кнопки. Замыкания в цикле JavaScript с let — это современный стандарт.

Продвинутый уровень: async/await и замыкания

С приходом async/await появился ещё один подводный камень:

for (var i = 0; i < 5; i++) {
    await new Promise(resolve => setTimeout(resolve, 100                

Блоги

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