Skip to main content

Помилка з var у циклі та closures

Класика: у циклі з var усі колбеки бачать одну й ту саму змінну i, бо var має функційну область видимості. Коли колбеки нарешті виконуються, цикл уже завершено, і i дорівнює кінцевому значенню, тому замість очікуваних 0, 1, 2 у консоль тричі потрапляє 3.

Теорія

TL;DR

  • var має функційну область видимості: на весь цикл існує рівно одна змінна.
  • Замикання захоплює змінну, а не її значення на момент створення функції.
  • Асинхронний колбек читає цю змінну вже після завершення циклу, коли в ній кінцеве значення.
  • let у заголовку for створює нове зв'язування на кожній ітерації, і проблема зникає.
  • Якщо var лишається, значення фіксують через IIFE, фабрику, forEach або аргумент setTimeout.

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

javascript
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 = ...) отримує окреме лексичне зв'язування на кожну ітерацію: рушій копіює поточне значення в нову змінну перед наступним кроком. Кожен колбек замикає власну змінну:

javascript
for (let i = 0; i < 3; i++) { setTimeout(() => console.log(i), 0); // 0, 1, 2 }

Це найпростіший і найбезпечніший шлях, і саме тому в сучасному коді var у циклах не використовують. Для for...of з незмінним елементом підійде і const.

Виправлення 2: зафіксувати значення вручну

Якщо з якоїсь причини потрібен саме var, значення поточної ітерації треба перекласти в окрему змінну.

IIFE (функція, що викликає себе одразу) створює нову область видимості і «заморожує» поточне значення i:

javascript
for (var i = 0; i < 3; i++) { (function(iCopy) { setTimeout(() => console.log(iCopy), 0); })(i); }

Фабрика функцій робить те саме, але читабельніше: параметр x живе у власному оточенні кожної створеної функції:

javascript
function makeLogger(x) { return () => console.log(x); } for (var i = 0; i < 3; i++) { setTimeout(makeLogger(i), 0); }

Методи масиву (forEach, map) знімають питання взагалі: параметр колбека вже є окремою змінною на кожен елемент:

javascript
[0, 1, 2].forEach(i => setTimeout(() => console.log(i), 0));

Третій аргумент setTimeout передає значення в колбек напряму (підтримується в браузерах і в Node.js):

javascript
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. Тут проблема суто в області видимості змінної; контекст виклику на це не впливає.

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

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

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