Помилка з var у циклі та closures
Класика: у циклі з var усі колбеки бачать одну й ту саму змінну i, бо var має функційну область видимості. Коли колбеки нарешті виконуються, цикл уже завершено, і i дорівнює кінцевому значенню, тому замість очікуваних 0, 1, 2 у консоль тричі потрапляє 3.
Теорія
TL;DR
varмає функційну область видимості: на весь цикл існує рівно одна змінна.- Замикання захоплює змінну, а не її значення на момент створення функції.
- Асинхронний колбек читає цю змінну вже після завершення циклу, коли в ній кінцеве значення.
letу заголовкуforстворює нове зв'язування на кожній ітерації, і проблема зникає.- Якщо
varлишається, значення фіксують через IIFE, фабрику,forEachабо аргументsetTimeout.
Швидкий приклад
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 3, 3, 3Чому так відбувається
Оголошення var i піднімається (hoisting) до початку функції або до глобальної області, тож змінна одна на всі три ітерації. Кожен колбек, переданий у setTimeout, замикає посилання на цю єдину змінну, а не копіює її значення.
Далі вмикається event loop: навіть із затримкою 0 колбек потрапляє в чергу макрозадач і виконається лише після того, як синхронний код (тобто весь цикл) завершиться. На той момент умова i < 3 уже хибна, бо i === 3. Усі три колбеки читають ту саму комірку і виводять 3.
Виправлення 1: let або const
У ES6 заголовок for (let i = ...) отримує окреме лексичне зв'язування на кожну ітерацію: рушій копіює поточне значення в нову змінну перед наступним кроком. Кожен колбек замикає власну змінну:
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 0, 1, 2
}Це найпростіший і найбезпечніший шлях, і саме тому в сучасному коді var у циклах не використовують. Для for...of з незмінним елементом підійде і const.
Виправлення 2: зафіксувати значення вручну
Якщо з якоїсь причини потрібен саме var, значення поточної ітерації треба перекласти в окрему змінну.
IIFE (функція, що викликає себе одразу) створює нову область видимості і «заморожує» поточне значення i:
for (var i = 0; i < 3; i++) {
(function(iCopy) {
setTimeout(() => console.log(iCopy), 0);
})(i);
}Фабрика функцій робить те саме, але читабельніше: параметр x живе у власному оточенні кожної створеної функції:
function makeLogger(x) {
return () => console.log(x);
}
for (var i = 0; i < 3; i++) {
setTimeout(makeLogger(i), 0);
}Методи масиву (forEach, map) знімають питання взагалі: параметр колбека вже є окремою змінною на кожен елемент:
[0, 1, 2].forEach(i => setTimeout(() => console.log(i), 0));Третій аргумент setTimeout передає значення в колбек напряму (підтримується в браузерах і в Node.js):
for (var i = 0; i < 3; i++) {
setTimeout(x => console.log(x), 0, i);
}Короткий чек-лист
- За замовчуванням використовуйте
letабоconstу циклах, це найпростіший і найбезпечніший шлях. - Якщо
varнеминучий, зафіксуйте значення через IIFE, фабрику або передавання аргумента. - Пам'ятайте: замикання захоплюють змінну, а не її миттєве значення, а у
varвона одна на весь цикл.
Типові помилки
- Вважати, що проблема в
setTimeout. Таймер ні до чого: те саме буде з будь-яким відкладеним викликом, обробником події чи промісом. - Думати, що затримка
0означає «негайно». Колбек усе одно піде в чергу і виконається після синхронного коду. - Замінювати
varнаletлише в тілі циклу. Нове зв'язування на ітерацію дає самеletу заголовкуfor, а не окрема змінна десь усередині блоку (хоча копія всередині блоку теж працює). - Використовувати
varуfor...inіfor...ofз колбеками. Пастка та сама, хоча значення виглядають «свіжими». - Плутати з
this. Тут проблема суто в області видимості змінної; контекст виклику на це не впливає.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.