Решать JS задачи

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), обрабатывается как микротаска.

Основной порядок выполнения

1

Синхронный код

Сначала выполняются все синхронные задачи из стека вызовов (Call Stack).

2

Очередь микротасков

После завершения синхронного кода выполняются все микротаски из очереди. Все микротаски выполняются до того, как браузер возьмет следующую макрозадачу. Если микротаска добавляет новую микротаску, она тоже выполнится в этом же цикле.

3

Рендеринг (при необходимости)

Браузер проверяет, нужно ли выполнить перерисовку страницы. Если есть изменения DOM или стилей, выполняются: requestAnimationFrame callbacks, пересчет стилей, layout, paint.

4

Одна макрозадача

Из очереди макротасков берется одна макрозадача и выполняется. Затем цикл повторяется с шага 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, postMessage
  • setImmediate (Node.js)
  • I/O операции (Node.js)

Микротаски:

  • .then(), .catch(), .finally() (Promise)
  • async/await (продолжение после await)
  • queueMicrotask()
  • MutationObserver
  • process.nextTick() (Node.js, приоритетнее других микротасков)

Базовый пример

console.log("1");

setTimeout(() => {
  console.log("2");
}, 0);

Promise.resolve().then(() => console.log("3"));

console.log("4");

Результат: 1, 4, 3, 2

Разбор:

  1. console.log("1") — синхронный код, выполняется сразу
  2. setTimeout — callback отправляется в очередь макротасков
  3. Promise.resolve().then(...) — callback отправляется в очередь микротасков
  4. console.log("4") — синхронный код, выполняется сразу
  5. Call Stack пуст — выполняются микротаски: выводится 3
  6. Микротаски закончились — берется макротаска: выводится 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

Разбор:

  1. console.log("3") — синхронный код
  2. Вызов foo(): console.log("1") выполняется синхронно
  3. await Promise.resolve() приостанавливает foo. Все, что после await, становится микротаской
  4. console.log("4") — синхронный код продолжается
  5. 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).

Связанные статьи

Решать JS задачи