Как работает Event Loop и очередь задач в JavaScript

Понимание того, как JavaScript выполняет код, является фундаментальным для написания производительных приложений. Многие разработчики сталкиваются с ситуацией, когда код выполняется не в том порядке, в котором они ожидали, особенно при работе с асинхронными операциями. В этой статье мы разберем механизм Event Loop, разницу между микрозадачами и макрозадачами, а также то, как это влияет на рендеринг страницы.

Однопоточность и асинхронность

JavaScript является однопоточным языком. Это означает, что он может выполнять только одну операцию за раз. Если бы не было механизма асинхронности, любой долгий запрос к серверу или тяжелое вычисление замораживали бы весь интерфейс. Для решения этой проблемы используется Event Loop (цикл событий), который координирует выполнение кода, колбэков и событий.

В основе лежит Call Stack (стек вызовов). Когда функция вызывается, она помещается в стек. Когда функция завершается, она убирается из стека. Но что происходит с setTimeout или fetch? Они не выполняются сразу. Они передаются в Web API (в браузере) или C++ API (в Node.js), а их колбэки попадают в очереди задач.

Микрозадачи против Макрозадач

Самый важный нюанс для разработчика — различие между очередью микрозадач (Microtasks) и очередью макрозадач (Macrotasks).

  • Макрозадачи: setTimeout, setInterval, setImmediate (Node.js), I/O операции, UI рендеринг.
  • Микрозадачи: Promise.then/catch/finally, queueMicrotask, MutationObserver.

Правило Event Loop гласит: после выполнения всего синхронного кода из стека, сначала выполняется вся очередь микрозадач, и только потом — одна макрозадача. После одной макрозадачи цикл повторяется.

console.log('1. Старт');

setTimeout(() => {
console.log('2. Timeout (Macrotask)');
}, 0);

Promise.resolve().then(() => {
console.log('3. Promise (Microtask)');
});

console.log('4. Конец');

// Вывод:
// 1. Старт
// 4. Конец
// 3. Promise (Microtask)
// 2. Timeout (Macrotask)

Влияние на рендеринг и производительность

Понимание очередей критично для оптимизации UI. Браузер пытается обновлять экран (paint) после каждой макрозадачи. Если вы заполните очередь микрозадач бесконечным циклом промисов, рендеринг никогда не произойдет, и браузер «зависнет».

// Плохой пример: блокировка рендеринга
function blockRender() {
  Promise.resolve().then(blockRender);
}
blockRender(); // Браузер не сможет отрисовать кадр

// Хороший пример: использование setTimeout для разгрузки
function yieldToBrowser() {
  // Даем браузеру отрисовать кадр
  setTimeout(() => {
    // Продолжаем работу
  }, 0);
}

Используйте микрозадачи для атомарных операций, которые должны выполниться до перерисовки (например, обновление состояния перед рендером React). Используйте макрозадачи для отложенных вычислений, чтобы не блокировать основной поток.