Event Loop JS. Полный разбор Microtasks & Macrotasks для собеседований
Event Loop — это механизм в JavaScript, который управляет асинхронными задачами и очередями событий. Он позволяет JavaScript работать в однопоточной модели, обрабатывая асинхронные операции, не блокируя основной поток выполнения.
Как работает Event Loop?
Event Loop — это бесконечный цикл, в котором выполняются обработчики событий. В процессе его работы браузер распределяет задачи по двум основным очередям:
- Микротаски — промисы (
.then,.catch,.finally),queueMicrotask(),MutationObserver. - Макротаски —
setTimeout,setInterval, события DOM (click,input),MessageChannel,requestAnimationFrame.
fetch — это не макротаска:
Распространенная ошибка на собеседованиях: fetch относят к макротаскам. На самом деле fetch() возвращает Promise. Сетевой запрос выполняется в Web API, но когда ответ приходит, callback из .then() попадает в очередь микротасков, а не макротасков. Запомните: все, что работает через Promise (fetch, async/await), обрабатывается как микротаска.
Основной порядок выполнения
Синхронный код
Сначала выполняются все синхронные задачи из стека вызовов (Call Stack).
Очередь микротасков
После завершения синхронного кода выполняются все микротаски из очереди. Все микротаски выполняются до того, как браузер возьмет следующую макрозадачу. Если микротаска добавляет новую микротаску, она тоже выполнится в этом же цикле.
Рендеринг (при необходимости)
Браузер проверяет, нужно ли выполнить перерисовку страницы. Если есть изменения DOM или стилей, выполняются: requestAnimationFrame callbacks, пересчет стилей, layout, paint.
Одна макрозадача
Из очереди макротасков берется одна макрозадача и выполняется. Затем цикл повторяется с шага 2.
Структуры данных
Call Stack (LIFO — Last In, First Out) — стек вызовов содержит все функции, которые выполняются в текущий момент. Когда вызывается функция, она добавляется в стек. После выполнения удаляется. Если внутри функции вызывается другая функция, она добавляется на вершину стека.
Web API — асинхронные операции (setTimeout, обработчики событий, сетевые запросы) передаются в Web API. Это среда, предоставляемая браузером (или Node.js). После завершения асинхронной операции callback помещается в соответствующую очередь.
Task Queue / Callback Queue (FIFO — First In, First Out) — здесь хранятся callback-функции макротасков. Event Loop перемещает задачи из очереди в Call Stack, когда стек пуст.
Microtask Queue — отдельная очередь для микротасков. Имеет приоритет над Task Queue. Полностью опустошается после каждой синхронной задачи или макрозадачи.
Микротаски vs Макротаски
Макротаски:
setTimeout,setInterval- события DOM (
click,input,load) MessageChannel,postMessagesetImmediate(Node.js)- I/O операции (Node.js)
Микротаски:
.then(),.catch(),.finally()(Promise)async/await(продолжение после await)queueMicrotask()MutationObserverprocess.nextTick()(Node.js, приоритетнее других микротасков)
Базовый пример
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
Результат: 1, 4, 3, 2
Разбор:
console.log("1")— синхронный код, выполняется сразуsetTimeout— callback отправляется в очередь макротасковPromise.resolve().then(...)— callback отправляется в очередь микротасковconsole.log("4")— синхронный код, выполняется сразу- Call Stack пуст — выполняются микротаски: выводится
3 - Микротаски закончились — берется макротаска: выводится
2
Важное замечание:
Микротаски имеют приоритет перед макротасками. Даже если setTimeout вызван с задержкой 0, его callback выполнится только после всех микротасков.
Сложный пример (частый на собеседованиях)
console.log("1");
setTimeout(() => {
console.log("2");
Promise.resolve().then(() => console.log("3"));
}, 0);
Promise.resolve().then(() => {
console.log("4");
setTimeout(() => console.log("5"), 0);
});
setTimeout(() => console.log("6"), 0);
Promise.resolve().then(() => console.log("7"));
console.log("8");
Результат: 1, 8, 4, 7, 2, 3, 6, 5
Разбор по шагам:
Синхронный код: выводится 1 и 8.
Микротаски (первый цикл): выводится 4 (первый Promise), затем 7 (второй Promise). Внутри callback 4 создается setTimeout с console.log("5") — он уходит в очередь макротасков.
Первая макрозадача: setTimeout с console.log("2"). Выводится 2. Внутри создается Promise с console.log("3") — это микротаска, выполняется сразу: выводится 3.
Вторая макрозадача: setTimeout с console.log("6"). Выводится 6.
Третья макрозадача: setTimeout с console.log("5") (созданный внутри микротаски). Выводится 5.
requestAnimationFrame
requestAnimationFrame (rAF) занимает особое место в Event Loop. Он выполняется после микротасков, но перед макротасками, непосредственно перед рендерингом.
setTimeout(() => console.log("setTimeout"), 0);
requestAnimationFrame(() => console.log("rAF"));
Promise.resolve().then(() => console.log("Promise"));
console.log("sync");
Ожидаемый результат: sync, Promise, rAF, setTimeout
На практике порядок rAF и setTimeout может варьироваться в разных браузерах, но rAF всегда выполняется перед следующим рендер-циклом. Используйте requestAnimationFrame для анимаций и работы с DOM.
Голодание макротасков (Starvation)
Если микротаски бесконечно добавляют новые микротаски, макротаски и рендеринг никогда не выполнятся. Это называется starvation.
function recursiveMicrotask() {
Promise.resolve().then(() => {
console.log("микротаска");
recursiveMicrotask();
});
}
recursiveMicrotask();
setTimeout(() => console.log("это никогда не выполнится"), 0);
Браузер зависнет, потому что очередь микротасков никогда не опустеет. Рендеринг заблокирован, setTimeout не выполнится. На собеседовании могут спросить: "Может ли Promise заблокировать рендеринг?" Ответ: да, если микротаски добавляют новые микротаски бесконечно.
async/await и Event Loop
На собеседованиях часто спрашивают, как async/await работает в контексте Event Loop. Код после await превращается в callback внутри .then(), то есть попадает в очередь микротасков.
async function foo() {
console.log("1");
await Promise.resolve();
console.log("2");
}
console.log("3");
foo();
console.log("4");
Результат: 3, 1, 4, 2
Разбор:
console.log("3")— синхронный код- Вызов
foo():console.log("1")выполняется синхронно await Promise.resolve()приостанавливаетfoo. Все, что после await, становится микротаскойconsole.log("4")— синхронный код продолжается- Call Stack пуст — выполняется микротаска:
console.log("2")
Ключевой момент: await не блокирует основной поток. Он "разрезает" функцию на две части: до await (синхронно) и после await (микротаска).
queueMicrotask
queueMicrotask() позволяет явно добавить задачу в очередь микротасков. Полезно, когда нужно отложить выполнение кода, но выполнить его раньше любых макротасков.
console.log("1");
queueMicrotask(() => console.log("2"));
setTimeout(() => console.log("3"), 0);
queueMicrotask(() => console.log("4"));
console.log("5");
Результат: 1, 5, 2, 4, 3
queueMicrotask имеет тот же приоритет, что и .then(). Обе микротаски (2 и 4) выполнятся до макротаски setTimeout (3).