Hoisting i domknięcia w JavaScript – jak działa zakres zmiennych?

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);
}

Programista i twórca serwisu Creative Coding, absolwent Politechniki Warszawskiej (WEiTI). Od 10+ lat łączy front‑end, grafikę generatywną i narzędzia dla twórców; opublikował 120+ projektów i artykułów, prowadził warsztaty dla 2 000+ uczestników. Pracuje z JavaScriptem, Three.js, P5.js i GLSL, bada wydajność i dokumentuje procesy, tworząc praktyczne przewodniki dla osób łączących kod z obrazem, dźwiękiem i interakcją.
Zostaw komentarz

Komentarze

Brak komentarzy. Dlaczego nie rozpoczniesz dyskusji?

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Wymagane pola są oznaczone *