W nawiązaniu do przedostatniego posta kilka słów o hostingu w javascripcie.

Co to ten hoisting?

W dużym uproszczeniu można sobie wyobrazić, że kompilator/interpreter javascript przenosi sobie (wirtualnie, kod nie jest zmieniany) deklaracje na początek swojego scope. Jeśli coś jest zdefiniowane w ramach funkcji, to na początek funkcji, a jeśli jest zdefiniowane poza funkcją, to na początek global-scope.

Natomiast w trochę mniejszym uproszczeniu, javascript używa czegoś co nazywa się lexical scope. Jest to coś w rodzaju słownika/mapy, gdzie trzyma sobie zmienne itp. widoczne w obecnym scope.

Kod javascript podczas wykonania jest (a przynajmniej oryginalnie był) czytany przez interpreter dwukrotnie. Pierwsza faza przetwarza deklaracje zmiennych i funkcji przenosząc je do lexical scope, a kod jest wykonywany dopiero podczas drugiej fazy. Takie coś pozwala, przykładowo, na odwołanie się do funkcji, która jest zadeklarowana poniżej wywołania.

Deklaracja a definicja

Czemu podkreślam, że podczas pierwszego przejścia przetwarzane są tylko deklaracje? Co to w ogóle za różnica deklaracja a definicja? Spójrzmy na to:

var name; // deklaracja
name = "Adam"; // definicja

W tym przykładzie var name to deklaracja, a dopiero name = "Adam" to definicja. Po pierwszym przejściu, dla interpretera, będzie to wyglądało mniej więcej tak:

lexicalScope = {
  name: undefined,
};

lexicalScope.name = "Adam";

nawet jeśli zrobilibyśmy to w jednej linijce (var name = "Adam").

Zmienne zadeklarowane przy użyciu var są inicjalizowane na undefined, więc jeśli spróbowalibyśmy wykorzystać tą zmienną przed jej definicją, to miałaby wartość undefined.

Przykłady

Spójrzmy na kilka przykładów.

Funkcje

fn();

function fn() {
  console.log("Hello from fn()");
}

tutaj wszystko zadziała - dostaniemy Hello from fn(). Wirtualnie, podczas pierwszego przejścia, javascript znajdzie sobie deklarację fn, wpisze ją do swojego lexical scope i podczas drugiego prześcia będzie ją widział, chociaż w pliku jest zadeklarowana niżej. Podczas drugiego przejścia mamy już coś typu:

lexicalScope = {
  fn: < function >
}

lexicalScope.fn(); // lookup w lexicalScope

function fn() {
 console.log("Hello from fn()");
}

No dobrze, to jest użyteczne. Funkcje zdefiniowane w taki sposób będą hoistowane. Ale zobaczmy,coś może pójść nie tak.

Otóż kiedy zdefiniujemy je jako wyrażenie (expression), to już nie zadziała:

fn();

fn = function () {
  console.log("Hello from fn()");
};

tutaj dostaniemy ReferenceError: Cannot access 'fn' before initialization.

A to dlatego, że kiedy interpreter napotka kod taki jak w pierwszym przypadku, to potraktuje całość jako deklarację funkcji - nie rozdzieli tego na deklarację i definicję. Drugi przypadek potraktuje jako deklarację zmiennej i tutaj już do definicję zobaczy dopiero przy drugim przejściu - deklaracja fn zostanie zostanie przeniesiona do lexical scope, ale definicja już nie, więc podczas wykonania będzie miała wartość undefined.

Zmienne

Co wypisze taki kod (wskazówka była wyżej)?

console.log(`Hello ${name}`);

var name = "Adam";

Można spodziewać się, że dostaniemy błąd referencji. A jednak: Hello undefined. Czemu? Tak jak przy funkcji jako wyrażeniu (bo to de facto ten sam scenariusz), javascript przeniesie sobie deklarację zmiennej na początek scope, a definicję zostawi niżej:

lexicalScope = {
  name: undefined,
};

console.log(`Hello ${name}`);

name = "Adam";

Podczas wykonania, kiedy robimy console.log, to zmienna ma wartość undefined, bo jeszcze nie doszliśmy do jej definicji. Tutaj nie dostaniemy wyjątku, bo undefined to normalna wartość, której używamy, a nie próbujemy traktować jej jako funkcji i wywoływać jej.

let i const

Ponieważ var powoduje spore WTF u programistów (i dużo błędów), to ES6 wprowadziło deklaracje zmiennych przy pomocylet i const. Zobaczmy teraz czemu to sprawiło, że javascript stał się (trochę bardziej ;) ) użytecznym językiem.

console.log(`Hello ${name}`);
let name = "Adam"; // to samo dla `const`

dostaniemy ReferenceError: Cannot access 'name' before initialization. A to dlatego, że zmienne let i const także są hoistowane, ale nie są inicjalizowane na undefined, tak jak zmienne typu var. Podczas pierwszego przejścia zmienna zostanie wpisana do lexical scope (stąd before initialization, a nie is not defined), ale inicjalizacja nastąpi dopiero po napotkaniu jej definicji. W tym przypadku definicja następuje po próbie jej wykorzystania, więc dostaniemy wyjątek.

Klasy

Z klasami będzie jak z let i const. Deklaracja klasy będzie hoistowana, ale nie będzie zaincjalizowana. To oznacza, że klasy możemy użyć dopiero po jej definicji:

const cat = new Cat("Filemon");

class Cat {
  constructor(name) {
    this.name = name;
  }
}

nie zadziała: ReferenceError: Cannot access 'Cat' before initialization.

Jeśli spróbujemy zrobić to samo, ale przez zadeklarowanie klasy jako wyrażenia:

const filemon = new Cat("Filemon");

// albo `const/let Cat`
var Cat = class {
  constructor(name) {
    this.name = name;
  }
};

to przy wykorzystaniu var dostaniemy TypeError: Cat is not a constructor (prawda, w końcu to undefined), a przy const/let spodziewany ReferenceError.

Podsumowanie

Hoisting w javascripcie bywa mylący, ale mam nadzieję, że ten post komuś trochę rozjaśnił temat. Mi napisanie tego na pewno pomogło w zrozumieniu mechanizmu hoistingu.

Ze swojej strony mocno zachęcam do zrobienia sobie i innym przysługi i używania let i const zamiast var wszędzie, gdzie się da.

Kod przykładów można znaleźć tutaj, a po więcej szczegółów zachęcam do zerknięcia na ten tutorial i ten wpis.