Pętla zdarzeń w JavaScripcie krok po kroku

Dlaczego setTimeout(fn, 0) wykonuje się dopiero po Promise i dlaczego kod po await wraca później? Prześledź linia po linii stos wywołań, kolejkę mikrozadań i kolejkę makrozadań i zgadnij wynik, zanim go zobaczysz.

Klasyczne pytanie z rozmów rekrutacyjnych. Co wypisze ten kod?

quiz.jsjavascript
console.log('A');

setTimeout(() => console.log('B'), 0);

Promise.resolve()
  .then(() => console.log('C'))
  .then(() => console.log('D'));

queueMicrotask(() => console.log('E'));

(async () => {
  console.log('F');
  await null;
  console.log('G');
})();

console.log('H');

Jeśli Twoja pierwsza myśl to "z góry na dół, od A do H", to spokojnie, większość ludzi myśli tak samo. Kod faktycznie wykonuje się z góry na dół. Ale niektóre linie nie uruchamiają swojego callbacka, tylko go planują. Żeby przewidzieć kolejność, potrzebujesz trzech rzeczy.

Trzy elementy układanki

  • Stos wywołań. JavaScript ma jeden wątek. Wykonuje to, co jest na stosie, i nigdy nie przerywa tego w połowie. Cały Twój skrypt trafia tam jako pierwszy.
  • Kolejka mikrozadań. .then, .catch, .finally, kod po await i queueMicrotask wrzucają callbacki właśnie tutaj. Myśl o niej jak o "zaraz po bieżącym kodzie".
  • Kolejka makrozadań. setTimeout, setInterval, zdarzenia DOM, sieć i I/O wrzucają callbacki tutaj. Myśl o niej jak o "później".

A spina to wszystko jedna zasada:

Wykonaj kod synchroniczny. Gdy stos jest pusty, wykonaj wszystkie mikrozadania, także te dodane po drodze. Potem weź jedno makrozadanie. Powtórz.

Prześledź to krok po kroku

Najpierw wpisz swoją odpowiedź, potem klikaj dalej. Patrz, co i kiedy ląduje w każdej z kolejek.

Prześledź pętlę zdarzeń krok po kroku

Wstecz i Dalej przesuwają o jeden krok. Działają też klawisze strzałek.

console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve()
.then(() => console.log('C'))
.then(() => console.log('D'));
queueMicrotask(() => console.log('E'));
(async () => {
console.log('F');
await null;
console.log('G');
})();
console.log('H');

Stos wywołańgóra działa teraz

  1. script

Kolejka mikrozadańPromise, await - najpierw

pusto

Kolejka makrozadańsetTimeout, zdarzenia - na końcu

pusto

Konsola

jeszcze nic
GotoweKrok 1 z 15

Skrypt startuje. Cały plik działa jako jedno zadanie na stosie wywołań, z góry na dół. Obie kolejki są puste.

Trzy rzeczy, na których najłatwiej się potknąć

1. Funkcja async startuje synchronicznie

Wywołanie funkcji async to zwykłe wywołanie funkcji. Wszystko przed pierwszym await wykonuje się od razu. Dlatego F jest drugie, przed H. await dzieli funkcję na dwie części: to, co przed nim, jest synchroniczne, a to, co po nim, zachowuje się jak callback .then.

2. Doczepiony .then nie trafia do kolejki, dopóki nie skończy się poprzedni

.then(D) czeka na promise zwrócony przez .then(C). Ten promise rozstrzyga się dopiero wtedy, gdy C się wykona. D dołącza więc do kolejki dopiero w tym momencie, na jej końcu, za E i G, które czekają tam od czasu wykonania skryptu. Wynik: C E G D, a nie C D E G.

3. setTimeout(fn, 0) nie znaczy "teraz"

Opóźnienie to minimum, a nie gwarancja. Nawet przy 0 callback jest makrozadaniem, więc czeka na kod synchroniczny i na wszystkie mikrozadania. B jest ostatnie.

Ostateczna odpowiedź: A F H C E G D B.

Ćwiczenia

Trzy kolejne scenariusze, od łatwego do podchwytliwego. Najpierw zgadnij, potem prześledź krok po kroku.

Ćwiczenia: zgadnij, potem prześledź krok po kroku

Wybierz scenariusz. Na ostatnim wykłada się wiele osób: setTimeout po await.

console.log('A');
setTimeout(() => console.log('B'), 0);
Promise.resolve()
.then(() => console.log('C'))
.then(() => console.log('D'));
queueMicrotask(() => console.log('E'));
(async () => {
console.log('F');
await null;
console.log('G');
})();
console.log('H');

Stos wywołańgóra działa teraz

  1. script

Kolejka mikrozadańPromise, await - najpierw

pusto

Kolejka makrozadańsetTimeout, zdarzenia - na końcu

pusto

Konsola

jeszcze nic
GotoweKrok 1 z 15

Skrypt startuje. Cały plik działa jako jedno zadanie na stosie wywołań, z góry na dół. Obie kolejki są puste.

Wersja w jednym zdaniu

Jeśli zapyta o to rekruter, tyle wystarczy:

JavaScript najpierw wykonuje kod synchroniczny, potem opróżnia całą kolejkę mikrozadań (promise, await, queueMicrotask), następnie bierze jedno makrozadanie (timery, zdarzenia, I/O) i powtarza cały cykl. Kolejka zapełnia się w takiej kolejności, w jakiej linie faktycznie się wykonują, a nie w takiej, w jakiej są napisane.

Najważniejsza jest ta ostatnia część. W końcowym scenariuszu setTimeout stoi po await, więc zostaje zarejestrowany dopiero wtedy, gdy funkcja wznawia działanie w fazie mikrozadań. Dlatego jego callback wykonuje się po timerze zapisanym niżej w pliku.