JavaScript выполняет код в одном потоке: в каждый момент работает только одна функция. Сетевые запросы, таймеры и клики при этом не блокируют страницу, потому что их ожидание берёт на себя окружение (браузер или Node), а результаты возвращаются в виде задач в очередь. Цикл, который разбирает эти очереди, и есть event loop.
Стек вызовов
Стек вызовов (call stack) хранит функции, которые выполняются прямо сейчас. Вызвали функцию: она легла на стек. Вернулась: снялась. Пока стек не пуст, ничего другого выполниться не может: ни таймер, ни обработчик клика, ни отрисовка. Поэтому долгий синхронный цикл замораживает страницу.
Задачи и микрозадачи
Асинхронные колбэки попадают в две разные очереди.
Задачи (macrotasks): setTimeout, setInterval, события ввода (клик, клавиатура), сообщения postMessage и MessageChannel, завершение сетевых операций. Строго говоря, в браузере таких очередей несколько, и браузер может отдавать приоритет вводу пользователя, но порядок внутри одного источника сохраняется.
Микрозадачи (microtasks): колбэки then/catch/finally, продолжение после await, queueMicrotask, MutationObserver.
Один оборот цикла
- Взять одну задачу из очереди и выполнить её до конца (до пустого стека).
- Выполнить все микрозадачи, включая те, что добавились по ходу.
- Если пора, отрисовать кадр: колбэки
requestAnimationFrame, пересчёт стилей, layout, paint. - Перейти к следующей задаче.
Сам запуск скрипта тоже задача. Отсюда главное правило порядка: синхронный код, затем все микрозадачи, затем следующая задача.
console.log('1');
setTimeout(() => console.log('2'));
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 1 4 3 2
Опасность микрозадач
Очередь микрозадач опустошается полностью. Если микрозадача постоянно добавляет новую, цикл никогда не дойдёт до следующей задачи и до отрисовки: страница зависнет так же, как от while (true). С setTimeout такой рекурсии не было бы: между задачами браузер успевает отрисовать кадр.
Таймеры
setTimeout(fn, 0)не значит «сразу». Это «не раньше чем через 0 мс, отдельной задачей после текущей и всех микрозадач».- Задержка минимальная, а не точная: если поток занят, таймер подождёт.
- При вложенности таймеров глубже 5 уровней браузер поднимает минимальную задержку до 4 мс, а в фоновых вкладках таймеры сильно замедляются.
- Таймеры с одинаковой задержкой срабатывают в порядке постановки.
requestAnimationFrame
requestAnimationFrame(cb) выполняет колбэк прямо перед следующей отрисовкой, обычно 60 раз в секунду (или с частотой экрана). Для анимаций это лучше таймера: нет лишних кадров и рывков, в скрытой вкладке не вызывается.
Долгие задачи
Задача длиннее 50 мс считается долгой (long task): пользователь чувствует задержку реакции, страдает метрика INP. Решения:
- разбить работу на куски и отдавать управление между ними (
setTimeout,scheduler.yield()в Chromium); - вынести вычисления в Web Worker, у которого свой поток и свой event loop;
- для низкоприоритетной работы
requestIdleCallback.
Отличия Node.js
В Node event loop реализован в libuv и разбит на фазы: timers, pending callbacks, poll (ввод-вывод), check (setImmediate), close callbacks. Микрозадачи выполняются после каждого колбэка. Ещё есть process.nextTick: его очередь выполняется даже раньше промисов. Порядок setTimeout(fn, 0) и setImmediate в главном модуле не гарантирован, а внутри колбэка ввода-вывода setImmediate всегда первый. Подробнее в треке Node.js.
Частые ошибки
- Считать, что
setTimeout(fn, 0)выполнится раньше промиса. - Думать, что
async-функция целиком асинхронна. Код до первогоawaitвыполняется синхронно. - Ожидать, что
awaitуступит место таймерам. Продолжение послеawaitэто микрозадача, она идёт раньше любого таймера. - Пытаться «подождать отрисовку» через промис: микрозадачи выполняются до рендера.
Что спросят на собесе
- Как работает event loop, что такое стек, задачи и микрозадачи.
- Что выведет код с
setTimeout,Promise.thenиasync/await. - Почему бесконечные микрозадачи вешают страницу, а бесконечные таймеры нет.
- Где в цикле происходит отрисовка и что такое
requestAnimationFrame. - Чем event loop в Node отличается от браузерного.