Цикл событий JavaScript, микрозадачи и макрозадачи
Узнайте, как работает цикл событий JavaScript и как планируются микрозадачи (Promise) и макрозадачи (таймеры, события), с примерами кода.
JavaScript выполняет ваш код в одном потоке: всё происходит последовательно, сверху вниз. Тем не менее он способен получать данные, запускать таймеры и реагировать на клики, не зависая. Механизм, который делает это возможным, — цикл событий, а работа, которую он планирует, делится на два вида задач: микрозадачи и макрозадачи. На этой странице объясняется, что представляет собой каждый из них, в каком порядке они выполняются и какие подводные камни встречаются на практике — все примеры можно запустить самостоятельно и проверить вывод.
Как работает цикл событий
Цикл событий — это планировщик, который решает, какой код выполнять следующим. Чтобы понять его устройство, достаточно трёх составляющих:
- Стек вызовов — здесь фактически выполняется ваш код. Функции добавляются в стек при вызове и извлекаются по завершении. JavaScript выполняет всё, что находится в стеке, до конца, прежде чем перейти к чему-то другому; это правило называется «выполнение до завершения».
- Куча — область памяти, где хранятся ваши объекты. Непосредственно в планировании не участвует, но традиционно упоминается как третья составляющая.
- Очереди задач — ожидающая работа, которая выполнится, как только стек опустеет. Их две: очередь макрозадач (таймеры, события UI, I/O) и очередь микрозадач (колбэки Promise и
queueMicrotask).
Один оборот цикла событий выглядит следующим образом:
- Выполнить текущую задачу в стеке до его полного опустошения.
- Опустошить всю очередь микрозадач — включая микрозадачи, добавленные в процессе опустошения.
- (В браузере) применить ожидающие визуальные обновления.
- Взять одну макрозадачу из очереди макрозадач и выполнить её, затем вернуться к шагу 2.
Ключевая асимметрия: после каждой макрозадачи движок опустошает все микрозадачи, но берёт из очереди макрозадач лишь одну за итерацию. Это единственное правило объясняет почти все неожиданности в порядке выполнения кода.
Вот простейшая демонстрация с использованием setTimeout для планирования макрозадачи:
В этом примере:
console.log('Start');выполняется первым и выводит «Start» в консоль.setTimeoutпланирует колбэк на выполнение не ранее чем через 1000 миллисекунд. Он возвращает управление немедленно и не блокирует следующие строки.console.log('End');выполняется сразу и выводит «End».- Только после завершения синхронного скрипта (и истечения задержки) цикл событий извлекает колбэк
setTimeoutиз очереди макрозадач и выполняет его, выводя «Timeout Callback».
Результат вывода: Start, End, затем Timeout Callback — колбэк таймера ждёт, даже если он написан в середине кода. Колбэк setTimeout является макрозадачей: он выполняется только после того, как текущий скрипт и все ожидающие микрозадачи завершатся. Именно это обеспечивает отзывчивость страницы — синхронный код никогда не ждёт таймера или сетевого запроса.
Микрозадачи и макрозадачи
Что такое макрозадачи?
Макрозадача (также называемая просто «задачей») — это отдельная самодостаточная единица работы, которую движок берёт на выполнение один раз за итерацию цикла. Распространённые источники:
setTimeout/setInterval: таймеры, запускающие колбэк с задержкой или периодически.- События DOM: обработчик
click,scrollилиinput. - I/O: сетевые ответы, чтение файлов и т. п.
Движок выполняет ровно одну макрозадачу, затем опустошает все микрозадачи, затем (в браузере) при необходимости выполняет отрисовку — и только потом берёт следующую макрозадачу. Таким образом, макрозадачи никогда не выполняются подряд без промежуточного опустошения очереди микрозадач.
Что такое микрозадачи?
Микрозадача — это небольшая работа, которую движок хочет выполнить сразу после завершения текущего блока кода — до перехода к следующей макрозадаче или к отрисовке. Их источники:
- Колбэки Promise: функции, переданные в
.then(),.catch()и.finally(), а также телоasync-функции послеawait. queueMicrotask(fn): встроенная функция, напрямую помещающая функцию в очередь микрозадач.
Ключевое отличие: после текущей задачи движок опустошает всю очередь микрозадач, прежде чем сделать что-либо ещё. Если микрозадача планирует ещё одну микрозадачу, та тоже выполняется в рамках того же опустошения — до того, как следующая макрозадача получит своё время.
Практические примеры кода
Пример 1: таймер — это макрозадача
Допустим, вы хотите показать сообщение через 2 секунды. Строка с планированием выполняется сейчас; колбэк помещается в очередь макрозадач и ждёт, пока не истечёт задержка и стек не опустеет.
Пояснение: setTimeout возвращает управление мгновенно, поэтому оба вызова console.log вне него выполняются первыми. Колбэк является макрозадачей, которая выполнится только после того, как синхронный скрипт завершится и сработает таймер. В браузере внутри колбэка обычно обновляют DOM, например: document.getElementById('message').textContent = 'Hello there!';.
Пример 2: колбэк Promise — это микрозадача
Колбэк .then() уже разрешённого Promise не выполняется встроенно — он помещается в очередь микрозадач и запускается, как только текущий синхронный код завершится.
Пояснение: вывод будет следующим: Before the promise, After the promise, затем Promise resolved (microtask). Несмотря на то что Promise уже разрешён, его колбэк .then() ждёт в очереди микрозадач, пока не завершится синхронный код — и только потом выполняется, опережая любой таймер.
Подробнее о приоритете микро- и макрозадач
Микрозадачи всегда имеют более высокий приоритет, чем макрозадачи. После завершения текущего скрипта движок опустошает все ожидающие микрозадачи, прежде чем взяться за какую-либо макрозадачу — даже setTimeout(..., 0), запланированный раньше. Обратите внимание в примере ниже: цепочечный Promise 2, созданный внутри микрозадачи, всё равно выполняется раньше обоих таймеров, поскольку очередь микрозадач полностью опустошается до перехода цикла к следующему шагу.
Ожидаемый вывод:
Start
End
Promise 1
Promise 2
Timeout 1
Timeout 2Это показывает, что микрозадачи выполняются сразу после синхронного кода, даже раньше таймеров, запланированных на тот же момент. Такой приоритет означает, что обновления на основе Promise применяются как можно скорее.
Подводный камень: истощение микрозадач
Поскольку движок опустошает всю очередь микрозадач перед следующей макрозадачей или отрисовкой, микрозадача, постоянно порождающая новые микрозадачи, может заблокировать всё остальное — таймеры перестают срабатывать, а страница не может перерисоваться. Это называется истощением микрозадач:
Все пять микрозадач выполняются до колбэка setTimeout, несмотря на то что таймер был запланирован раньше. В реальном приложении неограниченная версия такого цикла заморозила бы интерфейс. Решение — разбить длительную работу на макрозадачи (например, setTimeout(..., 0)), что даёт циклу событий возможность выполнить отрисовку и обработать события между частями.
Когда что использовать
- Используйте микрозадачи (Promise,
queueMicrotask), когда хотите выполнить код сразу после завершения текущей операции, но всё же асинхронно — например, для реакции на данные сразу после разрешенияfetch. - Используйте макрозадачи (
setTimeout, разбивка работы на таймеры), когда намеренно хотите уступить браузеру время для отрисовки или обработки ввода перед продолжением — например, при разбивке тяжёлых вычислений, чтобы страница оставалась отзывчивой.
Заключение
Цикл событий выполняет ваш синхронный код до конца, затем опустошает все микрозадачи, затем берёт одну макрозадачу — и повторяет это снова. Микрозадачи (колбэки Promise, queueMicrotask) всегда выполняются перед следующей макрозадачей (таймеры, события, I/O). Усвоив это единственное правило, вы сможете предсказать точный порядок выполнения любого асинхронного кода.
Чтобы углубиться в тему, продолжите изучение с разделов Promise, цепочки Promise, async/await и отдельной главы о микрозадачах. Об API таймеров, используемых здесь, читайте в разделе планирование с помощью setTimeout и setInterval.