Представьте, что вы пишете код, который должен загрузить данные с сервера, обновить интерфейс и потом показать уведомление. Вы используете Promise и setTimeout. Но почему уведомление появляется позже, чем вы ожидали? Почему обновление интерфейса происходит сразу после загрузки данных, а не после таймера? Ответ кроется в том, как JavaScript разделяет асинхронные задачи на микротаски и макротаски.
Что такое event loop и зачем он нужен
JavaScript - однопоточный язык. Это значит, что он может делать только одну вещь за раз. Но вы ведь видите, как сайты загружают данные, реагируют на клики, анимируют элементы - всё одновременно. Как это работает? Ответ - event loop.
Event loop - это бесконечный цикл, который следит за двумя очередями задач: микротасками и макротасками. Он не даёт коду «заблокироваться» на долгих операциях. Вместо этого он ставит асинхронные задачи в очередь и выполняет их в нужный момент. Главное правило: всё синхронное код выполняется первым. Только после того, как стек вызовов опустеет, event loop начинает обрабатывать асинхронные задачи.
Микротаски: приоритетные, быстрые, неотложные
Микротаски - это задачи, которые должны выполниться сразу после синхронного кода, но до любого другого асинхронного события. Они имеют самый высокий приоритет в event loop. Даже если в очереди макротасок сотни задач - event loop сначала пройдёт по всем микротаскам, пока они не закончатся.
Когда вы используете Promise.then(), Promise.catch() или await в async функции - вы создаёте микротаску. То же самое делает queueMicrotask(). Даже MutationObserver, который следит за изменениями в DOM, работает через микротаски.
Вот что важно: если в процессе выполнения одной микротаски вы добавляете новую - она тоже будет выполнена в этом же цикле. Нет ограничений. Event loop не остановится, пока очередь микротасок не станет пустой. Это может быть как полезно, так и опасно.
Макротаски: всё остальное, что ждёт своей очереди
Макротаски - это задачи, которые выполняются после того, как все микротаски обработаны. И самое главное: за один проход event loop выполняется только одна макротаска.
К макротаскам относятся:
setTimeout()иsetInterval()- События DOM:
click,load,input postMessage()иMessageChannelsetImmediate()(в Node.js)- Операции ввода-вывода: запросы к файловой системе, сети (в Node.js)
Даже если вы пишете setTimeout(fn, 0), это не значит, что функция выполнится сразу. Она попадёт в очередь макротаск, и дождётся своей очереди - после того, как все микротаски будут обработаны.
Как работает порядок выполнения: шаг за шагом
Вот пример, который показывает, как всё происходит на практике:
console.log('1');
Promise.resolve().then(() => console.log('2'));
setTimeout(() => console.log('3'), 0);
console.log('4');
Что выведет этот код?
Порядок: 1, 4, 2, 3.
Почему?
console.log('1')- синхронный код, выполняется сразу.Promise.resolve().then(...)- регистрирует микротаску, но не выполняет её ещё.setTimeout(...)- регистрирует макротаску, тоже не выполняет.console.log('4')- синхронный код, выполняется сразу.- Стек вызовов пуст. Event loop проверяет очередь микротаск - находит
Promise.then()и выполняет её: выводит 2. - После обработки всех микротаск (их тут была одна), event loop берёт одну макротаску -
setTimeout- и выполняет её: выводит 3.
Если бы вы заменили Promise.then() на setTimeout, вывод был бы 1, 4, 3. Но микротаска сработала раньше, потому что у неё приоритет.
Почему это важно на практике
Понимание разницы между микротасками и макротасками - это не академическая задача. Это влияет на реальный код.
Допустим, вы делаете веб-приложение, где нужно обновить интерфейс после загрузки данных, но до того, как пользователь сможет нажать кнопку. Если вы используете setTimeout, интерфейс обновится, но браузер может успеть обработать клик до этого. А если вы используете queueMicrotask() - обновление произойдёт до обработки любого нового события.
// Вариант 1 - плохо: кнопка может быть кликнута до обновления
setTimeout(() => {
updateUI();
}, 0);
// Вариант 2 - лучше: обновление до любого нового события
queueMicrotask(() => {
updateUI();
});
Если вы используете async/await - всё, что идёт после await, автоматически становится микротаской. Это значит, что если вы делаете await fetch(), а потом сразу обновляете DOM - это произойдёт до обработки следующего клика или ввода. Это предсказуемо и безопасно.
Опасность: бесконечные микротаски
Но есть обратная сторона медали. Если вы постоянно добавляете микротаски в процессе их выполнения - event loop никогда не перейдёт к макротаскам.
function loop() {
queueMicrotask(loop);
}
loop();
Этот код создаст бесконечную цепочку микротаск. Event loop будет бесконечно обрабатывать их, игнорируя все остальные события - клики, таймеры, сетевые ответы. Браузер зависнет. Никакая макротаска не получит шанса.
То же самое может случиться, если вы в Promise.then() снова запускаете Promise.then() без условия выхода. Это редкий, но реальный баг в продакшен-коде.
Как не ошибаться
- Если вам нужно выполнить код после синхронного кода, но до обработки событий - используйте
queueMicrotask(). - Если вам нужно отложить выполнение до следующего цикла (например, чтобы дать браузеру нарисовать интерфейс) - используйте
setTimeout(fn, 0). - Никогда не создавайте бесконечные микротаски. Добавляйте проверки на выход.
- Помните:
await- это микротаска. Если вы используетеawaitв цикле - каждое выполнение после него будет в микротаске.
Микротаски и макротаски - это не просто «два типа асинхронности». Это фундаментальная часть того, как JavaScript управляет временем. Без понимания их разницы вы не сможете писать предсказуемый асинхронный код. Вы будете удивляться, почему «всё работает вроде правильно, но иногда странно». А потом вы поймёте - всё дело в очередях.
Чем отличается Promise.then() от setTimeout?
Promise.then() - это микротаска. Она выполняется сразу после синхронного кода, до любого другого асинхронного события. setTimeout - это макротаска. Она выполняется только после того, как все микротаски обработаны, и только одна за проход цикла. Поэтому Promise.then() всегда сработает раньше, даже если setTimeout был вызван раньше в коде.
Почему async/await работает как микротаска?
Когда вы используете await, JavaScript приостанавливает выполнение функции до завершения промиса. После этого он возвращает управление в event loop. Но когда промис разрешён, код после await помещается в очередь микротаск, а не макротаск. Это сделано для того, чтобы асинхронный код вёл себя предсказуемо и не блокировал другие микротаски, например, обновление интерфейса.
Можно ли использовать queueMicrotask вместо setTimeout(0)?
Да, и часто это лучше. setTimeout(0) - это макротаска, и она выполняется только после отрисовки интерфейса и обработки всех событий. queueMicrotask - это микротаска, и она выполняется до этих этапов. Если вам нужно, чтобы код сработал до клика или ввода пользователя - используйте queueMicrotask. Только не создавайте бесконечные цепочки.
Что происходит, если в микротаске создать ещё одну микротаску?
Она добавится в очередь микротаск, и event loop продолжит их выполнять, пока очередь не опустеет. Даже если вы добавите 100 новых микротаск в процессе выполнения - все они будут обработаны до того, как event loop перейдёт к макротаске. Это мощный механизм, но его легко сломать, если не контролировать условия выхода.
Как узнать, что ваш код создаёт бесконечные микротаски?
Если ваш браузер зависает, а консоль не выводит ошибки - это признак. Особенно если вы используете Promise.then(), queueMicrotask() или async/await внутри циклов без условий остановки. Проверьте, не вызываете ли вы асинхронную функцию из самого себя без условия выхода. Инструменты разработчика в браузере покажут, что стек вызовов не пуст, но он не содержит обычных функций - только микротаски.