Strona główna / Artykuły

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ć.

Wydawca: REACTAPP.TOP16 min czytania3605 słów

Ź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.
  • Fabryki funkcji – tworzenie spersonalizowanych funkcji na podstawie parametrów.
  • Callbacki – przechowywanie wartości, aby były nadal dostępne w momencie wywołania callbacka.
  • Czasy wykonywania – dostęp do zmiennych z wnętrza funkcji setTimeout lub setInterval.
  • Obsługi zdarzeń – przechowywanie danych powiązanych z konkretnym zdarzeniem.
  • React – rozumienie callbacki, efektów oraz problemu „stale closure”.
  • 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:

    1. 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.
    2. 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.
    3. 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ć.
  • Menedżer sali (pętla zdarzeń). Ten menedżer ciągle przemieszcza się tam i z powrotem między ladą a kucharzem, przestrzegając jednej ścisłej zasady: nigdy nie przerywać kucharza podczas pracy. Gdy tylko ręce kucharza są całkowicie wolne, menedżer bierze następny bilecik czekający na ladzie i go przekazuje.
  • 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:

    • stepOne jest dodawany na stos.
    • stepOne wywołuje stepTwo, więc stepTwo jest dodawany na szczyt.
    • stepTwo wywołuje stepThree, więc stepThree jest dodawany na szczyt tego stosu.
    • stepThree uruchamia console.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.
  • Stos wraca do stanu pustego.
  • 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() i setInterval()
    • Słuchacze zdarzeń DOM, takie jak click, scroll lub keydown
    • 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 await w funkcji async
    • 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:

    1. Opuść stos wywołań. Kontynuuj wykonywanie kodu synchronicznego, aż na stosie nie pozostanie już nic.
    2. 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.
    3. 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.
    4. 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.
    5. 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

  • Jedna nit JavaScript vs. wieloprocesowy silnik przeglądarki — Dowiedz się, dlaczego JavaScript działa na jednej nici, podczas gdy przeglądarki jednocześnie obsługują sieć, renderowanie i zegary za pomocą mechanizmu pętli wydarzeń.
  • Currying w JavaScript: zamknięcia, częściowe zastosowanie i ponowne użycie — Przeczytaj, jak currying przekształca funkcje JavaScript z wieloma argumentami w użyteczne łańcuchy z jednym argumentem, w jaki sposób różni się od częściowego zastosowania i kiedy warto go pominąć.