Как работает event loop в браузере: задачи, микрозадачи, рендеринг
Продвинутое3 минРедакция Innovialabs
Коротко
Браузер берёт одну задачу, выполняет её до конца, затем опустошает всю очередь микрозадач и только потом может отрисовать кадр; пока работает ваш код, страница не перерисовывается и не реагирует на клики.
Короткий ответ
У страницы один основной поток. На нём выполняется JavaScript, обрабатываются события и происходит отрисовка. Event loop — это цикл, который решает, что делать дальше:
- Взять одну задачу (task) из очереди и выполнить её целиком.
- Выполнить все микрозадачи (microtasks), включая те, что появились по ходу.
- Если пришло время кадра — выполнить колбэки requestAnimationFrame, пересчитать стили и раскладку и отрисовать страницу.
- Повторить.
Отсюда главное правило: пока выполняется ваш код, браузер не рисует и не обрабатывает клики.
Стек вызовов
Когда функция вызывается, она кладётся в стек вызовов (call stack); когда возвращает результат — снимается. Event loop берёт следующую задачу только когда стек пуст. Длинная синхронная функция держит стек занятым, и всё остальное ждёт.
Задачи и микрозадачи
| Задачи (tasks) | Микрозадачи (microtasks) | |
|---|---|---|
| Откуда | setTimeout, setInterval, события пользователя, сетевые колбэки, MessageChannel | Promise.then/catch/finally, await, queueMicrotask, MutationObserver |
| Сколько за раз | Одна за оборот цикла | Вся очередь до конца |
| Рендеринг между ними | Возможен | Невозможен |
Задачка 1: порядок вывода
console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
Результат: 1, 4, 3, 2.
1и4— синхронный код текущей задачи.3— микрозадача: выполняется сразу после того, как текущая задача закончилась.2— новая задача: ждёт следующего оборота цикла.setTimeout(fn, 0)означает «не раньше чем», а не «немедленно».
Задачка 2: почему спиннер не крутится
button.addEventListener("click", () => {
spinner.hidden = false;
heavyCalculation(); // несколько секунд синхронной работы
spinner.hidden = true;
});
Спиннер так и не появится. DOM меняется, но отрисовка произойдёт только после завершения обработчика, а к тому моменту спиннер уже снова скрыт. Всё это время страница заморожена.
Задачка 3: микрозадачи тоже могут заморозить
function loop() {
Promise.resolve().then(loop);
}
loop();
Каждая микрозадача ставит новую, а очередь микрозадач выполняется до конца. Браузер никогда не дойдёт до рендеринга — вкладка зависнет так же, как от while (true). С setTimeout(loop, 0) вкладка останется живой: между задачами есть шанс отрисовать кадр.
Где место requestAnimationFrame
requestAnimationFrame(callback) вызывает функцию прямо перед отрисовкой следующего кадра. Поэтому анимации и измерения, которые должны совпасть с кадром, делают в нём, а не в setTimeout:
- колбэки rAF синхронизированы с частотой обновления экрана;
- в фоновой вкладке они обычно приостанавливаются и не тратят ресурсы;
- изменения стилей из нескольких rAF-колбэков попадают в один кадр.
Как не замораживать интерфейс
- Разбивайте длинную работу на части и отдавайте управление между ними — через
setTimeoutилиscheduler.yield()там, где он поддерживается. Так браузер успевает обработать клики и отрисовать кадр. - Тяжёлые вычисления — в Web Worker. Воркер работает в отдельном потоке и общается с основным через сообщения.
- Не смешивайте чтение и запись раскладки в цикле: чтение
offsetHeightпосле изменения стилей заставляет браузер синхронно пересчитать layout. - Обновляйте UI до тяжёлой работы, если нужно показать индикатор: сначала покажите его, дождитесь кадра, затем запускайте вычисления.
async function run() {
spinner.hidden = false;
await new Promise((r) => requestAnimationFrame(() => setTimeout(r, 0)));
heavyCalculation();
spinner.hidden = true;
}
Ожидание rAF и затем setTimeout гарантирует, что кадр со спиннером успеет отрисоваться. Но интерфейс всё равно замрёт на время расчёта — по-настоящему решает задачу только Web Worker или разбиение работы на части.
Как это увидеть
Вкладка Performance в DevTools показывает задачи на основном потоке. Задачи длиннее примерно 50 мс помечаются как long tasks — именно они делают интерфейс неотзывчивым и ухудшают метрику INP.