Przewodnik po zmiennych w JavaScript - część 2

W pierwszej części przewodnika po zmiennych pisałem czym są zmienne, o pomocnych podczas nauki modelach mentalnych działania zmiennych, oraz o tym, jak zmienne w JavaScript działają w rzeczywistości. Teraz nadszedł czas by omówić zasięg zmiennych oraz różne sposoby deklarowania zmiennych.

Zasięg i deklaracja zmiennych są ze sobą powiązane. Zasięg zmiennej zależy od miejsca jej utworzenia, jak również (w pewnym stopniu) od sposobu jej utworzenia. Ale zacznijmy od tego, czym właściwie jest zasięg zmiennych.

Czym jest zasięg zmiennych

Zasięg zmiennej (scope) to obszar programu, w którym zmienna o określonej nazwie może być używana i zachowuje swoje znaczenie. Jeżeli zmienna nie znajduje się w zasięgu danej części programu, wówczas nie będzie dostępna do użycia.

W językach programowania występują dwa różne sposoby obsługi zasięgu zmiennych: zasięg leksykalny (lexical scope) oraz zasięg dynamiczny (dynamic scope). W niektórych językach możliwe jest korzystanie z obydwu typów zasięgu, ale w JavaScript mamy jedynie zasięg leksykalny. Dlatego pozostawmy zasięg dynamiczny i przyjrzyjmy się bliżej zasięgowi leksykalnemu.

Co oznacza zasięg leksykalny? Zasięg leksykalny to taki, w którym obszar dostępności zmiennej wynika z samego tekstu programu. W zasięgu leksykalnym miejsce w kodzie programu w którym dana zmienna jest utworzona określa dostępność tej zmiennej.

Dla zilustrowania leksykalnego zasięgu zmiennych spójrzmy taki przykład:

var x = "Jestem X";
function foo() {
var y = "Jestem Y";
console.log("[wewnątrz funkcji foo] zmienna x:", x);
console.log("[wewnątrz funkcji foo] zmienna y:", y);
}
foo();
console.log("[poza funkcją foo] zmienna x:", x);
console.log("[poza funkcją foo] zmienna y:", y);
// Wynik:
// [wewnątrz funkcji foo] zmienna x: Jestem X
// [wewnątrz funkcji foo] zmienna y: Jestem Y
// [poza funkcją foo] zmienna x: Jestem X
// Uncaught ReferenceError: y is not defined

Mamy tutaj dwie zmienne: x i y, każda z nich ma inny zasięg.

Zmienna x została utworzona na najwyższym poziomie programu i ma zasięg globalny. Dlatego console.log(x) zwraca wartość "Jestem X" zarówno w funkcji foo, jak i w głównym programie.

Natomiast zmienna y została utworzona wewnątrz funkcji foo, a jej zasięg obejmuje tylko “ciało” funkcji foo. Mówiąc inaczej, y jest zmienną lokalną dla funkcji foo. Dlatego gdy wywołaliśmy console.log(y) poza funkcją foo, dostaliśmy błąd ReferenceError, interpreter JavaScript mówi nam, że próbujemy użyć zmiennej, która nie jest zdefiniowana. Nie jest zdefiniowana w tym zasięgu.

Powyższy kod obrazuje działanie zasięgu leksykalnego na przykładzie dwóch rodzajów zasięgu: zasięgu globalnego oraz zasięgu funkcji. W JavaScript występują też inne rodzaje zasięgów, ale zanim omówimy je szczegółowo, musimy spojrzeć na sposoby tworzenia (deklarowania) zmiennych.

Deklarowanie zmiennych

W JavaScript deklaracja zmiennej składa się ze słowa kluczowego deklaracji, nazwy zmiennej oraz opcjonalnego przypisania początkowej wartości:

Struktura deklaracji zmiennej var foo = "Jestem foo";

Dla deklarowania zmiennych mamy do dyspozycji trzy słowa kluczowe: var, let i const.

var foo = "Jestem foo";
let bar = "Jestem bar";
const baz = "Jestem baz";

Deklaracje te różnią się między innymi zasięgiem. Słowo kluczowe var tworzy zmienną o zasięgu globalnym lub o zasięgu funkcji. Słowo let tworzy zmienną o zasięgu blokowym (który omówimy w sekcji o rodzajach zasięgów). Natomiast słowo const działa podobnie jak let, ale z jedną istotną różnicą: nie można przypisać do niego innej wartości, niż ta przypisana początkowo. Dlatego zmienne zadeklarowane przy użyciu const nazywamy stałymi.

Wspomniałem, że przypisanie początkowej wartości zmiennej jest opcjonalne, ale dotyczy to tylko deklaracji przy użyciu var i let. Deklarując stałą przy użyciu const musimy przypisać do niej początkową wartość.

Spójrzmy znów na przykład kodu:

var foo;
let bar;
console.log(foo, bar);
// Wynik:
// undefined undefined

Zmienne foo i bar są zadeklarowane i istnieją w bieżącym zasięgu. Obie mają wartość undefined, bo niczego konkretnego do nich nie przypisaliśmy, ale JavaScript nie zgłosił błędu. Nasze zmienne istnieją, mają określony zasięg i można ich używać.

Jeśli w podobny sposób spróbujemy zadeklarować stałą, JavaScript rzuci błędem:

const baz;
// Wynik:
// Uncaught SyntaxError: Missing initializer in const declaration

Stała bez początkowej wartości nie miałaby sensu, bo jej wartością zawsze byłoby undefined, dlatego JavaScript nie pozwala na jej utworzenie.

Deklarowanie zmiennych bez użycia słów kluczowych

Istnieją w JavaScript dwie możliwości utworzenia zmiennej bez użycia słów kluczowych var, let czy const.

Pierwsza z nich dotyczy parametrów funkcji. Tworząc funkcję która przyjmuje parametr, jednocześnie tworzymy wewnątrz tej funkcji zmienną o tej samej nazwie. Mówiąc krótko, parametr to zmienna o określonej nazwie przekazana do funkcji.

function foo(bar) {
console.log("[wewnątrz funkcji foo] zmienna bar:", bar);
}
foo("Hello BAR!");
// Rezultat:
// [wewnątrz funkcji foo] zmienna bar: Hello BAR!
foo();
// Rezultat:
// [wewnątrz funkcji foo] zmienna bar: undefined

Wywołaliśmy funkcję foo dwukrotnie. Za pierwszym razem przekazaliśmy "Hello BAR!" jako argument dla parametru bar, za drugim razem wywołaliśmy foo bez argumentów. W żadnym z tych przypadków wywołanie console.log(bar) nie spowodowało ReferenceError, bo zmienna bar istnieje wewnątrz tej funkcji i ma domyślną wartość undefined.

O drugiej możliwości utworzenia zmiennej bez użycia słów kluczowych wolałbym nie pisać, niemniej świadomość jej istnienia jest ważna, więc ją omówimy. Chodzi o utworzenie zmiennej poprzez przypisanie wartości do nazwy:

veryBadGlobalVariable = "Czyste zło!";
console.log(veryBadGlobalVariable);
// Rezultat:
// Czyste zło!

Przypisując jakąś wartość do nazwy bez użycia var, let czy const tworzymy globalną zmienną. Co więcej, zmienna ta jest globalna niezależnie od tego, w jakim miejscu ją utworzymy:

function foo() {
veryBadGlobalVariable = "Czyste zło!";
}
function bar() {
console.log("[wewnątrz funkcji bar]", veryBadGlobalVariable);
}
foo();
console.log(veryBadGlobalVariable);
// Rezultat:
// Czyste zło!
bar();
// Rezultat:
// [wewnątrz funkcji bar] Czyste zło!

W tym przykładzie zmienna veryBadGlobalVariable jest tworzona wewnątrz funkcji foo, mimo to jest też widoczna w funkcji bar (i w każdym innym miejscu programu).

Jest to bardzo niepożądane zjawisko, bo pozwala na przypadkowe utworzenie globalnej zmiennej, co może prowadzić do trudnych do zdiagnozowania błędów w programie. Z tego powodu JavaScript w trybie ścisłym (strict mode) nie pozwala na taki sposób tworzenia zmiennych.

"use strict";
veryBadGlobalVariable = "Czyste zło!";
// Rezultat:
// Uncaught ReferenceError: veryBadGlobalVariable is not defined

Między innymi po to, by uniknąć problemów z przypadkowymi zmiennymi globalnymi, powinniśmy używać strict mode używać zawsze!

Globalne zmienne w ogóle są problematyczne, ale o tym z chwilę.

Rodzaje zasięgów

W JavaScript występują cztery rodzaje zasięgów:

  • Zasięg globalny
  • Zasięg modułu
  • Zasięg funkcji
  • Zasięg bloku (dotyczy tylko deklaracji let i const)

Zasięg globalny jest najszerszy, a zasięg bloku jest najwęższym z możliwych do zdefiniowania w JavaScript.

Zagnieżdżone prostokąty przedstawiające cztery rodzaje zasięgu

Zasięg globalny

W zasięgu globalnym znajdują się wszystkie zmienne zadeklarowane na najwyższym poziomie programu, niezależnie od tego, czy utworzyliśmy je przy pomocy var, let, czy const. Zmienne zadeklarowane na najwyższym poziomie, czyli nie umieszczone w module, oraz nie zamknięte wewnątrz funkcji ani bloku.

W zwykłym JavaScript nie istnieje zasięg związany z plikami, więc zasięg globalny jest wspólny dla wszystkich skryptów uruchamianych na danej stronie HTML.

Dla zilustrowania zasięgu globalnego utwórzmy stronę HTML, która będzie zawierać 5 znaczników <script>. Dwa pierwsze będą skryptami wbudowanymi w HTML, dwa kolejne będą ładowane z osobnych plików .js, ostatni też będzie wbudowany. W każdym z pierwszych czterech skryptów zadeklarujemy zmienną przy pomocy var, a w ostatnim (wbudowanym) skrypcie zalogujemy do konsoli wartości tych zmiennych:

<!DOCTYPE html>
<html>
<body>
<script>
var a = "Jestem A";
</script>
<script>
var b = "Jestem B";
</script>
<script src="scripts-1.js"></script>
<script src="scripts-2.js"></script>
<script>
console.log(a);
console.log(b);
console.log(c);
console.log(d);
</script>
</body>
</html>

Plik scripts-1.js:

var c = "Jestem C";

Plik scripts-2.js:

var d = "Jestem D";

Po załadowaniu strony do przeglądarki w konsoli ukaże się taki rezultat:

Jestem A
Jestem B
Jestem C
Jestem D

Jak widać, wszystkie zmienne znalazły się w globalnym zasięgu i są dostępne dla każdego działającego na stronie skryptu.

Z takiego działania zmiennych globalnych wynikają bardzo poważne konsekwencje: możliwe są kolizje nazw! Zmienna z pewnego skryptu może nadpisać zmienną o takiej samej nazwie zadeklarowaną wcześniej w innym skrypcie. Właśnie to miałem na myśli mówiąc, że globalne zmienne są problematyczne.

Jeżeli w powyższym przykładzie przed zalogowaniem wartości zmiennych do konsoli dodamy taki skrypt:

var d = "DZONG!";

Wówczas rezultat będzie wyglądał następująco:

Jestem A
Jestem B
Jestem C
DZONG!

Zmienna d nadpisała wcześniejszą zmienną o tej samej nazwie.

Im więcej zmiennych trafi do globalnego zasięgu, tym większe prawdopodobieństwo kolizji nazw. Mówi się nawet o “zanieczyszczaniu” globalnego zasięgu (global scope pollution). Dlatego zwykle staramy się ograniczać zasięg zmiennych, czy to poprzez umieszczenie ich w modułach, czy też poprzez zamykanie ich wewnątrz funkcji.

Zasięg modułu

Moduły JavaScript, nazywane też modułami ECMAScript lub modułami ES, są zagadnieniem zbyt dużym aby omówić je ze szczegółami w tym artykule. Dlatego tylko pokrótce opiszę jak moduły mają się do zasięgu zmiennych.

Moduły JavaScript są plikami JavaScript ładowanymi z dodatkowym atrybutem type="module":

<script type="module" src="main.js"></script>

Załadowane w ten sposób moduły mogą importować kod z innych modułów, co powoduje doładowywanie kolejnych plików.

Wcześniej napisałem, że w JavaScript nie istnieje zasięg związany z plikami. Ale to nie do końca prawda, moduły mają właśnie taki zasięg! Zmienne globalne z main.js w powyższym przykładzie nie trafią do globalnego zasięgu strony na której ten skrypt jest załadowany.

Nie będę rozwijał bardziej wątku modułów JavaScript, więcej na ten temat możesz przeczytać na przykład w MDN.

Zasięg funkcji

Funkcje w JavaScript tworzą własny zasięg. Zmienna zadeklarowana wewnątrz funkcji przy użyciu var, let, lub const ma zasięg w ramach tejże funkcji, oraz funkcji i bloków niej zagnieżdżonych.

Dotyczy to zarówno funkcji utworzonych przy pomocy deklaracji funkcji (czyli funkcji nazwanych), wyrażeń funkcyjnych, jak i funkcji strzałkowych:

// deklaracja funkcji
function declaredFunc() {
var a = "Jestem A";
console.log("[wewnątrz declaredFunc]", a);
// zagnieżdżone wyrażenie funkcyjne
const anonFunc = function () {
var b = "Jestem b";
console.log("[wewnątrz anonFunc]", a);
console.log("[wewnątrz anonFunc]", b);
};
// zagnieżdżona funkcja strzałkowa
const arrowFunc = () => {
var c = "Jestem C";
console.log("[wewnątrz arrowFunc]", a);
console.log("[wewnątrz arrowFunc]", c);
};
anonFunc();
arrowFunc();
}
declaredFunc();
// Wynik:
// [wewnątrz declaredFunc] Jestem A
// [wewnątrz anonFunc] Jestem A
// [wewnątrz anonFunc] Jestem b
// [wewnątrz arrowFunc] Jestem C
// [wewnątrz arrowFunc] Jestem C

Uwaga! Zasięg funkcji jest leksykalny. Oznacza to, że rozciąga się on na funkcje zagnieżdżone, ale nie na funkcje wywołane z danej funkcji. Inne funkcje wywołane z naszej funkcji mają własny zasięg:

function funcA() {
var a = "Jestem A";
console.log("[wewnątrz funcA]", a);
function funcB() {
var b = "Jestem b";
console.log("[wewnątrz funcB]", a);
console.log("[wewnątrz funcB]", b);
}
funcB();
funcC();
}
function funcC() {
var c = "Jestem C";
console.log("[wewnątrz funcC]", c);
console.log("[wewnątrz funcC]", a);
}
funcA();
// Wynik:
// [wewnątrz funcA] Jestem A
// [wewnątrz funcB] Jestem A
// [wewnątrz funcB] Jestem b
// [wewnątrz funcC] Jestem C
// Uncaught ReferenceError: a is not defined

Zmienna a jest dostępna w funkcji funcB, bo funkcja funcB znajduje się w leksykalnym zasięgu funcA. Tymczasem próba użycia zmiennej a w funkcji funcC powoduje błąd, mimo że funcC również jest wywoływana z funcA. Dzieje się tak, bo zmienne z funcA są poza zasięgiem funcC. W JavaScript miejsce wywołania funkcji nie ma wpływu na zasięg, wynika on z umiejscowienia samego kodu funkcji.

Zasięg funkcji pozwala na wykorzystywanie ich do rozwiązywania problemów z globalnym zasięgiem. Zanim powstały moduły JavaScript, funkcje i wyrażenia funkcyjne były jedynym sposobem na odseparowanie kodu i uniknięcie zaśmiecania globalnego zasięgu. Służy do tego konstrukcja nazywana Immediately Invoked Function Expression (natychmiastowo wywołane wyrażenie funkcyjne), w skrócie IIFE.

// Ta funkcja będzie wykonana natychmiast po
// załadowaniu skryptu, bez konieczności jej wywołania
(function () {
// zmienne zadeklarowane tutaj będą w zasięgu tej funkcji,
// a tym samym oddzielone od reszty zmiennych
// obecnych na tej samej stronie
})();

Wzorzec IIFE nadal jest dość powszechnie używany (nawet jeżeli nie piszemy go ręcznie, tylko robi to za nas Webpack.)

Zasięg bloku

Zmienne utworzone przy pomocy let i const mają zasięg w ramach bloku kodu w którym zostały zadeklarowane.

Czym jest ów blok? Blokiem w JavaScript jest fragment kodu zamknięty w nawiasach klamrowych: { … }. O ile możemy po prostu wydzielić fragment kodu zamykając go w bloku:

{
// to jest blok
{
// a to to kolejny blok
{
// i jeszcze jeden blok
}
}
}

O tyle w praktyce bloki zwykle występują jako część instrukcji języka, na przykład w instrukcji warunkowej if…else, czy pętli for.

Zasięg blokowy można osiągnąć jedynie deklarując zmienną przy pomocy let lub const. Zmienne z deklaracją let lub const na głównym poziomie programu są zmiennymi globalnymi, zadeklarowane w ciele funkcji są zmiennymi funkcyjnymi, natomiast gdy umieścimy je wewnątrz bloku, wówczas ich zasięg jest ograniczony jedynie do bloku, w którym pojawia się ich deklaracja.

if (true) {
let a = "Jestem A";
console.log("[wewnątrz bloku]", a);
}
console.log("[zasięg globalny]", a);
// Wynik:
// [wewnątrz bloku] Jestem A
// Uncaught ReferenceError: a is not defined

Próba użycia zmiennej a w zasięgu globalnym zakończyła się komunikatem o błędzie. Jeżeli ta sama zmienna byłaby zadeklarowana jako var a, wówczas błąd by nie wystąpił, bo zasięg var nie jest ograniczany przez blok i zmienna znalazłaby się w zasięgu globalnym.

Ten sam mechanizm dotyczy bloków występujących wewnątrz funkcji:

function foo() {
if (true) {
let a = "Jestem A";
console.log("[wewnątrz bloku]", a);
}
console.log("[zasięg funkcji foo]", a);
}
foo();
// Wynik:
// [wewnątrz bloku] Jestem A
// Uncaught ReferenceError: a is not defined

W przypadku zagnieżdżonych bloków, zasięg zadeklarowanych w nich zmiennych obejmuje blok w którym pojawia się deklaracja oraz bloki wewnątrz tego bloku:

if (true) {
let a = "Jestem A";
// w zakresie tego bloku jest tylko zmienna a
if (true) {
let b = "Jestem B";
// w zakresie tego bloku są zmienne a i b
if (true) {
let c = "Jestem C";
// w zakresie są zmienne a, b i c
console.log(a, b, c);
}
}
}
// Wynik:
// Jestem A Jestem B Jestem C

Łańcuch zasięgów

Gdy silnik JavaScript napotka w kodzie użycie zmiennej o pewnej nazwie, zmienna ta może pochodzić z dowolnego z zasięgów dostępnych w danym miejscu. JavaScript musi wówczas zidentyfikować o jaką właściwie zmienną chodzi. W jaki sposób to robi?

JavaScript zaczyna od środka, od lokalnego zasięgu, czyli najbliższego otoczenia względem miejsca w którym zmienna wystąpiła. Tym lokalnym zasięgiem może być blok, funkcja, lub zasięg globalny, zależy w którym miejscu znajduje się interesująca nas linia kodu zawierająca użycie zmiennej. W każdym razie, JavaScript najpierw szuka deklaracji owej zmiennej w lokalnym zasięgu, jeżeli jej tam nie znajdzie, wówczas przechodzi do szerszego zasięgu powyżej i próbuje odnaleźć ją tam. Jeżeli i to się nie uda, przechodzi do kolejnego zasięgu powyżej, i tak dalej, aż do zasięgu globalnego. Dopiero gdy nie uda się odnaleźć zmiennej w zasięgu globalnym, wówczas dostaniemy ReferenceError.

Ten proces poszukiwania zmiennej w kolejnych zakresach od najwęższego (lokalnego) od najszerszego (globalnego) nazywa się łańcuchem zasięgów. Interpreter przesuwa się po kolejnych zasięgach jak po ogniwach łańcucha.

Diagram łańcucha zasięgów

Przesłanianie zmiennych (shadowing)

Ostatnim związanym z zasięgiem zmiennych zagadnieniem które chciałbym omówić jest zjawisko przesłaniania zmiennych (variable shadowing).

Najlepiej zacząć od przykładu:

"use strict";
let x = 10;
console.log("Log a", x);
function foo() {
let x = 20;
console.log("Log b", x);
if (true) {
let x = 30;
console.log("Log c", x);
}
console.log("Log d", x);
}
foo();
console.log("Log e", x);
// Wynik:
// Log a 10
// Log b 20
// Log c 30
// Log d 20
// Log e 10

W powyższym kodzie zmienna o nazwie x jest zadeklarowana 3-krotnie. Każda z tych deklaracji pojawia się w innym (choć zagnieżdżonym) zasięgu i każda z nich tworzy zupełnie nową zmienną, bez żadnego związku ze zmienną o tej samej nazwie występującą w zasięgach powyżej. W ramach bieżącego zasięgu, lokalnie zadeklarowana zmienna x przesłania zmienne o tej samej nazwie z wyższych zasięgów.

Przesłanianie zmiennych pogarsza czytelność kodu. Łatwo nie zauważyć, że zmienna o tej samej nazwie nie jest w istocie tą samą zmienną. Z tego powodu lepiej unikać przesłaniania zmiennych.

Podsumowanie

Dotarliśmy do końca naszego przewodnika po zmiennych. Tutaj znajdziesz jego pierwszą część:

Przewodnik po zmiennych w JavaScript - część 1

Podsumowując część drugą, oto kilka najważniejszych informacji, które warto zapamiętać:

  • Zmienne w JavaScript możemy deklarować na 4 sposoby - przy pomocy słów kluczowych var, let i const, oraz poprzez parametry funkcji.
  • Zmienne mają zasięg leksykalny - miejsce deklaracji zmiennej decyduje o jej zasięgu.
  • JavaScript posiada 4 rodzaje zasięgu: globalny, zasięg modułu, funkcji oraz bloku.
  • Interpreter JavaScript poszukuje deklaracji zmiennej podążając łańcuchem zasięgów od zasięgu najbardziej lokalnego, aż po zasięg globalny.
  • Możliwe jest przesłonięcie nazwy zmiennej wewnątrz zagnieżdżonych zakresów poprzez zmienną o takiej samej nazwie.
Ikona Smiley Obserwuj DevSchool!
Logo FacebookLogo Twitter

Copyright 2022 – 2023 ©
Michał Wilkosiński CONIFER MEDIA
Wszystkie prawa zastrzeżone