Пошук уроків, статей та іншого контенту
Розберете порядок виконання мікрозавдань і макрозавдань та навчитеся передбачати результат змішаного асинхронного коду.
JavaScript виконує код в одному потоці. Це означає, що в певний момент часу рушій JavaScript виконує лише одну операцію.
Асинхронність у JavaScript реалізується завдяки взаємодії:
стеку викликів;
черги завдань;
черги мікрозавдань;
середовища виконання — браузера або Node.js.
Коли синхронний код завершується, цикл подій перевіряє, чи є відкладені операції, і визначає, коли виконати їхні callback-функції.
Спрощений порядок можна описати так:
Виконати поточне завдання.
Повністю очистити чергу мікрозавдань.
За можливості виконати оновлення інтерфейсу.
Взяти наступне завдання з черги макрозавдань.
Повторити процес.
Макрозавдання, або просто завдання, — це більші одиниці роботи, які цикл подій бере з черги по одному.
До макрозавдань у браузері належать, зокрема:
виконання початкового синхронного скрипту;
callback функції setTimeout;
callback функції setInterval;
обробники подій користувача;
деякі операції введення та виведення;
повідомлення між контекстами виконання.
Наприклад:
setTimeout(() => {
console.log("Макрозавдання");
}, 0);
console.log("Синхронний код");Результат:
Синхронний код
МакрозавданняЗначення 0 у setTimeout не означає «виконати негайно». Callback буде поставлено в чергу, і він зможе виконатися лише після завершення поточного коду та інших завдань, які мають вищий пріоритет.
Також setTimeout не гарантує точний час виконання. Він задає мінімальну затримку перед тим, як callback може потрапити до черги.
Мікрозавдання — це короткі відкладені операції, які виконуються після завершення поточного макрозавдання, але до переходу до наступного макрозавдання.
До мікрозавдань належать:
callback-функції Promise.prototype.then;
callback-функції catch і finally;
продовження виконання async-функції після await;
callback функції queueMicrotask;
у браузері — callback-и MutationObserver.
Приклад:
console.log("Початок");
Promise.resolve().then(() => {
console.log("Мікрозавдання");
});
console.log("Кінець");Результат:
Початок
Кінець
МікрозавданняCallback, переданий у then, не виконується одразу, навіть якщо проміс уже виконано. Він додається до черги мікрозавдань.
queueMicrotaskФункція queueMicrotask безпосередньо додає функцію до черги мікрозавдань:
console.log("1");
queueMicrotask(() => {
console.log("3");
});
console.log("2");Результат:
1
2
3queueMicrotask зручно використовувати, коли потрібно виконати код асинхронно, але ще до наступного таймера або обробника події.
Після завершення кожного макрозавдання JavaScript повністю очищає чергу мікрозавдань.
Це означає, що цикл подій не просто бере одне мікрозавдання і переходить до наступного макрозавдання. Він виконує всі доступні мікрозавдання, включно з тими, які були додані під час виконання інших мікрозавдань.
Розглянемо приклад:
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
Promise.resolve().then(() => {
console.log("3");
});
queueMicrotask(() => {
console.log("4");
});
console.log("5");Порядок:
1
5
3
4
2Пояснення:
console.log("1") виконується синхронно.
Callback setTimeout додається до черги макрозавдань.
Callback then додається до черги мікрозавдань.
Callback queueMicrotask також додається до черги мікрозавдань.
Виконується console.log("5").
Поточний скрипт завершено.
Виконуються всі мікрозавдання: спочатку 3, потім 4.
Лише після цього виконується callback setTimeout.
Мікрозавдання можуть додавати нові мікрозавдання:
console.log("1");
queueMicrotask(() => {
console.log("2");
queueMicrotask(() => {
console.log("3");
});
});
setTimeout(() => {
console.log("4");
}, 0);
console.log("5");Результат:
1
5
2
3
4Після виконання мікрозавдання з виведенням 2 у черзі з'являється нове мікрозавдання з виведенням 3. Цикл подій виконає його до переходу до таймера.
Наступний приклад можна запустити в браузері або в сучасному Node.js:
console.log("1: початок");
setTimeout(() => {
console.log("6: перший таймер");
Promise.resolve().then(() => {
console.log("7: мікрозавдання з таймера");
});
}, 0);
Promise.resolve().then(() => {
console.log("3: перше мікрозавдання");
setTimeout(() => {
console.log("8: таймер з мікрозавдання");
}, 0);
});
queueMicrotask(() => {
console.log("4: друге мікрозавдання");
});
console.log("2: кінець синхронного коду");Очікуваний результат у браузері:
1: початок
2: кінець синхронного коду
3: перше мікрозавдання
4: друге мікрозавдання
6: перший таймер
7: мікрозавдання з таймера
8: таймер з мікрозавданняРозглянемо виконання поетапно.
Спочатку виконується весь синхронний код:
1: початок
2: кінець синхронного кодуЗа цей час:
перший таймер додано до черги макрозавдань;
callback проміса додано до черги мікрозавдань;
callback queueMicrotask додано до черги мікрозавдань.
Спочатку виконується callback проміса:
3: перше мікрозавданняВін додає новий таймер, але цей таймер не може виконатися одразу. Він потрапляє до черги макрозавдань.
Потім виконується вже наявне мікрозавдання:
4: друге мікрозавданняЧерга мікрозавдань порожня, тому цикл подій переходить до таймерів.
Виконується callback першого таймера:
6: перший таймерПід час його виконання до черги мікрозавдань додається callback проміса. Він виконується одразу після завершення callback таймера:
7: мікрозавдання з таймераЛише після очищення цієї черги цикл подій переходить до наступного таймера:
8: таймер з мікрозавданняВажливо розрізняти створення проміса та виконання його callback-ів.
Функція-виконавець, передана в new Promise, запускається синхронно:
console.log("1");
new Promise((resolve) => {
console.log("2");
resolve();
}).then(() => {
console.log("3");
});
console.log("4");Результат:
1
2
4
3Функція всередині конструктора виконується одразу, а callback then додається до черги мікрозавдань.
Тому поширена модель така:
тіло конструктора Promise — синхронне;
then, catch, finally — мікрозавдання.
async і awaitФункція, оголошена з async, до першого await виконується синхронно. Продовження після await виконується як мікрозавдання.
async function showMessage() {
console.log("2: до await");
await Promise.resolve();
console.log("4: після await");
}
console.log("1: до виклику");
showMessage();
console.log("3: після виклику");Результат:
1: до виклику
2: до await
3: після виклику
4: після awaitawait Promise.resolve() не продовжує функцію синхронно. Виконання функції призупиняється, а код після await планується як мікрозавдання.
Те саме правило діє і для промісів, які вже виконано:
async function example() {
return "готово";
}
example().then((value) => {
console.log(value);
});
console.log("синхронний код");Результат:
синхронний код
готовоМікрозавдання зазвичай виконуються в порядку додавання до черги.
Promise.resolve().then(() => {
console.log("A");
});
queueMicrotask(() => {
console.log("B");
});
Promise.resolve().then(() => {
console.log("C");
});Результат:
A
B
CВажливе не те, яка саме API створила мікрозавдання, а порядок, у якому вони були поставлені в чергу.
У браузері після виконання поточного завдання та очищення мікрозавдань браузер може виконати оновлення відображення сторінки.
Спрощена послідовність виглядає так:
Виконати макрозавдання.
Виконати всі мікрозавдання.
За потреби оновити відображення.
Виконати наступне макрозавдання.
Через це велика кількість мікрозавдань може блокувати оновлення інтерфейсу. Наприклад, нескінченне додавання мікрозавдань не дозволить циклу подій перейти до таймера, обробника події або відображення сторінки.
function createMicrotaskLoop() {
queueMicrotask(createMicrotaskLoop);
}
createMicrotaskLoop();
setTimeout(() => {
console.log("Цей код може не виконатися");
}, 0);У цьому прикладі черга мікрозавдань ніколи не спорожнюється. Це створює блокування циклу подій і є помилкою.
Якщо потрібно розбити довгу роботу на частини та дозволити браузеру обробити інші події, іноді використовують макрозавдання:
let count = 0;
function processChunk() {
for (let i = 0; i < 1000; i += 1) {
count += 1;
}
if (count < 10000) {
setTimeout(processChunk, 0);
} else {
console.log("Обробку завершено");
}
}
processChunk();Тут кожна частина роботи завершується окремим завданням. Це дає циклу подій можливість обробити інші операції між частинами.
Загальна ідея мікрозавдань і макрозавдань є спільною для браузера та Node.js:
синхронний код має пріоритет над відкладеним;
мікрозавдання виконуються перед переходом до наступного звичайного завдання;
проміси та queueMicrotask використовують мікрозавдання.
Водночас конкретні деталі планування можуть відрізнятися між середовищами. Node.js має власний цикл подій і додаткові механізми, зокрема process.nextTick, який має особливі правила та пріоритет.
Тому під час аналізу коду потрібно враховувати середовище, у якому він запускається. Для браузерного коду не варто автоматично переносити всі деталі планування Node.js, і навпаки.
Щоб визначити порядок виведення, використовуйте такий алгоритм:
Запишіть усі синхронні операції в порядку виконання.
Знайдіть таймери, обробники подій та інші макрозавдання.
Знайдіть callback-и промісів, await і queueMicrotask.
Після завершення синхронного коду виконайте мікрозавдання в порядку додавання.
Якщо мікрозавдання додає нове мікрозавдання, вставте його в кінець поточної черги.
Лише після спорожнення черги мікрозавдань переходьте до наступного макрозавдання.
Після кожного макрозавдання знову повністю очистьте чергу мікрозавдань.
Наприклад:
console.log("A");
setTimeout(() => {
console.log("B");
}, 0);
Promise.resolve().then(() => {
console.log("C");
queueMicrotask(() => {
console.log("D");
});
});
queueMicrotask(() => {
console.log("E");
});
console.log("F");Правильний результат:
A
F
C
E
D
BПояснення:
A і F — синхронний код;
C і E — початкові мікрозавдання;
D додано під час виконання C, тому воно виконається після вже наявного E;
B — макрозавдання, тому воно виконується після всіх мікрозавдань.
setTimeout(..., 0) синхроннимНульова затримка не запускає callback негайно. Callback буде виконано лише після завершення поточного завдання та очищення черги мікрозавдань.
setTimeout і Promise.then не потрапляють до однієї черги. Callback проміса зазвичай виконується раніше за callback таймера.
Цикл подій продовжує очищати чергу, доки вона не стане порожньою. Нове мікрозавдання не відкладається до наступного циклу.
await повністю синхроннимКод до await може виконатися синхронно, але код після await продовжується через мікрозавдання.
Promise виконується асинхронноВиконавець конструктора Promise запускається одразу. Асинхронними є callback-и, зареєстровані через then, catch або finally.
setTimeout задає мінімальну затримку, а не гарантує запуск рівно через вказану кількість мілісекунд.
Нескінченний ланцюг мікрозавдань може заблокувати таймери, обробку подій і оновлення інтерфейсу.
Макрозавдання — це, зокрема, таймери, події та початковий запуск скрипту.
Мікрозавдання — це callback-и промісів, продовження після await і queueMicrotask.
Синхронний код виконується першим.
Після кожного макрозавдання цикл подій повністю очищає чергу мікрозавдань.
Мікрозавдання, створене іншим мікрозавданням, також виконується до наступного макрозавдання.
setTimeout(..., 0) не означає негайне виконання.
Виконавець Promise запускається синхронно, а callback then — асинхронно як мікрозавдання.
Код після await продовжується через мікрозавдання.
Точний порядок потрібно визначати окремо для кожного середовища виконання, особливо якщо код запускається не в браузері.