Hoisting zmiennych i funkcji w JavaScript

Prowadzę bloga już od pewnego czasu ale w zasadzie nie omawiałem dotąd jednej z podstawowych kwestii, jaką jest hoisting zmiennych i funkcji w JavaScript. Obecnie wiele osób zaleca stosowanie wyłącznie deklaracji let/const ale wielu programistów nadal używa instrukcji var, często nie zdając sobie do końca sprawy z występowania „zjawiska” hoistingu.

Na początek warto powiedzieć, czym w zasadzie jest wspomniany „hoisting”. Najogólniej mówiąc można zdefiniować go jako automatyczne przenoszenie deklaracji zmiennych i funkcji na początek zakresu leksykalnego. Jest jednak pewien problem… otóż taką definicję można było stosować w czasach ES5. Obecnie natomiast jest to nieco bardziej skomplikowane, co postaram się krok po kroku omówić w dzisiejszym wpisie.

Artykuł ten w pewnym sensie nawiązuje do dwóch wcześniejszych wpisów dotyczących Zasięgu zmiennych oraz Deklaracji let/const.

Hoisting zmiennych deklarowanych instrukcją var

W ECMAScript 6 wprowadzono dwie nowe instrukcje służące do deklarowania zmiennych: let oraz const, ale nadal można używać instrukcji var i wielu programistów tak właśnie robi. Ze względu na konieczność zachowania zgodności wstecz i umożliwienia korzystania z transpilatorów prawdopodobnie var nigdy nie zniknie z JS, dlatego warto poznać pewne zasady związane z tą instrukcją.

W przypadku deklaracji let lub const nie występuje automatyczne przenoszenie deklaracji, co może spowodować wystąpienie błędu ReferenceError, ale do tego zagadnienia powrócimy za chwilę.

Zacznijmy od prostego przykładu, który dokładnie obrazuje „zjawisko” hositingu:

(function () {
    console.log(x);
    var x = 5;
    console.log(x);
})();

//undefined -> pierwsze console.log
//5 -> drugie console.log

Teoretycznie pierwsze wywołanie console.log powinno zwrócić błąd, gdyż odwołujemy się do zmiennej, którą deklarujemy dopiero później. Tak by było, gdybyśmy zamiast var użyli słowa let lub const. W naszym wypadku doszło jednak do automatycznego (ukrytego) przeniesienia deklaracji zmiennej x na początek zakresu leksykalnego. Celowo zastosowałem tutaj funkcję IIFE żeby ułatwić czytelnikom weryfikację kodu w consoli przeglądarki. Bez IIFE consola może wyrzucić błąd ReferenceError.

Nasz kod tak na prawdę przez JS jest interpretowany następująco:

(function () {
    var x; //tożsame z var x = undefined
    console.log(x);
    x = 5; //zmiana wartości zmiennej x
    console.log(x);
})();

//undefined -> pierwsze console.log
//5 -> drugie console.log

Widzimy zatem, że JavaScript „w tle” przeniósł deklarację zmiennej x na początek zakresu bez przypisania jej wartości. W praktyce zapis „var x" oznacza to samo co zapis „var x = undefined„, gdyż w JS undefined oznacza po prostu „brak wartości”.

Z tego powodu pierwsze wywołanie console.log odwołuje się właśnie do undefined i taką wartość wyświetla. Dopiero po nim następuje zmiana wartości zmiennej x co uwzględnia drugie console.log.

Zwróć uwagę, że użyłem tutaj słowa „zmiana wartości”, a nie „przypisanie wartości”. Przypisanie bowiem można wykonać tylko w czasie deklaracji zmiennej, a później można jedynie zmieniać jej wartość. To właśnie kluczowa kwestia w analizie hoistingu.

Czy warto zatem deklarować zmienne na początku zakresu?

Na to pytanie nie ma jednoznacznej odpowiedzi. Nie ma obowiązku deklarowania wszystkich zmiennych na początku zakresu, jednakże pozwala to w wielu przypadkach na lepszą kontrolę stosowanych zmiennych i zarządzanie nimi.

Nie zawsze jednak faktycznie poprawia to czytelność kodu. Przykładem może być chociażby standardowa deklaracja zmiennej „i” w pętlach for:

for (var i = 0; i < 10; i+=2) {
    console.log(i)
}

//0
//2
//4
//6
//8

W powyższym przykładzie tak na prawdę nastąpiło przeniesienie deklaracji zmiennej „i” przed pętlę:

var i; //undefined
for (i = 0; i < 10; i+=2) {
    console.log(i)
}

//0
//2
//4
//6
//8

Akurat w przypadku pętli for uważam, że lepszą praktyką jest świadome godzenie się na występowanie hoistingu i używanie składni deklarującej wewnątrz pętli. Niektórzy zalecają aby zawsze umieszczać deklaracje wszystkich zmiennych na początku zakresu. Zgadzam się z takim podejściem (o ile pracujemy w ES5 i nie stosujemy let/const o czym piszę na końcu artykułu), ale ja wspomniałem – za wyjątkiem deklarowania zmiennej iteracyjnej w pętli for. Wynika to z faktu, że w standardzie ES6 dysponujemy instrukcją let, która użyta wewnątrz pętli for zachowuje się nieco niestandardowo i powoduje każdorazowe zadeklarowanie nowej zmiennej.

(function () {
    for (var i = 0; i <= 6; i+=2) {
        console.log(i);
    }
    for (var i = 5; i <= 20; i+=5) {
        console.log(i);
    }
})();

//0 -> start pierwszej pętli
//2
//4
//6
//5 -> start drugiej pętli
//10
//15
//20

Zauważ, że w obu przypadkach próbujemy zadeklarować taką samą zmienną „i”. W praktyce jednak deklaracja przenoszona jest na początek funkcji IIFE (var i = undefined) i w każdej z pętli dokonywana jest zmiana wartości zmiennej.

Jeśli interesują Cię niuanse let/const to zapraszam do lektury mojego artykułu Deklaracje let/const, gdzie wyjaśniam to na różnych przykładach (tutaj nie będę ich powielał).

Hoisting funkcji

W JavaScript funkcje tworzymy najczęściej na dwa sposoby: poprzez bezpośredni zapis:

function add (a,b) { return a + b; }

lub poprzez przypisanie funkcji do zmiennej, czyli stworzenie tzw. wyrażenia funkcyjnego:

var fn = function (a,b) { return a + b; };

Czy pod względem hoistingu oba sposoby czymś się różnią? Otóż tak i to zasadniczo.  Przeanalizuj poniższy kod:

(function () {
    var fn = function (a,b) { return a + b; };
    function fn (a,b) { return a * b; }

    return fn(2,3);
})();

Zauważ, że najpierw tworzymy wyrażenie funkcyjne fn, czyli zmienną, której przypisujemy funkcję sumującą przekazane argumenty, a następnie tworzymy jawnie funkcję o nazwie fn, której zadaniem jest mnożenie dwóch liczb. Ostatnim krokiem jest wywołanie naszej funkcji fn z argumentami 2 i 3.

Jaki powinien być wynik? Sumowanie, czyli 5, czy może mnożenie czyli 6?

Otóż wynik to 5, ponieważ w pierwszej kolejności przenoszone są definicje funkcji, a dopiero później następuje hoisting zmiennych deklarowanych przez var. Ostatecznie więc najpierw tworzymy funkcję mnożącą dwa argumenty, ale za chwilę nadpisujemy ją wyrażeniem funkcyjnym sumującym liczby. Ostatecznie pracujemy więc z funkcją sumującą a+b.

A co gdybyśmy dwa razy próbowali zdefiniować funkcję o takiej samej nazwie?

(function () {
    function fn (a,b) { return a * b; }
    function fn (a,b) { return a * b * 2; }

    return fn(2,3);
})();

//12

Wynikiem wykonania naszej funkcji IIFE jest liczba 12, ponieważ kolejne definicje funkcji „fn” nadpisują poprzednie. Ostatecznie więc w funkcji IIFE nigdy nie zostanie wykonana funkcji a*b i zawsze każde wywołanie funkcji fn będzie realizować instrukcję return a*b*2. Należy na to uważać jeśli tworzymy długi kod i świadomie lub przypadkowo kilka razy zdefiniowaliśmy funkcję o takiej samej nazwie (oczywiście dotyczy to tego samego zakresu leksykalnego). Silnik JS nie zgłosi w takiej sytuacji błędu, a znalezienie go może zająć dłuższą chwilę.

No to pobawmy się instrukcjami let/const

Nie będę tutaj prawił teoretycznych wywodów na temat sposobu w jaki silnik JS dokonuje deklaracji zmiennych z użyciem let i const. Myślę, że wszystko wyjaśni się, gdy powrócimy do pierwszego przykładu z dzisiejszego wpisu:

// DEKLARACJA Z UŻYCIEM VAR
(function () {
    console.log(x);
    var x = 5;
    console.log(x);
})();
//undefined -> pierwsze console.log
//5 -> drugie console.log


// DEKLARACJA Z UŻYCIEM LET
(function () {
    console.log(x);
    let x = 5;
    console.log(x);
})();
//ReferenceError


// DEKLARACJA Z UŻYCIEM LET - BEZ PIERWSZEGO CONSOLE.LOG
(function () {
    let x = 5;
    console.log(x);
})();
//5

W drugim przypadku zamieniliśmy słowo var na let. Zauważ jednak, że w tym wypadku nie następuje hoisting i zmienna „x” nie została zadeklarowana na początku funkcji IIFE jako undefined. Otóż w przypadku, gdy w danym zakresie leksykalnym silnik JS wykryje deklarację zmiennej słowem let lub const, to użycie jej jest możliwe dopiero po tej deklaracji. Próba odwołania się do nieistniejącej zmiennej deklarowanej później przez let/const kończy się błędem ReferenceError.

Wiele poradników definiuje deklaracje let/const jako zmienne o zasięgu blokowym. Ja jednak uważam, że nie do końca jest to pełna definicja.  W praktyce zmienne zadeklarowane przez let/const są widoczne wyłącznie w danym bloku (czyli np. instrukcji if), ale dopiero od momentu ich deklaracji! Nie występuje tutaj hoisting do początku zakresu blokowego. Może jest to niuans, ale warto o tym pamiętać jeśli tworzymy bardziej rozbudowane bloki, szczególnie w instrukcjach warunkowych.

Deklaracje zmiennych blokowych w instrukcjach warunkowych

Aby dokładnie wyjaśnić działanie zasięgu i pokazać brak hoistingu deklaracji let/const przeanalizujmy poniższy kod:

(function () {
   let a = 5;
   if (true) {
        let b = 10;
        console.log(a+b);
        let a = 100;
        console.log(a+b);
    }
})();
//ReferenceError

Powyższy kod kończy się błędem ReferenceError, który odnosi się tutaj do próby użycia zmiennej „a”, która nie została jeszcze zadeklarowana. Dotyczy to pierwszego wywołania console.log(a+b). Teoretycznie mogłoby się wydawać, że pierwsze wywołanie console.log powinno odnieść się do zmiennej z zasięgu nadrzędnego, czyli do a=5. Tak się jednak nie stało, ponieważ silnik JS odnalazł w zasięgu bloku instrukcji if deklarację zmiennej „a” z użyciem słowa let.

Zobaczmy jak nasz kod zachowałby się, gdyby deklaracje let zamienić na var:

(function () {
   var a = 5;
   if (true) {
       var b = 10;
       console.log(a+b);
       var a = 100;
       console.log(a+b);
    }
})();
//15
//110

Tym razem uzyskaliśmy prawdopodobnie oczekiwane wyniki. Zobaczmy jednak jak w praktyce analizowany jest nasz kod i co przyczyniło się do takich właśnie wyników:

(function () {
   var a = undefined, //przeniesienie z "var a=100"
       b = undefined; //przeniesienie z "var b=10"
   a = 5;             //zmiana z undefined na 5
   if (true) {
       b = 10; //zmiana z undefined na 10
       console.log(a+b); //5 + 10
       a = 100;          //zmiana z 5 na 100
       console.log(a+b); //100 + 10
    }
})();
//15
//110

Jak zatem deklarować zmienne – z użyciem var czy let/const?

Analizując powyższy przykład można by odnieść wrażenie, że nowa składnia z let/const czasami wprowadza tylko zamieszanie. Z taką opinią spotkałem się w czasie jednej z rozmów prywatnych na temat hoistingu, gdzie tłumaczyłem różnice między var/let właśnie z użyciem tych dwóch przykładów.

Osobiście uważam, że lepszym rozwiązaniem jest stosowanie deklaracji let/const ponieważ przybliża nas to do myślenia kryteriami języków C++/Java, gdzie panuje zasada, że zmienne powinno deklarować się dopiero wtedy, gdy są potrzebne. I na to właśnie pozwala nam zasięg blokowy.

Bardzo często w opisach deklaracji let/const widzę przykłady z instrukcją warunkową IF jako wydzielonym blokiem, do którego te zmienne są ograniczone. Pamiętaj jednak, że pod pojęciem bloku możemy w pewnym sensie rozważać też zakres całej funkcji, czyli część kodu ograniczoną nawiasami klamrowymi. I to właśnie takie myślenie pozwala na precyzyjne określanie miejsc, w których konkretne zmienne są nam potrzebne.

Czy w związku z tym należy kategorycznie unikać deklaracji var? Powiem tak: w praktyce jeśli stosujesz co chwilę inną bibliotekę np. do galerii zdjęć, walidacji danych itp. itd. to istnieje duże prawdopodobieństwo, że i tak w Twoim kodzie znajdą się deklaracje z użyciem var. Jeśli jednak masz pełną kontrolę nad swoim kodem to osobiście uważam, że warto przejść wyłącznie na deklaracje let/const.

Zapewnia nam to większe bezpieczeństwo, na przykład warto pamiętać, że nie można w jednym zasięgu blokowym dwa razy użyć słowa let do tej samej zmiennej. Zmusza nas to do stosowana jednorazowej deklaracji, a następnie do zmiany samej wartości, co wg mnie jest bardziej czytelne i tworzy kod łatwiejszy w utrzymaniu.

A tak na koniec… jeśli korzystasz z transpilatora (np. Babel) to de facto w docelowym kodzie i tak znajdą się wyłącznie deklaracje var, gdyż tylko takie są obsługiwane w ECMAScript 5… i to tyle w temacie „czy var to zło i trzeba z niego zrezygnować” 🙂

  • > A tak na koniec… jeśli korzystasz z transpilatora (np. Babel) to de facto w docelowym kodzie i tak znajdą się wyłącznie deklaracje var, gdyż tylko takie są obsługiwane w ECMAScript 5… i to tyle w temacie „czy var to zło i trzeba z niego zrezygnować”

    Z tym, że w przypadku transpilera wynikowy kod jest tak naprawdę „binarką”, która – dopóki działa – nie powinna nas obchodzić. let/const rozpatrywałbym wyłącznie na płaszczyźnie Developer Experience, jako coś, co pozwala nam pisać bardziej sensowny kod, bez dziwactw typu hoisting – ergo ułatwia nam pracę i myślenie.

    • Szczerze mówiąc to ten ostatni akapit o transpilacji miał być tak trochę z przymrużeniem oka 🙂
      Natomiast nie do końca zgodzę się ze stwierdzeniem, że jeśli coś działa to nie powinno nas obchodzić jak. Uważam, że warto czasami chociaż pobieżnie kontrolować jak dodatkowe narzędzia „obrabiają” nasz kod. A ponad to wciąż wiele osób nagminnie korzysta wyłącznie z var deklarując kilka jak nie kilkanaście razy zmienne o tych samych nazwach.
      Z drugiej strony wiele pisze się, aby przechodzić wyłącznie na let/const. Owszem, zgadzam się, gdyż zwiększa to czytelność i ułatwia utrzymanie kodu, ale gdy takie osoby na ślepo zamienią wszystkie var na let to dostaną ReferenceError dlatego uważam, że warto mimo wszystko poruszać temat hoistingu var i tematykę let/const i świadomie pisać własny kod i używać różnych gotowców (których akurat w świecie JS to chyba przybywa wiele każdego dnia).

      • > Natomiast nie do końca zgodzę się ze stwierdzeniem, że jeśli coś działa to nie powinno nas obchodzić jak.

        Tego nie powiedziałem. Niemniej założeniem transpilacji jest to, że wygenerowany kod nas nie obchodzi. Inaczej byśmy już osiwieli 😉 Natomiast fakt, w ramach ciekawostki można obadać jak Babel optymalizuje pewne case’y.