Пошук уроків, статей та іншого контенту
Розберіть поведінку замикань усередині циклів і класичну проблему використання var в асинхронних callback-функціях. Дізнайтеся, як let, блокова область видимості та правильна структура коду допомагають уникати неочікуваних результатів.
Замикання — це функція разом із лексичним оточенням, у якому її було створено. Функція може звертатися до змінних зовнішньої області видимості навіть після завершення виконання цієї області.
function createCounter() {
let value = 0;
return function () {
value += 1;
return value;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3Внутрішня функція зберігає доступ до value. Саме цей доступ і є важливим у циклах: callback, створений під час ітерації, може виконатися значно пізніше.
Однак замикання зберігає не обов’язково копію поточного значення. Воно зберігає посилання на змінну — binding. Якщо ця змінна змінюється, callback побачить її значення на момент власного виконання.
varРозглянемо асинхронні callback-функції:
for (var i = 0; i < 3; i += 1) {
setTimeout(() => {
console.log(i);
}, 0);
}Результат:
3
3
3На перший погляд може здаватися, що кожен callback має запам’ятати власне значення i: 0, 1 або 2. Але цього не відбувається.
3var має функціональну область видимості, а не блокову. У цьому прикладі існує одна змінна i, спільна для всіх ітерацій:
Цикл створює перший callback.
Цикл створює другий callback.
Цикл створює третій callback.
Цикл завершується, і i стає рівною 3.
Асинхронні callback-функції виконуються пізніше.
Усі вони читають одну й ту саму змінну i, яка вже дорівнює 3.
Замикання не фіксує значення змінної на момент створення функції. Воно зберігає доступ до самої змінної.
let виправляє проблемуЗмінна, оголошена через let у заголовку циклу, має окреме лексичне оточення для кожної ітерації:
for (let i = 0; i < 3; i += 1) {
setTimeout(() => {
console.log(i);
}, 0);
}Результат:
0
1
2Для кожної ітерації створюється окреме значення i. Кожен callback замикає власне оточення.
Важливо розрізняти два випадки:
let i = 0;
for (; i < 3; i += 1) {
setTimeout(() => {
console.log(i);
}, 0);
}Тут i оголошена поза циклом, тому всі callback-функції використовують одну змінну. Результат буде:
3
3
3Отже, значення має не лише використання let, а й місце, де змінну оголошено.
varПроблема не обмежується асинхронністю. Вона виникає тоді, коли callback виконується після завершення ітерації або після зміни змінної:
var callbacks = [];
for (var i = 0; i < 3; i += 1) {
callbacks.push(() => i);
}
console.log(callbacks[0]()); // 3
console.log(callbacks[1]()); // 3
console.log(callbacks[2]()); // 3Усі функції повертають значення спільної змінної i.
Для порівняння:
const callbacks = [];
for (let i = 0; i < 3; i += 1) {
callbacks.push(() => i);
}
console.log(callbacks[0]()); // 0
console.log(callbacks[1]()); // 1
console.log(callbacks[2]()); // 2let і const мають блокову область видимості. Блоком є, зокрема, тіло циклу:
{
let message = "Привіт";
console.log(message);
}
// console.log(message); // ReferenceErrorУ циклі блокова область створює корисну ізоляцію:
for (let index = 0; index < 3; index += 1) {
const currentIndex = index;
setTimeout(() => {
console.log(currentIndex);
}, 0);
}У цьому прикладі currentIndex є константою для конкретної ітерації. Фактично її значення не змінюється, тому код простіше аналізувати.
Втім, додавання проміжної змінної не є необхідним, якщо використовується let у заголовку циклу:
for (let index = 0; index < 3; index += 1) {
setTimeout(() => {
console.log(index);
}, 0);
}varletЦе стандартний сучасний варіант:
for (let index = 0; index < 3; index += 1) {
setTimeout(() => {
console.log(`Ітерація: ${index}`);
}, 10);
}Можна створити окрему функцію, яка отримує поточне значення аргументом:
function scheduleLog(value) {
setTimeout(() => {
console.log(value);
}, 0);
}
for (var index = 0; index < 3; index += 1) {
scheduleLog(index);
}Результат:
0
1
2Параметр value створюється окремо для кожного виклику scheduleLog. Callback замикає саме цей параметр.
До появи let часто використовували IIFE — функцію, яку створюють і відразу викликають:
for (var index = 0; index < 3; index += 1) {
(function (currentIndex) {
setTimeout(() => {
console.log(currentIndex);
}, 0);
})(index);
}Параметр currentIndex отримує поточне значення index під час кожної ітерації. Кожен виклик IIFE створює окреме замикання.
Такий підхід досі може зустрічатися в застарілому коді, але для нового коду зазвичай краще використовувати let або передавання аргументів.
for...of, for...in і замиканняСучасні цикли також можуть працювати із замиканнями:
const names = ["Олена", "Тарас", "Марія"];
const callbacks = [];
for (const name of names) {
callbacks.push(() => name);
}
console.log(callbacks.map((callback) => callback()));
// ["Олена", "Тарас", "Марія"]У циклі for...of змінна, оголошена через const, отримує окреме значення для кожної ітерації.
Схожий принцип діє для for...in:
const user = {
name: "Олена",
role: "admin",
active: true,
};
const propertyReaders = [];
for (const property of Object.keys(user)) {
propertyReaders.push(() => user[property]);
}
console.log(propertyReaders.map((read) => read()));
// ["Олена", "admin", true]У цьому прикладі callback замикає значення property, створене для відповідної ітерації.
Асинхронність робить проблему помітнішою, бо між створенням callback і його виконанням минає час.
const tasks = ["завантаження", "обробка", "збереження"];
tasks.forEach((task, index) => {
setTimeout(() => {
console.log(`${index}: ${task}`);
}, index * 100);
});Метод forEach передає callback окремі аргументи для кожного виклику. Параметри task та index є локальними змінними цього виклику, тому кожен setTimeout отримує правильні дані.
Це відрізняється від ручного циклу з однією зовнішньою змінною:
var currentTask;
for (var index = 0; index < tasks.length; index += 1) {
currentTask = tasks[index];
setTimeout(() => {
console.log(`${index}: ${currentTask}`);
}, 0);
}Результат буде приблизно таким:
3: збереження
3: збереження
3: збереженняУсі callback-функції використовують спільні змінні index і currentTask.
setTimeout(..., 0) не виконується негайноНульова затримка не означає синхронне виконання:
for (let index = 0; index < 3; index += 1) {
setTimeout(() => {
console.log(`Таймер: ${index}`);
}, 0);
}
console.log("Кінець синхронного коду");Результат:
Кінець синхронного коду
Таймер: 0
Таймер: 1
Таймер: 2Callback таймера потрапляє до черги завдань і буде виконаний після завершення поточного синхронного коду. Тому під час його виконання цикл уже завершено.
Замикання визначає, які змінні доступні callback-функції, а цикл подій визначає, коли ця функція буде виконана. Для правильного аналізу асинхронного коду потрібно враховувати обидва механізми.
async та awaitawait призупиняє виконання поточної async-функції, але не створює автоматичну копію зовнішніх змінних. Наприклад:
const delays = [30, 10, 20];
async function run() {
for (let index = 0; index < delays.length; index += 1) {
await new Promise((resolve) => {
setTimeout(resolve, delays[index]);
});
console.log(`Завершено: ${index}`);
}
}
run();Результат:
Завершено: 0
Завершено: 1
Завершено: 2Цикл очікує кожну ітерацію послідовно.
Якщо ж створити всі проміси одразу, порядок завершення може відрізнятися від порядку створення:
const delays = [30, 10, 20];
async function runInParallel() {
const promises = delays.map((delay, index) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(`Завершено: ${index}`);
}, delay);
});
});
const results = await Promise.all(promises);
console.log(results);
}
runInParallel();Promise.all повертає результати в порядку промісів, навіть якщо самі операції завершилися в іншому порядку:
["Завершено: 0", "Завершено: 1", "Завершено: 2"]Тут map передає кожному callback власні параметри delay та index, тому замикання працюють коректно.
Замикання не робить глибоку копію об’єкта:
const state = {
count: 0,
};
const readers = [];
for (let index = 0; index < 3; index += 1) {
readers.push(() => state.count);
}
state.count = 10;
console.log(readers.map((read) => read()));
// [10, 10, 10]Усі функції замикають той самий об’єкт state. Зміна його властивості доступна всім callback-функціям.
Якщо потрібно зберегти значення на момент ітерації, створіть окреме примітивне значення або копію об’єкта:
const state = {
count: 0,
};
const readers = [];
for (let index = 0; index < 3; index += 1) {
const savedCount = state.count;
readers.push(() => savedCount);
state.count += 1;
}
console.log(readers.map((read) => read()));
// [0, 1, 2]Для об’єктів потрібна явна копія:
const versions = [];
const config = {
enabled: true,
};
for (let index = 0; index < 3; index += 1) {
versions.push({ ...config, version: index });
}
config.enabled = false;
console.log(versions);
// [
// { enabled: true, version: 0 },
// { enabled: true, version: 1 },
// { enabled: true, version: 2 }
// ]Оператор розширення створює поверхневу копію. Вкладені об’єкти при цьому все ще можуть бути спільними.
var у циклі з асинхронним callbackfor (var index = 0; index < 5; index += 1) {
setTimeout(() => console.log(index), 0);
}Якщо потрібне значення конкретної ітерації, використовуйте let, параметр функції або інше окреме замикання.
let value = 1;
const read = () => value;
value = 2;
console.log(read()); // 2Функція читає змінну під час виконання, а не під час створення.
let не в тому місціlet index = 0;
for (; index < 3; index += 1) {
setTimeout(() => console.log(index), 0);
}Попри використання let, усі callback-функції бачать одну змінну. Оголошуйте змінну ітерації безпосередньо в заголовку циклу:
for (let index = 0; index < 3; index += 1) {
setTimeout(() => console.log(index), 0);
}Якщо callback використовує об’єкт, властивості якого змінюються, він побачить актуальний стан об’єкта на момент виконання. Коли потрібно зберегти знімок стану, створіть копію.
forEachforEach не очікує async callback:
async function processItems(items) {
items.forEach(async (item) => {
await processItem(item);
console.log(item);
});
console.log("Усі оброблені");
}Повідомлення "Усі оброблені" може з’явитися раніше за завершення обробки елементів.
Для послідовної обробки використовуйте for...of:
async function processItems(items) {
for (const item of items) {
await processItem(item);
console.log(item);
}
console.log("Усі оброблені");
}Для паралельної обробки створюйте проміси та очікуйте Promise.all.
Замикання можуть утримувати великі об’єкти в пам’яті, поки функції залишаються доступними:
function createHandler(largeData) {
return () => {
console.log(largeData.name);
};
}Це не помилка саме по собі. Проблема виникає, якщо такі обробники накопичуються, наприклад у колекції або довгоживучому об’єкті. Видаляйте непотрібні callback-функції та обробники подій, якщо вони більше не потрібні.
Коли результат замикання в циклі неочікуваний, перевірте:
Де оголошена змінна — всередині циклу чи зовні?
Використовується var, let або const?
Callback виконується одразу чи асинхронно?
Callback читає змінну чи її копію?
Чи змінюється об’єкт після створення замикання?
Чи створює метод на кшталт map, forEach або окрема функція локальні параметри?
Чи потрібен послідовний порядок виконання, чи операції можуть виконуватися паралельно?
Ці запитання допомагають відокремити проблему області видимості від проблеми асинхронного порядку виконання.
Замикання зберігає доступ до змінних зовнішньої області видимості.
Замикання працює з binding змінної, а не обов’язково з копією її значення.
var створює одну функціональну змінну, тому callback-функції в циклі часто бачать фінальне значення лічильника.
let у заголовку циклу створює окреме лексичне оточення для кожної ітерації.
Параметри функції також забезпечують окремі binding для кожного виклику.
setTimeout(..., 0) виконується після завершення синхронного коду.
Замикання не копіює об’єкти: усі функції можуть посилатися на один змінюваний об’єкт.
Для послідовної асинхронної обробки використовуйте for...of з await, а для паралельної — Promise.all.
Під час пошуку помилки потрібно окремо аналізувати область видимості, момент читання змінної та порядок виконання callback.