Розуміння замикань у JavaScript та циклу подій
Замикання в JavaScript зберігають змінні зовнішнього контексту живими; цикл подій впорядковує стек викликів, мікозавдання та макрозавдання. Обидва аспекти пояснюються за допомогою прикладів коду, які можна запустити.
Джерела: JavaScript Closures Explained in 2 Minutes, The JavaScript Event Loop — Finally Explained With Zero Confusion
JavaScript часто описують як мову, сповнену дивацтвами, але дві з її найбільш неправильно розуміних особливостей — замикання та цикл подій — насправді походять від однієї й тієї самої ідеї: функції та заплановані операції не зникають лише через те, що код навколо них продовжує виконуватися. Щоб зрозуміти, як функція може „пам’ятати“ змінні після повернення свого батьківського об’єкта, та як система виконання вирішує, коли запустити код, який ще не готовий до виконання, потрібно перестати сприймати JavaScript як простий скрипт, що виконується рядок за рядком, та почати бачити в ньому систему з правилами керування пам’яттю та часом. У цій статті розглядаються обидві ідеї, починаючи з замикань.
Розуміння замикань
Що насправді є замиканням
Замикання відбувається тоді, коли функція зберігає змінні з області видимості, в якій вона була створена, і може до них отримувати доступ навіть після того, як зовнішня функція вже завершила свою роботу.
Корисним способом уявити це є образ функції, яка куди б не йшла, носить із собою невеликий рюкзак зі змінними.
function createCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
Зверніть увагу: createCounter() виконується та повертається, проте внутрішня функція, яку вона повертає, все ще має доступ до count. Саме цей постійний доступ після технічного завершення зовнішнього виклику і є замиканням.
Типові ситуації, коли зустрічаються замикання
Замикання мають значення тоді, коли функції потрібно зберігати певні дані після того, як функція, що їх створила, вже завершила свою роботу. Ви зіткнетеся з цим паттерном у багатьох повсякденних ситуаціях:
- Приватні змінні – приховування даних, щоб зовнішній код не міг до них безпосередньо отримати доступ.
setTimeout або setInterval.Чому це важливо
Замикання дозволяють зберігати стан без використання глобальних змінних, що робить код чистішим та безпечнішим.
function createAccount(initialBalance) {
let balance = initialBalance;
return {
deposit(amount) {
balance += amount;
},
getBalance() {
return balance;
}
};
}
const account = createAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined
У цьому прикладі до balance неможливо отримати доступ безпосередньо ззовні функції. До нього мають доступ лише методи об’єкта, які повертаються з createAccount.
Ця структура надає кілька справжніх переваг:
- Краща інкапсуляція
- Справді приватний стан
- Простіший та більш зрозумілий код
- Зменшена залежність від глобальних змінних
Класичний приклад циклу
Популярним прикладом демонстрації роботи замикань є різниця між використанням var та let усередині циклу, який запускає функції-відповідачі.
Ось що відбувається з var:
for (var i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// Output:
// 4
// 4
// 4
Результат спочатку не є тим, чого очікують багато хто, і причина в тому, що var має область видимості на рівні функції, а не блоку. Усі три функції-відповідачі setTimeout посилаються на одну й ту саму змінну i. До моменту їх виконання цикл вже завершився, і значення i вже досягло 4.
Тепер порівняйте це з let:
for (let i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// Output:
// 1
// 2
// 3
Оскільки ключове слово let має блоковий обсяг видимості, кожен прохід через цикл створює власне незалежне зв’язування змінної i. Це означає, що кожен калебек має доступ до іншого значення цієї змінної, тож числа виводяться у порядку, який ми природно очікуємо.
Поширений діалог під час співбесіди
Питання: Що таке замикання в JavaScript?
Замикання — це функція, яка пам’ятає та може продовжувати отримувати доступ до змінних з того обсягу видимості, в якому вона була визначена, навіть після того, як зовнішня функція вже завершила свою роботу.
Додаткове питання: Де саме ви б використовували замикання?
Хороша відповідь охоплює практичне застосування, а не лише визначення:
Замикання найчастіше зустрічаються у функціях-викликах, таймерах, обробниках подій та фабриках функцій. Вони також корисні тоді, коли певний стан має залишатися приватним, а не знаходитися у глобальному просторі імен.
Ключовий висновок
Основна ідея зводиться до однієї формули: функція плюс змінні, які її оточують, дорівнюють замиканню. Якщо уявити собі зображення рюкзака — функція, яка бере з собою збережені змінні куди б її не викликали, — то весь цей концепт стає значно менш загадковим.
Розуміння циклу подій
Замикання пояснюють, як функція може зберігати дані протягом певного часу. Цикл подій пояснює, як JavaScript вирішує коли запускати код, який не готовий миттєво — і саме звідси походить багато заплутаних виводів у консолі.
Майже кожен розробник JavaScript рано чи пізно стикається з таким фрагментом коду, чи то під час співбесіди, чи випадково:
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
console.log("3");
Якщо прочитати цей код зверху вниз, можна очікувати, що консоль виведе 1, потім 2, а потім 3 — таймаут встановлений на нуль мілісекунд, тож не повинно бути жодних затримок.
Але якщо запустити його у консолі браузера чи в Node, фактичний результат буде таким:
1
3
2
То чому код, призначений для виконання „негайно“, насправді виконується після коду, написаного пізніше у файлі?
Щоб відповісти на це запитання, потрібно зрозуміти механізм, який працює під час виконання коду: цикл подій. Замість того, щоб загублюватися у формальній термінології про контексти виконання та алгоритми планування, корисно створити просту та зрозумілу уявну модель того, що насправді відбувається.
Основна суперечність у серці JavaScript
Перш ніж розглядати діаграми та визначення, є один факт, який потрібно усвідомити:
JavaScript є суто однопотоковою мовою.
Однопотоковість означає, що JavaScript працює з лише однією стеком викликів. У неї є один процесорний „мозок“, і вона може виконувати лише одну рядок коду в будь-який момент часу. Вона не може запускати дві функції паралельно та не може самостійно розділити обчислення між кількома ядрами.
Ця обмеженість ставить очевидне запитання. Уявіть код, який мусить чекати три повні секунди на відповідь повільного API — при наявності лише одного потоку та без додаткової підтримки, що ж зможе робити решта сторінки під час цього очікування?
У суто однопотоковому середовищі без жодної допомоги весь браузер зупиниться. Ви не зможете нічого клікнути, прокрутити сторінку, ввести текст у поле чи переглядати анімацію — все застигне, поки нарешті не надійде відповідь від мережі.
Але насправді так не відбувається. Сучасні веб-додатки регулярно транслюють відео, надсилають запити до мережі у фоновому режимі, тихо оновлюють статистику та приймають введення з клавіатури одночасно, не зупиняючись.
То як мова з одним потоком це досягає?
Відповідь: сам JavaScript є однопотоковим, але середовище навколо нього — браузер чи Node.js — не є таким.
Кухня ресторану як ментальна модель
Найзрозуміліший спосіб зрозуміти цю архітектуру — уявити жваву кухню фаст-фуду:
[ Customer Orders ] ──> [ The Solo Chef ] (Call Stack)
│
▼ Hands off slow jobs
[ The Kitchen Staff ] (Web APIs)
│
▼ Ready order slips
[ Order Counter ] (Queues)
▲
│ Checks constantly
[ The Floor Manager ] (The Event Loop)
Ось як відповідають один одному ролі:
- Один кухар (стек викликів). Швидкий та кваліфікований, але може працювати лише над одним замовленням за раз — завершуючи його повністю перед тим, як почати наступне.
- Помічники на кухні (Web API). Коли замовлення вимагає тривалого очікування — через таймер або повільну відповідь кур’єра — кухар не стоїть, дивлячись на годинник. Натомість він передає замовлення помічнику з інструкціями стежити за ним та повідомити, коли воно буде готове, тоді як кухар повертається до наступного завдання.
- Підсвічник замовлень (очереді повернених викликів та мікрозавдань). Як тільки таймер закінчує роботу, помічник не перериває кухаря посеред завдання, підкладаючи замовлення йому в руки. Це створить хаос. Натомість готове замовлення кладеться на підсвічник, щоб чекати.
Це і є вся система у скороченому вигляді. Тепер, маючи на увазі схему ресторану, настав час замінити ці ролі їхніми справжніми назвами в двигуні JavaScript.
Двигун називає ці чотири елементи по-різному, але кожен з них безпосередньо відповідає певній ролі у ресторані.
На малюнку нижче показано, як середовище виконання (браузер або Node.js) оточує двигун JavaScript та з’єднує ці елементи між собою.
┌───────────────────────────────────────────────────────────┐
│ BROWSER / RUNTIME │
│ │
│ ┌─────────────────┐ ┌───────────────────┐ │
│ │ CALL STACK │ │ WEB APIs │ │
│ │ (JS Engine/V8) │ │ │ │
│ │ │ ──────────> │ setTimeout() │ │
│ │ fnA() │ │ fetch() │ │
│ │ console.log() │ │ DOM Events │ │
│ └────────┬────────┘ └─────────┬─────────┘ │
│ ▲ │ │
│ │ │ Finished │
│ │ ▼ │
│ │ ┌───────────────────┐ │
│ │ │ MICROTASK QUEUE │ │
│ │ │ (Promises, VIP) │ │
│ │ └─────────┬─────────┘ │
│ │ │ │
│ EVENT LOOP ▼ │
│ "Is Stack empty?" ┌───────────────────┐ │
│ └───────────────────── │ MACROTASK QUEUE │ │
│ │ (Timers, Clicks) │ │
│ └───────────────────┘ │
└───────────────────────────────────────────────────────────┘
Стек викликів — „те, що відбувається прямо зараз“
Стек викликів — це звичайна структура типу LIFO (останній, хто додався, перший виходить), якою керує сам двигун JavaScript — це той самий механізм, що використовується у V8 у Chrome та Node, або JavaScriptCore у Safari.
Щоразу, коли ви викликаєте функцію, вона додається на вершину цього стеку. Як тільки функція повертається або завершує виконання, вона видаляється з вершини.
function stepThree() {
console.log("Done!");
}
function stepTwo() {
stepThree();
}
function stepOne() {
stepTwo();
}
stepOne();
Простеження невеликої послідовності викликів чітко демонструє цей механізм:
stepOneдодається на стек.stepOneвикликаєstepTwo, тожstepTwoтакож додається на вершину стеку.stepTwoвикликаєstepThree, тожstepThreeдодається ще вище на стеку.stepThreeвиконуєconsole.log, який виводить „Done!“, після чогоstepThreeвидаляється зі стеку.
stepThree завершується, тому він видаляється з стеку.stepTwo завершується, тому він також видаляється з стеку.stepOne завершується, тому він також видаляється з стеку.Якщо будь-яка з функцій у цьому стеку потребує, скажімо, десяти секунд інтенсивних обчислень поспіль, весь час виконання програми зупиняється на ці десять секунд. Ніщо інше не може працювати, поки стек не спорожніє — це пряма наслідок того, що у «кухаря» лише дві руки.
Web API — де насправді відбувається „очікування“
Ось що дивує багатьох людей на початку їхньої кар’єри: setTimeout, fetch та слухачі подій DOM не є функціями самої мови JavaScript.
Ви не знайдете їх ніде у специфікації ECMAScript. Їх надає середовище, яке виконує ваш код — браузер, а у випадку Node.js — поєднання біндингів на C++ та бібліотеки libuv.
Розгляньмо цей виклик:
setTimeout(callback, 2000);
Коли стек викликів виконує цей виклик setTimeout, насправді він лише просить внутрішній механізм таймера браузера розпочати відлік 2000 мілісекунд. Як тільки ця інструкція передається далі, сам setTimeout негайно видаляється зі стеку викликів, і виконання продовжується з наступного елемента вашого коду. Сам відлік відбувається повністю поза єдиним потоком JavaScript, у середовищі хоста.
Очерга макрозавдань — звичайна черга
Цю очергу також називають очергою калейборів або очергою завдань, залежно від того, кого ви запитаєте.
Як тільки Web API завершує свою фонову роботу — таймер досягає нуля, надходження мережевої відповіді закінчується, користувач щось натискає — середовище хоста поміщає відповідний калецбек у цю чергу.
Усі елементи, що знаходяться в цій черзі, поводяться пристойно: це стандартна черга FIFO (First In, First Out), де кожен елемент чекає своєї черги для виконання у стеку викликів.
Типовими прикладами макрозавдань є:
- Калецбеки, заплановані за допомогою
setTimeout()таsetInterval() - Прослуховувачі подій DOM, такі як
click,scrollчиkeydown setImmediate(), який є специфічним для Node.js
Черга мікрозавдань — прискорений шлях
Паралельно з цією звичайною чергою існує менша черга з набагато вищим пріоритетом: Черга мікрозавдань.
Будь-що, що потрапляє сюди, фактично переміщується на початок черги. Як тільки стек викликів спорожнюється, цикл подій спочатку перевіряє чергу мікрозавдань — ще до того, як розглянути те, що чекає в черзі макрозавдань.
Елементи, які потрапляють у чергу мікрозавдань, включають:
- Обробники, приєднані за допомогою
.then(),.catch()або.finally()до обіцянок - Код, який виконується після
awaitу функціїasync - Будь-що, що було заплановано за допомогою
queueMicrotask() - Функції-відповіді, викликані
MutationObserver
Основне правило, що керує циклом
Якщо позбутися метафор, цикл подій насправді є просто нескінченним циклом while, який виконується всередині середовища хоста, дотримуючись п’яти суворих кроків знову та знову:
- Спорожніть стек викликів. Продовжуйте виконувати синхронний код, поки на стеку абсолютно нічого не залишиться.
- Повністю спорожніть чергу мікрозавдань. Як тільки є мікрозавдання, яке чекає на виконання, запустіть його. Якщо його виконання призводить до появи ще одного мікрозавдання, запустіть і його. Цикл не перейде до наступного кроку, поки в черзі мікрозавдань не залишиться нічого.
- Обробіть відображення, якщо ви у браузері. Двигун перевіряє, чи настав час знову намалювати екран — зазвичай близько 60 разів на секунду, приблизно кожні 16,6 мілісекунд — і якщо так, то перераховує макет та знову намальовує екран.
- Виконайте рівно одне макрозавдання. Подивіться на чергу макрозавдань, виберіть те завдання, яке чекає найдовше, додайте його до стеку викликів та дозвольте йому виконатися до кінця.
- Почніть цикл знову з першого кроку.
Деталь, яка спричиняє плутанину, — ця асиметрія: мікрозавдання повністю виконуються, кожне з них, перш ніж цикл продовжить роботу — але макрозавдання обробляються суворо по одному, з повною перевіркою стеку та черги мікрозавдань між кожним з них.
Розбір відомої задачі з інтерв’ю
Тепер, коли механізми з’ясовані, варто розглянути фрагмент коду, який створює проблеми у великої кількості розробників:
console.log("Start");
setTimeout(() => {
console.log("Timeout Callback");
}, 0);
Promise.resolve().then(() => {
console.log("Promise Callback");
});
console.log("End");
Якщо простежити цей код рядок за рядком у всіх чотирьох компонентах, несподіваний результат стає зрозумілим.
Спочатку синхронний проходження
console.log("Start") додається до стеку, виводить "Start", а потім видаляється з нього.
Далі на стек додається setTimeout(...). Він наказує шару Web API браузера запустити таймер з інтервалом 0 мілісекунд із заданим функцією-відповіддю. Оскільки затримка дорівнює нулю, цей таймер фактично миттєво закінчує свою роботу на фоні, тож браузер негайно додає () => console.log("Timeout Callback") до черги Macrotask. Сам виклик setTimeout потім знімається зі стеку.
Після цього на стек додається Promise.resolve().then(...). Обіцянка миттєво виконується, а її обробник .then() — () => console.log("Promise Callback") — додається безпосередньо до черги Microtask. Цей виклик також знімається зі стеку.
Нарешті додається console.log("End"), який виводить „End“ та знімається зі стеку.
Коли стек спорожніє
На цьому етапі у стеку викликів більше нічого не залишається. Цикл подій прокидається та перевіряє свої дві черги: у черзі мікрозавдань знаходиться функція-відповідь Promise, а у черзі макрозавдань — функція-відповідь Timeout.
Спочатку виконуються мікрозавдання
Цикл спочатку перевіряє чергу мікрозавдань. Він знаходить функцію-відповідь Promise, додає її до стеку, і виводить „Promise Callback“. Стек знову спорожніє, і тепер черга мікрозавдань також порожня.
Потім — одне макрозавдання
Оскільки мікрозавдань більше немає, цикл нарешті звертається до черги макрозавдань. Він бере найстаріший елемент — функцію-відповідь Timeout, додає її до стеку, виводить „Timeout Callback“ та видаляє її зі стеку.
Якщо об’єднати все це, кінцева послідовність виведення буде такою:
Start
End
Promise Callback
Timeout Callback
Хоча таймаут був налаштований на 0 мілісекунд та був вказаний у коді раніше за обіцянку, спочатку вивелась функція-колбек обіцянки. Це підтверджує правило, що мікрозавдання завжди виконуються раніше за макрозавдання, незалежно від того, наскільки малим є затримка для таймера.
Це призводить до поширеного непорозуміння щодо того, що насправді обіцяє значення затримки. Багато початківців вважають, що setTimeout(fn, 1000) означає „виконати цю функцію рівно через 1 000 мілісекунд“. Насправді це не так. Цей метод гарантує лише те, що функція-колбек не буде додана до черги завдань, поки не мине принаймні 1 000 мілісекунд. Коли вона потрапляє до черги, їй все одно доводиться чекати своєї черги, поки стек викликів не спорожніє та всі поточні мікрозавдання не будуть виконані.
Отже, якщо ваша стек викликів зайнята протягом п’яти секунд виконанням ресурсоємного сортування чи обробкою зображення, функція-колбек setTimeout(fn, 100) залишатиметься у черзі без дії протягом усіх цих п’яти секунд, перш ніж цикл подій зможе до неї навіть глянути. Коротко кажучи, затримка через таймаут — це мінімальний час очікування, а не гарантований час виконання.
Ця поведінка, яка спричиняє спустошення черги, має ще один небезпечний побічний ефект, який варто розуміти окремо. Оскільки цикл подій відмовляється переходити далі, поки черга мікрозавдань не стане повністю порожньою, можливо заблокувати ваше додатку, не блокуючи при цьому синхронну стек викликів. Цей режим збою називається голодуванням мікрозавдань, іноді — голодуванням черги.
function infiniteMicrotasks() {
Promise.resolve().then(() => {
infiniteMicrotasks();
});
}
infiniteMicrotasks();
Розглянемо, що відбувається: початковий виклик завершується, і стек спорожнюється. Потім цикл подій перевіряє чергу мікрозавдань та знаходить запис у ній. Він виконує цей запис, але одночасно заплановує ще одне мікрозавдання. Цикл подій знову перевіряє чергу, бачить, що вона все ще зайнята, і виконує наступне мікрозавдання — яке, у свою чергу, також заплановує ще одне. Стек продовжує спорожнюватися після кожного мікрозавдання, але цикл подій ніколи не виходить за межі цього кроку перевірки. Він ніколи не просувається настільки далеко, щоб дозволити браузеру перерисувати інтерфейс або дістатися до елементів, які чекають у черзі макрозавдань.
Практичним наслідком є те, що сторінка здається повністю замороженою. Кліки ігноруються, анімації припиняються, і зрештою браузер може попередити про проблему з нереагуючим скриптом.
Щоб підсумувати, як кожен тип роботи планується та має пріоритет протягом часу виконання, корисно подивитися порівняльний розбір того, як синхронний код, мікрозавдання, макрозавдання та процес відображення вписуються у цей цикл, як показано на супровідній діаграмі.
Розуміння того, як працює цикл подій, — це не просто корисна інформація для співбесід; воно безпосередньо впливає на те, як слід проектувати реальні додатки, і з цього випливають три практичні звички.
По-перше, розбийте дорогі синхронні цикли. Якщо вам потрібно обробити масив із, скажімо, 500 000 елементів у основному потоці, уникайте виконання цього за допомогою одного безперервного for-циклу, адже це зупинить інтерфейс. Розділіть роботу на частини та повертайте керування циклу подій між ними за допомогою setTimeout(chunkHandler, 0) або requestIdleCallback(). Це дасть браузеру можливість обробляти кліки та оновлювати екрани між частинами роботи.
По-друге, будьте точні щодо того, що насправді роблять async/await у суті. Це синтаксичний «цукор», який покриває обіцянки: будь-який код, написаний до першого await, виконується синхронно, тоді як все, що йде після await, загортається у мікрозавдання-колбек, яке залишається призупиненим до тих пір, поки обіцянка не буде виконана.
По-третє, ніколи не вважайте таймери точними стоп-годинниками. Якщо ви створюєте щось на кшталт таймера в додатку чи аудіо-секвенцера, не покладайтеся на setInterval чи setTimeout, щоб досягти точних позначок у мілісекундах. Навантаження системи, обмеження браузером роботи фонових вкладок для економії акумулятора та завантаження в чергах — усе це може спричинити відхилення. Натомість вимірюйте пройдений час за допомогою системних годинників за допомогою performance.now().
JavaScript, який працює на одній потоці, — це не обмеження, яке потрібно обійти; це один із найсильніших дизайнерських виборів мови. Поєднуючи швидкий синхронний двигун із набором фонових API браузера та ретельно керованим циклом подій, JavaScript створює враження одночасної роботи з багатьма завданнями, уникаючи при цьому проблем з конкурентними ситуаціями, застряганнями та проблемами безпеки потоків, які супроводжують справжнє багатопотокове програмування в інших мовах.
Як тільки ви зрозумієте та почнете поважати принципи роботи черг, виконання вашого коду більше не буде несподіванкою.