Event Loop: мікрозадачі vs макрозадачі
Event Loop (цикл подій) - це планувальник задач в JavaScript: спочатку виконується весь синхронний код, потім повністю вичерпується черга мікрозадач (Promise, queueMicrotask), за потреби відбувається рендеринг, потім береться одна макрозадача (setTimeout, I/O), і так по колу.
Теорія
TL;DR
- Аналогія: один кухар (call stack), термінові замовлення (мікрозадачі), звичайні замовлення (макрозадачі). Усі термінові виконуються перед тим як береться наступне звичайне.
- Мікрозадачі виконуються пачкою після кожного синхронного блоку. Макрозадачі по одній, між ними браузер може малювати.
- Мікрозадачі блокують рендеринг, якщо постійно ставлять одна одну в чергу. Одинарний
setTimeoutцього не робить. - Потрібен результат до наступного paint? Мікрозадача. Може почекати кадр? Макрозадача.
Швидкий приклад
console.log('1. Sync');
setTimeout(() => console.log('2. Macrotask'), 0);
Promise.resolve()
.then(() => console.log('3. Microtask'));
console.log('4. Sync');
// Вивід:
// 1. Sync
// 4. Sync
// 3. Microtask
// 2. MacrotaskСинхронний код виконується зверху вниз. Потім мікрозадачі. Потім макрозадачі - навіть якщо затримка в таймері 0ms.
Головна різниця
Черга мікрозадач завжди вичерпується повністю до того як відбудеться щось інше. Якщо мікрозадача ставить у чергу іншу мікрозадачу, та теж виконається в цьому ж циклі, до будь-якого рендеру чи макрозадачі. З макрозадачами інакше: браузер бере рівно одну за цикл, перевіряє чи треба малювати, і лише потім знову перевіряє мікрозадачі. Саме тому сотня ланцюжкових Promise може заморозити UI, а цикл setTimeout(..., 0) не може.
Коли що використовувати
- Мікрозадачі: оновлення стану, які мають відобразитись до наступного кадру (React batching, Vue реактивність), вимірювання DOM, валідація що йде в той самий рендер
- Макрозадачі: таймери, I/O (
fetch, читання файлів), обробники подій (click, scroll), все що може поступитися браузеру між кадрами
Якщо робота має завершитись до наступного paint, використовуй мікрозадачу. Якщо може почекати кадр, підійде макрозадача.
Таблиця порівняння
| Мікрозадачі | Макрозадачі | |
|---|---|---|
| Приклади | Promise.then, queueMicrotask(), MutationObserver | setTimeout, setInterval, fetch, DOM events |
| Момент виконання | Після поточного скрипту, до рендеру | По одній за цикл, після перевірки рендеру |
| Блокує рендеринг | Так, якщо їх багато | Ні, браузер малює між ними |
| Поведінка черги | Повністю вичерпується за цикл | Одна задача за цикл |
| Коли використовувати | Оновлення стану, DOM-читання, валідація | Затримки, I/O, взаємодії користувача |
Як це працює всередині
Рушій JavaScript тримає дві окремі черги. Після очищення call stack запускається MicrotaskQueue::RunMicrotasks() і це продовжується поки черга порожня. Тільки потім браузер вирішує чи треба перемалювати сторінку. Тільки потім береться одна макрозадача. Мікрозадачі є частиною поточного контексту виконання. Макрозадачі являють собою нову роботу ззовні.
Типові помилки
Помилка 1: вважати що setTimeout(..., 0) виконується одразу
setTimeout(() => console.log('швидко'), 0);
console.log('повільно');
// Вивід: повільно, швидко
// setTimeout - макрозадача. Вона завжди чекає поки вичерпається черга мікрозадач.Якщо потрібно виконати щось одразу після поточного скрипту, використовуй Promise.resolve().then() або queueMicrotask(), а не setTimeout.
Помилка 2: рекурсивні мікрозадачі заморожують UI
// Погано: браузер ніколи не отримує черги на рендер
function starveUI() {
function loop() {
Promise.resolve().then(loop); // ставить себе у мікрочергу
}
loop();
}
// Виправлення: поступайся через макрозадачу
function safeLoop() {
function step() {
console.log('step');
setTimeout(step, 0); // між кроками браузер може малювати
}
step();
}Помилка 3: думати що await в циклі поступається браузеру
// Неправильно: await resolved Promise - це мікрозадача, рендер не відбудеться
async function processItems(items) {
for (const item of items) {
await Promise.resolve(); // мікрозадача, рендеринг НЕ відбувається
expensiveCalculation(item);
}
}
// Правильно: yield через setTimeout, тоді браузер встигає намалювати кадр
async function processItems(items) {
for (const item of items) {
await new Promise(r => setTimeout(r, 0)); // макрозадача, браузер може рендерити
expensiveCalculation(item);
}
}Помилка 4: ігнорувати вартість MutationObserver у щільних циклах
// Погано: 1000 мутацій DOM = 1000 колбеків у мікрочерзі до рендеру
const observer = new MutationObserver(() => updateUI());
for (let i = 0; i < 1000; i++) {
element.textContent = i;
}
// Виправлення: одна зміна DOM = один колбек
element.textContent = 999;Помилка 5: вважати що Node.js і браузер поводяться однаково
У браузерах і Node.js v11+ мікрозадачі завжди виконуються перед наступною макрозадачею. У Node.js v10 і раніше колбеки таймерів могли виконуватись раніше мікрозадач в певних фазах циклу подій. Якщо підтримуєш старіші версії Node, перевіряй поведінку там, а не роби припущення.
Де це використовується
- React: збирає кілька
setStateчерезPromise.resolve().then()і застосовує їх одним рендером замість кількох - Vue: реактивні оновлення проходять через
Promise.then()з тієї самої причини - Node.js streams: між порціями даних використовуються макрозадачі щоб backpressure не блокував цикл подій
- Intersection Observer: колбеки виконуються як макрозадачі, тому браузер встигає малювати між спостереженнями
- Express: async-middleware виконується в черзі мікрозадач у рамках одного запиту
На практиці баг зі starvation зустрічається в непомітній формі: пишеш await somePromise в циклі й думаєш що поступаєшся браузеру, але якщо Promise вже resolved, це мікрозадача. Рендер так і не відбудеться.
Питання на співбесіді
Q: Чому мікрозадачі виконуються раніше макрозадач?
A: Мікрозадачі є роботою поточного контексту виконання (Promise, що вирішується після синхронного коду). Макрозадачі являють нову роботу ззовні (таймер спрацював, прийшла відповідь мережі). Вичерпати мікрозадачі першими означає завершити весь поточний async-код до того як братися за щось нове.
Q: Що відбувається якщо мікрозадача ставить у чергу іншу мікрозадачу?
A: Вона виконується в тому ж циклі. Event loop не переходить до макрозадач поки мікрочерга не спустіє повністю. Саме так і виникає starvation.
Q: Як async/await вписується в цю картину?
A: async/await є синтаксисом над Promise. Коли ти пишеш await, функція зупиняється і її залишок ставиться в мікрочергу. Вона відновлюється як мікрозадача, не як макрозадача.
Q: У чому різниця між queueMicrotask() і Promise.resolve().then()?
A: Обидва ставлять колбек у ту ж мікрочергу. queueMicrotask() не створює об'єкт Promise, тому трохи дешевший. Використовуй його коли не потрібні chaining або обробка помилок.
Q: (Senior) React-застосунок пропускає кадри під час великого оновлення даних. Підозра на накопичення мікрозадач. Що робиш?
A: Переносиш дорогу роботу в макрозадачі через setTimeout(..., 0), браузер встигає рендерити між порціями. У React 18+ обгортаєш несрочні оновлення в startTransition(), що дозволяє React переривати роботу і поступатися браузеру. Для точного контролю requestIdleCallback() запускає роботу тільки коли браузер вільний, а requestAnimationFrame() прив'язує роботу до циклу відмальовування.
Приклади
Базовий: порядок виконання
console.log('start');
setTimeout(() => console.log('timeout'), 0);
Promise.resolve()
.then(() => console.log('promise 1'))
.then(() => console.log('promise 2'));
console.log('end');
// Вивід:
// start
// end
// promise 1
// promise 2
// timeoutОбидва .then() виконуються до setTimeout тому що кожен наступний .then() в ланцюжку ставить нову мікрозадачу одразу після вирішення попередньої. Весь ланцюжок вичерпується до того як макрозадача отримує чергу.
Middle: батчинг стану в React
function handleClick() {
setCount(c => c + 1); // ставиться в мікрочергу
setCount(c => c + 1); // ставиться в мікрочергу
console.log('обробник виконано');
// стан ще не оновився в цей момент
}
// React збирає обидва оновлення в мікрочерзі
// і застосовує їх разом після завершення обробника.
// Результат: один ре-рендер замість двох.React використовує Promise.resolve().then() всередині щоб зібрати всі зміни стану з одного обробника події і застосувати їх за один прохід рендеру. Без цього кожен setCount викликав би окремий рендер.
Senior: starvation мікрозадач і правильне yielding
// Погано: повністю заморожує браузер
function starve() {
function tick() {
Promise.resolve().then(tick);
}
tick();
// Кожна мікрозадача ставить наступну.
// Рендер ніколи не відбувається.
}
// Краще: поступається браузеру між ітераціями
function safeYield() {
function tick() {
// якась робота
setTimeout(tick, 0); // браузер може перемалювати між викликами
}
tick();
}
// React 18+: startTransition для несрочних оновлень
import { startTransition } from 'react';
startTransition(() => {
setItems(buildLargeList()); // React може призупинити і намалювати проміжний стан
});startTransition позначає оновлення як несрочне. React може перервати роботу посередині, намалювати те що є, і продовжити. Сторінка залишається відзивчивою замість зависання на весь час обчислення.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.