Skip to main content

Що не так з var у циклі та setTimeout?

var у циклі з setTimeout - всі колбеки виводять фінальне значення лічильника, а не значення своєї ітерації, бо var має функціональну область видимості і всі ітерації діляться однією змінною.

Теорія

TL;DR

  • var i - одна змінна на всю функцію. Всі три колбеки setTimeout посилаються на неї.
  • setTimeout - макрозадача. Вона виконується після завершення циклу, коли i вже дорівнює 3.
  • let i створює нову прив'язку для кожної ітерації. Кожен колбек бачить своє значення.
  • Фікс: let замість var, або IIFE у старому ES5-коді.

Швидкий приклад

javascript
// Проблема: var (виводить 3, 3, 3) for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); // всі колбеки закриваються над однією i } // Фікс: let (виводить 0, 1, 2) for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); // кожна ітерація має власну i }

var i піднімається (hoisting) у функціональну область. До моменту виконання колбеків цикл вже завершився, і i дорівнює 3. З let i рушій створює нову прив'язку для кожної ітерації, тому кожен колбек захоплює окреме значення.

Чому var тут не працює

Цикл виконується синхронно. JavaScript додає кожен колбек setTimeout до черги макрозадач, яка обробляється лише після завершення поточного синхронного коду. Всі три колбеки запускаються, коли i вже 3. Вони читають одну і ту саму змінну і виводять однакове значення.

Варіант із setTimeout(..., 0) ловить людей на співбесідах частіше, ніж із 100ms. Нуль мілісекунд підсвідомо відчувається як «зараз». Але 0ms не означає синхронно - колбек все одно чекає в черзі.

let вирішує це, бо специфікація визначає нову прив'язку i для кожної ітерації. Кожне замикання (closure) захоплює окрему прив'язку, а не спільну змінну. Це стає зрозумілішим, якщо знати як працює Event Loop і що таке замикання (closure).

Що використовувати

  • Цикл із setTimeout, Promises або обробниками подій: завжди let.
  • Кодова база ES5 без let: IIFE з передачею значення як аргументу: (function(i){ setTimeout(() => console.log(i), 100); })(i).
  • forEach або .map(): безпечні за замовчуванням, кожен аргумент колбека отримує власну область видимості.

Як це обробляє рушій

V8 піднімає var i на початок функції, створюючи один запис Lexical Environment для всього циклу. let i вмикає механізм «for loop block scope» зі специфікації: рушій створює новий Lexical Environment на кожну ітерацію і копіює туди поточне значення прив'язки. Саме тому варто розуміти hoisting (підняття) перед тим, як це питання з'явиться на співбесіді.

Типові помилки

Помилка 1: думати, що setTimeout(..., 0) виконується під час циклу

javascript
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); // все одно 3, 3, 3 }

Затримка 0ms не означає синхронне виконання. Колбек все одно потрапляє в чергу макрозадач і виконується після завершення циклу.

Помилка 2: IIFE без передачі i як аргументу

javascript
// Неправильно: все одно закривається над зовнішнім i for (var i = 0; i < 3; i++) { (function() { setTimeout(() => console.log(i), 100); // 3, 3, 3 })(); } // Правильно: i стає локальним параметром for (var i = 0; i < 3; i++) { (function(i) { setTimeout(() => console.log(i), 100); // 0, 1, 2 })(i); }

IIFE створює нову область видимості, але якщо не передати i як аргумент, внутрішня функція все одно читає зовнішній i. Передача у вигляді аргументу створює локальну копію на момент виклику.

Помилка 3: спроба використати const у for-циклі

javascript
for (const i = 0; i < 3; i++) {} // SyntaxError: Assignment to constant variable

const забороняє повторне присвоєння, тому крок i++ одразу ламає виконання. Тут потрібен let.

Питання на співбесіді

Q: Чому синхронний console.log(i) усередині циклу виводить 0, 1, 2, а setTimeout - ні?
A: Синхронний код виконується прямо у тілі циклу, де i має значення поточної ітерації. setTimeout відкладає виконання через Event Loop на час після завершення циклу, коли i вже 3.

Q: Що з for...of і var?
A: Та сама проблема. for (var x of arr) перезаписує x на кожній ітерації. Всі колбеки закриваються над одним x і читають його фінальне значення. for (let x of arr) виправляє це.

Q: Два способи виправити це в ES5?
A: Перший: IIFE з передачею аргументу: (function(i){ setTimeout(() => console.log(i), 100); })(i). Другий: .forEach(), який передає кожен елемент як аргумент функції і автоматично створює нове замикання на кожен виклик.

Q: Чи може forEach мати ту ж проблему?
A: Так, якщо закриватися над зовнішнім var замість аргументу колбека. arr.forEach(function(){ setTimeout(() => console.log(x), 100); }) прочитає x у момент виконання, а не реєстрації. Аргумент колбека завжди безпечний.

Приклади

Базовий: класична проблема

javascript
for (var i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); } // Вивід: 3, 3, 3 for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 100); } // Вивід: 0, 1, 2

Три колбеки, одна спільна змінна, черга яка спрацьовує після циклу. Ось уся проблема. let створює три окремі прив'язки замість однієї.

Проміжний: for...of має ту саму пастку

javascript
// var ламає і for...of for (var item of ['a', 'b', 'c']) { setTimeout(() => console.log(item), 0); } // Вивід: 'c', 'c', 'c' // let виправляє for (let item of ['a', 'b', 'c']) { setTimeout(() => console.log(item), 0); } // Вивід: 'a', 'b', 'c'

Те саме правило видимості діє в for...of. Оголошення var item піднімається з циклу, тому всі колбеки бачать останнє присвоєне значення. let item створює нову прив'язку для кожної ітерації.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?