Strona główna / Artykuły

Wyjaśnianie JavaScripta w przeglądarce podczas rozmów kwalifikacyjnych: kolejki, stos, wycieki pamięci, zamknięcia

Strukturalny przegląd środowiska działania przeglądarki przygotowujący do rozmów kwalifikacyjnych: stos wywołań, kolejki zadań i mikrozadań, pamięć heap oraz zbieranie śmieci, wycieki pamięci, delegacja, prototypy i zamknięcia.

Wydawca: REACTAPP.TOP10 min czytania2346 słów

Źródła: #1 FrontEnd Concepts For Interviews — Javascript Fundament

Rozmowy kwalifikacyjne z dziedziny front-endu rzadko doceniają zapamiętane frazy. Pytający sprawdzają, czy potrafisz wyjaśnić, co robi silnik z twoim kodem: gdzie czeka callback, dlaczego obiekt przetrwa swoją funkcję, dlaczego jeden słuchacz w kontenerze może zastąpić setki. Ten przewodnik omawia te podstawy w kolejności, w jakiej się one wzajemnie opierają, abyś mógł rozważyć trudne pytanie zamiast po prostu recytować odpowiedź. Dobry test samodzielny: po każdej sekcji wyjaśnij jej zasadę na głos, jakbyś rozmawiał z kolegą, który o niej nigdy nie słyszał.

Stos wywołań: gdzie wykonywany jest twój kod

Każdy silnik JavaScriptu wykonywa kod na stosie wywołań, której struktura typu „ostatni dodany, pierwszy usunięty” można sobie wyobrazić jako tablicę, w której operacje dodawania i usuwania odbywają się wyłącznie na końcu. Wywołanie funkcji dodaje do stosu jej kontekst wykonywania; jego zakończenie powoduje usunięcie tego elementu ze stosu. Na wątku głównym istnieje tylko jeden taki stos, więc dopóki jakiś element jest w trakcie wykonywania, nic innego nie może działać. Długi pętla synchroniczna zamyka renderowanie i obsługę wprowadzeń, dopóki się nie zakończy.

Dlaczego żądanie sieciowe nie zamyka strony

Skoro jednocześnie może działać tylko jedna operacja, dlaczego strona nie zatrzymuje się w oczekiwaniu na odpowiedź od serwera? Ponieważ to nie JavaScript czeka. Wyobraź sobie ekran, który przetwarza duży zbiór danych, a następnie kolejno wywołuje kilka API. Funkcja fetch() przekazuje to zadanie Web API dostarczanemu przez przeglądarkę, który zajmuje się komunikacją sieciową poza obszarem stosu. Funkcja natychmiast zwraca obietnicę oczekującą na wynik, dzięki czemu użytkownik może nadal korzystać ze strony.

Gdy przychodzi odpowiedź, obietnica zostaje zrealizowana, a jej funkcje .then() są umieszczane w kolejce mikrozadań, które są wykonywane po opróżnieniu stosu. Ściśle mówiąc, informacja o ukończeniu transmisji sieciowej jest przekazywana za pośrednictwem mechanizmu zarządzania zadaniami przeglądarki, a do kolejki mikrozadań trafiają reakcje związane z obietnicą. Kluczowy punkt dotyczący rozmów kwalifikacyjnych polega na tym, że twoja kontynuacja jest umieszczana w kolejce, a nie wykonywana bezpośrednio.

Kolejka zadań versus kolejka mikrozadań

Timerzy podążają inną ścieżką: funkcja powrotna z setTimeout() trafia do kolejki zadań, nazywanej również kolejką makrozadań. Zasada pętli zdarzeń polega na tym, że po zakończeniu bieżącego zadania i opróżnieniu stosu, pętla najpierw wykona wszystkie mikrozadania z kolejki, zanim wybierze następne zadanie. Ta funkcja planuje po jednym zadaniu z każdej kolejki; numery w komunikatach wskazują na kolejność ich wykonywania.

function triggerBothQueues() {
  console.log("1. Synchronous: Code starts on Call Stack");

  // Enqueue a Macrotask (Task Queue)
  setTimeout(() => {
    console.log("4. Task Queue (Macrotask): setTimeout callback executed");
  }, 0);

  // Enqueue a Microtask (Microtask Queue)
  Promise.resolve().then(() => {
    console.log("3. Microtask Queue: Promise.then callback executed");
  });

  console.log("2. Synchronous: Code ends on Call Stack");
}

triggerBothQueues();

Dwie linie synchroniczne są wyświetlane najpierw, potem funkcja callback obietnicy, a następnie timer. Opóźnienie 0 oznacza „umieść w kolejce jak najszybciej”, a nie „wykonaj teraz”, i mimo to mikrozadanie ma pierwszeństwo. Częste pytanie: mikrozadanie, które planuje inne mikrozadanie, dołącza do tej samej kolejki i jest wykonywane przed każdym timerem, dlatego nieskończona łańcuch mikrozadań może spowolnić renderowanie.

Heap: miejsce, gdzie znajdują się obiekty

Stos przechowuje konteksty wykonywania; pamięć dynamiczna przechowuje dane, do których one wskazują. Obiekty, tablice, funkcje oraz wszystko, co jest obsługiwane przez referencję, znajdują się w pamięci dynamicznej, natomiast lokalne wartości prymitywne, takie jak liczby i wartości logiczne, są koncepcyjnie przechowywane razem z kontekstem wykonywania funkcji. Podczas rozmowy kwalifikacyjnej należy zaznaczyć, że to uproszczenie – silniki programistyczne zwykle alokują łańcuchy znaków w pamięci dynamicznej i mogą je dzielić, a wartość prymitywna przechowywana jako właściwość obiektu znajduje się w pamięci dynamicznej razem z tym obiektem. Ten model służy jako narzędzie pomocnicze, a nie jako specyfikacja.

Zbieranie śmieci i dostępność

Pamięć dynamiczna nie jest nieskończona. Tło zbiornika śmieci odzyskuje pamięć, stosując jeden kluczowy pomysł – dostępność:

  • Korzenie są z definicji dostępne: zmienne globalne, a także parametry i zmienne lokalne funkcji obecnych na stosie.
  • Wartości dostępne to wszystko, do czego można dotrzeć, śledząc odniesienia od korzenia.
  • Wszystko, do czego nie można dotrzeć od żadnego korzenia, jest śmieciem. Zbieracz nie przejmuje się tym, czy nadal zamierzasz używać obiektu; jeśli istnieje dostępne odniesienie, obiekt pozostaje. W ten sposób powstają wycieki pamięci. Aby dowiedzieć się więcej na temat tego zagadnienia, zobacz jak łapać wycieki pamięci w JavaScript za pomocą kryteriów dostępności i retenerów.

    Dwa klasyczne sposoby wycieku pamięci

    Czasownik, który nigdy się nie zatrzymuje

    W tym narzędziu do śledzenia na panelu kontrolnym duża tablica jest tworzona wewnątrz funkcji, a funkcja callback interwału czyta jej długość co sekundę.

    function startDashboardTracker() {
      const hugeDataPayload = new Array(1000000).fill("data");
    
      // Timer is launched
      setInterval(() => {
        // Because hugeDataPayload is referenced here,
        // it stays trapped in memory forever!
        console.log("Tracker pinging... Payload length:", hugeDataPayload.length);
      }, 1000);
    }
    
    startDashboardTracker();
    // Even after startDashboardTracker finishes executing and leaves the Call Stack,
    // hugeDataPayload CANNOT be garbage collected because the timer's closure holds a reference to it.
    

    Gdy funkcja startDashboardTracker zakończy się, można by oczekiwać, że tablica stanie się bezużyteczna. Tak jednak nie jest: przeglądarka przechowuje aktywny interwał, ten z kolei przechowuje swoją funkcję odwoławczą, a ta z kolei przechowuje hugeDataPayload. Należy zachować ID zwrócone przez setInterval() i wywołać clearInterval(), gdy praca zostanie zakończona lub gdy komponent interfejsu zostanie usunięty; zerwanie tego pierwszego połączenia uwalnia cały łańcuch.

    Słuchacz na obiekcie globalnym

    Ten przypadek jest subtelniejszy, ponieważ wydaje się, że nic nie jest uruchamiane. Widget do wyszukiwania tworzy duży indeks i rejestruje obsługę zmian rozmiaru na obiekcie window.

    function setupSearchWidget() {
      // Heavy data tied to the widget scope
      const heavySearchIndex = new Array(1000000).fill("search_index_item");
    
      function handleResize() {
        console.log("Window resized. Index size:", heavySearchIndex.length);
      }
    
      // Listener attached to the global 'window' object (a Root)
      window.addEventListener("resize", handleResize);
    }
    
    setupSearchWidget();
    
    // Later, user navigates away and the Search Widget DOM element is deleted.
    // BUT 'window' still holds a reference to 'handleResize',
    // which holds a reference to 'heavySearchIndex'.
    

    Gdy użytkownik odchodzi, a DOM widgetu jest usuwany, window, który stanowi element korzeniowy, nadal zawiera referencję do funkcji handleResize, która odwołuje się do zmiennej heavySearchIndex. Należy wywołać metodę removeEventListener, gdy widget jest usuwany, przekazując tę samą referencję funkcji, którą zarejestrowano; nowa funkcja strzałkowa to zupełnie inny obiekt i nic nie usunie.

    Bubblowanie zdarzeń i delegacja

    React i Angular ukrywają dużą część tych operacji zarządzania zdarzeniami. Jednym z powodów jest delegacja zdarzeń: zamiast mieć słuchacze na każdym węźle, React nasłuchuje w kontenerze korzeniowym i przekazuje zdarzenia do odpowiednich funkcji obsługi w miarę ich propagacji, a jego syntetyczny system zdarzeń przyłącza i odłącza te funkcje w momencie montowania i demontowania komponentów.

    Delegacja opiera się na mechanizmie przenoszenia zdarzeń w górę drzewa: kliknięcie jest wysyłane do elementu docelowego, a następnie przechodzi przez każdego przodka. Na tej stronie znajdują się trzy kolorowe pola, z których każde ma słuchacz kliknięć.

    <!DOCTYPE html>
    <html lang="en">
    <head>
        <meta charset="UTF-8">
        <meta name="viewport" content="width=device-width, initial-scale=1.0">
        <title>Event Bubbling Example</title>
        <style>
            #grandparent {
                background-color: lightcoral;
                padding: 20px;
            }
            #parent {
                background-color: lightblue;
                padding: 20px;
            }
            #child {
                background-color: lightgreen;
                padding: 20px;
            }
        </style>
    </head>
    <body>
    
    <div id="grandparent">
        <h1>Grandparent - only one event listener</h1>
        <div id="parent">
            Parent
            <div id="child">
                Child
            </div>
        </div>
    </div>
    
    <script>
        document.getElementById('grandparent').addEventListener('click', function(event) {
            console.log('Grandparent clicked!');
            console.log('target: ', event.target.getAttribute("id"));
            // if (event.target.getAttribute("id") === 'child') {
            //     //
            // }
        });
    
        document.getElementById('parent').addEventListener('click', function(event) {
            console.log('Parent clicked!');
            // event.stopPropagation();
        });
    
        document.getElementById('child').addEventListener('click', function(event) {
            console.log('Child clicked!');
            // event.stopPropagation();
        });
    </script>
    
    </body>
    </html>
    

    Kliknięcie w najgłębsze pole zapisuje informacje o dziecku, rodzicu oraz dziadku. Dziadek czyta event.target, aby dowiedzieć się, który element faktycznie został kliknięty, a skomentowany if wskazuje, jak ten jeden słuchacz mógłby rozróżniać elementy na podstawie ID, dzięki czemu dzieci nie potrzebowałyby własnych słuchaczy. To jest delegacja w miniaturze: jeden słuchacz umieszczony wysoko w drzewie obejmuje wszystkie potomne elementy, w tym te dodane później, przy użyciu znikomej ilości pamięci.

    Komentowane wywołania event.stopPropagation() pokazują odwrotną zasadę sterowania: gdy są aktywne w potomku, zapobiegają przekazaniu zdarzenia do jego przodków (zobacz dokumentację do stopPropagation). Należy ich używać oszczędnie, ponieważ uniemożliwiają one również działanie delegowanych obsługowników na wyższych poziomach, w tym słuchacza korzeniowego frameworka. Warto bezpośrednio eksperymentować z tym plikiem – zwykłe addEventListener powinno wydawać się znajome jeszcze przed poznaniem abstrakcji frameworka.

    Programowanie funkcyjne w skrócie

    Programowanie funkcyjne (FP) jest często przedstawiane jako linia montażowa izolowanych maszyn: każda z nich przyjmuje dane wejściowe, wytwarza wynik i nie wpływa na nic innego. Podczas gdy kod orientowany na obiekty ma tendencję do modyfikowania stanu obiektów, programowanie funkcyjne unika takich zmian i opiera się na pięciu zasadach:

    1. Funkcje czyste: wynik zależy wyłącznie od danych wejściowych, bez żadnych zewnętrznych efektów ubocznych.
  • Niezmienniczość: transformacje zwracają nowe wartości zamiast modyfikować stare.
  • Funkcje pierwszej klasy: funkcje to wartości, które można przechowywać i przekazywać dalej.
  • Funkcje wyższej klasy: funkcje, które przyjmują lub zwracają inne funkcje, umożliwiające kompozycję (zobacz ten przegląd funkcji wyższej klasy).
  • Styl deklaratywny: opisuje się pożądany wynik, na przykład za pomocą map i filter, zamiast zarządzać licznikami i stanem.
  • Klasy i prototypy

    Od ES2022 klasy obsługują prawdziwe pola prywatne z prefiksem #. Tutaj #balance jest dostępne tylko wewnątrz ciała klasy.

    // 1. PURE JAVASCRIPT (Native ES2022+)
    class BankAccount {
      #balance = 0; // Native JS private field (using #)
    
      constructor(owner, initialBalance) {
        this.owner = owner;
        this.#balance = initialBalance;
      }
    
      deposit(amount) {
        this.#balance += amount;
      }
    }
    
    const account = new BankAccount("Alex", 1000);
    // account.#balance; ❌ Uncaught SyntaxError (Hard runtime privacy)
    

    Czytanie account.#balance z zewnątrz powoduje błąd SyntaxError, więc prywatność jest zapewniana przez język, a nie konwencję.

    Poniżej JavaScript nadal wykorzystuje dziedziczenie prototypowe: obiekty delegują wyszukiwanie właściwości do prototypu. Przed ES6 struktury przypominające klasy były tworzone ręcznie za pomocą funkcji konstruktora, jak w tej wersji konta z podklasą oszczędnościową.

    // 1. BASE CONSTRUCTOR FUNCTION (Parent Class)
    function BankAccount(owner, initialBalance) {
      this.owner = owner; // Public property
    
      // PRIVATE VARIABLE (Data Privacy via Closure)
      // Lives in function scope, completely hidden from the outside world!
      var balance = initialBalance;
    
      // Public method privileged to access the private 'balance' variable
      this.deposit = function (amount) {
        balance += amount;
        this._logTransaction("Deposited quot; + amount + ". New Balance: quot; + balance);
      };
    }
    
    // PROTECTED / SHARED METHODS (Prototypal Inheritance)
    // Attached to the prototype so methods aren't re-created for every single instance
    BankAccount.prototype._logTransaction = function (action) {
      console.log("[" + this.owner + "]: " + action);
    };
    
    // 2. DERIVED CONSTRUCTOR FUNCTION (Child Class)
    function SavingsAccount(owner, initialBalance, interestRate) {
      // Call Parent Constructor with 'this' context (Super Call)
      BankAccount.call(this, owner, initialBalance);
    
      this.interestRate = interestRate;
    }
    
    // Inherit parent prototype methods on the child
    SavingsAccount.prototype = Object.create(BankAccount.prototype);
    SavingsAccount.prototype.constructor = SavingsAccount; // Reset constructor reference
    
    // Child Public Method
    SavingsAccount.prototype.addInterest = function () {
      // Calls the inherited prototype method
      this._logTransaction("Applied " + this.interestRate + "% interest");
    };
    
    // 3. USAGE
    var mySavings = new SavingsAccount("Alex", 1000, 5);
    mySavings.deposit(200);   // -> [Alex]: Deposited $200. New Balance: $1200
    mySavings.addInterest();  // -> [Alex]: Applied 5% interest
    

    Zauważ, że balance jest prywatny dzięki zamknięciu: znajduje się w zakresie konstruktora i tylko deposit może go widzieć. Funkcja _logTransaction znajduje się na prototypie, więc wszystkie instancje dzielą się jedną funkcją, a jej podkreślenie oznacza jedynie „zachowane” zgodnie z konwencją. BankAccount.call(this, ...) to ręczna wersja super(), a Object.create łączy prototypy przed przywróceniem przepisanej właściwości constructor.

    Współczesny kod front-endowego faworyzuje kompozycję zamiast dziedziczenia. Jeśli potrzebujesz bardziej rygorystycznych sprawdzeń związanych z programowaniem obiektowym, takich jak modyfikatory dostępu i interfejsy, TypeScript jest lepszym wyborem; to bardziej szczegółowe omówienie zagadnienia zajmuje się tym jeszcze szerzej.

    Zamknięcia

    Istnieje o wiele więcej tematów do zbadania, od Canvas i innych API przeglądarek po moduły, iteratorów i generatorów, ale zamknięcia występują niemal we wszystkich rozmowach kwalifikacyjnych, a już spotkaliście je w przykładach z wyciekami danych. Zamknięcie to funkcja połączona z środowiskiem leksykalnym, w którym została stworzona. Ponieważ funkcja wewnętrzna przechowuje odniesienie do tego środowiska, może nadal czytać i aktualizować zmienne funkcji zewnętrznej długo po tym, jak ta ostatnia się zakończyła.

    function createCounter() {
      let count = 0; // Outer variable
    
      return function() {
        count++; // Inner function accesses 'count'
        return count;
      };
    }
    
    const counter = createCounter();
    
    console.log(counter()); // 1
    console.log(counter()); // 2
    console.log(counter()); // 3
    

    count pozostaje niezmieniony pomiędzy wywołaniami, ponieważ zwracana funkcja utrzymuje dostęp do swojego środowiska, co bezpośrednio wiąże się z zasadami zbierania śmieci. Każde wywołanie createCounter generuje niezależny licznik.

    Praktyczne zastosowanie zamknięcia: debounce

    Rzeczywistym zastosowaniem jest mechanizm debounce: funkcja jest wykonywana tylko wtedy, gdy przerwy między wywołaniami przekroczą określony czas oczekiwania, jak to ma miejsce w funkcji wyszukiwania podczas pisania.

    function debounce(func, delay = 300) {
      let timer; // 1. The closure variable that holds the timeout reference
    
      return function (...args) {
        // 2. If the function is called again before the delay finishes,
        // cancel the previous scheduled run
        clearTimeout(timer);
    
        // 3. Schedule a new run after the delay
        timer = setTimeout(() => {
          func.apply(this, args);
        }, delay);
      };
    }
    
    // --- Usage Example ---
    
    function handleSearch(query) {
      console.log(`Searching API for: ${query}`);
    }
    
    // Wrap our search function in the debounce function
    const debouncedSearch = debounce(handleSearch, 500);
    
    // Simulating fast typing into an input field:
    debouncedSearch("a");    // Timer starts...
    debouncedSearch("ap");   // Cancels "a", resets timer...
    debouncedSearch("app");  // Cancels "ap", resets timer...
    debouncedSearch("apple");// Cancels "app", resets timer...
    
    // Only "Searching API for: apple" will log to the console
    // 500ms after the last keypress!
    

    Zamknięty timer stanowi aktualny stan aplikacji. Każde wywołanie usuwa bieżący timeout i planuje nowy, więc uruchamia się tylko ostatnie wywołanie w serii: szybkie wpisywanie „a”, „ap”, „app”, „apple” powoduje jedno wyszukiwanie słowa „apple” 500 ms po ostatnim naciśnięciu klawisza. Funkcja func.apply(this, args) przekazuje zarówno argumenty, jak i kontekst wywołania. Informacje na temat anulowania oraz przypadków krawędziowych znajdują się pod adresem pisania narzędzia debounce od zera.

    Główne wnioski

    • Kod jest wykonywany w ramach jednej stosu wywołań; API przeglądarki zajmują się oczekiwaniem i zwracają wyniki za pośrednictwem kolejek.
    • Kolektor przechowuje wszystko, do czego można dotrzeć od punktu początkowego, niezależnie od tego, czy jest to potrzebne, czy nie.
  • Nierozwiązane interwały oraz słuchacze w zmiennych globalnych to klasyczne wycieki pamięci; należy je usunąć, używając oryginalnych referencji.
  • Mechanizm bubbling umożliwia delegację, dzięki której frameworki mogą efektywnie obsługiwać zdarzenia.
  • Klasy opierają się bardziej na składni niż na prototypach, a zamknięcia wyjaśniają koncepcję stanu prywatnego, mechanizm debounce oraz wiele innych problemów związanych z wyciekami pamięci.