Цикл з var і setTimeout
Цикл із var і setTimeout друкує три однакові числа через дві речі одночасно: var створює одну змінну на весь цикл, а колбеки таймера виконуються вже після того, як цикл завершився. Обидві причини мають збігтися: окремо ні асинхронність, ні var такої поведінки не дають.
Теорія
TL;DR
varмає функціональну область видимості: у циклі створюється одна зміннаiна всю функцію, а не нова на кожну ітерацію.- Усі колбеки
setTimeoutзамикаються на одну й ту саму змінну. setTimeoutвиконується пізніше: колбеки потрапляють у чергу макрозадач.- Черга обробляється тільки після того, як поточний стек JavaScript спорожніє.
- До моменту першого спрацювання таймера цикл уже завершено, і
iмає фінальне значення (наприклад,3). - Виправляється через
let, IIFE, третій аргументsetTimeoutабоbind.
Швидкий приклад
Симптом:
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 3
// 3
// 3Причина 1: var, це функціональна область видимості
У циклі for (var i = 0; i < 3; i++) { ... } створюється одна змінна i на всю функцію (або на глобальну область), а не нова на кожну ітерацію. Усі три стрілкові функції, передані в setTimeout, замикаються не на «значення i в момент створення», а на саме зв'язування (binding) єдиної змінної.
Тому після завершення циклу всі три колбеки читають одну й ту саму комірку пам'яті, у якій лежить 3.
Причина 2: setTimeout виконується пізніше
Колбеки setTimeout не виконуються одразу, навіть із затримкою 0. Вони потрапляють у чергу макрозадач і запускаються тільки після того, як поточний стек JavaScript спорожніє, тобто після того, як синхронний код (включно з усім циклом) відпрацював.
Порядок такий:
- Цикл виконується повністю, три рази викликаючи
setTimeoutі плануючи три колбеки. - Цикл завершується,
iстає рівним3. - Стек порожніє, event loop дістає колбеки з черги й виконує їх один за одним.
- Кожен колбек читає поточне значення
i, а це вже3.
Як виправити
Варіант 1: let (блокова область видимості)
for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0); // 0, 1, 2
}Специфікація вимагає, щоб for з let створював нове зв'язування на кожну ітерацію і копіював у нього попереднє значення. Кожен колбек замикається на власну копію.
Варіант 2: IIFE або замикання з передачею поточного i
for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(() => console.log(j), 0); // 0, 1, 2
})(i);
}Виклик функції створює нову область видимості, а параметр j отримує копію значення i на момент виклику.
Варіант 3: передати аргументом у setTimeout
for (var i = 0; i < 3; i++) {
setTimeout(j => console.log(j), 0, i); // 0, 1, 2
}Третій і наступні аргументи setTimeout передаються в колбек, і їхні значення фіксуються в момент планування таймера.
Варіант 4: bind
for (var i = 0; i < 3; i++) {
setTimeout(console.log.bind(null, i), 0); // 0, 1, 2
}bind створює нову функцію з наперед зафіксованим аргументом, тобто теж копіює значення одразу.
Підсумок
| Варіант | Як фіксує значення |
|---|---|
let | Нове зв'язування на кожну ітерацію циклу |
| IIFE | Нова область видимості на кожен виклик функції |
Аргумент setTimeout | Значення передається в колбек у момент планування |
bind | Аргумент «вшивається» у нову функцію одразу |
Коротко:
varдає одну «спільну» змінну на весь цикл, аsetTimeoutзапускається, коли цикл уже закінчено. Використовуйтеletабо ізолюйте значення через замикання чи аргументи.
Типові помилки
- Думати, що проблема в затримці, і ставити
setTimeout(..., 100). Затримка тут ні до чого: колбек усе одно виконається після циклу. - Вважати, що замикання копіює значення змінної. Замикання зберігає посилання на зв'язування, а не знімок значення.
- Замінити
varнаletлише в оголошенні всередині тіла циклу, а не в самомуfor. Нове зв'язування на ітерацію дає самеletу заголовку циклу. - Використати
varізforEachі вирішити, що проблема зникла «сама». Вона зникла тому, щоforEachвикликає функцію на кожен елемент, а це вже окрема область видимості. - Плутати цей ефект із hoisting. Hoisting пояснює, чому
iвидно до оголошення, а не чому всі колбеки бачать3.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.