Zrozumienie zamknięć w JavaScript i pętli zdarzeń
Zamknięcia w JavaScript utrzymują zmienne zewnętrzne w stanie aktywnym; pętla zdarzeń zarządza stosem wywołań, mikrozadaniami i makrozadaniami. Obie koncepcje wyjaśniono za pomocą przykładów kodu, które można uruchomić.
Źródła: JavaScript Closures Explained in 2 Minutes, The JavaScript Event Loop — Finally Explained With Zero Confusion
Język JavaScript jest często opisywany jako język pełen dziwnych cech, ale dwa z jego najbardziej niezrozumianych zachowań – zamknięcia i pętla zdarzeń – w rzeczywistości wywodzą się z tej samej podstawowej koncepcji: funkcje oraz zadania zaplanowane do wykonania nie znikają tylko dlatego, że kod wokół nich przeszedł dalej. Zrozumienie tego, jak funkcja może „pamiętać” zmienne po zakończeniu działania jej funkcji nadrzędnej, oraz tego, jak silnik wykonawczy decyduje, kiedy uruchomić kod, który nie jest natychmiast gotowy do wykonania, wymaga od nas przestania postrzegania JavaScripta jako prostego skryptu wiersz po wierszu i zaczęcia traktowania go jako systemu z regułami dotyczącymi pamięci i czasu. Ten artykuł omawia obie te koncepcje, zaczynając od zamknięć.
Rozumienie zamknięć
Czym właściwie jest zamknięcie
Zamknięcie funkcji występuje wtedy, gdy funkcja przechowuje zmienne z obszaru, w którym została stworzona, i może do nich uzyskać dostęp nawet po zakończeniu wykonywania funkcji zewnętrznej.
Pożytecznym sposobem na zilustrowanie tego jest wyobrażenie funkcji niosącej ze sobą mały „plecak” z zmiennymi, gdziekolwiek by się nie udała.
function createCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
Zauważ, że createCounter() się uruchamia i zwraca wartość, a mimo to funkcja wewnętrzna, którą zwraca, nadal ma dostęp do count. Ten trwały dostęp po faktycznym zakończeniu wywołania zewnętrznego to właśnie zamknięcie funkcji.
Typowe sytuacje, w których występują zamknięcia funkcji
Zamknięcia są istotne wtedy, gdy funkcja musi przechowywać jakieś dane po tym, jak funkcja, która ją stworzyła, już się zakończyła. Zetkniesz się z tym wzorcem w wielu codziennych sytuacjach:
- Zmienne prywatne – ukrywanie danych, aby kod z zewnątrz nie mógł do nich bezpośrednio uzyskać dostępu.
setTimeout lub setInterval.Dlaczego są ważne
Zamknięcia pozwalają utrzymywać stan bez korzystania z zmiennych globalnych, co sprawia, że kod jest czystszy i bezpieczniejszy.
function createAccount(initialBalance) {
let balance = initialBalance;
return {
deposit(amount) {
balance += amount;
},
getBalance() {
return balance;
}
};
}
const account = createAccount(100);
account.deposit(50);
console.log(account.getBalance()); // 150
console.log(account.balance); // undefined
W tym przykładzie do zmiennej balance nie można uzyskać dostępu bezpośrednio z zewnątrz funkcji. Tylko metody obiektu, zwracane przez createAccount, mają do niej dostęp.
Taka struktura daje kilka istotnych zalet:
- Lepsza inkapsulacja
- Prawdziwie prywatny stan
- Prostszy, łatwiejszy do odczytania kod
- Mniejsza zależność od zmiennych globalnych
Klasyczny przykład pętli
Częstym przykładem ilustrującym działanie zamknięć jest różnica między użyciem var a let w pętli, która planuje wywołania zwrotne.
Oto co dzieje się przy użyciu var:
for (var i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// Output:
// 4
// 4
// 4
Wynik nie jest tym, czego wielu ludzi oczekuje na początku, a powodem jest to, że var ma zasięg funkcji, a nie bloku. Wszystkie trzy wywołania zwrotne setTimeout odnoszą się do dokładnie tej samej zmiennej i. Zanim któreś z nich faktycznie zostanie wywołane, pętla już się zakończyła, a i osiągnęło wartość 4.
Porównaj to teraz z użyciem let:
for (let i = 1; i <= 3; i++) {
setTimeout(() => {
console.log(i);
}, 1000);
}
// Output:
// 1
// 2
// 3
Ponieważ let ma zasięg blokowy, każda iteracja pętli uzyskuje własną, niezależną wartość zmiennej i. Oznacza to, że każda funkcja callback domyka się wokół innej wartości, dzięki czemu liczby są wyświetlane w kolejności, jakiej można się naturalnie spodziewać.
Częsty temat na rozmowie kwalifikacyjnej
Pytanie: Co to jest domknięcie w JavaScript?
Domknięcie to funkcja, która pamięta i nadal może dostępować do zmiennych z obszaru, w którym została zdefiniowana, nawet po zakończeniu wykonywania funkcji zewnętrznej.
Kontynuacja: Gdzie faktycznie używa się domknieć?
Dobra odpowiedź obejmuje praktyczne zastosowania, a nie tylko definicję:
Zamknięcia występują najczęściej w funkcjach zwrotnych, timerach, obsługiwanie zdarzeń oraz fabrykach funkcji. Są również przydatne, gdy jakaś wartość stanu musi pozostać prywatna zamiast znajdować się w zakresie globalnym.
Główny wniosek
Idea polega na jednej prostej zależności: funkcja wraz z otaczającymi ją zmiennymi równa się zamknięciu. Jeśli obraz plecaka utknie ci w pamięci – funkcja niosąca ze sobą zmiennice, gdziekolwiek jest wywoływana – całe to pojęcie staje się znacznie mniej tajemnicze.
Rozumienie pętli zdarzeń
Zamknięcia wyjaśniają, jak funkcja może przechowywać dane przez czas. Pętla zdarzeń wyjaśnia natomiast, jak JavaScript decyduje kiedy uruchomić kod, który nie jest gotowy od razu – i stąd pochodzi wiele mylących wyjść z konsoli.
Prawie każdy programista pracujący z JavaScriptem prędzej czy później napotyka taki właśnie fragment kodu, czy to podczas rozmowy kwalifikacyjnej, czy przypadkowo:
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
console.log("3");
Czytając to od góry do dołu, można by oczekiwać, że konsola wyświetli najpierw 1, potem 2, a następnie 3 — timeout jest ustawiony na zero milisekund, więc nie powinno być żadnego opóźnienia.
Jednak gdy uruchomi się ten kod w konsoli przeglądarki lub w Node, rzeczywisty wynik wygląda tak:
1
3
2
Dlaczego więc kod zaplanowany do wykonania „bezpośrednio” faktycznie jest wykonywany *po* kodzie napisanym później w pliku?
Aby na to odpowiedzieć, trzeba zrozumieć mechanizm działający pod kodem: pętlę zdarzeń. Zamiast zagłębiać się w formalną terminologię dotyczącą kontekstów wykonywania i algorytmów planowania, pomocne jest stworzenie prostego, trwałego modelu mentalnego tego, co tak naprawdę się dzieje.
Główna sprzeczność w sercu JavaScript
Zanim przejdziemy do diagramów i definicji, jest jeden fakt, który musisz sobie uświadomić:
JavaScript jest ściśle jednowątkowy.
Jednowątkowość oznacza, że JavaScript działa przy użyciu dokładnie jednej stosu wywołań. Ma jeden „mózg” do przetwarzania i może w danym momencie wykonywać tylko jedną linię kodu. Nie może uruchamiać dwóch funkcji równocześnie ani samodzielnie dzielić obliczeń między kilka rdzeni.
To ograniczenie rodzi oczywiste pytanie. Wyobraź sobie fragment kodu, który musi czekać trzy pełne sekundy na odpowiedź wolnego API — przy dostępności tylko jednego wątku i bez żadnej pomocy, co pozostała część strony będzie mogła robić podczas tego oczekiwania?
W środowisku czysto jednowątkowym, bez żadnej pomocy, cały przeglądarka by się zablokowała. Nie byłoby możliwe kliknięcie w cokolwiek, przewijanie, wpisywanie tekstu do pola ani oglądanie animacji — wszystko zamarłoby, dopóki w końcu nie nadejdzie odpowiedź sieciowa.
Ale tak naprawdę nie dzieje się to w ten sposób. Współczesne aplikacje internetowe regularnie streamują wideo, wysyłają żądania sieciowe w tle, cicho aktualizują dane analityczne i przyjmują dane z klawiatury jednocześnie, bez blokowania.
Jak więc język jednowątkowy może to osiągnąć?
Odpowiedź: sam JavaScript jest jednowątkowy, ale środowisko wokół niego — przeglądarka lub Node.js — nie jest.
Kuchnia restauracji jako model mentalny
Najjaśniejszy sposób na zrozumienie tej architektury to wyobrażenie sobie zajętej kuchni fast-food:
[ Customer Orders ] ──> [ The Solo Chef ] (Call Stack)
│
▼ Hands off slow jobs
[ The Kitchen Staff ] (Web APIs)
│
▼ Ready order slips
[ Order Counter ] (Queues)
▲
│ Checks constantly
[ The Floor Manager ] (The Event Loop)
Oto jak wyglądają role:
- Samodzielny szef kuchni (stack wywołań). Szybki i umiejętny, ale może pracować tylko nad jednym zamówieniem naraz — musi je w pełni zakończyć, zanim rozpocznie kolejne.
- Pomocnicy kuchenni (Web API). Gdy zamówienie wymaga długiego oczekiwania — z powodu timera lub wolnej odpowiedzi od kuriera — szef kuchni nie stoi bezczynnie, obserwując zegar. Zamiast tego przekazuje zamówienie pomocnikowi z poleceniem, by ten je monitorował i zgłosił wynik po jego ukończeniu, podczas gdy szef kuchni wraca do kolejnego zadania.
- Licznik zamówień (kolejki callback i microtask). Gdy timer się zakończy, pomocnik nie przerywa pracy szefa kuchni, wkładając mu zamówienie prosto do rąk. To spowodowałoby chaos. Zamiast tego ukończone zamówienie trafia na licznik, by tam czekać.
To jest cały system w miniaturze. Mając na myśli obraz restauracji, nadszedł czas, aby zastąpić te role ich rzeczywistymi nazwami w silniku JavaScript.
Silnik nazywa te cztery elementy inaczej, ale każdy z nich odpowiada bezpośrednio jednej z ról w restauracji.
Poniższy diagram pokazuje, w jaki sposób środowisko wykonawcze (przeglądarka lub Node.js) otacza silnik JavaScript i łączy te elementy ze sobą.
┌───────────────────────────────────────────────────────────┐
│ BROWSER / RUNTIME │
│ │
│ ┌─────────────────┐ ┌───────────────────┐ │
│ │ CALL STACK │ │ WEB APIs │ │
│ │ (JS Engine/V8) │ │ │ │
│ │ │ ──────────> │ setTimeout() │ │
│ │ fnA() │ │ fetch() │ │
│ │ console.log() │ │ DOM Events │ │
│ └────────┬────────┘ └─────────┬─────────┘ │
│ ▲ │ │
│ │ │ Finished │
│ │ ▼ │
│ │ ┌───────────────────┐ │
│ │ │ MICROTASK QUEUE │ │
│ │ │ (Promises, VIP) │ │
│ │ └─────────┬─────────┘ │
│ │ │ │
│ EVENT LOOP ▼ │
│ "Is Stack empty?" ┌───────────────────┐ │
│ └───────────────────── │ MACROTASK QUEUE │ │
│ │ (Timers, Clicks) │ │
│ └───────────────────┘ │
└───────────────────────────────────────────────────────────┘
Stos wywołań — „to, co dzieje się w tym momencie”
Stack wywołań to zwykła struktura typu LIFO (ostatni dodany, pierwszy usunięty), którą zarządza sam silnik JavaScript — to ten sam mechanizm używany w V8 w Chrome i Node, lub w JavaScriptCore w Safari.
Za każdym razem, gdy wywołujesz funkcję, jest ona dodawana na szczyt tego stosu. Gdy funkcja zakończy działanie lub zwróci się, jest usuwana ze szczytu.
function stepThree() {
console.log("Done!");
}
function stepTwo() {
stepThree();
}
function stepOne() {
stepTwo();
}
stepOne();
Prześledzenie krótkiego łańcucha wywołań w ten sposób jasno pokazuje jego mechanizm:
stepOnejest dodawany na stos.stepOnewywołujestepTwo, więcstepTwojest dodawany na szczyt.stepTwowywołujestepThree, więcstepThreejest dodawany na szczyt tego stosu.stepThreeuruchamiaconsole.log, który wyświetla „Done!” i następnie jest usuwany ze stosu.
stepThree kończy się, więc jest usuwany.stepTwo kończy się, więc jest usuwany.stepOne kończy się, więc jest usuwany.Jeśli jakakolwiek funkcja na tym stosie potrzebuje, powiedzmy, dziesięciu sekund ciężkich obliczeń z rzędu, cały czas działania programu zamraża się na te dziesięć sekund. Nic innego nie może być wykonywane, dopóki stos się nie opróżni — to bezpośrednia konsekwencja tego, że „kucharz” ma tylko dwie ręce.
Web API — gdzie faktycznie następuje „czekanie”
Oto co zaskakuje wielu osób na początku ich kariery: setTimeout, fetch oraz słuchacze zdarzeń DOM nie są funkcjami samego języka JavaScript.
Nie znajdziesz ich nigdzie w specyfikacji ECMAScript. Są dostarczane przez środowisko, które hostuje twój kod — przeglądarkę, a w przypadku Node.js – połączenie bindingów C++ z biblioteką libuv.
Rozważmy następujące wywołanie:
setTimeout(callback, 2000);
Gdy stos wywołań wykonuje tę instrukcję setTimeout, faktycznie jedynie informuje wewnętrzny mechanizm zegara przeglądarki o rozpoczęciu odliczania 2000 milisekund. Gdy tylko ta instrukcja zostanie przekazana, sam setTimeout jest natychmiast usuwany ze stosu wywołań, a wykonanie kontynuuje się z kolejnego elementu w twoim kodzie. Samo odliczanie odbywa się całkowicie poza pojedynczą wątkiem JavaScripta, w środowisku hostującym.
Kolejka makrotasków — zwykła kolejka
Ta kolejka jest również nazywana kolejką callbacków lub kolejką zadań, w zależności od tego, kogo o to zapytasz.
Gdy Web API zakończy swoją zadanie w tle — timer osiągnie wartość zero, odpowiedź sieciowa dotrze do końca, użytkownik coś kliknie — środowisko hosta umieszcza powiązany callback do tej kolejki.
Wszystko, co znajduje się w tej kolejce, zachowuje się uprzejmie: jest to standardowa kolejka FIFO (First In, First Out), w której każdy element czeka na swoją kolej, by zostać wykonywany w stosie wywołań.
Typowe przykłady makrozadań to:
- Callbacks zaplanowane za pomocą
setTimeout()isetInterval() - Słuchacze zdarzeń DOM, takie jak
click,scrolllubkeydown setImmediate(), charakterystyczny dla Node.js
Kolejka mikrozadań — szybka ścieżka
Równolegle do tej zwykłej kolejki istnieje mniejsza kolejka o znacznie wyższej priorytecie: Kolejka mikrozadań.
Wszystko, co zostanie tu umieszczone, faktycznie przechodzi na początek kolejki. Gdy tylko stos wywołań stanie się pusty, pętla zdarzeń sprawdza najpierw kolejkę mikrozadań — zanim w ogóle rozważy to, co czeka w kolejce makrozadań.
Pozycje, które trafiają do kolejki mikrozadań, to:
- Obsługi przypisane za pomocą
.then(),.catch()lub.finally()do obietnic (Promises) - Kod, który jest wykonywany po instrukcji
awaitw funkcjiasync - Wszystko, co zostało zaplanowane za pomocą
queueMicrotask() - Funkcje zwrotne uruchamiane przez
MutationObserver
Zasada rządząca pętlą
Gdy usuniemy metafory, okazuje się, że pętla zdarzeń to w rzeczywistości tylko nieskończona pętla while działająca w środowisku hosta, która wielokrotnie wykonywa pięć ścisłych kroków:
- Opuść stos wywołań. Kontynuuj wykonywanie kodu synchronicznego, aż na stosie nie pozostanie już nic.
- Powyżej pełni opróżnij kolejkę mikrozadań. Dopóki istnieje jakieś mikrozadanie w oczekiwaniu, uruchom je. Jeśli jego wykonanie spowoduje powstanie kolejnego mikrozadań, uruchom również je. Pętla nie przejdzie do następnego kroku, dopóki w kolejce mikrozadań nie zostanie już nic.
- Zajmij się renderowaniem, jeśli znajdujesz się w przeglądarce. Silnik sprawdza, czy nadszedł czas na ponowne narysowanie ekranu – zazwyczaj około 60 razy na sekundę, mniej więcej co 16,6 milisekundy – a jeśli tak, przelicza układ elementów i ponownie je rysuje.
- Wykonaj dokładnie jedno makrozadanie. Spójrz na kolejkę makrozadań, wybierz to, które czeka najdłużej, umieść je na stosie wywołań i pozwól mu zostać w pełni zrealizowanym.
- Rozpocznij cykl od pierwszego kroku.
Tym elementem, który sprawia problemy, jest ta asymetria: mikrozadania są w pełni wykorzystywane, każde z nich, zanim pętla przejdzie do następnego kroku — natomiast makrozadania są obsługiwane ściśle po jednym, przy czym między nimi odbywa się pełna weryfikacja stosu oraz kolejki mikrozadań.
Przeanalizowanie dobrze znanej zagadki z rozmowy kwalifikacyjnej
Gdy już znamy te mechanizmy, warto przyjrzeć się fragmentowi kodu, który wprowadza w błąd wielu programistów:
console.log("Start");
setTimeout(() => {
console.log("Timeout Callback");
}, 0);
Promise.resolve().then(() => {
console.log("Promise Callback");
});
console.log("End");
Analiza tego kodu krok po kroku w czterech komponentach pomaga zrozumieć to zaskakujące wynik.
Najpierw przechodzenie synchroniczne
console.log("Start") jest umieszczany na stosie, wyświetla tekst „Start” i następnie jest usuwany z niego.
Następnie na stos jest umieszczany setTimeout(...). Poleca on warstwie Web API przeglądarki uruchomienie timera o czasie 0 milisekund z podanym funkcją odwołania. Ponieważ opóźnienie wynosi zero, timer ten wygasza się praktycznie natychmiast w tle, więc przeglądarka od razu umieszcza () => console.log("Timeout Callback") w kolejce Macrotask. Sam wywołanie setTimeout następnie jest usuwane ze stosu.
Po tym na stos jest umieszczany Promise.resolve().then(...). Obietnica rozwiązuje się natychmiast, a jej obsługa .then() — () => console.log("Promise Callback") — jest umieszczana bezpośrednio w kolejce Microtask. To wywołanie również jest usuwane ze stosu.
Na koniec jest umieszczane console.log("End"), który wyświetla tekst „End” i jest następnie usuwany.
Gdy stos się opróżni
W tym momencie na stosie wywołań nie pozostaje już nic. Pętla zdarzeń się budzi i sprawdza swoje dwie kolejki: kolejka mikrozadań zawiera funkcję zwrotną Promise, a kolejka makrozadań – funkcję zwrotną Timeout.
Mikrozadania są wykonywane najpierw
Pętla sprawdza najpierw kolejkę mikrozadań. Znajduje funkcję zwrotną Promise, umieszcza ją na stosie i wyświetla „Promise Callback”. Stos znów się opróżnia, a teraz pusta jest już także kolejka mikrozadań.
Następnie jedno makrozadanie
Gdy nie ma już mikrozadań, pętla w końcu przechodzi do kolejki makrozadań. Bierze najstarszy element – funkcję zwrotną Timeout, umieszcza ją na stosie, wyświetla „Timeout Callback” i usuwa ją ze stosu.
Podsumowując, ostateczna sekwencja wyświetlanych informacji wygląda następująco:
Start
End
Promise Callback
Timeout Callback
Mimo że czas wygaśnięcia został ustawiony na 0 milisekund i zapisany przed obietnicą w kodzie, to funkcja callback dla obietnicy została wywołana pierwsza. Potwierdza to zasadę, że mikrozadania zawsze są wykonywane przed makrozadaniami, niezależnie od tego, jak małe jest opóźnienie zadane zegarowi.
To prowadzi do powszechnego nieporozumienia co do tego, co faktycznie obiecuje wartość opóźnienia. Wielu początkujących zakłada, że setTimeout(fn, 1000) oznacza „wykonaj tę funkcję dokładnie po 1000 milisekundach”. Tak jednak nie jest. W rzeczywistości gwarantuje to tylko, że funkcja callback nie zostanie umieszczona w kolejce zadań, dopóki nie upłynie co najmniej 1000 milisekund. Gdy już trafi do kolejki, musi nadal czekać na swoją kolej, aż stos wywołań będzie pusty i wszystkie pending mikrozadania zostaną zakończone.
Jeśli twoja stosowa pila jest zajęta przez pięć sekund podczas wykonywania złożonej operacji sortowania lub przetwarzania obrazu, funkcja powrotna setTimeout(fn, 100) będzie czekać bezczynnie w kolejce przez całe te pięć sekund, zanim pętla zdarzeń w ogóle będzie mogła na nią spojrzeć. Krótko mówiąc, opóźnienie spowodowane timeoutem to minimalny czas oczekiwania, a nie gwarantowany moment wywołania.
To zachowanie wyczerpywania kolejki ma jeszcze jeden, bardziej niebezpieczny skutek uboczny, który warto zrozumieć osobno. Ponieważ pętla zdarzeń odmawia przejścia dalej, dopóki kolejka mikrozadań nie będzie całkowicie pusta, możliwe jest zablokowanie aplikacji bez faktycznego zablokowania synchronicznej pili wywołań. Ten tryb awarii nazywany jest głodem mikrozadań, czasami także głodem kolejki.
Pomyśl o funkcji takiej jak ta:
function infiniteMicrotasks() {
Promise.resolve().then(() => {
infiniteMicrotasks();
});
}
infiniteMicrotasks();
Opis procesu: początkowe wezwanie zostaje zakończone, a stos jest opróżniany. Pętla zdarzeń sprawdza następnie kolejkę mikrozadań i znajduje w niej element. Uruchamia ten element, ale jego natychmiastowe wykonanie planuje kolejne mikrozadanie. Pętla zdarzeń ponownie sprawdza kolejkę – okazuje się ona nadal zajęta – i uruchamia następny element, który z kolei planuje kolejne mikrozadanie. Stos jest opróżniany po każdym mikrozadaniu, ale pętla zdarzeń nigdy nie przechodzi do kolejnego kroku. Nigdy nie postępuje na tyle daleko, by pozwolić przeglądarce ponownie narysować interfejs lub dotrzeć do elementów czekających w kolejce makrozadań.
Praktycznym skutkiem jest to, że strona wygląda na całkowicie zamarłą. Kliki są ignorowane, animacje przestają się uruchamiać, a w końcu przeglądarka może oznaczyć kartę ostrzeżeniem o nieresponsywnym skrypcie.
Aby podsumować, w jaki sposób każdy rodzaj zadań jest planowany i uprzywilejowywany w trakcie wykonywania programu, pomocne jest przyjrzenie się porównawczej analizie tego, jak kod synchroniczny, mikrozadania, makrozadania oraz proces renderowania pasują do tego cyklu, jak pokazano na załączonym diagramie.
Zrozumienie sposobu działania pętli zdarzeń to nie tylko przydatna wiedza pomocnicza podczas rozmów kwalifikacyjnych — bezpośrednio wpływa na to, jak powinno się projektować rzeczywiste aplikacje, a z tego wynikają trzy praktyczne nawyki.
Najpierw rozbierz drogie, synchroniczne pętle. Jeśli musisz przetworzyć tablicę z na przykład 500 000 elementów w głównym wątku, unikaj robienia tego w jednej nieprzerwanej pętli for, ponieważ to sparaliżuje interfejs. Podziel pracę na mniejsze części i zwracaj kontrolę do pętli zdarzeń między nimi za pomocą setTimeout(chunkHandler, 0) lub requestIdleCallback(). Dzięki temu przeglądarka ma czas na obsługę kliknięć i ponowne narysowanie klatek pomiędzy poszczególnymi częściami.
Następnie bądź precyzyjny co do tego, co tak naprawdę robią async/await w głębi. Są to syntaktyczne uproszczenia oparte na obietnicach: kod napisany przed pierwszym await jest wykonywany synchronicznie, natomiast wszystko po await jest zamknięte w funkcji zwrotnej typu microtask, która pozostaje w stanie pauzy aż obietnica, na którą czekamy, zostanie zrealizowana.
Po trzecie, nigdy nie traktuj zegarów jako dokładnych stoperów. Jeśli tworzysz coś w rodzaju zegara w aplikacji lub sekwencera dźwiękowego, nie polegaj na setInterval ani setTimeout, aby osiągnąć precyzyjne wartości w milisekundach. Obciążenie systemu, ograniczanie działania kart w tle przez przeglądarki w celu oszczędzania baterii oraz zatory w kolejkach mogą powodować odchylenia. Zamiast tego mierz upływ czasu w stosunku do zegara systemowego za pomocą performance.now().
Język JavaScript działający na pojedynczej nitce nie jest ograniczeniem, którego należy unikać — to jedna z najsilniejszych decyzji projektowych tego języka. Dzięki połączeniu szybkiego silnika synchronicznego z zestawem API przeglądarki działających w tle oraz ściśle zarządzanym pętlą zdarzeń, JavaScript daje wrażenie wykonywania wielu operacji jednocześnie, unikając przy tym problemów związanych z warunkami wyścigu, martwymi punktami i kwestiami bezpieczeństwa wątków, które występują przy prawdziwym wielowątkowości w innych językach.
Gdy już zrozumiesz i zaakceptujesz sposób działania kolejek, wykonywanie twojego kodu przestanie być niespodzianką.
Literatura pokrewna
- Jak process.nextTick() cicho hamuje pętlę zdarzeń Node.js — Wyjaśnia, dlaczego rekurencyjne wywołania process.nextTick() całkowicie blokują fazę poll w libuv oraz jak naprawić problem zahamowania pętli zdarzeń za pomocą setImmediate().
- Wyjaśnienie konkurencji w Node.js: libuv, pętla zdarzeń i pool nici — Dowiedz się, jak Node.js wykorzystuje prymitywy systemu operacyjnego libuv oraz pool nici roboczych do obsługi asynchronicznego I/O, a także o częstych problemach z pooliem nici i wskazówkach dotyczących jego optymalizacji.