Событийный цикл детально
Как браузер исполняет JS: call stack, microtask- и macrotask-очереди и почему порядок именно такой.
Event loop (событийный цикл) — механизм, который по очереди забирает задачи из очередей и выполняет их в одном потоке, создавая иллюзию параллельности.
Один поток, но не блокирующий
JavaScript исполняется в одном потоке: в каждый момент работает ровно одна функция. Но при этом интерфейс не «висит» во время сетевых запросов и таймеров. Секрет — в том, что долгие операции отдаются окружению (браузеру или Node), а их колбэки возвращаются в JS позже, через очереди. Дирижирует этим event loop.
Чтобы понимать порядок выполнения, держите в голове три сущности:
- Call stack — стек вызовов. Сюда кладутся вызовы функций; пока он не опустеет, ничего из очередей не запускается.
- Очередь macrotask (задачи): колбэки
setTimeout,setInterval, события I/O. - Очередь microtask (микрозадачи): колбэки
.then/.catch/.finallyу промисов,queueMicrotask,await.
Главное правило порядка
Алгоритм цикла можно описать так:
- Выполнить весь синхронный код (опустошить call stack).
- Полностью опустошить очередь microtask (включая микрозадачи, которые добавились по ходу).
- Взять одну macrotask, выполнить её.
- Снова опустошить все microtask. Повторять.
Ключевой вывод: микрозадачи всегда выполняются раньше следующей макрозадачи. Проверим на «что выведет»:
console.log("1: синхронно");
setTimeout(() => console.log("2: setTimeout (macrotask)"), 0);
Promise.resolve().then(() => console.log("3: Promise (microtask)"));
console.log("4: синхронно");Вывод:
1: синхронно 4: синхронно 3: Promise (microtask) 2: setTimeout (macrotask)
Сначала отработал весь синхронный код (строки 1 и 4). Потом — микрозадача (3). И только в конце — макрозадача из setTimeout (2), хотя задержка была нулевой. Ноль миллисекунд означает «как можно скорее», но после текущего синхронного кода и всех микрозадач.
Микрозадачи опустошаются полностью
Цепочка .then().then() добавляет микрозадачи одна за другой, и все они отработают до любого таймера:
console.log("старт");
setTimeout(() => console.log("timeout 1"), 0);
Promise.resolve()
.then(() => console.log("promise 1"))
.then(() => console.log("promise 2"));
setTimeout(() => console.log("timeout 2"), 0);
console.log("конец");Вывод:
старт конец promise 1 promise 2 timeout 1 timeout 2
Обе микрозадачи (promise 1, promise 2) вышли раньше обоих таймеров, потому что очередь microtask опустошается целиком перед тем, как взять первую macrotask.
Микрозадача, рождённая внутри микрозадачи
Если внутри .then запланировать новый таймер, он попадёт в очередь macrotask и выполнится позже остальных синхронных микрозадач:
setTimeout(() => console.log("A timeout"), 0);
Promise.resolve().then(() => {
console.log("B promise");
setTimeout(() => console.log("C timeout внутри promise"), 0);
});
Promise.resolve().then(() => console.log("D promise"));
console.log("E sync");Вывод:
E sync B promise D promise A timeout C timeout внутри promise
Порядок: синхронный E; затем обе микрозадачи B и D; затем макрозадача A (она была поставлена раньше); и последней — C, добавленная уже по ходу.
Зачем это знать на практике
- Тяжёлый синхронный цикл блокирует отрисовку и обработку кликов — поток один. Разбивайте долгие вычисления.
- Лавина микрозадач (например, рекурсивный
queueMicrotask) тоже способна «заморозить» страницу: до отрисовки очередь обязана опустеть. - Понимание порядка снимает «магию» багов вида «почему мой
console.logвывелся не там».
Итог
- JS однопоточен; event loop тасует задачи из очередей.
- Синхронный код → все microtask → одна macrotask → снова все microtask.
- Промисы (microtask) всегда обгоняют
setTimeout(macrotask).