Strona główna / Artykuły

Miejsce, w którym zapisana jest funkcja, decyduje o tym, co może widzieć: zakres leksykalny w JavaScript

Zrozum, w jaki sposób JavaScript rozwiązuje nazwę zmiennej poprzez środowiska leksykalne, dlaczego miejsce wywołania nigdy nie ma znaczenia przy wyszukiwaniu, oraz jak to wpływa na obsługiwanie zdarzeń w React.

Wydawca: REACTAPP.TOP11 min czytania2586 słów

Źródła: How JavaScript Finds Variables: Scope, Lexical Environment & Scope Chain

Każdy raz, gdy JavaScript ewaluuje nazwę taką jak user, silnik musi określić, do którego powiązania odnosi się ta nazwa. Najczęstszy błąd, polegający na założeniu, że funkcja wywoływana może widzieć zmienne swojego wywołującego, występuje zarówno podczas rozmów kwalifikacyjnych, jak i w błędach w produkcji. Ten przewodnik omawia temat, przechodząc od zasięgu po środowiska leksykalne aż po rozwiązywanie problemów z identyfikatorami, przy szczególnym nacisku na różnicę między łańcuchem zasięgów a stosem wywołań, abyś mógł przewidzieć wszelkie problemy z funkcjami nawzajem zawartymi i jasno myśleć o obsługach w React.

Pytanie: jak funkcja nawzajem zawarta znajduje zmienną zewnętrzną?

Zacznijmy od trzech poziomów zawierania się:

const appName = "HealthApp";

function dashboard() {
  const user = "Jashan";
  function showProfile() {
    const status = "Active";
    console.log(appName);
    console.log(user);
    console.log(status);
  }
  showProfile();
}

dashboard();

showProfile() deklaruje tylko status, a mimo to wszystkie trzy wywołania console.log odnoszą sukces. Każda nazwa jest znajdowana w innym miejscu:

status  → showProfile()
user    → dashboard()
appName → global

Wyjaśnienie tego wymaga krótkiej sekwencji koncepcji:

Scope
   ↓
Lexical Scope
   ↓
Lexical Environment
   ↓
Scope Chain
   ↓
Identifier Resolution

Zakres: gdzie nazwa może być używana

Zakres to obszar kodu, w którym można odwołać się do identyfikatora: odpowiada na pytanie „skąd mogę użyć tej nazwy?”

function dashboard() {
  const user = "Jashan";
  console.log(user); // ✅
}

dashboard();
console.log(user); // ❌ ReferenceError

user należy do dashboard(); kod znajdujący się poza nim powoduje błąd ReferenceError. Przedstawione jako drzewo:

GLOBAL
│
└── dashboard()
      └── user

To prowadzi do następującej zasady:

Inside dashboard  → accessible ✅
Outside dashboard → inaccessible ❌

Dlaczego izolacja jest przydatna

Każda z tych dwóch funkcji deklaruje zmienną data:

function loadPatients() {
  const data = ["Patient A"];
}
function loadAppointments() {
  const data = ["Appointment A"];
}

Nigdy nie dochodzi między nimi do kolizji, ponieważ każda z nich funkcjonuje w swoim własnym zakresie funkcji. Ta izolacja sprawia, że kod jest wielokrotnie używalny i łatwiejszy do debugowania.

Cztery rodzaje zakresu

JavaScript posiada cztery główne typy zakresu:

Scope
├── Global Scope
├── Function Scope
├── Block Scope
└── Module Scope

Zakres globalny

W klasycznym skrypcie (niemodułowym) deklaracje na najwyższym poziomie należą do zakresu globalnego i są dostępne z dowolnego miejsca:

var appName = "HealthApp";

function showAppName() {
  console.log(appName);
}

showAppName() znajduje appName, ponieważ ścieżka wyszukiwania kończy się w środowisku globalnym. Jeden szczegół: deklaracje var na najwyższym poziomie stają się również właściwościami obiektu globalnego (window w przeglądarkach), podczas gdy deklaracje let i const na najwyższym poziomie tak nie robią. Trzymaj stan globalny w minimalnym stopniu – powoduje on powiązanie odległego kodu i utrudnia testowanie.

Zakres funkcji

Wszystko, co jest deklarowane wewnątrz funkcji, jest lokalne dla niej:

function calculateTotal() {
  const price = 100;
  const tax = 20;
  return price + tax;
}
console.log(price); // ❌

price i tax są zmiennymi lokalnymi, dlatego ostatni log wywołuje błąd.

var ignoruje bloki

var ma zakres najbliższej funkcji, a nie najbliższego pary nawiasów:

function example() {
  if (true) {
    var message = "Hello";
  }
  console.log(message); // ✅ Hello
}

Blok if nie zawiera zmiennej message, więc pozostaje czytelny po zakończeniu tego bloku.

Zakres bloku z let i const

Zmienne let i const są ograniczone do bloku, w którym zostały zadeklarowane:

if (true) {
  const message = "Payment successful";
  let status = "completed";
}

console.log(message); // ❌
console.log(status);  // ❌

Oba logi nie działają poprawnie. Pętle zachowują się w ten sam sposób, a przy każdej iteracji pętli for (let ...) zmienne i otrzymuje nową wartość:

for (let i = 0; i < 3; i++) {
  console.log(i);
}
console.log(i); // ❌

Należy pamiętać, że nawiasy literala obiektu opisują wartość, a nie blok, więc nie tworzą żadnego zakresu.

Zakres modułu

W modułach ES deklaracje na najwyższym poziomie są prywatne dla danego pliku:

// auth.js
const token = "secret";
export function getUser() {}

Inny moduł nie może uzyskać dostępu do zmiennej token:

// dashboard.js
console.log(token); // ❌

Tylko to, co moduł wyraźnie eksportuje, może być importowane w innym miejscu:

import { getUser } from "./auth.js";

To jest standardowy zakres w projektach React, Next.js, Vite oraz nowoczesnych projektach Node.js.

Zakres leksykalny: decyduje pozycja w kodzie źródłowym

JavaScript ma zakres leksykalny: funkcja ma dostęp do zmiennych ze wszystkich zakresów, które ją otaczają w kodzie źródłowym. Ta struktura jest ustalana podczas analizy kodu, zanim cokolwiek zostanie uruchomione.

const name = "Global";

function outer() {
  const name = "Outer";
  function inner() {
    console.log(name);
  }
  inner();
}

outer();

Program wyświetla:

Outer

inner() jest zawarty wewnątrz outer() w kodzie źródłowym, więc name z outer jest najbliższy:

GLOBAL
│
└── outer()
      ├── name = "Outer"
      │
      └── inner()

Poиск name odbywa się od inner i zatrzymuje się przy outer:

inner
  ↓
not found locally
  ↓
outer
  ↓
name = "Outer" ✅

Zakres leksykalny jest określany przez miejsce, w którym zdefiniowana została funkcja, a nie przez to, gdzie jest ona wywoływana.

Pułapka na rozmowie kwalifikacyjnej: definicja versus wywołanie

Teraz przenieś inner() poza outer(), podczas gdy outer() nadal go wywołuje:

const name = "Global";
function outer() {
  const name = "Outer";
  inner();
}
function inner() {
  console.log(name);
}
outer();

Częstym przypuszczeniem jest:

Outer

Rzeczywisty wynik jest następujący:

Global

inner() jest zdefiniowany na najwyższym poziomie, obok outer(), a nie w jego wnętrzu.

GLOBAL
├── name = "Global"
├── outer()
└── inner()

Dlatego ścieżka wyszukiwania trafia bezpośrednio do środowiska globalnego:

inner
  ↓
Global

Nie przechodzi przez outer:

inner
  ↓
outer ❌

To, że outer() wywołało inner(), nic nie zmienia. Miejsce wywołania kształtuje stos wywołań; miejsce definicji określa zakres leksykalny.

Środowiska leksykalne: mechanizm stojący za zakresem

Specyfikacja ECMAScript modeluje zakres za pomocą środowisk leksykalnych. Każde z nich rejestruje dwie rzeczy:

  1. identyfikatory związane w tym środowisku
  • link do środowiska, które je otacza
  • Jako model mentalny:

    Lexical Environment
    │
    ├── Environment Record
    └── Outer Environment Reference
    

    Nowsze wersje specyfikacji łączą zewnętrzny link z Rekordem Środowiska jako pole [[OuterEnv]]; model pozostaje niezmieniony.

    Rekord środowiska przechowuje powiązania

    Weźmy funkcję z dwiema lokalnymi stałymi:

    function dashboard() {
      const user = "Jashan";
      const role = "Admin";
    }
    

    Koncepcyjnie jej rekord środowiska mapuje nazwy na wartości:

    dashboard Environment
    user → "Jashan"
    role → "Admin"
    

    To jest model, a nie obiekt, który twój kod może sprawdzić.

    Zewnętrzne odniesienie łączy środowiska

    Każde środowisko wskazuje na to, które leksykalnie je otacza:

    const appName = "HealthApp";
    
    function dashboard() {
      const user = "Jashan";
    }
    

    Dla dashboard() wygląda to w ten sposób:

    dashboard Environment
    │
    ├── user = "Jashan"
    └── Outer
          ↓
    Global Environment
    

    Ten wskaźnik umożliwia wyszukiwanie poza zakresem lokalnym. Jest ustawiany podczas tworzenia funkcji, w zależności od miejsca jej definicji, dlatego lokalizacja wywołania nie ma znaczenia.

    Śledzenie całego łańcucha

    Z powrotem do początkowego przykładu, z połączonymi trzema logami:

    const appName = "HealthApp";
    
    function dashboard() {
      const user = "Jashan";
      function showProfile() {
        const status = "Active";
        console.log(appName, user, status);
      }
      showProfile();
    }
    
    dashboard();
    

    Gdy uruchamiana jest funkcja showProfile(), jej środowiska są powiązane w ten sposób:

    SHOWPROFILE ENVIRONMENT
    │
    ├── status = "Active"
    └── Outer
          ↓
    DASHBOARD ENVIRONMENT
    │
    ├── user = "Jashan"
    └── Outer
          ↓
    GLOBAL ENVIRONMENT
    │
    ├── appName = "HealthApp"
    └── Outer
          ↓
         null
    

    Zewnętrzy odwołanie środowiska globalnego wynosi null, co kończy łańcuch.

    Łańcuch zakresów i rozwiązywanie identyfikatorów

    Łańcuch zakresów to ścieżka, którą podąża silnik w celu znalezienia identyfikatora: sprawdza bieżące środowisko, następnie podąża za zewnętrznym odwołaniem, aż zostanie znaleziona nazwa lub łańcuch się zakończy.

    Current Environment
            ↓
    Outer Environment
            ↓
    Outer Environment
            ↓
    Global
            ↓
    null
    

    Znajdowanie powiązania wzdłuż tej ścieżki nazywa się rozwiązywaniem identyfikatorów. Weźmy trzy takie wyszukiwania:

    console.log(status);
    console.log(user);
    console.log(appName);
    

    Rozwiązywanie problemu z status

    Znaleziono bezpośrednio w środowisku lokalnym:

    showProfile
         ↓
    status found ✅
    

    Rozwiązywanie problemu z user

    Nie ma go lokalnie, znaleziono na poziom wyżej:

    showProfile ❌
         ↓
    dashboard
         ↓
    user found ✅
    

    Rozwiązywanie problemu z appName

    Nie ma go w żadnej z funkcji, znaleziono w środowisku globalnym:

    showProfile ❌
         ↓
    dashboard ❌
         ↓
    Global
         ↓
    appName found ✅
    

    We wszystkich przypadkach wyszukiwanie zatrzymuje się przy pierwszym dopasowaniu. Aby lepiej poznać sam algorytm wyszukiwania, sprawdź jak łańcuch zakresów w JavaScript faktycznie rozwiązuje zmienne.

    Gdy rozwiązanie nie udaje się

    Rozważmy funkcję, która próbuje odczytać nazwę, której nikt nie deklarował:

    function showProfile() {
      console.log(age);
    }
    

    i wywołanie tej funkcji:

    showProfile();
    

    Jeśli age nie istnieje w żadnym z dostępnych środowisk, wyszukiwanie się kończy:

    showProfile
         ↓
    Global
         ↓
    null
    

    Motor nie może rozwiązać nazwy i rzuca błędem:

    ReferenceError: age is not defined
    

    Różni się to od zmiennej zawierającej undefined lub związku typu let, który jeszcze nie został inicjalizowany (strefa martwa tymczasowa). Te trzy stany łatwo się mylić:

    undefined
    uninitialized
    undeclared
    

    Trzy zasady obejmujące większość przypadków

    Szukanie odbywa się tylko na zewnątrz

    Inner
      ↓
    Outer
      ↓
    Global
    

    Kod wewnętrzny może odczytywać zmienne zewnętrzne. Kod zewnętrzny nie może dotrzeć do zagnieżdżonego zakresu, aby odczytać zmienne deklarowane tylko tam.

    Zwycięża najbliższy związek

    Gdy ta sama nazwa istnieje na kilku poziomach, używany jest ten najbliższy:

    const user = "Global User";
    
    function dashboard() {
      const user = "Dashboard User";
      function showProfile() {
        console.log(user);
      }
      showProfile();
    }
    

    Wynik:

    Dashboard User
    

    Poszukiwanie kończy się w dashboard, po jednym kroku wyżej:

    showProfile ❌
         ↓
    dashboard ✅
         ↓
    STOP
    

    Globalna zmienna user nigdy nie jest sprawdzana. Taka wewnętrzna deklaracja, która ukrywa zmienną zewnętrzną, nazywana jest zacienianiem.

    Nic nie jest kopiowane do funkcji wewnętrznych

    Kuszącym, ale błędnym obrazem jest to, że funkcje wewnętrzne otrzymują kopie zmiennych zewnętrznych:

    showProfile
    ├── copied user
    └── copied appName
    

    Prawidłowy obraz to łańcuch referencji:

    showProfile
         ↓
    dashboard
         ↓
    Global
    

    Czytanie wartości przez link oznacza, że funkcja wewnętrzna zawsze widzi aktualną wartość zmiennych zewnętrznych, co umożliwia działanie zamknięć funkcji.

    Łańcuch zakresów versus stos wywołań

    Oto ponownie przykład pułapki:

    const name = "Global";
    
    function outer() {
      const name = "Outer";
      inner();
    }
    function inner() {
      console.log(name);
    }
    
    outer();
    

    Gdy wykonywana jest funkcja inner(), stos wywołań wygląda w ten sposób:

    ┌──────────────────┐
    │    inner FEC     │ ← Current
    ├──────────────────┤
    │    outer FEC     │
    ├──────────────────┤
    │    Global EC     │
    └──────────────────┘
    

    Funkcja outer() znajduje się oczywiście na stosie, ponieważ wywołała inner(). Mimo to inner() nadal wyświetla:

    Global
    

    ponieważ wyszukiwanie wartości odbywa się według łańcucha zakresów, a łańcuch funkcji inner prowadzi bezpośrednio do środowiska globalnego. Te dwie struktury odpowiadają na różne pytania:

    • Stos wywołań śledzi konteksty wykonawcze, które są obecnie aktywne; łańcuch zakresu rozwiązuje identyfikatory.
    • Stos wywołań jest uporządkowany według tego, która funkcja wywołuje którą; łańcuch zakresu jest kształtowany przez sposób użycia nawiasów w kodzie źródłowym.
    • W stosie wywołań obowiązuje zasada „ostatnio dodane, pierwsze usunięte”; łańcuch zakresu podąża za powiązaniami ze zewnętrznym środowiskiem.
    • Stos wywołań odpowiada na pytanie „co jest aktualnie w trakcie działania?”, natomiast łańcuch zakresu odpowiada na pytanie „gdzie znajduje się ten identyfikator?”

    Konteksty wykonawcze i środowiska leksykalne

    Kontekst wykonawczy

    Kontekst wykonawczy przechowuje stan niezbędny podczas działania kodu. Istnieją dwa główne rodzaje:

    Global Execution Context
    Function Execution Context
    

    Środowisko leksykalne

    Środowisko leksykalne przechowuje powiązania identyfikatorów oraz odnośnik do jego zewnętrznego środowiska. Kontekst wykonawczy odwołuje się do swojego środowiska mniej więcej w ten sposób:

    Execution Context
          │
          └── environment information
                   ↓
           Lexical Environment
                   │
                   ├── bindings
                   └── outer environment
    

    Zatem nie są to to samo:

    Execution Context ≠ Lexical Environment
    

    Kontekst wykonywania wyjaśnia, jak uruchamia się kod; środowisko leksykalne wyjaśnia, jak znajdują się nazwy.

    Zakres leksykalny w komponentach React

    Kod React stale polega na zakresie leksykalnym. Rozważmy obsługę zdarzeń zdefiniowaną wewnątrz komponentu:

    function UserDashboard({ userId }: { userId: string }) {
      const role = "Admin";
    
      function handleProfileClick() {
        console.log(userId);
        console.log(role);
      }
    
      return (
        <button onClick={handleProfileClick}>
          View Profile
        </button>
      );
    }
    

    handleProfileClick() nie deklaruje żadnej z tych nazw:

    userId
    role
    

    Mimo to je używa, ponieważ jest napisana wewnątrz UserDashboard:

    handleProfileClick
            ↓
    UserDashboard
            ├── userId
            └── role
    

    Ten sam mechanizm dotyczy propów, stanu, funkcji zwrotnych, timerów, obietnic i hooków. Każde renderowanie tworzy nowe środowisko z wartościami tego renderowania, a funkcje zwrotne zdefiniowane w jego trakcie odnoszą się dokładnie do tych wartości. To jest podstawa zamknięć, a także starych zamknięć, w których funkcja zwrotna z wcześniejszego renderowania nadal odczytuje stary stan.

    Powszechne błędne przekonania

    "Wyszukiwanie odbywa się zgodnie z miejscem wywołania"

    To nieprawda. Decydujące jest miejsce definicji:

    Where function is called ❌
    Where function is defined ✅
    

    ">Stos wywołań i łańcuch zakresu to to samo"

    Służą one różnym celom:

    Call Stack  → execution
    Scope Chain → identifier lookup
    

    ">Kod zewnętrzny może odczytywać zmienne wewnętrzne"

    Wyszukiwanie jest jednokierunkowe:

    Inner → Outer → Global ✅
    Outer → Inner ❌
    

    "var uwzględnia bloki"

    To nieprawda; tylko let i const to robią:

    var       → function-scoped
    let/const → block-scoped
    

    ">Środowisko leksykalne to zwykły obiekt"

    Jest to koncepcja z specyfikacji; silniki implementują je swobodnie, a twój kod nie może do nich uzyskać dostępu.

    Model mentalny do zapamiętania

    Temat można streścić w serii pytań:

    Scope
       ↓
    Where can I use this variable?
       ↓
    Lexical Scope
       ↓
    Where was this function defined?
    Lexical Environment
       ↓
    What variables exist here?
    Outer Environment
       ↓
    Where do I look next?
    Scope Chain
       ↓
    Current → Outer → Global
    Identifier Resolution
       ↓
    Find the matching variable
    

    Główne wnioski

    • Zakres określa, gdzie można używać nazwy; JavaScript posiada zakres globalny, funkcji, bloku i modułu, przy czym var jest ograniczony do funkcji, a let/const – do bloków.
    • Zakres jest leksykalny: zewnętrzne środowisko funkcji jest ustalone w miejscu jej definicji, więc wywołanie jej z innego miejsca nigdy nie zmienia tego, co może ona widzieć.
    • Rozwiązywanie identyfikatorów odbywa się od bieżącego środowiska na zewnątrz, zatrzymuje się przy pierwszym dopasowaniu (zaciemnianiu) i wywołuje błąd ReferenceError, jeśli dotrze do końca.
    • Funkcje wewnętrzne przechowują odniesienia do zewnętrznych środowisk, a nie ich kopie, co stanowi podstawę zamknięć oraz zastarzałych zamknięć w React.

    Literatura pokrewna

  • Przejście na htmx 4: Fetch, wyraźna dziedziczność i co zostaje zniszczone — Zrozum zmiany w architekturze htmx 4, od rdzenia opartego na Fetch i wyraźnej dziedziczności atrybutów po mechanizmy wymiany błędów, transformacji i historii zmian, oraz zaplanuj bezpieczną migrację.
  • Virtual DOM Diffing demystyfikowany: Co React porównuje i dlaczego to przynosi korzyści — Zrozum, czym naprawdę jest Virtual DOM w React, jak proces synchronizacji porównuje dwa drzewa elementów, jakie zmiany zachodzą na etapie zapisu oraz skąd pochodzi poprawa wydajności.