Почему ваш код на 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