Hoisting w JavaScript, czyli o czym tak naprawdę mówi ta metafora

Standardowe tłumaczenie hoistingu mówi, że deklaracje przesuwają się na górę kodu. Specyfikacja ECMA-262 nie definiuje hoistingu jako mechanizmu, a let zachowuje się tak, jakby tej zasady nie znał. Co więc naprawdę dzieje się przed wykonaniem twojego kodu i dlaczego TDZ wygląda dokładnie tak, jak wygląda?
Większość ludzi pierwszy raz spotyka się z hoistingiem w taki sposób: deklaracje zmiennych i funkcji są przesuwane na górę kodu, jeszcze zanim cokolwiek się wykona. Dla funkcji ta zasada tłumaczy, dlaczego coś takiego po prostu działa:
greet("Anna");
function greet(name) {
console.log(`Cześć, ${name}!`);
}Wywołujesz greet zanim go zdefiniujesz, a silnik nie protestuje. Skoro funkcja jest dostępna wcześniej, ktoś musiał ją tam podnieść. Stąd słowo hoisting - z angielskiego hoist, podnosić. Zostańmy przy nim i zobaczmy, jak daleko ta metafora sięga.
Dla zmiennych ta sama teoria mówi nieco inaczej: na górę przesuwa się sama deklaracja, ale nie przypisanie wartości. Dlatego to też działa bez błędu:
console.log(value); // undefined
var value = 10;Według teorii nazwa value jest już zarejestrowana na początku scope’u, więc console.log(value) ma się do czego odwołać - silnik widzi, że taka nazwa istnieje. Wartość 10 zostaje natomiast w swojej linii i trafia do zmiennej dopiero w momencie, gdy silnik tam dojdzie. Pomiędzy nimi value jest undefined - i to się zgadza z obserwacją. Logika spójna.
Wystarcza, dopóki nie zrobisz tego samego z let-em:
console.log(value);
let value = 10;Według tej samej teorii powinieneś dostać undefined - tak samo jak przy var. Tymczasem silnik rzuca ReferenceError: Cannot access 'value' before initialization. Coś tu się nie zgadza. Czas spojrzeć, co się tam naprawdę dzieje - bo to nie jest przesuwanie kodu.
#Silnik wykonuje twój kod w dwóch krokach
Zanim silnik uruchomi pierwszą linię, najpierw musi cały plik przeczytać. To są dwa osobne etapy. Opisałem je wcześniej w poście o execution context - tu chodzi o jeden szczegół: nazwy są znane silnikowi zanim którakolwiek linia kodu się wykona.
W pierwszym etapie - fazie kompilacji - silnik przechodzi przez każdy scope i robi inwentaryzację: jakie deklaracje tu są, jakie nazwy będą widoczne, gdzie one będą wskazywać. Tworzy w pamięci strukturę zwaną Environment Record - listę identyfikatorów dostępnych w danym scope. Spec nazywa ten krok GlobalDeclarationInstantiation. To wszystko dzieje się zanim jakakolwiek twoja linia kodu się wykona.
W drugim etapie - wykonaniu - silnik leci po kodzie linia po linii. Kiedy napotyka odwołanie do nazwy, sprawdza w Environment Record bieżącego scope (i w łańcuchu rodziców), czy taka nazwa istnieje, a jeśli tak - co tam siedzi.
Hoisting to potoczna nazwa dla tego, że nazwy są zarejestrowane przed wykonaniem. Nic się fizycznie nie przesuwa. Twój plik źródłowy zostaje taki, jaki napisałeś - tylko silnik podzielił sobie pracę na dwa przebiegi i wszystkie nazwy znał już przed pierwszą linią.
#Trzy deklaracje, trzy zachowania
I tu zaczyna się ciekawszy wątek - bo nie każda deklaracja jest zarejestrowana w ten sam sposób. To, co dostaniesz w Environment Record dla function, var i let po fazie kompilacji, jest trzema różnymi rzeczami.
Deklaracja funkcji (function nazwa() {}) jest po kompilacji w pełni utworzona. W Environment Record nazwa wskazuje już na gotowy obiekt funkcji, z ciałem i wszystkim. Dlatego wywołanie funkcji przed jej deklaracją tekstową w pliku po prostu działa - dla silnika ta funkcja istniała przed pierwszą linią.
Deklaracja var (var nazwa = ...) jest hoistowana, ale tylko jako sama nazwa - identyfikator istnieje w Environment Record, a jego wartość to undefined. Konkretna wartość trafia tam dopiero w fazie wykonania, kiedy silnik dojdzie do linii z przypisaniem.
To wyjaśnia subtelność, którą warto zapamiętać. Function declaration jest hoistowana z całym ciałem. Function expression przypisana do var - już nie. Nazwa jest, owszem, zarezerwowana, ale wartość to undefined, dopóki silnik nie wykona linii z przypisaniem.
Deklaracja let (i const) jest hoistowana, ale w stanie uninitialized. Nazwa istnieje w Environment Record, ale nie da się do niej sięgnąć - próba czytania rzuca ReferenceError. To jest Temporal Dead Zone (TDZ). Stan uninitialized kończy się dopiero w momencie, gdy silnik wykona linię z deklaracją w fazie wykonania.
Najszybciej zobaczyć to wszystko obok siebie:
Tak nas uczą
Popularna intuicja: silnik najpierw skanuje plik i przesuwa wszystkie deklaracje na górę swojego scope. Funkcje, var, let, const - wszystkie razem.
1console.log(addOne(5));2console.log(addTwo(5));3console.log(triple);4 5function addOne(x) {6 return x + 1;7}8var addTwo = function (x) {9 return x + 2;10};11let triple = 3;↑ wszystkie skaczą do góry
Brzmi przekonująco - i częściowo wyjaśnia, dlaczego można wywołać funkcję zanim ją zdefiniujesz. Ale nie wyjaśnia, dlaczego let rzuca ReferenceError. Bo to nie tak działa silnik.
Trzy linie u góry pliku odpalają trzy różne sytuacje:
addOne(5)działa, boaddOnejest deklaracją funkcji - po fazie kompilacji jest w pamięci w komplecie.addTwo(5)rzucaTypeError, boaddTwoto wyrażenie funkcyjne przypisane dovar. W Environment Record stoiundefined, a wywoływanieundefinedjako funkcji to TypeError.triplerzucaReferenceError, bolet triplejest w fazie kompilacji zarejestrowane jako uninitialized, a do uninitialized nazw silnik nie pozwala się dostać.
Wszystkie trzy są „hoistowane” - w tym sensie, że ich nazwy istnieją w Environment Record przed pierwszą linią kodu. Ale zachowują się na trzy różne sposoby, bo hoisting nie jest jedną operacją. Jest skrótem myślowym dla „nazwa zarejestrowana w fazie kompilacji”, a nie dla „wartość gotowa od razu”.
#TDZ ma swoje powody
TDZ - czyli ten okres, w którym let i const istnieją, ale rzucają ReferenceError - bywa nazywany dziwactwem standardu. Ale jeśli prześledzisz to, co właśnie pokazałem, TDZ wynika naturalnie z dwóch decyzji projektowych.
Pierwsza decyzja: let i const są hoistowane jak inne deklaracje. Inaczej mówiąc - silnik ma znać te nazwy od początku scope’u, jeszcze przed wykonaniem pierwszej linii w bloku, a nie dopiero w momencie, gdy w trakcie wykonywania kodu dojdzie do linii z let albo const. To upraszcza całą analizę scope - wszystko, co jest widoczne w danym bloku, jest znane już na samym jego starcie. Ten sam mechanizm działa dla class - deklaracje klas też są zarejestrowane w stanie uninitialized aż do swojej linii w kodzie.
Druga decyzja: let i const nie mogą udawać var-a. var daje ci undefined przed przypisaniem - to znaczy, że kod napisany nieprawidłowo przechodzi, tyle że milcząco. Standard ES2015 chciał uciąć to milczące przepuszczanie. Skoro nazwa jest zarejestrowana, ale wartości jeszcze nie ma - silnik nie wraca z undefined. Wraca z błędem.
Innymi słowy: TDZ to różnica między „nazwa nie istnieje” a „nazwa istnieje, ale jej wartość jeszcze nie została nadana”. var łączy te dwie sytuacje - obie zwracają undefined. let/const celowo je rozdzielają.
Mechanicznie wygląda to tak: w specyfikacji w §9.1.1.1.6 GetBindingValue jest zapisany krok: jeśli odczytujesz nazwę, która jeszcze nie została zainicjalizowana, rzuć ReferenceError. To nie jest specjalna ścieżka dla TDZ - to jest domyślne zachowanie dla uninitialized wpisów w Environment Record. TDZ jest tylko nazwą dla okresu, w którym ten warunek jest prawdziwy dla let/const/class.
#Hoisting to kiedy, a nie co
Wracając do głównego wątku. Hoisting nie jest operacją na twoim kodzie. Twój plik nie jest reorganizowany, deklaracje nie skaczą do góry, nic się fizycznie nie przesuwa. Silnik dzieli swoją pracę na dwa przebiegi - kompilację i wykonanie - i jedyne, co „hoisting” w skrócie opisuje, to fakt, że nazwy są rejestrowane w fazie kompilacji, zanim pierwsza linia twojego kodu się wykona.
To wszystko sprowadza się do dwóch praktycznych konsekwencji:
- Funkcje deklarowane przez
functionsą dostępne wszędzie w swoim scope, włącznie z liniami przed deklaracją tekstową. To dlatego, że po kompilacji nazwa wskazuje już na pełny obiekt funkcji. var,let,constwszystkie mają zarezerwowane nazwy, ale różny stan początkowy -varmaundefined,let/constsą uninitialized (czytanie =ReferenceError). Stan uninitialized kończy się dopiero, gdy faza wykonania dojdzie do linii z deklaracją.
Stąd reguła praktyczna, którą i tak przyjąłeś, jeśli pisałeś nowoczesny JS: deklaruj zanim użyjesz. Nie dlatego, że hoisting jest „zły” - tylko dlatego, że let/const nie pozwalają inaczej, a var pozwala, ale dostajesz undefined, gdzie najczęściej chciałeś dostać błąd.
Wracając do tego, czym zaczęliśmy: deklaracje nie przesuwają się na górę kodu. Silnik tylko inwentaryzuje twoje nazwy, zanim cokolwiek wykona - a z tej jednej decyzji wypadają trzy różne zachowania: gotowe funkcje, var z undefined, TDZ przy let i const. Trzy efekty, jedna mechanika. Hoisting nie zmienia twojego kodu - to po prostu nazwa momentu, w którym silnik zaczyna o nim wiedzieć.
Komentarze
Wczytywanie komentarzy…