Hoisting, domknięcia i zakres zmiennych tworzą wspólny mechanizm, który kontroluje dostępność danych, alokację pamięci i kontekst wykonania w JavaScripcie.
Hoisting to domyślne zachowanie silnika polegające na rejestrowaniu deklaracji na początku ich zakresów przed uruchomieniem kodu, co wpływa na to, kiedy i gdzie zmienne oraz funkcje są dostępne.
Domknięcia pozwalają funkcjom zachować dostęp do zmiennych z ich zakresu leksykalnego nawet po zakończeniu wykonania tego zakresu, dzięki czemu możliwa jest enkapsulacja i utrzymywanie stanu.
Zakres zmiennej określa, gdzie w programie można się do niej odwołać, ustalając granice widoczności i kontroli dostępu. Zrozumienie tych trzech koncepcji jest kluczowe, by pisać wydajny, przewidywalny kod i unikać subtelnych błędów.
Fundamentalna natura zakresu zmiennych w JavaScript
Zakres wyznacza, gdzie zmienne można odczytywać i modyfikować. JavaScript tworzy hierarchię dostępności: od globalnego, przez funkcje, po bloki.
Niewłaściwe zarządzanie zakresem prowadzi do konfliktów nazw, wycieków pamięci i trudnych do wykrycia błędów.
Trzy podstawowe rodzaje zakresu w praktyce wyglądają następująco:
- zakres globalny – zmienne dostępne w całym programie; w przeglądarce stają się właściwościami window, co zwiększa ryzyko kolizji nazw;
- zakres funkcji (lokalny) – zmienne zadeklarowane wewnątrz funkcji są widoczne tylko w jej ciele i znikają po zakończeniu działania (o ile nie są referencje);
- zakres blokowy – zmienne let i const żyją wyłącznie wewnątrz pary klamr (np. w if, for), w przeciwieństwie do var, który ma zakres funkcji.
Dla czytelności porównaj zachowanie var i let w bloku:
// var — widoczny poza blokiem (zakres funkcji)
function demoVar() {
if (true) {
var x = 1;
}
console.log(x); // 1
}
// let — widoczny tylko w bloku
function demoLet() {
if (true) {
let y = 2;
}
console.log(y); // ReferenceError
}
Mechanizm i zachowanie hoistingu
W fazie tworzenia kontekstu wykonania silnik rejestruje deklaracje, zanim uruchomi jakiekolwiek instrukcje. To nie „fizyczne przenoszenie” kodu, lecz sposób, w jaki silnik przygotowuje środowisko przed wykonaniem.
Deklaracje funkcji są hoistowane w całości i można ich użyć przed miejscem zapisania w kodzie, co ułatwia organizację dużych modułów.
Najważniejsze różnice w hoistingu i dostępie do zmiennych przedstawia poniższa tabela:
| Konstrukcja | Zakres | Co się dzieje podczas hoistingu | Dostęp przed deklaracją |
|---|---|---|---|
| var | funkcja lub globalny | rejestracja i inicjalizacja do undefined | dozwolony, zwraca undefined |
| let | blok | rejestracja bez inicjalizacji (TDZ) | ReferenceError (w TDZ) |
| const | blok | rejestracja bez inicjalizacji (TDZ), wymaga wartości w deklaracji | ReferenceError (w TDZ) |
| deklaracja funkcji | jak w miejscu definicji | hoistowana cała definicja (nagłówek + ciało) | dozwolony, działa poprawnie |
| wyrażenie funkcyjne przypisane do var | funkcja lub globalny | hoistowana jest zmienna (= undefined), nie przypisanie funkcji | TypeError przy wywołaniu (undefined nie jest funkcją) |
| wyrażenie funkcyjne przypisane do let/const | blok | rejestracja bez inicjalizacji (TDZ) | ReferenceError przed inicjalizacją |
Poniższe krótkie przykłady ilustrują praktyczne skutki hoistingu:
// var — odczyt przed deklaracją daje undefined
console.log(a); // undefined
var a = 10;
// deklaracja funkcji — działa przed definicją
greet(); // "Hi!"
function greet() { console.log("Hi!"); }
// wyrażenie funkcyjne — różne błędy zależnie od deklaracji
try {
fnVar(); // TypeError: fnVar is not a function
} catch (e) { console.log(e.name); }
var fnVar = function() {};
try {
fnLet(); // ReferenceError
} catch (e) { console.log(e.name); }
let fnLet = function() {};
Zrozumienie tymczasowej martwej strefy (TDZ)
Tymczasowa martwa strefa (TDZ) to okres od wejścia nazwy do zakresu do chwili jej inicjalizacji. Zmienna istnieje, ale jest niedostępna.
Próba dostępu w TDZ kończy się ReferenceError „Cannot access … before initialization”. Dla const dodatkowo wymagana jest inicjalizacja w miejscu deklaracji.
Najważniejsze reguły TDZ warto zapamiętać w skrócie:
- dotyczy let i const – obie deklaracje są hoistowane bez inicjalizacji i pozostają niedostępne do momentu przypisania;
- kończy się w linii inicjalizacji – od tej instrukcji odwołania są bezpieczne i przewidywalne;
- cieniowanie tworzy własną TDZ – wewnętrzna zmienna o tej samej nazwie maskuje zewnętrzną aż do swojego zainicjalizowania.
Przykład TDZ i cieniowania tej samej nazwy w zakresie wewnętrznym:
let value = 1;
{
// Próba użycia 'value' odnosi się do wewnętrznej nazwy (w TDZ), nie do zewnętrznej
try { console.log(value); } catch (e) { console.log(e.name); } // ReferenceError
let value = 2;
console.log(value); // 2
}
// const bez inicjalizacji — błąd składniowy (SyntaxError) już podczas parsowania
// const x; // SyntaxError
Zakres leksykalny i podstawa domknięć
JavaScript stosuje wyłącznie zakres leksykalny — dostępność zmiennych wynika z miejsca zapisu funkcji w kodzie, a nie z kontekstu wywołania.
Funkcja „zapamiętuje” środowisko, w którym została zdefiniowana, tworząc łańcuch zakresów od wewnątrz na zewnątrz. Zakresy zewnętrzne nie widzą zmiennych zakresów wewnętrznych, ale te wewnętrzne mają dostęp do zewnętrznych.
Schemat leksykalny najlepiej widać w przykładzie:
const outer = 10;
function make() {
const inner = 20;
return function use() {
console.log(outer + inner); // 30 — dostęp do 'outer' i 'inner'
};
}
const fn = make();
fn(); // nadal widzi 'inner' po zakończeniu make()
Domknięcia – funkcje z pamięcią
Domknięcie to funkcja, która zachowuje dostęp do swojego zakresu leksykalnego po zakończeniu działania funkcji tworzącej, dzięki czemu może przechowywać i modyfikować stan bez uciekania się do globali.
Typowe i praktyczne zastosowania domknięć obejmują:
- prywatny stan – ukrywanie danych i tworzenie „pól prywatnych” bez klas;
- fabryki funkcji – generowanie wyspecjalizowanych funkcji poprzez częściową aplikację parametrów;
- obsługa zdarzeń i callbacków – przekazywanie kontekstu i zachowanie dostępu do danych w momencie wywołania.
Klasyczny licznik z prywatnym stanem działa tak:
function createCounter(start = 0) {
let count = start;
return {
inc() { count += 1; return count; },
dec() { count -= 1; return count; },
get() { return count; }
};
}
const c1 = createCounter(5);
console.log(c1.inc()); // 6
console.log(c1.get()); // 6
const c2 = createCounter();
console.log(c2.get()); // 0 — niezależny stan
Uważaj na domknięcia w pętlach z var; każda iteracja współdzieli jedną zmienną. let tworzy nowe wiązanie na iterację i eliminuje błąd:
// Z var — wszystkie logują 3
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log("var:", i), 0);
}
// Z let — każde loguje właściwą wartość 0, 1, 2
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log("let:", j), 0);
}