Strona główna / Artykuły / Jak łańcuch zakresów w JavaScript faktycznie rozwiązuje zmienne

Jak łańcuch zakresów w JavaScript faktycznie rozwiązuje zmienne

Wyjaśnia mechanizm łańcucha zakresu leksykalnego odpowiadający za wyszukiwanie zmiennych, dlaczego użycie klucza var powoduje błędy w pętlach oraz jak z tego naturalnie wynikają zamknięcia.

1323 słów

Wyszukiwanie zmiennych nie ma nic wspólnego z nawiasami

Zwykły sposób, w jaki ludzie po raz pierwszy poznają koncepcję zakresu dostępu, to stwierdzenie „zakres to wszystko, co znajduje się w nawiasach”. Taki opis pomaga przejść test, ale nie pomoże przewidzieć, co faktycznie robi dany fragment kodu. Prawdziwy mechanizm jest bardziej mechaniczny i przewidywalny, niż sugeruje ta skrótowa forma opisu, a gdy go zrozumiesz, zamknięcia przestaną wyglądać jak magia.

Zakres dostępu jest określany przez miejsce zapisania kodu, a nie przez sposób jego wykonywania

JavaScript rozwiązuje problemy związane ze zmiennymi w sposób leksykalny. Oznacza to, że zakres dostępu zmiennej jest ustalany na podstawie jej fizycznego położenia w pliku źródłowym w momencie jego tworzenia, a nie na podstawie tego, która funkcja akurat wywołuje inną funkcję w trakcie działania programu.

const value = "outer";

function readValue() {
  console.log(value);
}

function runWithDifferentValue() {
  const value = "inner";
  readValue(); // still logs "outer", not "inner"
}

runWithDifferentValue();

readValue nie jest zainteresowany tym, kto go wywołuje ani jakie zmienne znajdują się w środowisku wywołującego. Jedyne, co go obchodzi, to miejsce, w którym został fizycznie zdefiniowany – tuż obok const value = „outer”. To jedyna wartość value, którą kiedykolwiek będzie mógł zobaczyć, bez względu na to, skąd w programie zostanie ostatecznie wywołany. To zazwyczaj moment, w którym programiści pochodzący z języków z dynamicznym zakresem dostępu (lub po prostu ci, którzy nigdy nie musieli się tym zajmować, ponieważ zakres leksykalny jest standardem niemal wszędzie) się mylą. Zakres dostępu jest wbudowany w strukturę kodu od chwili jego napisania; nie zmienia się w zależności od stosu wywołań w czasie wykonania.

Prawdziwy mechanizm wyszukiwania: przemierzanie łańcucha zakresów

Gdy silnik musi rozwiązać odniesienie do zmiennej, nie przeszukuje całego kodu. Zaczyna dokładnie w miejscu, gdzie zmiennej ta jest użyta, i przesuwa się na zewnątrz, po jednym otaczającym kontekście naraz, zatrzymując się natychmiast po znalezieniu odpowiedniej wartości:

const a = "global";

function outer() {
  const b = "outer";

  function inner() {
    const c = "inner";
    console.log(a, b, c); // "global outer inner"
  }

  inner();
}

outer();

inner najpierw sprawdza wartość c i znajduje ją bezpośrednio tam, bez konieczności dalszych poszukiwań. Następnie sprawdza b – ta zmienna nie jest zdefiniowana lokalnie, więc poszukiwania przechodzą do obszaru dostępu outer, gdzie zostaje ona odnaleziona. Potem sprawdza a, która również nie znajduje się ani w inner, ani w outer; dlatego poszukiwania kontynuują się, przechodząc coraz wyżej, aż osiągną obszar dostępu globalnego, gdzie w końcu zostanie ona odnaleziona. Ta sekwencja – obszar dostępu inner, następnie obszar go otaczający, potem kolejny obszar itd., aż do poziomu globalnego – stanowi cały algorytm wyszukiwania. Nie ma tu nic bardziej złożonego niż prosty proces „spójrz tutaj, potem sprawdź poziom wyżej, powtarzaj aż do odnalezienia”.

To sam mechanizm wyjaśnia zjawisko cieniowania zmiennych bez konieczności stosowania oddzielnej reguły. Gdyby inner deklarował własną zmienną const b, poszukiwania zatrzymałyby się w momencie znalezienia tej lokalnej zmiennej b i w ogóle nie doszłyby do zmiennej b zdefiniowanej w outer. W tym scenariuszu nic nie zostaje przepisane; po prostu najbliższy dopasowanie jest znajdowany pierwszy, więc poszukiwania nie mają powodu, by kontynuować się dalej.

Dlaczego var zachowuje się inaczej i dlaczego to powoduje dobrze znany błąd

let i const przypinają się do najbliższego otaczającego bloku, czyli do par nawiasów klamrowych, niezależnie od tego, czy chodzi o instrukcję if, czy ciało pętli. var w ogóle nie przestrzega tej zasady. Zamiast tego var przypina się do najbliższej otaczającej funkcji, całkowicie ignorując granice bloków.

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// logs: 3, 3, 3

W tym fragmencie znajduje się dokładnie jedna zmienna i, dostępna tylko w obrębie funkcji, a każda iteracja pętli korzysta z tej samej zmiennej. Zanim jakikolwiek z callbacki setTimeout zostanie faktycznie uruchomiony, pętla już się zakończyła, a wartość i osiągnęła 3. Wszystkie trzy callbacki odnoszą się do tej samej zmiennej, zamiast mieć po swojej kopii, dlatego wszystkie pokazują ostateczną wartość, jaką ta zmienna miała w momencie zakończenia pętli.

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0);
}
// logs: 0, 1, 2

Ponieważ `let` ma zasięg blokowy, a język specjalnie tworzy nową zmienną `i` przy każdym przetworzeniu pętli, każda funkcja zwrotna ma dostęp do swojej odrębnej zmiennej `i`, zamrożonej na wartości, którą miała podczas danej iteracji. Kod wygląda niemal identycznie jak w poprzednim przykładzie, ale podstawowa zasada zasięgu jest inna, co przynosi znacznie bardziej przewidywalny wynik.

Zamknięcia nie są odrębną funkcją, lecz skutkiem łańcucha zasięgów

Gdy już zrozumiesz, jak działa łańcuch zakresów, zamknięcia prawie nie wymagają dodatkowych wyjaśnień. Zamknięcie to nie jest jakiś dodatkowy mechanizm nałożony na zakresy. To po prostu to, co naturalnie się dzieje, gdy definiujesz funkcję wewnątrz innej funkcji, a ta funkcja wewnętrzna jest następnie używana gdzieś po tym, jak zwykle zostałby wyrzucony zewnętrzny zakres.

function debounce(fn, delayMs) {
  let timeoutId;
  return function (...args) {
    clearTimeout(timeoutId);
    timeoutId = setTimeout(() => fn(...args), delayMs);
  };
}

const debouncedSearch = debounce((query) => runSearch(query), 300);

debounce jest wykonywany dokładnie raz. Jeśli jesteś przyzwyczajony do języków bez zamknięć, twoim instynktem może być to, że timeoutId powinien zniknąć zaraz po zakończeniu wykonywania debounce. Nie znika on, ponieważ zwracana funkcja debounce została zdefiniowana wewnątrz niego, co oznacza, że łańcuch zakresu tej zwracanej funkcji trwale zawiera własny zakres debounce, w tym również timeoutId. Każde kolejne wezwanie debouncedSearch, bez względu na to, jak późno nastąpi, przemierza ten sam łańcuch zakresu, aby dotrzeć do tego samego timeoutId. Właśnie dlatego logika debounce działa – konieczna jest jedna, trwała zmienna śledząca odkładany timeout we wszystkich wywołaniach, a zamknięcie gwarantuje to właśnie.

To samo zjawisko, które umożliwia zamknięcia, może również powodować wyciek pamięci

To, co sprawia, że zamykanie jest przydatne, to dokładnie ta sama cecha, która powoduje konkretny, powtarzający się problem: zamykanie zachowuje cały otaczający je zakres, a nie tylko zmienne, do których faktycznie odwołuje się, oraz przechowuje żywe odniesienie do samej zmiennej, a nie zamrożoną kopię jej wartości z chwili utworzenia zamykania.

function createHandlers() {
  let clickCount = 0;
  const massiveDataset = loadHugeArray(); // large, no longer needed after setup

  return {
    onClick: () => {
      clickCount++; // only this variable is actually used
      console.log(clickCount);
    },
  };
}

onClick przechowuje cały zakres należący do createHandlers, w tym massiveDataset, mimo że onClick nigdy go faktycznie nie odczytuje. Dopóki onClick istnieje, wszystko inne, co zostało przez niego przechowane, również pozostaje aktywne, co stanowi poważny problem pamięciowy, choć zazwyczaj niewielki, gdy długo żyjąca funkcja zamknięta posiada referencje do dużych lub niepotrzebnych danych. Ponieważ funkcja zamknięta przechowuje oryginalną zmienną, a nie jej kopię, zawsze widzi aktualny, najnowszy stan. To ta sama zasada, która sprawiła, że przykład debounce działał poprawnie, a pętla z var wyświetlała wartości 3, 3, 3 – jeden mechanizm, który w jednym kontekście jest przydatną funkcją, a w innym błędem.

Zrozumienie mechanizmu jest lepsze niż zapamiętywanie definicji

Stwierdzenie z podręcznika, że „zamknięcie to funkcja połączona ze swoim środowiskiem leksykalnym”, jest technicznie poprawne, ale rzadko dociera do nas, dopóki kilka razy osobiście nie prześledzimy łańcucha zakresu i dokładnie nie zobaczymy, gdzie następuje zatrzymanie poszukiwań. Gdy prześledzanie tego łańcucha staje się czymś naturalnym, zamknięcia w ogóle nie wymagają już specjalnego wyjaśnienia. Są to po prostu zwykłe konsekwencje sposobu, w jaki zawsze funkcjonował zakres, zastosowane do funkcji, która akurat przetrwa swoje środowisko powstania.

Literatura pokrewna

  • React 19.2 wyjaśnione: Activity, useEffectEvent i statyczne renderowanie — Dowiedz się, jak nowy komponent Activity, hook useEffectEvent oraz częściowe statyczne renderowanie w React 19.2 eliminują ukryte koszty wydajności w nowoczesnych interfejsach użytkownika.