Professional Documents
Culture Documents
JavaScript. Przewodnik - David Flanagan
JavaScript. Przewodnik - David Flanagan
JavaScript. Przewodnik - David Flanagan
JavaScript
Przewodnik
Poznaj język mistrzów
programowania
Wydanie VII
© 2021 Helion SA
Authorized Polish translation of the English edition of JavaScript The Definitive Guide ISBN
9781491952023 © 2020 David Flanagan
This translation is published and sold by permission of O’Reilly Media, Inc., which owns or
controls all rights to publish and sell the same.
All rights reserved. No part of this book may be reproduced or transmitted in any form or by
any means, electronic or mechanical, including photocopying, recording or by any information
storage retrieval system, without permission from the Publisher.
Wszelkie prawa zastrzeżone. Nieautoryzowane rozpowszechnianie całości lub fragmentu
niniejszej publikacji w jakiejkolwiek postaci jest zabronione. Wykonywanie kopii metodą
kserograficzną, fotograficzną, a także kopiowanie książki na nośniku filmowym, magnetycznym
lub innym powoduje naruszenie praw autorskich niniejszej publikacji.
Wszystkie znaki występujące w tekście są zastrzeżonymi znakami firmowymi bądź towarowymi
ich właścicieli.
Autor oraz Helion SA dołożyli wszelkich starań, by zawarte w tej książce informacje były
kompletne i rzetelne. Nie biorą jednak żadnej odpowiedzialności ani za ich wykorzystanie, ani
za związane z tym ewentualne naruszenie praw patentowych lub autorskich. Autor oraz Helion
SA nie ponoszą również żadnej odpowiedzialności za ewentualne szkody wynikłe z
wykorzystania informacji zawartych w książce.
Helion SA ul. Kościuszki 1c, 44-100 Gliwice tel. 32 231 22 19, 32 230 98 63 e-mail:
helion@helion.pl WWW: http://helion.pl (księgarnia internetowa, katalog książek)
Poleć książkę
Kup w wersji papierowej
Oceń książkę
Księgarnia internetowa
Lubię to! » nasza społeczność
Opinie o książce JavaScript. Przewodnik.
Poznaj język mistrzów programowania.
Wydanie VII
„Ta książka zawiera wszystko, czego nie wiedziałeś, a co powinieneś wiedzieć o języku
JavaScript. Dzięki niej wzniesiesz jakość i produktywność swojego kodu na wyższy poziom.
Wiedza autora o tym języku, jego zawiłościach i pułapkach jest imponująca, o czym świadczy
ten naprawdę kompletny przewodnik”.
— Schalk Neethling, starszy inżynier stron WWW, MDN Web Docs
„David Flanagan zabiera czytelnika w podróż po JavaScripcie, przekazując pełny obraz tego
języka i jego ekosystemu”.
— Sarah Wachs, programistka stron WWW, Women Who Code, oddział w Berlinie
„Każdy programista, chcący tworzyć wydajny, rozwojowy i wykorzystujący najnowsze
funkcjonalności kod, wiele wyniesie z kształcącej i inspirującej podróży po języku JavaScript, w
którą zabierze go ta wszechstronna i wyczerpująca książka”.
— Brian Sletten, prezes Bosatsu Consulting
Wstęp
Niniejsza książka opisuje język JavaScript i jego interfejsy API zaimplementowane w
przeglądarkach internetowych i w środowisku Node. Jest przeznaczona zarówno dla
czytelników, którzy mają już pewne doświadczenie w programowaniu i chcą poznać JavaScript,
jak i tych, którzy już używają tego języka, chcą dogłębnie go poznać i osiągnąć wyższy stopień
wtajemniczenia. Moim celem było opracowanie kompleksowej, wyczerpującej dokumentacji
języka JavaScript oraz najważniejszych klienckich i serwerowych interfejsów API. Dlatego ta
książka jest tak obszerna i szczegółowa. Mam nadzieję, że czas poświęcony na jej wnikliwe
przestudiowane zwróci się z nawiązką w postaci wzbogaconych umiejętności
programistycznych.
Każde z poprzednich wydań książki zawierało obszerny rozdział z odniesieniami do różnych
materiałów. Tym razem uznałem, że nie ma potrzeby umieszczania tego rodzaju informacji w
drukowanej książce, skoro najnowsze odniesienia można szybko i łatwo znaleźć w internecie.
Gdybyś poszukiwał jakiejkolwiek wiedzy na temat programowania klientów i serwerów
JavaScript, zachęcam do odwiedzenia strony MDN (https://developer.mozilla.org). Jeżeli
natomiast będziesz chciał się dowiedzieć więcej o serwerowych interfejsach API środowiska
Node, warto sięgnąć bezpośrednio do źródła, czyli dokumentacji Node.js
(https://nodejs.org/api).
Konwencje typograficzne
W książce zostały zastosowane następujące konwencje typograficzne:
Pogrubiona czcionka o stałej szerokości oznacza instrukcje i inne ciągi znaków wpisywane
przez użytkownika.
Pochyła czcionka o stałej szerokości oznacza ciągi znaków, które muszą być zmienione na
dane wprowadzone przez użytkownika lub inne dane wynikające z kontekstu opisu.
Książka ta ma pomóc Ci w pracy. Ogólnie rzecz biorąc, kodu znajdującego się w niej można
używać we własnych programach i dokumentacjach bez proszenia kogokolwiek o zgodę, chyba
że wykorzystasz duże fragmenty. Jeśli na przykład w pisanym programie użyjesz kilku
fragmentów kodu z tej książki, nie musisz pytać o pozwolenie. Natomiast aby sprzedawać i
rozprowadzać płyty CD-ROM z przykładami, trzeba mieć zezwolenie. Na to aby odpowiedzieć
komuś na pytanie, cytując fragment tej książki wraz z kodem źródłowym, nie trzeba mieć
zezwolenia. Aby wykorzystać dużą ilość kodu źródłowego z tej książki w dokumentacji własnego
produktu, trzeba mieć pozwolenie.
Informacje o źródle użytych fragmentów są mile widziane, ale niewymagane. Notka powinna
zawierać nazwisko autora, tytuł publikacji, numer ISBN, wydawcę oraz datę i miejsce
publikacji, na przykład: David Flanagan, JavaScript. Przewodnik. Poznaj język mistrzów
programowania. Wydanie VII, ISBN 978-83-283-7308-2, Helion, Gliwice 2021.
Podziękowania
Do powstania tej książki przyczyniło się bardzo wiele osób. Podziękowania niech przyjmie moja
redaktorka Angela Rufino, za dyscyplinowanie mnie i cierpliwość, gdy nie dotrzymywałem
terminów. Wiele cennych opinii i wskazówek, dzięki którym ta książka jest lepsza, dostarczyli
mi korektorzy merytoryczni: Brian Sletten, Elisabeth Robson, Ethan Flanagan, Maximiliano
Firtman, Sarah Wachs i Schalk Neethling.
Świetną robotę, jak zawsze, wykonał zespół wydawnictwa O’Reilly: Kristen Brown, która
zarządzała całym procesem wydawniczym, Deborah Baker, która pracowała nad składem,
Rebecca Demarest, która przygotowała rysunki, i Judy McConville, która opracowała indeks.
W 2010 r. pojawiło się nowe środowisko gospodarza — platforma Node. Od tamtego czasu
JavaScript nie jest już ograniczony do interfejsów API oferowanych przez przeglądarkę,
ponieważ nowa platforma daje mu dostęp do całego systemu operacyjnego, umożliwiając
zapisywanie i odczytywanie plików, wysyłanie i odbieranie danych przez sieć, jak również
wysyłanie i obieranie zapytań HTTP. Node jest popularną platformą wykorzystywaną do
implementowania serwerów WWW, jak również wygodnego tworzenia prostych skryptów
narzędziowych stanowiących alternatywę dla skryptów powłoki.
Niniejsza książka skupia się na samym języku JavaScript. W rozdziale 11. opisana jest
standardowa biblioteka, rozdział 15. stanowi wprowadzenie do przeglądarki jako środowiska
gospodarza, a rozdział 16. opisuje platformę Node.
Aby napisać kilka wierszy kodu w JavaScripcie, najprościej jest otworzyć zawarte w
przeglądarce narzędzia dla programistów — nacisnąć klawisze F12, Ctrl+Shift+I lub
Command+Option+I, a następnie wybrać zakładkę Konsola. Po otwarciu konsoli można za
znakiem zachęty wpisywać kod i sprawdzać efekty. Narzędzia dla programistów są
prezentowane w panelu umieszczonym w dolnej lub prawej części okna, jednak zazwyczaj
oddziela się je od przeglądarki i wygodnie wyświetla w osobnym oknie, jak na rysunku 1.1.
Inny sposób testowania kodu JavaScript polega na pobraniu środowiska Node ze strony
https://nodejs.org i zainstalowaniu go. Aby rozpocząć interaktywną sesję, wystarczy otworzyć
terminal i wpisać polecenie node, jak niżej:
$ node
true
$ node kod.js
console.log("Witaj, świecie!");
Jeżeli chcesz ten sam napis zobaczyć w konsoli przeglądarki, utwórz plik o nazwie witaj.html i
wpisz w nim poniższy kod:
<script src="witaj.js"></script>
file:///Users/username/javascript/witaj.html
Otwórz konsolę zawartą w narzędziach dla programistów i sprawdź, co się w niej pojawiło.
x = 1; // Liczby.
Inne dwa ważne typy danych stosowane w języku JavaScript to obiekty i tablice. Będą opisane
w rozdziałach 6. i 7., ale są tak ważne, że będziesz z nimi miał często do czynienia wcześniej:
empty.length // => 0
{x: 1, y: 1}
];
let data = { // Obiekt zawierający dwie właściwości.
};
3 + 2 // => 5: dodawanie
3 - 2 // => 1: odejmowanie
3 * 2 // => 6: mnożenie
"two" > "three" // => true: ciąg "tw" jest alfabetycznie większy
niż "th"
false === (x > y) // => true: wartość false jest równa false
// Operatory logiczne łączą lub odwracają wartości logiczne.
(x === 2) && (y === 3) // => true: oba porównania zwracają wartości true.
Symbole &&
// oznaczają ORAZ.
(x > 3) || (y < 3) // => false: żadne porównanie nie zwraca wartości
true. Symbole ||
// oznaczają LUB.
!(x === y) // => true: symbol ! odwraca wartość logiczną.
Jeżeli wyrażenia porównany do fraz, to instrukcje można traktować jak pełne zdania.
Instrukcje są tematem rozdziału 5. Ogólnie mówiąc, wyrażenie to coś, co zwraca wartość i nie
robi nic więcej, tj. w żaden sposób nie zmienia stanu programu. Natomiast instrukcja nie ma
wartości, za to zmienia stan programu. Powyższy kod zawiera deklaracje zmiennych i instrukcje
przypisujące im wartości. Inną szeroką kategorią instrukcji są struktury sterujące, na
przykład instrukcje warunkowe lub pętle. Za chwilę poznasz ich przykłady, ale najpierw
zajmiemy się funkcjami.
Funkcja jest opatrzonym nazwą i posiadającym parametry blokiem kodu, który definiuje się raz
i wywołuje wielokrotnie. Funkcje formalnie zostaną opisane dopiero w rozdziale 8., ale
podobnie jak tablice i obiekty będziesz je często widział wcześniej. Poniżej przedstawionych jest
kilka prostych przykładów funkcji:
square(plus1(y)) // => 16
Funkcje użyte w obiektach są metodami:
// Funkcja przypisana właściwości obiektu nosi nazwę "metody".
// Wszystkie obiekty, włącznie z tablicami, posiadają metody.
};
points.dist() // => Math.sqrt(2): odległość między dwoma
punktami.
Teraz, zgodnie z obietnicą, poznasz kilka funkcji, których ciała zawierają często stosowane
instrukcje sterujące:
} // Koniec pętli.
return sum; // Zwrócenie sumy.
}
sum(primes) // => 28: suma pięciu początkowych liczb
pierwszych: 2+3+5+7+11.
} // Koniec pętli.
return product; // Zwrócenie iloczynu.
}
factorial(4) // => 24: 1*4*3*2
}
}
Na tym kończy się wycieczka wprowadzająca w składnię i możliwości języka JavaScript. Kolejne
rozdziały stanowią samodzielne sekcje opisujące dodatkowe funkcjonalności języka.
Rozdział 10. „Moduły”
W tym rozdziale opisano, jak w kodzie zapisanym w pliku można wykorzystywać funkcje i
klasy zdefiniowane w innych plikach i skryptach.
W tym rozdziale wyjaśniono, jak działa pętla for/of i jak definiuje się własne klasy, które
można iterować za pomocą tej pętli. Rozdział przedstawia również funkcję generatora i
instrukcję yield.
Opis narzędzi i rozszerzeń języka, które warto znać, ponieważ są szeroko stosowane i
dzięki nim będziesz bardziej produktywny jako programista.
E: ######## 8.13%
A: ######## 7.80%
T: ####### 7.40%
I: ###### 6.02%
N: ##### 5.29%
R: ##### 5.29%
O: ##### 5.07%
S: ##### 4.63%
C: #### 4.08%
Z: ### 3.14%
W: ### 3.10%
L: ### 2.99%
Kod wykorzystuje kilka zaawansowanych funkcjonalności języka JavaScript i pokazuje, jak
wyglądają praktyczne programy tworzone w tym języku. Na razie nie musisz rozumieć całego
kodu. Możesz być jednak spokojny, ponieważ wszystko będzie opisane w kolejnych rozdziałach.
Listing 1.1. Generowanie histogramu częstości występowania znaków
/**
* Ten program, przeznaczony dla platformy Node, odczytuje tekst ze
// Ta klasa rozszerza klasę Map tak, aby metoda get() zwracała określoną
// wartość, a nie null, gdy mapa nie zawiera klucza.
}
get(key) {
}
else {
}
}
add(text) {
// Usunięcie białych znaków z tekstu i zamiana go na wielkie litery.
text = text.replace(/\s/g, "").toUpperCase();
// Iterowanie kolejnych znaków w tekście.
for(let character of text) {
let count = this.letterCounts.get(character); // Pobranie poprzedniej
liczby
this.letterCounts.set(character, count+1); // i powiększenie jej.
this.totalLetters++;
}
}
);
// Zwrócenie wierszy połączonych za pomocą znaku końca wiersza.
return lines.join("\n");
}
}
return histogram;
}
// Ostatni wiersz stanowi główne ciało programu.
// Tworzy obiekt Histogram na podstawie danych uzyskanych
1.5. Podsumowanie
W książce opisano język JavaScript od podstaw. Oznacza to, że zaczyna się ona od
niskopoziomowych szczegółów, takich jak komentarze, identyfikatory, zmienne i typy. Następnie
przechodzi się w niej do wyrażeń, instrukcji, obiektów i funkcji, a w dalszej kolejności do
wysokopoziomowych abstrakcji, takich jak klasy i moduły. Słowo „kompletny” w tytule
umieściłem świadomie i w następnych rozdziałach opisuję szczegóły, które początkowo mogą
wydawać się zniechęcające. Jednak aby mistrzowsko opanować JavaScript, trzeba poznać
wszystkie szczegóły i dlatego mam nadzieję, że przeczytasz tę książkę od początku do końca.
Nie musisz jednak tego robić od razu. Jeżeli utkniesz w jakimś rozdziale, po prostu przejdź do
następnego. Wrócisz później i poznasz pominięte szczegóły, gdy posiądziesz praktyczną
umiejętność posługiwania się językiem jako całością.
Rozdział 2.
Struktura leksykalna
Struktura leksykalna języka programowania to zestaw podstawowych zasad pisania kodu w tym
języku. Jest to niskopoziomowa składnia specyfikująca nazwy zmiennych, znaki komentarza,
sposób oddzielania instrukcji od tekstu itp. Ten krótki rozdział opisuje strukturę leksykalną
języka JavaScript, w szczególności:
2.2. Komentarze
W JavaScripcie stosowane są dwa style komentarzy. Tekst znajdujący się od znaków // do
końca wiersza jest traktowany jako komentarz i pomijany. Podobnie komentarzem jest tekst
umieszczony pomiędzy parami znaków /* i */. Tego rodzaju komentarz może składać się z
wielu wierszy. Komentarzy nie można zagnieżdżać. Poniżej przedstawione są przykłady
poprawnych komentarzy w JavaScripcie:
// Jednowierszowy komentarz.
/* To również jest komentarz. */ // A to jest inny komentarz.
/*
* To jest komentarz wielowierszowy. Dodatkowy znak * na początku
2.3. Literały
Literał to dane umieszczone bezpośrednio w programie. Poniższe przykłady są literałami:
12 // Liczba dwanaście.
1.2 // Liczba jeden i dwie dziesiąte.
nazwa_mojej_zmiennej
v13
_dummy
$str
2.5. Unicode
Kod JavaScript składa się ze znaków Unicode. Wszystkie znaki z tego zestawu można stosować
w komentarzach i ciągach. Jednak aby kod był przenośny i łatwy w edytowaniu, zazwyczaj w
identyfikatorach stosuje się wyłącznie znaki ASCII. Jest to jednak tylko przyjęta konwencja
programistyczna, ponieważ w identyfikatorach można stosować litery, cyfry i ideogramy
Unicode (ale nie symbole emoji). Oznacza to, że nazwami stałych i zmiennych mogą być
symbole matematyczne i słowa z innych języków niż angielski:
const π = 3.14;
const wartość = true;
W początkowych wersjach języka JavaScript sekwencja ucieczki mogła składać się wyłącznie
z czterech cyfr. Zapis z nawiasami klamrowymi został wprowadzony w wersji ES6, aby można
było stosować znaki Unicode zakodowane za pomocą więcej niż 16 bitów, na przykład emoji:
a = 3;
b = 4;
a = 3; b = 4;
Pamiętaj, że nie zawsze podział wiersza jest traktowany jak średnik. Zasada ta (z trzema
wyjątkami opisanymi niżej) obowiązuje wtedy, gdy nie można znaku następującego po podziale
wiersza traktować jako kontynuacji bieżącej instrukcji. Rozważmy następujący przykład:
let a
=
3
console.log(a)
let a; a = 3; console.log(a);
Pierwszy podział jest traktowany jak średnik, ponieważ zapis let a a nie jest poprawnym
kodem. Druga litera a może być samodzielną instrukcją, jednak następujący po niej podział
wiersza nie jest traktowany jak średnik, ponieważ kolejne wiersze mogą stanowić kontynuację
dłuższej instrukcji a = 3;.
Opisana zasada rozdzielania instrukcji może być źródłem nieprzewidzianych efektów. Poniższy
kod wygląda jak dwie instrukcje rozdzielone podziałem wiersza:
let y = x + f
(a+b).toString()
Jednak nawiasy użyte w drugim wierszu można traktować jak wywołanie funkcji f umieszczonej
w pierwszym wierszu. Zatem kod ten jest interpretowany w następujący sposób:
let y = x + f(a+b).toString();
Prawdopodobnie nie jest to interpretacja, jakiej oczekiwałby autor kodu. W takim przypadku,
aby oba wiersze stanowiły osobne instrukcje, należy jawnie użyć średnika.
Zazwyczaj, jeżeli instrukcja rozpoczyna się znakiem (, [, /, + lub -, jest interpretowana jako
kontynuacja poprzedniej instrukcji. W praktyce bardzo rzadko stosuje się instrukcje
rozpoczynające się od znaków /, + i -, ale od znaków ( i [ całkiem często, przynajmniej w
niektórych stylach programowania. Programiści lubią umieszczać na początku takiej instrukcji
zabezpieczający średnik, aby poprzedzająca go instrukcja działała poprawnie nawet po jej
zmodyfikowaniu lub usunięciu znajdującego się na jej końcu innego średnika:
Są trzy wyjątki od ogólnej reguły interpretowania podziałów wierszy jako średników, gdy
kolejnego wiersza nie można traktować jako kontynuacji instrukcji z poprzedniego wiersza.
Pierwszy wyjątek dotyczy słów kluczowych return, throw, yield, break i continue (patrz
rozdział 5.). Słowa te są często samodzielnymi instrukcjami, ale czasami umieszcza się po nich
identyfikatory lub wyrażenia. Podział wiersza następujący po takim słowie (przed następnym
tokenem) jest zawsze traktowany jak średnik. Jeżeli na przykład wpiszesz taki kod:
return
true;
return; true;
return true;
Oznacza to, że nie można wprowadzać podziałów wierszy pomiędzy instrukcjami return, break
i continue a następującymi po nich wyrażeniami, ponieważ kod będzie wtedy działał
nieprawidłowo, a przyczyna problemu będzie nieoczywista i trudna do wykrycia.
2.7. Podsumowanie
W tym rozdziale pokazałem, jak pisze się programy w języku JavaScript na najniższym
poziomie. W następnym rozdziale wejdziemy jeden stopień wyżej i zajmiemy się prymitywnymi
typami i wartościami (liczbami, ciągami znaków itp.), będącymi podstawowymi jednostkami
danych w kodzie JavaScript.
Rozdział 3.
Typy, wartości i zmienne
Programy komputerowe wykonują operacje na wartościach, na przykład liczbie 3,14 lub ciągu
„Witaj, świecie!”. Rodzaje reprezentowanych i przetwarzanych wartości noszą nazwę typów,
a jedną z fundamentalnych cech każdego języka programowania jest zestaw dostępnych typów
danych. Jeżeli w programie trzeba przechować wartość w celu jej późniejszego wykorzystania,
przypisuje się ją zmiennej (lub inaczej — „zapisuje” w niej). Funkcjonowanie zmiennych jest
następną fundamentalną cechą każdego języka programowania. W tym rozdziale opisane są
typy danych, wartości i zmienne dostępne w języku JavaScript. Zacznijmy od ogólnych
informacji i kilku definicji.
JavaScript definiuje oprócz podstawowych obiektów i tablic kilka innych przydatnych typów
obiektowych. Na przykład Set reprezentuje zestaw wartości, a Map klucze skojarzone z
wartościami. Różnego rodzaju typy tablicowe ułatwiają wykonywanie operacji na bajtach i
danych binarnych. Typ RegExp reprezentuje wzorzec tekstowy umożliwiający zaawansowane
porównywanie, wyszukiwanie i zastępowane fragmentów ciągów znaków. Typ Date
reprezentuje daty i godziny, jak również pozwala wykonywać podstawowe operacje na tych
wielkościach. Typ Errors i jego podtypy reprezentują błędy pojawiające się podczas działania
skryptu. Wszystkie wymienione typy danych zostaną opisane w rozdziale 11.
JavaScript tym się różni od bardziej statycznych języków, że funkcje i klasy nie są zwykłymi
częściami składni, ale wartościami, które można przetwarzać. Podobnie jak inne nieprymitywne
wartości, są to specjalnego typu obiekty, które zostaną szczegółowo przedstawione w
rozdziałach 8. i 9.
Interpreter JavaScriptu automatycznie porządkuje pamięć. Oznacza to, że programista nie musi
zajmować się alokowaniem i usuwaniem obiektów i innych wartości. Jeżeli dana wartość
przestaje być dostępna, tj. ustaje możliwość odwoływania się do niej, interpreter uznaje, że
wartość nie będzie już więcej używana, i zwalnia zajmowaną przez nią pamięć. Programista
musi jednak dbać o to, aby wartości nie były niepotrzebnie dostępne, a więc nie zajmowały
pamięci dłużej, niż jest to konieczne.
JavaScript jest językiem obiektowym. Ogólnie mówiąc, oznacza to, że nie definiuje się w nim
globalnych funkcji operujących na wartościach różnych typów, tylko stosuje typy zawierające
metody przetwarzające wartości. Na przykład aby posortować elementy tablicy a, nie
umieszcza się jej w argumencie funkcji sort(), tylko wywołuje metodę sort() obiektu a:
W języku JavaScript konwersja typów wartości jest dość liberalna. Jeżeli na przykład w miejscu,
w którym oczekiwana jest wartość tekstowa, zostanie umieszczona wartość liczbowa,
automatycznie jest wykonywana konwersja liczby na tekst. Jeżeli w miejscu, w którym powinna
być użyta wartość logiczna, pojawi się wartość innego typu, również zostanie automatycznie
przekształcona. Zasady konwersji typów zostaną przedstawione w podrozdziale 3.9. Liberalne
reguły konwersji wpływają na pojęcie równości wartości. Operator == dokonuje konwersji
typów w sposób opisany w punkcie 3.9.1. W praktyce jednak zamiast niego stosuje się operator
ścisłej równości ===, który nie przekształca typów. Więcej informacji o obu operatorach
znajdziesz w punkcie 4.9.1.
Stałe i zmienne pełnią rolę nazw skojarzonych z wartościami. Stałą deklaruje się za pomocą
słowa const, a zmienną za pomocą let (lub var w starszych wersjach języka). Stałe i zmienne
nie mają określonych typów. W ich deklaracjach nie określa się, jakiego typu wartości można im
przypisywać. Deklarowanie zmiennych i przypisywanie im wartości zostanie opisane w
podrozdziale 3.10.
Jak można się domyślić z tego przydługiego wprowadzenia, będzie to obszerny rozdział,
zawierający wiele fundamentalnych szczegółów związanych z reprezentowaniem i
przetwarzaniem danych w języku JavaScript. Zacznijmy od dokładnego zapoznania się z
niuansami liczb i tekstów.
3.2. Liczby
Podstawowy typ Number służy do reprezentowania liczb całkowitych i przybliżania liczb
rzeczywistych. Liczby w JavaScripcie są zapisywane w 64-bitowym formacie zdefiniowanym w
normie IEEE 754[1]. Za jego pomocą można wyrażać liczby od najmniejszych ±5×10−324 do
największych ±1,7976931348623157×10308.
3
10000000
Oprócz liczb dziesiętnych można stosować liczby szesnastkowe. Tego rodzaju literał składa się
ze znaków 0x lub 0X i następujących po nich cyfr szesnastkowych. Cyfra szesnastkowa jest
zwykłą cyfrą z zakresu od 0 do 9 lub literą z zakresu od a (lub A) do f (F) reprezentującą liczbę
z zakresu od 10 do 15. Poniżej są przedstawione przykładowe literały szesnastkowe:
W wersjach języka ES6 i nowszych można również wyrażać dwójkowe i ósemkowe liczby
całkowite. W tym celu należy, odpowiednio, użyć prefiksu 0b lub 0o (albo 0B lub 0O):
[cyfry][.cyfry][(E|e)[(+|-)]cyfry]
Przykłady:
3.14
2345.6789
.333333333333333333
6.02e23 // 6,02×10²³
1.4738223E-32 // 1,4738223×10–³²
W języku JavaScript przepełnienie, niedomiar i dzielenie przez zero nie powodują zgłoszenia
błędu. Jeżeli wynik operacji jest większy niż największa dopuszczalna wartość (przepełnienie),
zwracana jest specjalna wartość Infinity (nieskończoność). Analogicznie, jeżeli bezwzględna
wartość ujemnego wyniku jest większa niż bezwzględna wartość dopuszczalnej liczby ujemnej,
zwracana jest wartość -Infinity. Wartości nieskończone funkcjonują zgodnie z oczekiwaniami,
tj. wynikiem dodawania, odejmowania, mnożenia i dzielenia z użyciem jakiejkolwiek innej
wartości jest również nieskończoność (z ewentualnie zmienionym znakiem).
Niedomiar ma miejsce wtedy, gdy wynik operacji jest bliższy zeru niż najmniejsza dostępna
wartość. W takich sytuacjach zwracana jest liczba 0. Jeżeli niedomiar dotyczy liczby ujemnej,
wynikiem jest specjalna wartość, „ujemne” zero, niemal nieodróżnialna od „zwykłego” zera. W
praktyce rzadko pojawia się potrzeba rozróżniania tego rodzaju wartości.
Dzielenie przez zero nie skutkuje zgłoszeniem błędu. Wynikiem jest po prostu wartość
Infinity lub -Infinity. Od tej reguły jest jeden wyjątek: wynikiem dzielenia zera przez zero
jest specjalna wartość NaN (ang. not-a-number — nieliczba). Reprezentuje ona również wynik
dzielenia wartości Infinity przez Infinity, pierwiastek kwadratowy z liczby ujemnej oraz
wynik operacji arytmetycznej z użyciem operandów, których nie można przekształcić na liczby.
W języku JavaScript są zdefiniowane globalne stałe Infinity i NaN, oznaczające, odpowiednio,
nieskończoność i wartość nieliczbową. Analogiczne wartości są również właściwościami obiektu
Number:
-0
Wartość nieliczbowa ma pewną nietypową cechę, mianowicie nie można jej porównywać z
żadną inną wartością, nawet z nią samą. Oznacza to, że stosując zapis x === NaN, nie można
sprawdzić, czy zmienna x ma wartość NaN. Zamiast tego należy użyć wyrażenia x != x lub
Number.isNaN(x). Każde z nich ma wartość true tylko wtedy, gdy x ma taką samą wartość jak
globalna stała NaN.
Globalna funkcja isNaN() jest podobna do Number.isNaN(). Zwraca wartość true, jeżeli jej
argument ma wartość NaN lub nie można go przekształcić w liczbę. Inna funkcja,
Number.isFinite(), zwraca wartość true, jeżeli jej argument ma wartość inną niż NaN,
Infinity i -Infinity. Globalna funkcja isFinite() zwraca true, jeżeli jej argument jest lub
może być przekształcony w skończoną liczbę.
„Ujemne” zero jest także nietypową wartością, równą „dodatniemu” zeru (nawet jeżeli użyje się
operatora ścisłego porównania). Wyjątkiem jest wynik dzielenia:
zero === negz // => true: "zwykłe" zero jest równe "ujemnemu" zeru.
1/zero === 1/negz // => false: Infinity i –Infinity nie są sobie równe.
Literał typu BigInt jest ciągiem cyfr zakończonym małą literą n. Domyślnie stosowany jest
system dziesiętny, ale można używać prefiksów 0b, 0o i 0x oznaczających, odpowiednio,
systemy binarny, ósemkowy i szesnastkowy:
Zapis BigInt() można traktować jako funkcję przekształcającą zwykłe liczby lub ciągi znaków
na wartości typu BigInt:
BigInt(Number.MAX_SAFE_INTEGER) // => 9007199254740991n
Natomiast operatory porównania można stosować z różnymi typami liczbowymi (więcej różnic
między operatorami == i === zostanie opisanych w punkcie 3.9.1):
1 < 2n // => true
let now = new Date(); // Aktualny czas jako obiekt typu Date.
let ms = now.getTime(); // Przekształcenie daty w znacznik czasu.
let iso = now.toISOString(); // Przekształcenie daty w ciąg znaków w
standardowym formacie.
Klasa Date i jej metody zostaną opisane w podrozdziale 11.4, jednak z obiektami tego typu
spotkasz się jeszcze w punkcie 3.9.3 szczegółowo opisującym konwersję typów.
3.3. Tekst
Typem reprezentującym tekst w języku JavaScript jest ciąg znaków. Jest to niemutowalna
sekwencja 16-bitowych wartości wyrażających zazwyczaj znaki Unicode. Długość ciągu jest
liczbą składających się na niego 16-bitowych wartości. Ciągi, podobnie jak tablice, są
indeksowane od zera, tj. pierwsza 16-bitowa wartość znajduje się na pozycji nr 0, druga na
pozycji nr 1 itd. Pusty ciąg ma długość równą 0. W języku JavaScript nie ma specjalnej
wartości reprezentującej pojedynczy element ciągu. Jest nim po prostu ciąg o długości 1.
'test'
"3.14"
'name="myform"'
"Lubisz książki wydawnictwa Helion?"
wiersz."
// Dwuwierszowy ciąg zapisany w dwóch wierszach:
Podczas ujmowania ciągów znaków w apostrofy należy uważać na angielskie skróty typu can’t
czy O’Reilly. Użyty w takim słowie apostrof trzeba poprzedzać lewym ukośnikiem (\). Tego
rodzaju sekwencje, zwane sekwencjami ucieczki, zostaną opisane w następnym punkcie.
W programach klienckich często umieszcza się ciągi HTML, które z kolei mogą zawierać ciągi
stanowiące kod JavaScript. W języku HTML, podobnie jak w JavaScripcie, ciągi znaków można
ujmować zarówno w apostrofy, jak i cudzysłowy. Zatem podczas łączenia obu rodzajów kodów
dobrą praktyką jest konsekwentne stosowanie w JavaScripcie jednego stylu, a w HTML innego.
W poniższym przykładzie ciąg „Dziękuję” jest ujęty w apostrofy, ponieważ stanowi część
wyrażenia w kodzie JavaScript, natomiast całe wyrażenie jest ujęte w cudzysłowy, ponieważ
stanowi atrybut procedury obsługi zdarzenia:
<button onclick="alert('Dziękuję')">Kliknij tutaj</button>
Lewy ukośnik, użyty przed znakiem innym niż jeden z wymienionych w tabeli 3.1, jest po prostu
pomijany (choć można się spodziewać, że w przyszłych wersjach języka będą pojawiać się nowe
sekwencje ucieczki). Na przykład zapis \# oznacza to samo co #. Ponadto, jak wspomniałem
wcześniej, począwszy od wersji ES5, umieszczając lewy ukośnik na końcu wiersza, można
tworzyć wielowierszowe literały znakowe.
Aby określić długość ciągu, tj. liczbę 16-bitowych wartości, z których się składa, należy użyć
właściwości length:
s.length
Oprócz powyższej właściwości język JavaScript oferuje bogaty interfejs API do wykonywania
różnych operacji na ciągach znaków:
// Przeszukiwanie ciągu.
s.indexOf("i") // => 1: pozycja pierwszej litery "i".
s.indexOf("i", 3) // => 9: pozycja pierwszej litery "i", większa niż 3.
// 3 znaków.
"x".padEnd(3) // => "x ": dodanie spacji z prawej strony w celu
uzyskania ciągu o długości
// 3 znaków.
"x".padStart(3, "*") // => "**x": dodanie gwiazdek z lewej strony w celu
uzyskania ciągu
// o długości 3 znaków.
"x".padEnd(3, "-") // => "x--": dodanie myślników z prawej strony w celu
uzyskania ciągu
// o długości 3 znaków.
// Usuwanie spacji w wersjach ES5 i nowszych (nazwy metod zmienione w
wersjach ES2019 i nowszych).
Lewy ukośnik znajdujący się na końcu pierwszego wiersza powoduje pominięcie podziału
wiersza. W efekcie wynikowy ciąg rozpoczyna się od znaku Unicode ✘ (o kodzie \u2718), a nie
od podziału wiersza.
Możliwość definiowania własnych funkcji znacznikowych jest bardzo przydatna. Funkcje te nie
muszą zwracać ciągów znaków i można je stosować w charakterze konstruktorów definiujących
nową składnię języka. W podrozdziale 14.5 poznasz przykłady użycia takich funkcji.
b = b + 1;
} else {
a = a + 1;
}
Powyższy kod sprawdza, czy zmienna a ma wartość 4. Jeżeli tak, zwiększa wartość zmiennej b o
1.
Jak się przekonasz w podrozdziale 3.9, każdą wartość w JavaScripcie można przekształcić w
wartość logiczną. Poniższe wartości są przekształcane i traktowane jak wartość logiczna false:
undefined
null
0
-0
NaN
"" // Pusty ciąg znaków.
Wszystkie inne wartości, włącznie z obiektami i tablicami, można przekształcić i traktować jak
wartość logiczną true. Wartość false i sześć powyższych jest czasami nazywanych wartościami
fałszywymi, a wszystkie pozostałe prawdziwymi. Wszędzie w kodzie, gdzie spodziewana jest
wartość logiczna, wartość fałszywa jest traktowana jako false, a prawdziwa jako true.
Załóżmy, że zmienna o może zawierać obiekt lub wartość null. Za pomocą poniższej instrukcji
if można jednoznacznie sprawdzić, czy zmienna ta zawiera wartość inną niż null:
// symbolu.
o[strname] // => 1: odwołanie do właściwości o nazwie
określonej
// za pomocą ciągu znaków.
o[symname] // => 2: odwołanie do właściwości o nazwie
określonej
// za pomocą symbolu.
Typ Symbol nie ma składni literału. Aby uzyskać wartość typu Symbol, należy wywołać funkcję
Symbol(). Funkcja ta nigdy nie zwraca dwa razy tej samej wartości, nawet jeżeli zostanie
wywołana z tym samym argumentem. Oznacza to, że wartość typu Symbol uzyskaną za pomocą
funkcji Symbol() można bezpiecznie traktować jako nazwę właściwości. Na przykład można
dodać do obiektu nową właściwość bez obawy o nadpisanie istniejącej właściwości o tej samej
nazwie. Analogicznie, jeżeli stosowane są symboliczne nazwy właściwości i symbole te nie są
współdzielone, można mieć pewność, że inne moduły wykorzystywane w programie nie
nadpiszą przypadkowo zdefiniowanych właściwości.
W praktyce symbole traktuje się jako mechanizm rozszerzający język. Gdy w wersji ES6 została
wprowadzona pętla for/of (patrz punkt 5.4.4) i obiekty iterowalne (rozdział 12.), pojawiła się
potrzeba definiowania standardowych metod, które można byłoby implementować w klasach,
aby były iterowalne. Jednak standaryzacja konkretnej tekstowej nazwy metody iteratora
mogłaby zakłócić istniejący kod. Dlatego zamiast niej została użyta nazwa symboliczna. Jak się
przekonasz w rozdziale 12., Symbol.iterator jest wartością typu Symbol, której używa się jako
nazwy metody, aby obiekt był iterowalny.
Funkcja Symbol() ma opcjonalny argument tekstowy i zwraca unikatową wartość typu Symbol.
Jeżeli argument ten zostanie określony, jego wartość zostanie umieszczona w wyniku
zwracanym przez metodę toString() symbolu. Zwróć jednak uwagę, że dwukrotnie wywołując
funkcję Symbol() z tym samym tekstowym argumentem, otrzymuje się dwie zupełnie odmienne
wartości typu Symbol:
let s = Symbol("sym_x");
s.toString() // => "Symbol(sym_x)"
Metoda toString() jest jedyną interesującą metodą obiektu typu Symbol. Warto jednak znać
jeszcze dwie inne funkcje związane z tym typem. Czasami, aby mieć pewność, że użyte
właściwości nie będą kolidowały z właściwościami użytymi w innym kodzie, trzeba definiować
prywatne symbole. Czasami jednak zdefiniowana wartość typu Symbol musi być szeroko
dostępna w kodzie. Tak jest na przykład w sytuacji, gdy w definiowanym rozszerzeniu powinien
być uwzględniony inny kod niż w opisanym wcześniej iteratorze Symbol.iterator.
Na wypadki takie jak powyższy zdefiniowany jest globalny rejestr symboli. Jest dostępna
metoda Symbol.for(), której argumentem jest ciąg znaków, a zwracanym wynikiem skojarzona
z nim wartość typu Symbol. Jeżeli z podanym ciągiem nie jest skojarzona żadna wartość,
metoda tworzy i zwraca nową. Oznacza to, że metoda Symbol.for() jest czymś zupełnie innym
niż funkcja Symbol(). Ta ostatnia nigdy nie zwraca dwa razy tej samej wartości, natomiast
Symbol.for(), wywołana z tym samym ciągiem, zawsze zwraca tę samą wartość. Ciąg podany
w argumencie tej funkcji jest umieszczany w wyniku zwracanym przez metodę toString()
symbolu. Można go również uzyskać, wywołując metodę Symbol.keyFor() z umieszczonym w
jej argumencie żądanym symbolem.
let s = Symbol.for("shared");
let t = Symbol.for("shared");
s === t // => true
s.toString() // => "Symbol(shared)"
Symbol.keyFor(t) // => "shared"
Właściwości obiektu globalnego nie są zarezerwowanymi słowami, ale zasługują na to, aby je
jako takie traktować. W tym rozdziale opisałem niektóre z tych właściwości. Większość
pozostałych przedstawię w różnych miejscach książki.
W środowisku Node obiekt globalny posiada właściwość o nazwie global, którego właściwością
jest sam obiekt globalny. Dzięki temu w środowisku można odwoływać się do obiektu
globalnego za pomocą tej nazwy.
W przeglądarkach rolę obiektu globalnego dla całego kodu zawartego w oknie pełni obiekt
Window. Posiada on właściwość window zawierającą odwołanie do samego siebie, jak również
kilka innych podstawowych właściwości charakterystycznych dla danej przeglądarki i
klienckiego skryptu JavaScriptu. Z wątkami roboczymi (patrz podrozdział 15.13) skojarzony jest
inny niż Window globalny obiekt, do którego można odwoływać się za pomocą nazwy self.
W wersji ES2020 została zdefiniowana uniwersalna nazwa globalThis oznaczająca globalny
obiekt w każdym kontekście. Na początku roku 2020 nazwa ta była już zaimplementowana we
wszystkich nowoczesnych przeglądarkach i w środowisku Node.
Obiekty różnią się od wartości prymitywnych. Przede wszystkim są mutowalne, a więc ich
wartości można zmieniać:
let o = { x: 1 }; // Początkowy obiekt.
o.x = 2; // Modyfikacja obiektu poprzez zmianę wartości
właściwości.
Obiektów nie porównuje się na podstawie wartości. Dwa obiekty są różne nawet wtedy, gdy
mają te same właściwości o takich samych wartościach. Podobnie dwie tablice są różne nawet
wtedy, gdy składają się z takich samych elementów ułożonych w tej samej kolejności:
let o = {x: 1}, p = {x: 1}; // Dwa obiekty o takich samych właściwościach.
o === p // => false: osobne obiekty są zawsze różne.
let a = [], b = []; // Dwie osobne, puste tablice.
a === b // => false: osobne tablice są zawsze różne.
Jak wynika z powyższego kodu, przypisanie obiektu lub tablicy zmiennej jest po prostu
przypisaniem referencji. Nie jest przy tym tworzona nowa kopia obiektu. Aby utworzyć kopię
obiektu lub tablicy, trzeba jawnie skopiować właściwości lub elementy. Można to zrobić za
pomocą pętli for (patrz punkt 5.4.3), jak w poniższym przykładzie:
let a = ["a","b","c"]; // Tablica przeznaczona do skopiowania.
let b = []; // Osobna docelowa tablica.
Analogicznie, aby porównać dwa osobne obiekty lub tablice, należy porównać ich właściwości
lub elementy. Poniższy kod definiuje funkcję porównującą dwie tablice:
if (a === b) return true; // Identyczne tablice są sobie
równe.
if (a.length !== b.length) return false; // Tablice o różnych długościach
nie są sobie równe.
true "true" 1
false "false" 0
0 "0" false
-0 "0" false
funkcja() {} (dowolna
Patrz punkt 3.9.3. NaN true
funkcja)
Niektóre operatory dokonują niejawnej konwersji i czasami stosuje się je w celu jawnego
przekształcania wartości. Jeżeli jeden z operandów operatora + jest ciągiem znaków, drugi
operand jest przekształcany w ciąg. Jednoargumentowy operator + przekształca operand w
liczbę, natomiast jednoargumentowy operator ! przekształca operand w wartość logiczną i
neguje ją. Te cechy sprawiają, że czasami w kodzie możesz napotkać następujące zapisy:
x + "" // => String(x)
+x // => Number(x)
x-0 // => Number(x)
!!x // => Boolean(x): zwróć uwagę na podwójny znak !
Często wykonywanymi operacjami jest formatowanie i parsowanie liczb. W JavaScripcie są
dostępne specjalne funkcje i metody dające ściślejszą kontrolę nad przekształcaniem liczb w
ciągi znaków i odwrotnie.
parseInt("0.1") // => 0
parseInt(".1") // => NaN: liczba całkowita nie może rozpoczynać
się znakiem ".".
parseFloat("$72.47") // => NaN: liczba nie może rozpoczynać się
znakiem "$".
Bez preferencji
Algorytm nie preferuje żadnego prymitywnego typu, więc klasy mogą definiować własne
konwersje. Wszystkie wbudowane klasy z wyjątkiem Date implementują ten algorytm jako
preferuj liczbę. Klasa Date implementuje ten algorytm jako preferuj ciąg znaków.
Implementacje powyższych algorytmów konwersji opiszę na końcu tego punktu. Teraz wyjaśnię,
w jaki sposób są one wykorzystywane w języku JavaScript.
Algorytm preferuj ciąg znaków najpierw próbuje wywołać metodę toString(). Jeżeli
metoda ta jest zdefiniowana i zwraca wartość prymitywną, algorytm wykorzystuje tę
wartość (nawet jeżeli nie jest to ciąg znaków!). Jeżeli metody tej nie ma lub zwraca ona
obiekt, algorytm próbuje wywołać metodę valueOf(). Jeżeli metoda ta istnieje i zwraca
wartość prymitywną, algorytm wykorzystuje tę wartość. W przeciwnym razie konwersja
kończy się niepowodzeniem i zgłaszany jest błąd TypeError.
Algorytm preferuj liczbę działa podobnie jak preferuj ciąg znaków. Różni się jedynie tym,
że najpierw próbuje wywołać metodę valueOf(), a potem toString().
Działanie algorytmu bez preferencji zależy od klasy przekształcanego obiektu. Jeżeli jest
to klasa Date, realizowany jest algorytm preferuj ciąg znaków, a w przypadku każdej innej
klasy — algorytm preferuj liczbę.
Powyższe reguły obowiązują zarówno dla wszystkich wbudowanych typów, jak również
niestandardowych, zdefiniowanych przez użytkownika. W punkcie 14.4.7 opiszę, jak się
definiuje niestandardowy algorytm konwersji własnych obiektów na wartości prymitywne.
Zanim zakończymy ten temat, zwróć uwagę, że zasada działania algorytmu preferuj liczbę
wyjaśnia, dlaczego pusta tablica jest przekształcana w liczbę 0, a tablica zawierająca jeden
element w liczbę:
Number([]) // => 0: nieoczekiwany wynik!
Number([99]) // => 99: naprawdę?
Konwersja obiektu na liczbę polega na przekształceniu najpierw obiektu w wartość prymitywną
za pomocą algorytmu preferuj liczbę, a następnie przekształceniu uzyskanego wyniku w liczbę.
Algorytm preferuj liczbę najpierw próbuje wywołać metodę valueOf(), a następnie
toString(). Jednak klasa Array dziedziczy domyślną metodę valueOf(), która nie zwraca
wartości prymitywnej. Dlatego przy próbie przekształcenia tablicy w liczbę wywoływana jest
metoda toString() klasy Array. Pusta tablica jest wtedy przekształcana w pusty ciąg znaków,
a ten z kolei w liczbę 0. Natomiast tablica zawierająca jeden element jest przekształcana w taki
sam ciąg, w jaki został przekształcony jej element. Jeżeli tablica zawiera jedną liczbę, jest ona
przekształcana w ciąg, a następnie z powrotem w liczbę.
W rozdziale 5. poznasz pętle for, for/in i for/of. Każda z nich wykorzystuje zmienną, której
przypisywana jest nowa wartość w kolejnej iteracji. W języku JavaScript można deklarować
taką zmienną w samej definicji pętli. Jest to kolejny przykład użycia słowa kluczowego let:
for(let i = 0, len = data.length; i < len; i++) console.log(data[i]);
for(let datum of data) console.log(datum);
for(let property in object) console.log(property);
Może się to wydawać dziwne, ale w pętli for/in i for/of dozwolone jest stosowanie instrukcji
const, jeżeli tylko w ciele pętli takiej „zmiennej” nie jest przypisywana nowa wartość. W takim
przypadku deklaracja stałej oznacza jedynie, że wartość pozostaje niezmienna na czas jednej
iteracji pętli:
for(const datum of data) console.log(datum);
for(const property in object) console.log(property);
Wielokrotne deklaracje
Użycie więcej niż jednej deklaracji let lub const z tą samą nazwą jest błędem składniowym.
Poprawne za to jest (choć należy tego unikać) deklarowanie zmiennych o takich samych
nazwach w zagnieżdżonych blokach:
const x = 1; // Deklaracja globalnej zmiennej x.
if (x === 1) {
let x = 2; // Zmienna x zadeklarowana wewnątrz tego bloku może
zawierać inną wartość.
console.log(x); // Wynik: 2.
}
console.log(x); // Wynik 1: wracamy do globalnej zmiennej.
let x = 3; // Błąd składniowy — ponowna deklaracja zmiennej x.
Deklaracje i typy
Jeżeli znasz język statycznie typowany, na przykład C++ lub Java, zapewne uważasz, że
głównym celem deklaracji zmiennej jest określenie typu wartości, jakie można tej zmiennej
przypisywać. Jak się jednak przekonasz, w języku JavaScript nie ma związku pomiędzy
deklaracją zmiennej a jej typem[2]. Oznacza to, że zmienna może zawierać wartość dowolnego
typu. Całkowicie poprawne jest na przykład przypisanie zmiennej najpierw liczby, a później
ciągu znaków (co jest jednak złą praktyką):
let i = 10;
i = "dziesięć";
Zasięg zmiennej zadeklarowanej za pomocą słowa var nie ogranicza się do bloku kodu,
tylko do całego ciała funkcji, w której jest zadeklarowana, niezależnie od tego, jak
głęboko.
Instrukcja var użyta poza ciałem funkcji deklaruje zmienną globalną. Jednak pomiędzy
globalnymi zmiennymi zadeklarowanymi za pomocą var i let jest istotna różnica. W
pierwszym przypadku zmienne są implementowane jako właściwości globalnego obiektu
(patrz podrozdział 3.7). Do tego obiektu można odwoływać się za pomocą nazwy
globalThis. Zatem kod var x = 2; umieszczony poza funkcją jest równoważny
globalThis.x = 2;. Zwróć jednak uwagę, że analogia nie jest idealna. Właściwości
utworzonych za pomocą globalnej deklaracji var nie można usunąć przy użyciu operatora
delete (patrz punkt 4.13.4). Natomiast zmienne i stałe zadeklarowane za pomocą
instrukcji let i const nie są właściwościami obiektu globalnego.
Za pomocą słowa var, inaczej niż w przypadku let, można wielokrotnie deklarować tę
samą zmienną. Ponieważ zasięgiem zmiennej zadeklarowanej za pomocą var jest funkcja,
a nie blok kodu, tego rodzaju deklaracje są powszechnie stosowane. Zmiennej i często
używa się do przechowywania liczb całkowitych, w szczególności indeksów w pętlach.
Jeżeli funkcja zawiera kilka pętli, zazwyczaj każda z nich rozpoczyna się od for(var i = 0;
.... Ponieważ słowo var nie ogranicza zasięgu zmiennej do ciała pętli, w każdej z nich
można bezproblemowo ponownie deklarować i inicjować tę samą zmienną.
Jedną z najbardziej niezwykłych cech deklaracji var jest windowanie. Deklaracja
zmiennej jest podnoszona („windowana”) w górę do obejmującej ją funkcji. Zmienna może
być zainicjowana w środku funkcji, ale jej definicja jest przenoszona na początek funkcji.
Zatem zmienną zadeklarowaną za pomocą słowa var można stosować w dowolnym
miejscu funkcji. Taka zmienna, zanim zostanie wykonany kod inicjujący, ma wartość
undefined. Niemniej jednak użycie takiej zmiennej przed jej zainicjowaniem nie powoduje
zgłoszenia błędu. Jest to jednak wadliwa funkcjonalność, będąca przyczyną wielu błędów,
którą koryguje instrukcja let. Odwołanie się do zadeklarowanej w ten sposób zmiennej
przed jej zainicjowaniem skutkuje zgłoszeniem błędu, a nie uzyskaniem wartości
undefined.
Jak już wiesz, zmienne i stałe można deklarować jako części różnego rodzaju pętli. W takich
konstrukcjach można również stosować zmienne złożone. Poniżej jest przedstawiony kod pętli
iterującej pary nazwa/wartość wszystkich właściwości obiektu i przekształcającej te pary z
dwuelementowych tablic w osobne zmienne:
let o = { x: 1, y: 2 }; // Iterowany obiekt.
for(const [name, value] of Object.entries(o)) {
console.log(name, value); // Wyświetlany wynik: "x 1" i "y 2".
}
Liczba zmiennych umieszczonych po lewej stronie przypisania destrukturyzującego nie musi
być zgodna z liczbą elementów tablicy po stronie prawej. Każdej dodatkowej zmiennej po lewej
stronie jest przypisywana wartość undefined, a dodatkowe wartości po prawej stronie są
pomijane. Lista zmiennych po lewej stronie może zawierać dodatkowe przecinki powodujące
pominięcie wybranych wartości umieszczonych po prawej stronie:
let [x,y] = [1]; // x == 1; y == undefined
[x,y] = [1,2,3]; // x == 1; y == 2
[,x,,y] = [1,2,3,4]; // x == 2; y == 4
Jeżeli przy destrukturyzowaniu tablicy trzeba zebrać w jedną zmienną wszystkie
niewykorzystane lub nadmiarowe wartości, należy przed ostatnią nazwą umieszczoną po lewej
stronie znaku równości umieścić wielokropek (...):
let [x, ...y] = [1,2,3,4]; // y == [2,3,4]
Z wielokropkiem spotkasz się jeszcze w punkcie 8.3.2, gdzie będzie użyty do wskazania, że
wszystkie nadmiarowe argumenty funkcji mają być zebrane w jedną tablicę.
(x1 === 1 && y1 === 2 && x2 === 3 && y2 === 4) // => true
Skomplikowana składnia destrukturyzująca, taka jak powyższa, jest trudna i nieczytelna,
dlatego lepiej użyć jawnego, tradycyjnego przypisania, na przykład let x1 = points.p1[0];.
Skomplikowane destrukturyzacje
Gdy będziesz pracował nad kodem wykorzystującym skomplikowane przypisania
destrukturyzujące, pamiętaj o pewnej zasadzie, która może Ci ułatwić zrozumienie
złożonych przypadków. Wyobraź sobie najpierw zwykłe przypisanie (z jedną wartością).
Po przypisaniu możesz zmienną umieszczoną po lewej stronie wykorzystać jako
wyrażenie w kodzie, którego wynikiem będzie przypisana wartość. Podobnie jest w
przypadku przypisania destrukturyzującego. Lewa strona wygląda jak literał tablicowy lub
obiektowy (patrz podrozdziały 2.1 i 6.10). Po przypisaniu literał ten możesz wykorzystać
w dowolnym miejscu kodu. Poprawność przypisania destrukturyzującego możesz
sprawdzić, umieszczając jego lewą stronę po prawej stronie innego wyrażenia
przypisującego:
// Najpierw zapisz strukturę danych i złożone przypisanie
destrukturyzujące.
let points = [{x: 1, y: 2}, {x: 3, y: 4}];
let [{x: x1, y: y1}, {x: x2, y: y2}] = points;
// Sprawdź poprawność zapisu, zamieniając strony miejscami.
let points2 = [{x: x1, y: y1}, {x: x2, y: y2}]; // points2 == points
3.11. Podsumowanie
Najważniejsze zagadnienia, o których należy pamiętać po przeczytaniu tego rozdziału:
[1] Ten format jest stosowany w typie double w większości nowoczesnych języków, m.in. Javie i
C++.
[2] Są rozszerzenia języka JavaScript, na przykład TypeScript lub Flow (patrz podrozdział 17.8),
w których w deklaracji zmiennych można określić jej typ, na przykład let x: number = 0;.
Rozdział 4.
Wyrażenia i operatory
W tym rozdziale opisane są wyrażenia stosowane w języku JavaScript oraz wykorzystywane
w nich operatory. Wyrażenie to fraza, którą można wyliczyć i uzyskać wartość. Bardzo prostym
przykładem wyrażenia jest stała. Zmienna też jest wyrażeniem, którego wynikiem jest wartość
przypisana tej zmiennej. Złożone wyrażenia składają się z prostszych wyrażeń. Na przykład
odwołanie do tablicy składa się z wyrażenia reprezentującego tablicę, kwadratowego nawiasu
otwierającego, drugiego wyrażenia (którego wartością jest liczba całkowita) oraz zamykającego
nawiasu kwadratowego. Wynikiem utworzonego w ten sposób nowego, bardziej złożonego
wyrażenia jest wartość elementu zadanej tablicy o zadanym indeksie. Podobnie wyrażenie
wywołujące funkcję składa się z wyrażenia, którego wynikiem jest obiekt reprezentujący daną
funkcję, oraz kilku ewentualnych dodatkowych wyrażeń będących jej argumentami.
Złożone wyrażenia najczęściej tworzy się za pomocą prostszych wyrażeń i operatorów.
Operator łączy w określony sposób wartości operandów (zazwyczaj dwóch) i tworzy nową
wartość. Prostym przykładem jest operator mnożenia *. Wynikiem wyrażenia x * y jest iloczyn
wartości zmiennych x i y. Czasami mówi się, upraszczając, że operator zwraca wartość.
W tym rozdziale opisane są wszystkie operatory stosowane w języku JavaScript, jak również
wyrażenia, które nie wykorzystują operatorów (na przykład indeksy tablicy czy wywołanie
funkcji). Jeżeli znasz inny język programowania o składni podobnej do stosowanej w języku C,
przekonasz się, że składnia większości wyrażeń JavaScript i operatorów jest podobna.
Interpreter JavaScript traktuje każdy użyty w kodzie identyfikator jako zmienną, stałą lub
właściwość obiektu globalnego i stara się uzyskać jego wartość. Jeżeli z identyfikatorem nie jest
skojarzona żadna wartość, próba jej uzyskania powoduje zgłoszenie wyjątku ReferenceError.
Poszczególne wyrażenia inicjatora tablicy mogą być inicjatorami innych tablic. W ten sposób
można tworzyć zagnieżdżone tablice:
Jeżeli w literale tablicowym pominie się wartości rozdzielone przecinkami, wówczas elementy
te nie zostaną zdefiniowane. Na przykład poniższa tablica składa się z pięciu elementów, z
których trzy są niezdefiniowane:
Jeżeli w inicjatorze tablicy po ostatnim wyrażeniu umieści się przecinek, nie spowoduje on
utworzenia niezdefiniowanego elementu. Jednak wynikiem wyrażenia odwołującego się do
elementu poza ostatnim wyrażeniem inicjującym będzie wartość undefined.
Wyrażenie inicjujące obiekt jest podobne do wyrażenia inicjującego tablicę. Różnica polega na
tym, że zamiast nawiasów kwadratowych stosuje się nawiasy klamrowe, a każde podwyrażenie
jest poprzedzone nazwą właściwości i dwukropkiem:
let p = { x: 2.3, y: -1.2 }; // Obiekt posiadający dwie właściwości.
Począwszy od wersji ES6 języka literały obiektowe mają znacznie bogatszą składnię
(szczegółowe informacje znajdziesz w podrozdziale 6.10). Na przykład można je zagnieżdżać:
let rectangle = {
upperLeft: { x: 2, y: 2 },
lowerRight: { x: 4, y: 5 }
};
Wyrażenie definiujące funkcję może również zawierać jej nazwę. Funkcję można też definiować
za pomocą instrukcji, a nie wyrażenia. W wersjach języka ES6 i nowszych można stosować
nową, zwięzłą składnię „strzałkową”. Szczegółowe informacje na temat definiowania funkcji
znajdziesz w rozdziale 8.
wyrażenie [ wyrażenie]
W obu składniach najpierw wyliczany jest wynik wyrażenia umieszczonego przed kropką lub
otwierającym nawiasem kwadratowym. Jeżeli wynikiem jest wartość null lub undefined,
zgłaszany jest wyjątek TypeError, ponieważ żadna z tych wartości nie ma właściwości. Jeżeli po
wyrażeniu obiektu znajduje się kropka i identyfikator, odczytywana jest wartość właściwości o
nazwie takiej jak identyfikator, która staje się wynikiem całego wyrażenia. Jeżeli po wyrażeniu
obiektu znajduje się inne wyrażenie umieszczone wewnątrz nawiasów kwadratowych, wyliczany
jest jego wynik, przekształcany następnie w ciąg znaków. Ostatecznym wynikiem wyrażenia jest
wartość właściwości o nazwie takiej jak uzyskany ciąg. W obu składniach, w przypadku braku
właściwości o zadanej nazwie, wynikiem wyrażenia dostępu do właściwości jest undefined.
Składnia z identyfikatorem jest prostsza. Zwróć jednak uwagę, że można ją stosować jedynie
wtedy, gdy nazwa żądanej właściwości jest poprawnym identyfikatorem i jest znana w chwili
pisania kodu. Jeżeli nazwa właściwości zawiera spacje lub znaki specjalne albo jest liczbą (w
przypadku tablicy), należy użyć składni z nawiasami kwadratowymi. Nawiasy są również
stosowane wtedy, gdy nazwa właściwości nie jest statyczna, czyli jest wynikiem wyliczeń (patrz
przykład w punkcie 6.3.1).
Obiekty i ich właściwości będą szczegółowo opisane w rozdziale 6., a tablice i ich elementy w
rozdziale 7.
wyrażenie ?. identyfikator
Wartości null i undefined są jedynymi wartościami w języku JavaScript, które nie mają
właściwości. W ich przypadku próba odwołania się do właściwości za pomocą kropki lub
nawiasów [] skutkuje zgłoszeniem wyjątku TypeError. Aby uchronić się przed takimi
sytuacjami, można użyć notacji ?. lub ?.[].
Przeanalizujmy wyrażenie a?.b. Jeżeli zmienna a ma wartość null lub undefined, wynikiem
całego wyrażenia jest undefined. Nie trzeba przy tym odwoływać się do właściwości b. Jeżeli
zmienna a ma inną wartość, wynikiem całego wyrażenia jest wartość właściwości a.b (jeżeli a
nie ma właściwości o nazwie b, wynikiem wyrażenia również jest undefined).
let a = { b: null };
Oczywiście, jeżeli wynikiem wyrażenia a.b jest obiekt, który nie posiada właściwości o nazwie
c, również zgłaszany jest wyjątek TypeError. W takim przypadku trzeba użyć kolejnego
warunkowego wyrażenia dostępu do właściwości:
let a = { b: {} };
let index = 0;
try {
Warunkowy dostęp do właściwości przy użyciu notacji ?. i ?.[] jest jedną z najnowszych
funkcjonalności języka JavaScript. Na początku 2020 r. składnia ta była obsługiwana we
wstępnych wersjach większości najpopularniejszych przeglądarek.
Klasa Array posiada metodę, w której opcjonalnym argumencie można umieszczać funkcję
definiującą porządek sortowania. W wersjach starszych niż ES2020, tworząc metodę z
opcjonalnym argumentem funkcyjnym, taką jak sort(), trzeba było przed wywołaniem
argumentu funkcyjnego sprawdzać za pomocą instrukcji if, czy został on określony:
Począwszy od wersji ES2020, stosując notację wywołania warunkowego ?.(), można uprościć
kod. Oczywiście wywołanie będzie miało miejsce tylko wtedy, gdy argument będzie funkcją:
}
Zwróć uwagę, że notacja ?.() sprawdza jedynie, czy po lewej stronie znajduje się wartość null
lub undefined. Nie sprawdza, czy wartość jest funkcją, którą można wywołać. Zatem w
powyższym przykładzie funkcja square() zgłosi wyjątek, jeżeli w jej argumentach zostaną
umieszczone na przykład dwie liczby.
let f = null, x = 0;
try {
} catch(e) {
}
f?.(x++) // => undefined: zmienna f ma wartość null, ale wyjątek nie jest
zgłaszany.
x // => 1: instrukcja zwiększająca zmienną jest pomijana, zgodnie z
regułą krótkiego zwarcia.
Wyrażenie wywołania warunkowego ?.() sprawdza się zarówno w przypadku metod, jak i
funkcji. Ponieważ jednak wywołanie jest również odwołaniem do właściwości, upewnij się, czy
zrozumiałe są różnice pomiędzy tymi dwiema operacjami:
new Point(2,3)
Jeżeli w argumentach konstruktora nie trzeba umieszczać wartości, można pominąć parę
nawiasów:
new Object
new Date
Wynikiem wyrażenia tworzącego obiekt jest nowy obiekt. Konstruktory będą szczegółowo
opisane w rozdziale 9.
l-wartość → wart.
delete Usunięcie właściwości P 1
logiczna
dowolny, dowolny →
== Nieścisła równość L 2
wart. logiczna
dowolny, dowolny →
!= Nieścisła nierówność L 2
wart. logiczna
dowolny, dowolny →
=== Ścisła równość L 2
wart. logiczna
dowolny, dowolny →
!== Ścisła nierówność L 2
wart. logiczna
dowolny, dowolny →
&& Logiczna operacja ORAZ L 2
dowolny
dowolny, dowolny →
|| Logiczna operacja LUB L 2
dowolny
Pominięcie pierwszego
dowolny, dowolny →
, operandu i zwrócenie L 2
dowolny
drugiego
Zwróć uwagę, że operatory przypisania i kilka innych wymagają operandu typu l-wartość. Jest
to historyczny termin oznaczający „poprawne wyrażenie znajdujące się po lewej stronie
operatora przypisania”. W języku JavaScript l-wartościami są zmienne, właściwości obiektów i
elementy tablic.
4.7.3. Efekty uboczne operatorów
Wyliczanie wyniku wyrażenia na przykład 2 * 3 nie wpływa na stan programu ani na wyniki
przyszłych wyliczeń. Jednak niektóre wyrażenia wywołują efekty uboczne i wpływają na
wyniki wyliczeń, które będą wykonane w przyszłości. Najlepszym przykładem jest operator
przypisania. Jeżeli zmiennej lub właściwości zostanie przypisana wartość, zmienią się wyniki
wyrażeń wykorzystujących tę zmienną lub właściwość. Podobnie jest w przypadku operatorów
inkrementacji ++ i dekrementacji ––, wykonujących niejawne przypisanie. Operator delete
również wywołuje efekt uboczny, ponieważ usunięcie właściwości można traktować jako
przypisanie jej wartości undefined (choć nie jest to dokładnie to samo).
Inne operatory nie wywołują efektów ubocznych. Wyrażenia wywołujące funkcje i tworzące
obiekty mogą wywoływać efekty, jeżeli wywołują je operatory użyte w ciele funkcji lub
konstruktora.
w = x + y*z;
Operator mnożenia * ma wyższy priorytet niż operator dodawania +, więc mnożenie jest
wykonywane przed dodawaniem. Najniższy priorytet ma operator przypisania =, więc jest
stosowany po wyliczeniu wyniku wyrażenia znajdującego się po jego prawej stronie.
Kolejność stosowania operatorów można zmieniać za pomocą nawiasów. Aby w powyższym
przykładzie wymusić najpierw wykonanie dodawania, należy wyrażenie sformułować w
następujący sposób:
w = (x + y)*z;
Zwróć uwagę, że odwołanie do właściwości i wywołanie funkcji mają wyższy priorytet niż
pozostałe operatory w tabeli 4.1. Przeanalizujmy następujące wyrażenie:
// Zmienna my jest obiektem posiadającym właściwość o nazwie functions,
której wartością
// jest tablica funkcji. Wywołujemy funkcję o numerze x, umieszczamy w jej
w = x - y - z;
jest równoważne następującemu:
w = ((x - y) - z);
Natomiast wyrażenia:
y = a ** b ** c;
x = ~-y;
w = x = y = z;
q = a?b:c?d:e?f:g;
są równoważne następującym wyrażeniom:
y = (a ** (b ** c));
x = ~(-y);
w = (x = (y = z));
q = a?b:(c?d:(e?f:g));
Kolejność działań jest inna, ponieważ operatory potęgowania, przypisania oraz operatory
jednoargumentowe i warunkowe trójargumentowe mają wiązania prawostronne.
Kolejność przetwarzania ma znaczenie tylko wtedy, gdy użyte podwyrażenia wywołują efekty
uboczne wpływające na wyniki innych podwyrażeń. Jeżeli na przykład wyrażenie x zwiększa
wartość zmiennej wykorzystywanej w wyrażeniu z, wówczas ma znaczenie, że wyrażenie x jest
przetwarzane przed z.
4.8. Operatory arytmetyczne
W tym podrozdziale opisane są operatory wykonujące na swoich operandach operacje
arytmetyczne i inne działania liczbowe. Operatory potęgowania, mnożenia, dzielenia i
odejmowania są proste, więc będą przedstawione w pierwszej kolejności. Operatorowi
dodawania jest poświęcony osobny punkt, ponieważ służy on również do łączenia ciągów
znaków i rządzą w nim nietypowe reguły konwersji typów. Operatory jednoargumentowe i
bitowe będą również wyjaśnione w osobnych punktach.
Operator ** ma wyższy priorytet niż operatory *, / i % (które z kolei mają wyższy priorytet niż +
i -). Operator ** w odróżnieniu od pozostałych ma wiązanie prawostronne. Zatem wyrażenie
2**2**3 jest równoważne 2**8, a nie 4**3. Naturalna niejednoznaczność pojawia się w
wyrażeniach takich jak -3**2. W zależności od względnych priorytetów operatorów zmiany
znaku i potęgowania wyrażenie to jest równoważne (-3)**2 lub -(3**2). W różnych językach
programowania przetwarzanie przebiega różnie, natomiast w języku JavaScript jest po prostu
zgłaszany błąd składniowy, co zmusza programistę do użycia nawiasów i napisania
jednoznacznego wyrażenia. Operator potęgowania jest jednym z najnowszych operatorów
arytmetycznych — został wprowadzony w wersji ES2016. Natomiast funkcja Math.pow(),
dostępna od najwcześniejszych wersji języka JavaScript, działa dokładne tak samo jak operator
**.
Operator / dzieli pierwszy operand przez drugi. Jeżeli znasz język programowania, w którym
rozróżniane są liczby całkowite i zmiennoprzecinkowe, zapewne spodziewasz się, że wynikiem
dzielenia dwóch liczb całkowitych jest również liczba całkowita. Jednak w języku JavaScript
wszystkie liczby są zmiennoprzecinkowe i taki też jest wynik każdej operacji dzielenia. Dlatego
wynikiem wyrażenia 5/2 jest 2.5, a nie 2. Wynikiem dzielenia przez zero jest dodatnia lub
ujemna nieskończoność, natomiast wynikiem 0/0 jest wartość NaN. W żadnym z tych
przypadków nie jest zgłaszany wyjątek.
Operator % wykonuje operację modulo, czyli wylicza resztę z dzielenia pierwszego operandu
przez drugi. Znak wyniku jest taki sam jak znak pierwszego operandu. Na przykład wynikiem
wyrażenia 5 % 2 jest 1, a wynikiem -5 % 2 jest -1. Operator modulo stosuje się zazwyczaj z
liczbami całkowitymi, ale można również używać liczb zmiennoprzecinkowych. Na przykład
wynikiem wyrażenia 6.5 % 2.1 jest 0.2.
4.8.1. Operator +
Dwuargumentowy operator + dodaje operandy liczbowe lub łączy ze sobą operandy tekstowe:
1 + 2 // => 3
"Dzień" + " " + "dobry" // => "Dzień dobry"
"1" + "2" // => "12"
Jeżeli oba operandy są liczbami lub ciągami znaków, wynik jest oczywisty. Jednak w każdym
innym przypadku, kiedy wymagana jest konwersja typów, wynik operacji zależy od rodzaju
konwersji. Wyższy priorytet ma konwersja na ciągi znaków. Jeżeli jeden operand jest ciągiem
lub obiektem, który można przekształcić w ciąg, wówczas drugi operand jest również
przekształcany w ciąg znaków i oba ciągi są ze sobą łączone. Dodawanie jest wykonywane tylko
wtedy, gdy żaden operand nie jest ciągiem.
Z technicznego punktu widzenia operand + działa w następujący sposób:
Minus (–)
Jednoargumentowy operator – przekształca w razie potrzeby operand w liczbę, a następnie
zmienia jej znak na przeciwny.
Inkrementacja (++)
Operator ++ inkrementuje, tj. zwiększa o 1, pojedynczy operand, który musi być l-wartością
(zmienną, elementem tablicy lub właściwością obiektu). Operator przekształca operand
w liczbę, dodaje do niej liczbę 1 i uzyskany wynik przypisuje z powrotem zmiennej,
elementowi lub właściwości.
Wartość zwracana przez ten operator zależy od jego pozycji względem operandu. Operator
umieszczony przed operandem jest operatorem preinkrementacji, tzn. najpierw zwiększa
wartość operandu, a następnie zwraca uzyskaną w ten sposób wartość. Operator
umieszczony za operandem jest operatorem postinkrementacji, tj. zwiększa jego wartość
operandu, ale zwraca jego oryginalną wartość. Przeanalizujmy różnice pomiędzy
poniższymi wierszami:
let i = 1, j = ++i; // Zmienne i oraz j mają wartość 2.
let n = 1, m = n++; // Zmienna n ma wartość 2, a zmienna m wartość 1.
Zwróć uwagę, że wyrażenie x++ nie zawsze jest równoważne x=x+1. Operator ++ nie łączy
ciągów znaków. Zawsze przekształca operand w liczbę i zwiększa jego wartość. Jeżeli x jest
ciągiem "1", to wyrażenie ++x jest liczbą 2, natomiast x+1 jest ciągiem "11".
Pamiętaj również, że ze względu na automatyczne umieszczanie średnika w języku
JavaScript, nie można wprowadzać podziału wiersza pomiędzy operator postinkrementacji
a poprzedzający go operand. W takim przypadku operator zostanie potraktowany jako
samodzielna instrukcja i zostanie przed nim umieszczony średnik.
Opisywany operator, zarówno pre-, jak i postinkrementacji, jest najczęściej stosowany do
modyfikowania licznika w pętli for (patrz punkt 5.4.3).
Dekrementacja (--)
Operandem operatora –– jest l-wartość. Operator ten przekształca operand w liczbę,
odejmuje od niej liczbę 1 i uzyskany wynik przypisuje z powrotem operandowi. Podobnie
jak w przypadku operatora ++, zwracana wartość zależy od pozycji operatora względem
operandu. Operator umieszczony przed operandem zmniejsza go i zwraca uzyskaną
wartość. Natomiast operator umieszczony za operandem zmniejsza go, ale zwraca
oryginalną wartość. Jeżeli operator znajduje się za operandem, nie można pomiędzy nimi
umieszczać podziału wiersza.
Operatory =, == i ===
W języku JavaScript można stosować operatory =, == i ===. Ważna jest świadomość różnic
pomiędzy przypisaniem, równością i ścisłą równością, aby podczas kodowania używać
właściwych operatorów. Choć wszystkie trzy operatory zazwyczaj czyta się jako „jest
równe”, warto w celu uniknięcia nieporozumień stosować dla operatora = określenie
„uzyskuje” lub „przypisuje”, dla == — określenie „jest równe”, a dla === — „jest ściśle
równe”.
Operator == jest przestarzałą funkcjonalnością języka JavaScript, powszechnie uważaną
za źródło błędów. Niemal zawsze należy zamiast niego stosować operator ===, a zamiast
!= operator !==.
Jak wspomniałem w podrozdziale 3.8, obiekty w języku JavaScript są porównywane na
podstawie referencji, a nie wartości. Obiekt jest równy samemu sobie, ale nie innemu
obiektowi. Nawet obiekty posiadające tyle samo właściwości o takich samych nazwach nie są
sobie równe. Analogicznie dwie tablice zawierające takie same elementy ułożone w tej samej
kolejności nie są sobie równe.
Ścisła równość
Operator ścisłej równości === wylicza wartości operandów, a następnie bez przekształcania
typów sprawdza ich równość w następujący sposób:
Jeżeli obie wartości są tego samego typu, sprawdzana jest ich równość w opisany wyżej
sposób.
Jeżeli wartości są różnych typów, stosowane są następujące reguły sprawdzania równości:
Jeżeli jedna wartość jest równa null, a druga undefined, obie są uznawane za
równe sobie.
Jeżeli jedna wartość jest liczbą, a druga ciągiem znaków, ciąg jest przekształcany w
liczbę, a następnie obie wartości są porównywane ponownie.
Jeżeli któraś z wartości jest równa true, jest przekształcana w liczbę 1, a następnie
obie wartości są porównywane ponownie. Jeżeli któraś z wartości jest równa false,
jest przekształcana w liczbę 0, a następnie obie wartości są porównywane ponownie.
Jeżeli jedna wartość jest obiektem, a druga liczbą lub ciągiem znaków, obiekt jest
przekształcany w wartość prymitywną zgodnie z algorytmem opisanym w punkcie
3.9.3, a następnie obie wartości są porównywane ponownie. Obiekt jest
przekształcany w wartość prymitywną poprzez wywołanie jego metody toString()
lub valueOf(). W przypadku wbudowanych klas najpierw jest wywoływana metoda
valueOf(). Wyjątkiem jest klasa Date, w przypadku której najpierw jest
wywoływana metoda toString().
Wszystkie inne kombinacje wartości są uznawane za różne.
"11" < 3 // => false: porównywanie liczb, ciąg "11" jest przekształcany w
liczbę 11.
"jeden" < 3 // => false: porównywanie liczb, ciąg "jeden" jest
przekształcany w wartość NaN.
Na koniec zwróć uwagę, że operatory <= (mniejszy lub równy) i >= (większy lub równy) nie
stosują reguł równości ani ścisłej równości do określenia, czy obie wartości są „równe”.
Pierwszy z operatorów jest po prostu zdefiniowany jako „nie większy niż”, a drugi „nie mniejszy
niż”. Wyjątkiem jest sytuacja, w której jeden lub oba operandy mają wartość NaN (lub są w nią
przekształcane). Wtedy każdy z czterech operatorów zwraca wartość false.
4.9.3. Operator in
Lewym operandem operatora in powinien być ciąg znaków, symbol lub wartość, którą można
przekształcić w ciąg. Natomiast prawym operandem powinien być obiekt. Operator zwraca
wartość true, jeżeli wartością znajdującą się po lewej stronie jest nazwa właściwości obiektu
znajdującego się po stronie prawej, na przykład:
let point = {x: 1, y: 1}; // Definicja obiektu.
"x" in point // => true: obiekt ma właściwość o nazwie "x".
"z" in point // => false: obiekt nie ma właściwości o nazwie
"z".
Ważne jest, że operator && może, ale nie musi wyliczać wartości operandu znajdującego się po
jego prawej stronie. W powyższym przykładzie zmiennej p została przypisana wartość null,
więc próba wyliczenia wartości wyrażenia p.x spowodowałaby zgłoszenie wyjątku TypeError.
Jednak operator jest stosowany w sposób idiomatyczny, przez co wyrażenie p.x jest wyliczane
tylko wtedy, gdy zmienna p ma wartość prawdziwą, czyli inną niż null i undefined.
Działanie operatora && jest czasami nazywane „krótkim zwarciem”. Możesz mieć do czynienia z
kodem, w którym ta cecha operatora będzie świadomie wykorzystana do warunkowego
wykonywania kodu. Na przykład poniższe dwa wiersze dają ten sam efekt:
if (a === b) stop(); // Funkcja stop() jest wywoływana tylko wtedy, gdy a
=== b.
(a === b) && stop(); // Ta instrukcja działa tak samo jak powyższa.
Choć operator || jest najczęściej wykorzystywany jako logiczny operator LUB, jego mechanizm
działania, podobnie jak operatora &&, jest dość skomplikowany. Operator najpierw wylicza
wartość lewego operandu. Jeżeli jest prawdziwa, robi „krótkie zwarcie”, tj. zwraca wartość
prawdziwą bez wyliczania wartości prawego operandu. Jeżeli natomiast wartość lewego
operandu jest fałszywa, operator wylicza i zwraca wartość prawego operandu.
Podobnie jak w przypadku operatora &&, należy unikać stosowania po prawej stronie operandu
wywołującego efekty uboczne, chyba że celowo wykorzystywany jest fakt, że nie zawsze
wartość takiego operandu jest wyliczana.
Idiomatyczne użycie operatora || polega na wybraniu pierwszej prawdziwej wartości w całym
zestawie:
// Jeżeli zmienna maxWidth jest prawdziwa, użyj jej. W przeciwnym razie
poszukaj jej
W wersjach języka starszych niż ES6 powyższy zapis był często stosowany w funkcjach do
nadawania parametrom domyślnych wartości:
// Kopiowanie właściwości obiektu o do p i zwrócenie p.
function copy(o, p) {
p = p || {}; // Jeżeli parametr p jest pusty, użyj nowo utworzonego
obiektu.
// Ciało funkcji.
}
Począwszy od wersji ES6 nie trzeba stosować powyższych sztuczek, ponieważ domyślnie
wartości parametrów można wprost określić w definicji funkcji, na przykład: function copy(o,
p={}) { ... }.
Stosując tego rodzaju zapisy, należy być świadomym różnic pomiędzy operatorami = i ===.
Zwróć uwagę, że operator = ma bardzo niski priorytet. Jeżeli wynik przypisania ma być
wykorzystany w większym wyrażeniu, niezbędne jest użycie nawiasów.
Operator przypisania ma wiązanie prawostronne, co oznacza, że w przypadku użycia kilku
operatorów przypisania w jednym wyrażeniu, wartości są wyliczane w kolejności od prawej do
lewej. Zatem w celu przypisania tej samej wartości kilku zmiennym można użyć następującego
kodu:
i = j = k = 0; // Zainicjowanie trzech zmiennych liczbą 0.
total += salesTax;
jest równoważne następującemu:
total = total + salesTax;
Jak się można spodziewać, operator += działa na liczbach i ciągach znaków. W przypadku
operandów liczbowych dodaje i przypisuje wartości, a w przypadku ciągów łączy je i przypisuje.
Podobne operatory to -=, *=, &= i kilka innych. Wszystkie są wymienione w tabeli 4.2.
Tabela 4.2 . Operatory przypisania z działaniem
+= a += b a = a +b
-= a -= b a = a -b
*= a *= b a = a *b
/= a /= b a = a /b
%= a %= b a = a %b
**= a **= b a = a ** b
<<= a <<= b a = a << b
>>= a >>= b a = a >> b
>>>= a >>>= b a = a >>> b
&= a &= b a = a &b
|= a |= b a = a |b
^= a ^= b a = a ^b
W pierwszym przypadku wyrażenie a jest wyliczane jeden raz, natomiast w drugim dwa razy.
Oba przypadki różnią się tylko wtedy, gdy wyrażenie a wywołuje efekty uboczne, na przykład
jest to funkcja wykorzystująca operator inkrementacji. Na przykład poniższe dwa wyrażenia
przypisujące nie są sobie równoważne:
data[i++] *= 2;
data[i++] = data[i++] * 2;
let g = f;
W takim wypadku interpreter „nie jest pewien”, która funkcja wywołuje funkcję eval(), i
nie może jej agresywnie zoptymalizować. Problemu można byłoby uniknąć, gdyby funkcja
eval() była operatorem lub zarezerwowanym słowem. W punktach 4.12.2 i 4.12.3
poznasz ograniczenia nałożone na tę funkcję sprawiające, że bardziej przypomina ona
operator.
Operandy tego operatora mogą być dowolnych typów. Pierwszy jest interpretowany jako
wartość logiczna. Jeżeli jest prawdziwa, wyliczana i zwracana jest wartość drugiego operandu,
a w przeciwnym razie trzeciego operandu. Zawsze wyliczana jest wartość tylko drugiego lub
trzeciego operandu, nigdy obu.
Ten sam efekt można osiągnąć za pomocą instrukcji if (patrz punkt 5.3.1), jednak operator ?:
jest bardziej zwięzły. Poniżej przedstawiony jest typowy przypadek, w którym operator
sprawdza, czy dana zmienna została zadeklarowana (tj. czy ma wartość prawdziwą). Jeżeli tak,
zwraca jej wartość. W przeciwnym razie operator zwraca wartość domyślną:
greeting = "dzień " + (username ? username : "dobry");
Jest to bardziej zwięzły odpowiednik poniższej instrukcji if:
greeting = "dzień ";
if (username) {
greeting += username;
} else {
greeting += "dobry";
}
4.13.2. Pierwszy zdefiniowany (??)
Operator ?? zwraca wartość pierwszego zdefiniowanego operandu. Jeżeli lewy operand ma
wartość inną niż null lub undefined, operator ją zwraca. W przeciwnym razie zwraca wartość
prawego operandu. Operator ten, podobnie jak && i ||, działa na zasadzie krótkiego zwarcia, tj.
wylicza wartość drugiego operandu tylko wtedy, gdy pierwszy ma wartość null lub undefined.
Jeżeli wyrażenie a ?? b nie wywołuje efektów ubocznych, jest ono równoważne następującemu
wyrażeniu:
x typeof x
undefined "undefined"
null "object"
4.14. Podsumowanie
W tym rozdziale zostało poruszonych wiele różnych tematów z odniesieniami do mnóstwa
fragmentów, które zapewne przeczytasz w przyszłości ponownie, kontynuując naukę
programowania w języku JavaScript. Poniżej wymienione są najważniejsze zagadnienia, o
których warto pamiętać:
Jednym ze sposobów „sprawiania, aby coś się stało”, jest wyliczanie wyrażeń wywołujących
efekty uboczne. Tego rodzaju wyrażenia, na przykład przypisania wartości lub wywołania
funkcji, mogą być samodzielnymi instrukcjami. Używane w takiej formie są nazywane
instrukcjami wyrażeniowymi. Inną kategorię tworzą instrukcje deklaracyjne, które
deklarują nowe zmienne i definiują nowe funkcje.
Program napisany w języku JavaScript nie jest niczym innym jak tylko sekwencją
wykonywanych instrukcji. Domyślnie interpreter JavaScript wykonuje instrukcje jedną po
drugiej w takiej kolejności, w jakiej zostały umieszczone w kodzie. Innym sposobem
„sprawiania, aby coś się stało”, jest zmienianie tego domyślnego porządku. Służy do tego celu
kilka opisanych niżej instrukcji, czyli struktur sterujących.
Instrukcje warunkowe
Instrukcje takie jak if lub switch powodujące, że interpreter wykonuje lub pomija inne
instrukcje w zależności od tego, jaką wartość ma dane wyrażenie.
Pętle
Instrukcje takie jak while lub for powodujące wielokrotne wykonanie określonych innych
instrukcji.
Skoki
Instrukcje takie jak break, return i throw powodujące, że interpreter przechodzi do innej
części programu.
W kolejnych podrozdziałach opisane są różne instrukcje języka JavaScript i ich składnie. Tabela
5.1 na końcu rozdziału zawiera podsumowanie wszystkich instrukcji. Program jest po prostu
sekwencją instrukcji oddzielonych średnikami. Po zapoznaniu się z instrukcjami będziesz mógł
zacząć pisać programy w języku JavaScript.
counter++;
Operator delete wywołuje ważny efekt uboczny polegający na usunięciu wartości obiektu.
Dlatego niemal zawsze jest stosowany jako instrukcja, a nie część większego wyrażenia:
delete o.x;
Wywołania funkcji są kolejną ważną kategorią instrukcji wyrażeniowych, na przykład:
console.log(debugMessage);
displaySpinner(); // Hipotetyczna funkcja wyświetlająca spinner w aplikacji
WWW.
Powyższe funkcje są wyrażeniami, ale ponieważ wywołują efekty uboczne wpływające na
środowisko systemu operacyjnego lub stan programu, są stosowane jako instrukcje. Jeżeli
funkcja nie wywołuje żadnych efektów, nie ma powodu, aby jej używać, chyba że jako część
większego wyrażenia lub instrukcji przypisania. Na przykład nie można po prostu wyliczać i
odrzucać wartości funkcji cosinus:
Math.cos(x);
x = Math.PI;
cx = Math.cos(x);
Zwróć uwagę na kilka szczegółów tego bloku instrukcji. Przede wszystkim nie kończy się
średnikiem. Na końcu każdej prymitywnej instrukcji wewnątrz bloku znajduje się średnik, ale
nie na końcu bloku. Ponadto wiersze w bloku są przesunięte względem nawiasów. Stosowanie
wcięć nie jest obowiązkowe, ale dzięki nim kod jest bardziej czytelny i zrozumiały.
Podobnie jak wyrażenia często zawierają podwyrażenia, tak wiele instrukcji zawiera
podinstrukcje. Formalnie składnia języka JavaScript dopuszcza stosowanie pojedynczych
podinstrukcji. Na przykład składnia pętli while dopuszcza stosowanie pojedynczej instrukcji
jako ciała pętli. Jednak stosując blok, można w miejscu pojedynczej podinstrukcji umieścić
wiele instrukcji.
Dzięki instrukcji złożonej można umieszczać wiele instrukcji tam, gdzie składnia języka
JavaScript przewiduje wpisanie jednej instrukcji. Czymś przeciwnym jest pusta instrukcja,
czyli brak instrukcji w miejscu, w którym powinna być. Pusta instrukcja wygląda jak niżej:
;
Interpreter JavaScript, przetwarzając pustą instrukcję, nie wykonuje żadnych czynności. Pusta
instrukcja przydaje się czasami do tworzenia pętli z pustym ciałem. Przeanalizujmy poniższą
pętlę (instrukcja for będzie opisana w punkcie 5.4.3):
// Zainicjowanie tabeli a.
W tej pętli wszystkie operacje są wykonywane w wyrażeniu a[i++] = 0, więc ciało pętli nie jest
potrzebne. Składnia języka JavaScript wymaga, aby ciałem pętli była instrukcja. Dlatego w
powyższym przykładzie użyta jest pusta instrukcja, czyli sam średnik.
Jeżeli użycie pustej instrukcji jest zamierzone, dobrą praktyką jest umieszczenie stosownego
komentarza, na przykład:
5.3.1. Instrukcja if
Instrukcja if jest fundamentalną instrukcją warunkową umożliwiającą podejmowanie decyzji,
czyli mówiąc ściślej, warunkowe wykonywanie innych instrukcji. Instrukcja ta występuje w
dwóch formach. Pierwsza z nich ma taką postać:
if (wyrażenie)
instrukcja
W tej formie wyliczane jest wyrażenie. Jeżeli jest prawdziwe (patrz definicje wartości
prawdziwej i fałszywej w podrozdziale 3.4), instrukcja jest wykonywana. W przeciwnym razie
jest pomijana. Poniżej przedstawiony jest przykład:
// Jeżeli zmienna username ma wartość null, undefined, false, 0, "" lub NaN,
nadaj jej nową wartość.
Zwróć uwagę na nawiasy, wewnątrz których umieszczone jest wyrażenie. Jest to wymagana
część składni instrukcji if.
Po słowie if należy umieścić wyrażenie w nawiasach, a za nim jedną instrukcję. Można również
użyć bloku łączącego wiele instrukcji w jedną. Zatem instrukcja if może wyglądać jak niżej:
if (!address) {
address = "";
Druga forma instrukcji if zawiera klauzulę else, która jest wykonywana wtedy, gdy wyrażenie
jest fałszywe. Składnia instrukcji jest następująca:
if (wyrażenie)
instrukcja1
else
instrukcja2
W tej formie, instrukcja1 jest wykonywana wtedy, gdy wyrażenie jest prawdziwe, a
instrukcja2 w przeciwnym razie, na przykład:
if (n === 1)
Stosując zagnieżdżone instrukcje if, należy zwracać uwagę, z którą z nich jest powiązana
klauzula else. Przeanalizujmy następujący kod:
i = j = 1;
k = 2;
if (i === j)
if (j === k)
else
if (j === k)
else
console.log("i nie jest równe j"); // Ups!
W języku JavaScript, podobnie jak w innych językach, obowiązuje reguła, że domyślnie klauzula
else dotyczy najbliższej instrukcji if. Aby powyższy przykład był bardziej czytelny, zrozumiały
oraz łatwiejszy w utrzymaniu i diagnostyce, należy zastosować nawiasy klamrowe:
if (i === j) {
if (j === k) {
}
Wielu programistów ma zwyczaj umieszczania po słowach if i else (jak również w instrukcjach
złożonych, na przykład w pętli while) wewnątrz nawiasów klamrowych nawet pojedynczych
instrukcji. Robiąc to konsekwentnie, można uniknąć problemów takich jak opisany wyżej.
Dlatego polecam tę praktykę. W papierowym wydaniu książki, aby oszczędzić miejsce, nie
zawsze przestrzegam tej zasady.
if (n === 1) {
} else if (n === 2) {
// Wykonaj blok kodu nr 2.
} else if (n === 3) {
} else {
W powyższym kodzie nie ma niczego niezwykłego. Jest to po prostu seria instrukcji if, z
których każda kolejna jest częścią klauzuli else poprzedniej instrukcji. Dzięki idiomowi else if
uzyskana struktura jest bardziej czytelna od równoważnej, w pełni zagnieżdżonej formy:
if (n === 1) {
else {
if (n === 2) {
else {
if (n === 3) {
else {
Specjalnie na tego rodzaju okazje jest przygotowana instrukcja switch, po której umieszcza się
wyrażenie w nawiasach zwykłych i blok kodu w nawiasach klamrowych:
switch(wyrażenie) {
instrukcje
Pełna składnia instrukcji switch jest jednak bardziej skomplikowana. Za pomocą słowa
kluczowego case, etykiety i dwukropka oznacza się miejsca w bloku kodu. Po wyliczeniu
wyrażenia interpreter szuka etykiety równej uzyskanemu wynikowi (przy czym równość jest
sprawdzana za pomocą operatora ===). Jeżeli ją znajdzie, wykonuje blok kodu oznaczony tą
etykietą. W przeciwnym razie szuka etykiety default:. Jeżeli jej nie znajdzie, pomija cały blok
kodu.
Opis instrukcji switch jest dość zawiły. Jej działanie stanie się bardziej zrozumiałe, gdy
posłużymy się przykładem. Poniższa instrukcja switch odpowiada serii instrukcji if/else z
poprzedniego przykładu:
switch(n) {
}
Zwróć uwagę, że na końcu każdej sekcji case jest umieszczona instrukcja break. Instrukcja ta,
opisana w dalszej części rozdziału, powoduje przejście interpretera na koniec instrukcji switch
(„wyrwanie się” z niej) i kontynuowanie wykonywania następnych instrukcji. Klauzula case
określa początek fragmentu kodu, ale nie jego koniec. Gdyby instrukcji break nie było,
zostałby wykonany fragment kodu począwszy od etykiety zgodnej z wynikiem wyrażenia do
końca bloku. Czasami tworzy się takie struktury, w których jedna klauzula case „przechodzi” w
drugą, ale w 99% przypadków na końcu każdej klauzuli umieszcza się instrukcję break. Jeżeli
instrukcja switch znajduje się wewnątrz funkcji, można zamiast break użyć instrukcji return.
W obu przypadkach następuje wyjście z instrukcji switch i pominięcie kolejnych klauzul.
function convert(x) {
switch(typeof x) {
}
}
Zwróć uwagę, że w poprzednich dwóch przykładach po słowach kluczowych case były użyte,
odpowiednio, literały liczbowe i tekstowe. W ten formie najczęściej stosuje się w praktyce
instrukcję switch. Pamiętaj jednak, że standard ECMAScript dopuszcza stosowanie po każdym
słowie case dowolnego wyrażenia.
W instrukcji switch najpierw wyliczane jest umieszczone po niej wyrażenie, a następnie
wyrażenia case w kolejności takiej, w jakiej występują, aż do znalezienia poszukiwanej
wartości[1]. Odpowiednia etykieta case jest wyszukiwana za pomocą operatora tożsamości ===,
a nie równości ==, zatem wynik wyrażenia musi być zgodny bez przeprowadzania konwersji
typów.
Ponieważ nie wszystkie wyrażenia case są wyliczane przy każdorazowym wykonaniu instrukcji
switch, należy unikać stosowania wyrażeń wywołujących efekty uboczne. Nie mogą to być na
przykład wywołania funkcji czy instrukcje przypisania. Najbezpieczniej jest ograniczyć się do
stałych.
Jak wyjaśniłem wcześniej, jeżeli żadne z wyrażeń case nie jest równe wyrażeniu switch,
wykonywany jest fragment kodu oznaczony etykietą default:. Jeżeli jej nie ma, pomijane jest
całe ciało instrukcji switch. Zwróć uwagę, że w przedstawionych przykładach etykieta
default: znajdowała się na końcu ciała instrukcji switch, za wszystkimi etykietami case. Jest
to logiczne i najczęściej stosowane położenie etykiety, która w rzeczywistości może znajdować
się w dowolnym miejscu ciała instrukcji.
5.4. Pętle
Aby lepiej opisać instrukcje warunkowe, porównałem kod źródłowy do ścieżki, którą podąża
interpreter. Pętla jest instrukcją powodującą cofnięcie interpretera na początek ścieżki, aby
ponownie wykonał określoną część kodu. W języku JavaScript jest pięć rodzajów pętli: while,
do/while, for, for/of (z odmianą for/await) oraz for/in. Wszystkie są opisane w kolejnych
punktach. Pętle najczęściej wykorzystuje się do iterowania elementów tablic. Tego rodzaju pętle
wraz ze specjalnymi metodami pętlowymi klasy Array będą szczegółowo opisane w
podrozdziale 7.6.
while (wyrażenie)
instrukcja
Interpreter zanim wykona instrukcję, wylicza wyrażenie. Jeżeli jego wartość jest fałszywa,
pomija instrukcję stanowiącą ciało instrukcji while i przechodzi do następnej instrukcji
programu. W przeciwnym razie wykonuje instrukcję i wraca na początek pętli, aby ponownie
wyliczyć wyrażenie. Innymi słowy, interpreter powtarza wykonywanie instrukcji, dopóki
wyrażenie jest prawdziwe. Zwróć uwagę, że za pomocą składni while(true) można
zdefiniować nieskończoną pętlę.
Zazwyczaj nie pisze się programów, które powtarzają tę samą operację. Niemal w każdej pętli w
kolejnych iteracjach jest modyfikowanych kilka zmiennych. W efekcie operacje wykonywane
przez instrukcje też się zmieniają w każdej iteracji. Co więcej, jeżeli modyfikowane zmienne są
wykorzystywane w wyrażeniu instrukcji while, wyrażenie to również może zmieniać swoją
wartość w każdej iteracji. Jest to ważne, ponieważ pętla, której wyrażenie zawsze jest
prawdziwe, nigdy się nie skończy. Poniżej przedstawiony jest przykład pętli wyświetlającej
liczby od 0 do 9:
let count = 0;
count++;
}
Jak widać, zmienna count na początku ma wartość 0, a następnie jest inkrementowana w
każdej iteracji pętli. Po wykonaniu 10 iteracji wyrażenie uzyskuje wartość false (ponieważ
zmienna count nie jest już mniejsza od 10), instrukcja while kończy działanie, a interpreter
przechodzi do następnej instrukcji programu. W wielu pętlach stosuje się zmienną licznikową
taką jak count. Często stosowane są zmienne o nazwach i, j i k, ale równie dobrze można
stosować bardziej opisowe nazwy, jeżeli poprawi to czytelność kodu.
instrukcja
while (wyrażenie);
Pętla do/while jest rzadziej stosowana niż jej kuzynka while. W praktyce rzadko jest potrzeba
wykonywania pętli przynajmniej raz.
Poniżej przedstawiony jest przykład pętli do/while:
function printArray(a) {
let len = a.length, i = 0;
if (len === 0) {
console.log("Pusta tablica");
} else {
do {
console.log(a[i]);
}
Pętla do/while różni się od zwykłej pętli while kilkoma szczegółami składniowymi. Przede
wszystkim pierwsza z nich wymaga użycia słowa kluczowego do oznaczającego początek pętli,
oraz słowa while oznaczającego koniec pętli i warunek jej wykonywania. Ponadto na jej końcu
należy umieścić średnik. W przypadku pliki while, jeżeli jej ciało jest ujęte w nawiasy
klamrowe, nie trzeba umieszczać średnika.
Najprościej działanie pętli for można opisać, porównując ją z równoważną pętlą while[2]:
inicjalizacja;
while(sprawdzenie) {
instrukcja
inkrementacja
}
Jak widać, wyrażenie inicjalizacja jest wyliczane jeden raz, przed rozpoczęciem
wykonywania pętli. Wyrażenie to, aby był z niego użytek, musi wywoływać skutek uboczny,
jakim jest zazwyczaj przypisanie wartości. W języku JavaScript można również w tym wyrażeniu
jednocześnie deklarować i inicjować zmienną. Wyrażenie sprawdzenie steruje działaniem pętli i
jest wyliczane przed wykonaniem każdej iteracji. Jeżeli jego wynik jest prawdziwy, wykonywany
jest kod ciała pętli. Na końcu wyliczane jest wyrażenie inkrementacja, które również musi
wywoływać efekty uboczne. Zazwyczaj są nimi przypisanie wartości, inkrementacja przy użyciu
operatora ++ lub dekrementacja przy użyciu --.
Oczywiście pętla może być bardziej złożona niż użyta w opisanym przykładzie. Czasami trzeba
w kolejnych iteracjach modyfikować kilka zmiennych. Jest to przypadek, w którym powszechnie
stosuje się przecinek jako operator łączący kilka wyrażeń inicjujących lub inkrementujących
w jedno wyrażenie:
let i, j, sum = 0;
return o;
}
Zwróć uwagę, że nie zostało tu użyte wyrażenie inicjujące. Żadne z trzech wyrażeń nie jest
obowiązkowe, jednak wymagane jest użycie dwóch średników. Jeżeli nie zostanie użyte
wyrażenie sprawdzające, pętla będzie wykonywana w nieskończoność. Zatem za pomocą
instrukcji for(;;) można definiować nieskończoną pętlę, podobnie jak za pomocą
while(true).
W poniższym przykładzie pętla for/of jest użyta do wyliczenia sumy wartości elementów
tablicy:
let data = [1, 2, 3, 4, 5, 6, 7, 8, 9], sum = 0;
}
sum // => 45
Na pierwszy rzut oka składnia jest podobna do zwykłej pętli for: za słowem kluczowym
znajdują się nawiasy zawierające szczegóły działania pętli. W tym przypadku są to: deklaracja
zmiennej (lub po prostu jej nazwa, jeżeli zmienna została zadeklarowana wcześniej), słowo
kluczowe of i wyrażenie będące iterowalnym obiektem, na przykład tablica, jak tutaj. Podobnie
jak we wszystkich rodzajach pętli za nawiasem zamykającym znajduje się ciało pętli,
umieszczane zazwyczaj wewnątrz nawiasów klamrowych.
W powyższym przykładzie pętla iteruje poszczególne elementy tablicy. Przed każdym
wykonaniem kodu ciała kolejny element tablicy data jest przypisywany zmiennej element.
Elementy tablicy są iterowane od pierwszego do ostatniego.
Tablica jest iterowana „na żywo”, tzn. zmiana wprowadzona w każdej iteracji może mieć wpływ
na jej wynik. Jeżeli zmieni się powyższy kod, wprowadzając w ciele pętli wiersz
data.push(sum);, pętla będzie wykonywana w nieskończoność, ponieważ ostatni element
tablicy nigdy nie zostanie osiągnięty.
}
Do iterowania właściwości obiektu można wykorzystać pętlę for/in (która będzie opisana w
punkcie 5.4.5) lub for/of z metodą Object.keys():
let o = { x: 1, y: 2, z: 3 };
let keys = "";
for(let k of Object.keys(o)) {
keys += k;
}
keys // => "xyz"
Powyższy kod działa poprawnie, ponieważ metoda Object.keys() zwraca tablicę zawierającą
nazwy właściwości obiektu, a tablicę można iterować za pomocą pętli for/of. Zwróć uwagę, że
klucze nie są iterowane „na żywo”, jak w poprzednim przykładzie. Tutaj zmiany wprowadzane
w ciele pętli nie mają wpływu na efekty iteracji. Jeżeli nazwy właściwości nie mają znaczenia,
można iterować odpowiadające im wartości:
let sum = 0;
for(let v of Object.values(o)) {
sum += v;
}
sum // => 6
Jeżeli natomiast trzeba przetwarzać zarówno nazwy, jak i wartości właściwości, można użyć
pętli for/of z metodą Object.entries() i przypisaniem destrukturyzującym:
let pairs = "";
frequency[letter]++;
} else {
frequency[letter] = 1;
}
}
frequency // => {m: 1, i: 4, s: 4, p: 2}
Zwróć uwagę, że iterowane są kody Unicode, a nie znaki UTF-16. Ciąg "I❤ " ma długość 5,
ponieważ każdy ze znaków emoji jest kodowany za pomocą dwóch znaków UTF-16. Jednak pętla
for/of wykona trzy iteracje, po jednej dla znaków "I", "❤" i " ".
}
unique // => ["Na", "na", "Batman!"]
Mapa jest ciekawym przypadkiem, ponieważ nie iteruje się jej kluczy ani wartości, tylko pary
klucz-wartość. W każdej iteracji zwracana jest dwuelementowa tablica, której pierwszym
elementem jest klucz, a drugim przypisana mu wartość. Na przykład poniższy kod iteruje i
rozkłada pary klucz-wartość mapy m:
console.log(chunk);
}
}
instrukcja
Identyfikator zmienna jest zazwyczaj nazwą zmiennej, ale może to być również deklaracja
zmiennej lub cokolwiek innego, co można umieścić po lewej stronie operatora przypisania. Tak
jak w innych pętlach, instrukcja jest pojedynczą instrukcją lub blokiem instrukcji
stanowiącym ciało pętli.
Pętli for/in używa się w następujący sposób:
let o = { x: 1, y: 2, z: 3 };
let a = [], i = 0;
for(a[i++] in o) /* puste ciało */;
Tablica w języku JavaScript jest po prostu szczególnego rodzaju obiektem, a jej indeks
właściwością, którą można iterować za pomocą pętli for/in. Na przykład po dopisaniu
poniższego wiersza do poprzedniego przykładu można wyświetlić indeksy tablicy, tj. liczby 0, 1 i
2:
for(let i in a) console.log(i);
Zauważyłem, że częstym źródłem błędów jest niezamierzone użycie z tablicą pętli for/in
zamiast for/of. Podczas pracy z tablicami niemal zawsze należy stosować pętlę for/of.
W rzeczywistości pętla for/in nie iteruje wszystkich właściwości obiektu. Nie uwzględnia na
przykład właściwości, których nazwy są symbolami. Ponadto spośród właściwości, których
nazwy są ciągami znaków, iteruje tylko te, które są wyliczalne (patrz podrozdział 14.1). Wiele
wbudowanych metod nie jest właściwościami wyliczalnymi. Na przykład każdy obiekt ma
metodę toString(), ale pętla for/in nie iteruje właściwości toString. Oprócz tego nie jest
wyliczalnych wiele właściwości wbudowanych. Domyślnie wyliczalne są wszystkie właściwości i
metody zdefiniowane w kodzie użytkownika (można je jednak zamienić na niewyliczalne,
stosując techniki opisane w podrozdziale14.1).
Pętla for/in iteruje również dziedziczone wyliczalne właściwości (patrz punkt 6.3.2). Oznacza
to, że w kodzie definiującym właściwości dziedziczone po innych obiektach pętla for/in może
nie działać zgodnie z oczekiwaniami. Z tego powodu programiści zazwyczaj zamiast pętli
for/in stosują pętlę for/of z metodą Object.keys().
Jeżeli w ciele pętli zostanie usunięta nieprzetworzona jeszcze właściwość obiektu, może ona,
choć nie musi, zostać pominięta w iteracji. Szczegółowe informacje o kolejności iterowania
właściwości w pętli for/in znajdziesz w punkcie 6.6.1.
5.5. Skoki
Kolejną kategorią instrukcji w języku JavaScript są skoki. Jak sugeruje nazwa, takie instrukcje
powodują przechodzenie iteratora do innych miejsc w kodzie. Instrukcja break przenosi
interpreter na koniec pętli lub innej instrukcji. Z kolei instrukcja continue powoduje, że
interpreter pomija pozostałą do wykonania część ciała pętli, przechodzi na jej początek i
wykonuje kolejną iterację. W języku JavaScript można instrukcje opatrywać nazwami, czyli
etykietami. Z instrukcją break i continue można użyć etykiety identyfikującej docelową pętlę
lub inną instrukcję.
Instrukcja return powoduje wyjście interpretera z funkcji i powrót do kodu, który ją wywołał,
jak również zwraca wartość, którą kod wywołujący może wykorzystać. Instrukcja yield
powoduje tymczasowe wyjście interpretera z funkcji generatora. Instrukcja throw zgłasza
wyjątek i jest stosowana razem z instrukcjami try/catch/finally definiującymi blok instrukcji
obsługujących ten wyjątek. Jest to skomplikowana instrukcja skoku. Gdy zostanie zgłoszony
wyjątek, interpreter przechodzi do najbliższego bloku obsługi tego wyjątku. Blok ten może
znajdować się wewnątrz aktualnie wykonywanej funkcji, jak również w kodzie ją wywołującym.
Szczegółowe informacje o wymienionych wyżej instrukcjach skoków znajdziesz w kolejnych
punktach.
// Kod pominięty…
continue mainloop; // Skok na początek nazwanej pętli.
// Kod pominięty…
}
Etykieta może zawierać dowolny, poprawny w języku JavaScript identyfikator. Nie może to być
słowo kluczowe. Przestrzeń nazw etykiet jest inna niż przestrzeń nazw zmiennych i funkcji,
więc identyfikatory mogą mieć takie same nazwy jak zmienne i funkcje. Etykiety obowiązują
tylko wewnątrz instrukcji (oraz ich podinstrukcji), w których są zdefiniowane. Instrukcja nie
może mieć takiej samej etykiety jak instrukcja, która ją obejmuje, natomiast dwie instrukcje
mogą mieć takie same etykiety, o ile tylko jedna nie jest zagnieżdżona w drugiej. Oznacza to, że
każda instrukcja może mieć wiele etykiet.
}
Instrukcję break można stosować z etykietą (samym identyfikatorem, bez dwukropka):
break identyfikator;
Interpreter po napotkaniu takiej instrukcji przerywa obejmującą ją instrukcję, tj. przechodzi na
jej koniec. Użycie instrukcji z etykietą, która nie jest zdefiniowana w instrukcji obejmującej, jest
błędem składniowym. Instrukcja opatrzona etykietą nie może być pętlą ani instrukcją switch.
Instrukcja break „wyłamuje się” z obejmującej ją instrukcji. Taką instrukcją może być nawet
blok instrukcji ujęty w nawiasy klamrowe tylko po to, aby można go było opatrzyć etykietą.
Pomiędzy instrukcją break a identyfikatorem nie można umieszczać podziału wiersza. Wiąże się
to z automatycznym dodawaniem pominiętych średników. Jeżeli pomiędzy instrukcją break
a identyfikatorem zostanie wstawiony podział wiersza, interpreter potraktuje go jako średnik
(patrz podrozdział 2.6) i wykona zwykłą instrukcję break bez etykiety.
Instrukcję break z identyfikatorem stosuje się wtedy, gdy interpreter musi wyjść poza
najbardziej zagnieżdżoną pętlę lub instrukcję switch. Ilustruje to poniższy kod:
let matrix = getData(); // Pobierz skądś tam dwuwymiarową tablicę elementów.
// Oblicz sumę wszystkich elementów tablicy.
let sum = 0, success = false;
sum += cell;
}
}
success = true;
}
W pętli while ponownie jest sprawdzane wyrażenie umieszczone na początku pętli. Jeżeli
ma wartość true, kod ciała pętli jest wykonywany od początku.
W pętli do/while interpreter przechodzi na koniec pętli, gdzie ponownie sprawdza
warunek przed wykonaniem kolejnej iteracji.
W pętli for jest przetwarzane wyrażenie inkrementujące, a kolejna iteracja jest
wykonywana w zależności od wartości wyrażenia warunkowego.
W pętlach for/of i for/in jest wykonywana kolejna iteracja z kolejną wartością lub
nazwą właściwości przypisaną użytej zmiennej.
Zwróć uwagę na różnice w działaniu instrukcji continue w pętlach while i for. W pętli while
interpreter od razu wraca do wyrażenia warunkowego, a w pętli for najpierw przetwarza
wyrażenie inkrementujące, a dopiero potem wraca do wyrażenia warunkowego. Wcześniej w
tym rozdziale przedstawiłem „odpowiednik” while pętli for. Ponieważ instrukcja continue w
obu pętlach funkcjonuje inaczej, nie można wiernie odwzorować pętli for za pomocą samej
pętli while.
Poniżej pokazany jest przykład użycia instrukcji continue bez etykiety, powodującej przerwanie
iteracji po pojawieniu się błędu:
for(let i = 0; i < data.length; i++) {
Instrukcję return można umieszczać tylko w ciele funkcji. Użycie jej w każdym innym miejscu
kodu jest błędem składniowym. Instrukcja ta powoduje, że funkcja zwraca wartość kodowi,
który ją wywołał, na przykład:
function square(x) { return x*x; } // Funkcja, która ma instrukcję return.
square(2) // => 4
Jeżeli instrukcji return nie ma, wykonywane są wszystkie instrukcje składające się na ciało
funkcji, po czym następuje powrót do kodu wywołującego. W takim przypadku wywołanie
funkcji ma wartość undefined. Często instrukcję return umieszcza się na samym końcu
funkcji, ale nie jest to reguła. Instrukcja return powoduje powrót do kodu wywołującego
funkcję, nawet jeżeli w ciele funkcji są jeszcze inne instrukcje.
Instrukcja return może być stosowana bez wyrażenia. Wtedy wartością zwracaną do kodu
wywołującego jest undefined, na przykład:
function displayObject(o) {
// Natychmiastowe wyjście z funkcji, jeżeli argument ma wartość null lub
undefined.
if (!o) return;
// Pozostałe instrukcje.
}
Ze względu na automatyczne wstawianie średnika (patrz podrozdział 2.6) nie można
umieszczać podziału wiersza pomiędzy instrukcją return a następującym po nim wyrażeniem.
Instrukcję yield poznasz dopiero w rozdziale 12. W tym rozdziale umieściłem ją dla
kompletności opisu. Z technicznego punktu widzenia instrukcja yield jest bardziej operatorem
niż instrukcją, o czym przekonasz się w punkcie 12.4.2.
return f;
}
factorial(4) // => 24
Gdy zostanie zgłoszony wyjątek, interpreter natychmiast przerywa wykonywanie kodu i
przechodzi do najbliższego bloku obsługi wyjątku. Blok obsługi definiuje się za pomocą
instrukcji catch opisanej w następnym punkcie. Jeżeli blok kodu, w którym został zgłoszony
wyjątek, nie jest powiązany z klauzulą catch, interpreter sprawdza, czy z nadrzędnym
obejmującym blokiem kodu jest powiązany blok obsługi wyjątku. Ten proces jest kontynuowany
do momentu znalezienia bloku obsługi wyjątku. Jeżeli wyjątek zostanie zgłoszony w funkcji, w
której ciele nie ma instrukcji try/catch/finally, jest eskalowany w górę do kodu, który
wywołał tę funkcję. W ten sposób wyjątki są eskalowane w górę hierarchicznej struktury kodu i
wywołań metod. Jeżeli blok obsługi wyjątku nie zostanie znaleziony, wyjątek jest uznawany za
błąd i sygnalizowany użytkownikowi.
// sposób, może go zignorować, nic nie robiąc, lub może ponowić zgłoszenie
wyjątku.
}
finally {
// Ten blok zawiera instrukcje, które są wykonywane zawsze,
}
catch(ex) { // Jeżeli użytkownik poda niepoprawną wartość, wykonywany jest
ten blok kodu.
alert(ex); // Wyświetlenie informacji o błędzie.
}
W tym przykładzie nie została użyta klauzula finally. Choć nie jest ona tak często
wykorzystywana jak catch, jest równie przydatna. Jej działanie wymaga jednak dodatkowych
wyjaśnień. Związany z nią kod jest wykonywany zawsze, niezależnie od tego, jak zakończy się
wykonywanie bloku try. Zazwyczaj jest wykorzystywana do porządkowania danych
przetwarzanych w powyższym bloku.
W normalnych warunkach interpreter po osiągnięciu końca bloku try przechodzi do bloku
finally, w którym porządkowane są dane. Jeżeli wyjście z bloku try nastąpi w wyniku użycia
instrukcji return, continue lub break, blok finally jest wykonywany przed przejściem
interpretera do innego miejsca kodu.
Jeżeli w bloku try zostanie zgłoszony wyjątek, z którym jest powiązany obsługujący go blok
catch, interpreter najpierw wykona ten blok, a następnie blok finally. Jeżeli nie ma bloku
catch obsługującego zgłoszony wyjątek, interpreter najpierw wykona blok finally, a potem
przejdzie do najbliższej obejmującej klauzuli catch.
Jeżeli w bloku finally znajduje się instrukcja return, continue, break lub throw, lub metoda
zgłaszająca wyjątek, interpreter przejdzie do innego miejsca kodu. Na przykład jeżeli w bloku
finally zostanie zgłoszony wyjątek, zastąpi on aktualnie obsługiwany wyjątek. Jeżeli w bloku
finally zostanie wykonana instrukcja return, nastąpi normalne wyjście z funkcji, niezależnie
od tego, czy wyjątek został zgłoszony i obsłużony.
Klauzule try i finally można stosować bez klauzuli catch. W takim wypadku blok finally po
prostu porządkuje dane i jest wykonywany niezależnie od tego, co się wydarzy w bloku try. Jak
wiadomo, nie można wiernie zasymulować pętli for za pomocą pętli while, ponieważ instrukcja
continue w każdej z pętli funkcjonuje inaczej. Wykorzystując instrukcje try/catch/finally,
można napisać pętlę while, która działa tak jak for i poprawne obsługuje instrukcję continue:
// Symulacja pętli for(inicjalizacja; sprawdzenie; inkrementacja) ciało;
inicjalizacja;
while(sprawdzenie) {
try {ciało;}
finally {inkrementacja;}
}
Zwróć jednak uwagę, że ciało zawierające instrukcję break funkcjonuje w pętli while nieco
inaczej niż w for (przed wyjściem z tej pętli jest dodatkowo wykonywana inkrementacja).
Zatem nawet wykorzystując klauzulę finally, nie można wiernie zasymulować pętli for za
pomocą pętli while.
} catch {
// Coś poszło źle, ale nieważne co.
return undefined;
}
with (obiekt)
instrukcja
W tak zdefiniowanym bloku właściwości obiektu obiekt są traktowane jak lokalne zmienne o
zasięgu danego bloku.
Instrukcji with nie można stosować w trybie ścisłym (patrz punkt 5.6.3), a poza tym trybem
należy ją traktować jako przestarzałą. Najlepiej w ogóle unikać jej stosowania, ponieważ
powoduje ona, że kod jest trudniejszy w optymalizacji i działa znacznie wolniej niż kod bez tej
instrukcji.
Instrukcję with najczęściej stosuje się w celu uproszczenia kodu wykorzystującego
rozbudowaną hierarchię obiektów. Na przykład w kodzie klienckim stosowane są następujące
wyrażenia odwołujące się do elementów formularza HTML:
document.forms[0].address.value
Jeżeli instrukcje takie jak powyższa trzeba wpisywać wielokrotnie, można użyć instrukcji with,
dzięki której właściwości obiektu formularza będą traktowane jak zmienne:
with(document.forms[0]) {
// W tym miejscu można bezpośrednio odwoływać się do elementów formularza,
na przykład:
name.value = "";
address.value = "";
email.value = "";
}
Dzięki tej instrukcji jest nieco mniej kodu do wpisania, ponieważ nie trzeba przed nazwą każdej
właściwości umieszczać prefiksu document.forms[0]. Oczywiście równie łatwo można napisać
podobny kod bez użycia instrukcji with:
let f = document.forms[0];
f.name.value = "";
f.address.value = "";
f.email.value = "";
Zwróć uwagę, że instrukcje let i const użyte wewnątrz ciała instrukcji with definiują,
odpowiednio, zwykłe zmienne i stałe, a nie nowe właściwości wskazanego obiektu.
Zwróć uwagę, że instrukcja debugger nie uruchamia automatycznie debugera. Niemniej jednak,
jeżeli kod jest wykonywany w przeglądarce i otwarte jest okno narzędzi dla programistów,
instrukcja debugger powoduje wstrzymanie wykonywania kodu w pułapce.
5.6.3. Dyrektywa "use strict"
Instrukcja "use strict" jest dyrektywą wprowadzoną w wersji języka ES5. Dyrektywy nie są
instrukcjami, ale są do nich na tyle podobne, że dyrektywa "use strict" została opisana w tym
rozdziale. Pomiędzy nią a zwykłymi instrukcjami są dwie istotne różnice:
Dyrektywa ta nie zawiera słów kluczowych. Jest to po prostu specjalny literał tekstowy
ujęty w cudzysłowy.
Dyrektywę można umieszczać tylko na początku skryptu lub ciała funkcji, przed
wszystkimi innymi instrukcjami.
Dyrektywa "use strict" oznacza, że znajdujący się pod nią kod (skryptu lub funkcji) jest
ścisły. Jeżeli zostanie użyta w kodzie najwyższego poziomu (ponad funkcjami), wówczas cały
skrypt jest ścisły. Kod ciała funkcji jest ścisły wtedy, gdy funkcja jest częścią kodu ścisłego lub
gdy na początku jej ciała jest umieszczona powyższa dyrektywa. Kod umieszczony w
argumencie funkcji eval() jest ścisły, jeżeli wywołujący ją kod jest ścisły lub gdy jej argument
zawiera dyrektywę "use strict". Oprócz tego domyślnie ścisły jest kod klasy (patrz rozdział 9.)
i modułu (patrz podrozdział 10.3). Oznacza to, że program składający się wyłącznie z modułów
jest w całości ścisły i nie trzeba stosować dyrektywy "use strict".
Kod ścisły jest wykonywany w trybie ścisłym, w którym funkcjonalności języka są ograniczone,
ale dzięki temu nie ujawniają się jego pewne mankamenty, trudniej jest popełnić błąd, a kod
jest bezpieczniejszy. Ponieważ tryb ścisły domyślnie nie jest włączony, starszy kod,
wykorzystujący niedoskonałe funkcjonalności języka, jest wykonywany poprawnie. Pomiędzy
trybem ścisłym a zwykłym są następujące różnice (szczególnie ważne są pierwsze trzy):
5.7. Deklaracje
Z technicznego punktu widzenia słowa kluczowe const, var, function, class, import i export
nie są instrukcjami, choć na takie wyglądają i tak są nieformalnie nazywane w tej książce.
Dlatego zasługują na miejsce w tym rozdziale.
Powyższe słowa można ściślej określić jako deklaracje, a nie instrukcje. Na początku rozdziału
wspomniałem, że instrukcje „sprawiają, aby coś się stało”. Deklaracje służą do definiowania
nowych wartości i nadawania im nazw, za pomocą których można się do tych wartości
odwoływać. Za sprawą samych deklaracji wprawdzie niewiele się dzieje w kodzie, ale dzięki
temu, że przypisują nazwy wartościom, nadają sens użyciu innych instrukcji.
Działanie programu polega na wyliczaniu wartości wyrażeń i wykonywaniu instrukcji.
Deklaracje nie są wykonywane w ten sposób. W rzeczywistości definiują one strukturę samego
programu. Można je sobie ogólnie wyobrazić jako części programu przetwarzane przed jego
uruchomieniem.
W języku JavaScript za pomocą deklaracji definiuje się stałe, funkcje i klasy oraz importuje i
eksportuje wartości pomiędzy modułami. W kolejnych punktach opisane są przykłady
wszystkich deklaracji. Znacznie dokładnie będą one opisane w innych miejscach książki.
let radius = 3;
var circumference = TAU * radius;
}
Deklaracja funkcji tworzy obiekt funkcyjny i przypisuje mu określoną nazwę, w tym przykładzie
area. Za pomocą tej nazwy można w każdym miejscu kodu odwoływać się do funkcji i
uruchamiać zawarty w niej kod. Deklaracja funkcji umieszczona wewnątrz bloku jest
przetwarzana przed uruchomieniem tego bloku, a nazwy są przypisane obiektom funkcyjnym w
obrębie bloku. Czasami mówi się, że nazwy funkcji są „windowane”. Oznacza to, że są
przesuwane w górę na początek bloku, w którym są zdefiniowane. Zatem kod wywołujący
funkcję może być umieszczony przed jej deklaracją.
W podrozdziale 12.3 będą opisane szczególnego rodzaju funkcje — generatory. Deklaracja
generatora składa się ze słowa kluczowego function i gwiazdki. W podrozdziale 13.3 poznasz
funkcje asynchroniczne, które również deklaruje się za pomocą słowa function, ale poprzedza
się je słowem async.
Instrukcja Przeznaczenie
[1] Wyliczanie wyrażeń case w trakcie działania programu sprawia, że instrukcja switch w
języku JavaScript istotnie różni się (jest mniej wydajna) od analogicznych instrukcji w językach
C, C++ i Java. W tych językach wyrażenia case muszą na etapie kompilacji kodu być stałymi
tego samego typu. Dzięki temu instrukcja switch jest kompilowana do postaci bardzo wydajnej
tablicy skoków.
[2] W punkcie 5.5.3 poświęconym instrukcji continue dowiesz się, że nie jest to ścisła analogia
pętli for.
Rozdział 6.
Obiekty
Obiekty stanowią fundamentalny typ danych w języku JavaScript. Wielokrotnie widziałeś je we
wcześniejszych rozdziałach. Ponieważ są one bardzo ważne, musisz poznać szczegóły ich
funkcjonowania, które znajdziesz w tym rozdziale. Zaczniemy od formalnego przeglądu
obiektów, a potem zajmiemy się praktycznymi tematami, takimi jak tworzenie obiektów oraz
odpytywanie, zapisywanie, usuwanie, sprawdzanie i wyliczanie ich właściwości. W kolejnych
podrozdziałach opisane jest rozszerzanie i serializowanie obiektów oraz definiowanie ważnych
metod. Na końcu rozdziału znajduje się długa część opisująca nową składnię literału
obiektowego, dostępną w wersjach ES6 języka i nowszych.
Właściwość ma nazwę i wartość. Nazwą może być dowolny ciąg znaków (również pusty) lub
symbol. Obiekt nie może zawierać dwóch właściwości o takich samych nazwach. Wartość
właściwości może być dowolna, jak również dowolne mogą być funkcje getter lub setter (lub
obie), które poznasz w punkcie 6.10.6.
Czasami trzeba rozróżniać właściwości zdefiniowane bezpośrednio w obiekcie od
odziedziczonych po prototypie. W języku JavaScript właściwości nieodziedziczone określa się
mianem własnych właściwości.
Wiele wbudowanych obiektów ma właściwości, które można wyłącznie odczytywać albo które
nie są wyliczalne, albo konfigurowalne. Domyślnie jednak wszystkie są zapisywalne, wyliczalne
i konfigurowalne. W podrozdziale 14.1 będą opisane techniki przypisywania właściwościom
atrybutów innych niż domyślne.
// w cudzysłowach.
author: { // Wartość tej właściwości jest obiektem.
firstname: "David",
surname: "Flanagan"
}
};
Za ostatnią właściwością można umieścić przecinek. Możesz się spotkać z zaleceniami, aby
zawsze go umieszczać w celu uniknięcia w przyszłości popełnienia błędu składniowego podczas
dodawania nowych właściwości na końcu literału obiektowego.
Literał obiektowy jest wyrażeniem, które za każdym razem, gdy jest wyliczane, tworzy nowy
obiekt i nadaje właściwościom nowe wartości. Oznacza to, że za pomocą jednego literału,
wielokrotnie wyliczanego wewnątrz pętli lub funkcji, można utworzyć wiele obiektów, których
właściwości mają różne wartości.
Przedstawione wyżej literały obiektowe mają prostą składnię, typową dla najwcześniejszych
wersji języka JavaScript. W nowszych wersjach zostało wprowadzonych wiele udoskonaleń
literałów obiektowych, które będą opisane w podrozdziale 6.10.
let r = new Map(); // Utworzenie obiektu typu Map dla par klucz-wartość.
Często definiuje się własne konstruktory inicjujące nowo utworzone obiekty. Ten temat będzie
opisany w rozdziale 9.
6.2.3. Prototypy
Zanim zajmiemy się trzecią techniką tworzenia obiektów, zatrzymajmy się na chwilę, aby
poznać prototypy. Niemal z każdym obiektem w języku JavaScript jest skojarzony inny obiekt,
tzw. prototyp, po którym dziedziczone są właściwości.
Każdy obiekt utworzony za pomocą literału ma ten sam prototyp, do którego w kodzie można
odwoływać się za pomocą właściwości Object.prototype. Podczas tworzenia obiektu przy
użyciu operatora new prototypem jest wartość właściwości prototype konstruktora. Zatem
obiekt utworzony za pomocą instrukcji new Object() oraz za pomocą literału {} dziedziczy
właściwości po prototypie Object.prototype. Analogicznie obiekt utworzony przy użyciu
instrukcji new Array() dziedziczy właściwości po prototypie Array.prototype, a utworzony za
pomocą instrukcji new Date() — po prototypie Date.prototype. Na początku może to brzmieć
dość zawile. Należy pamiętać, że niemal wszystkie obiekty mają prototypy, ale tylko ich
niewielka część ma właściwość prototype. Są to obiekty definiujące prototypy dla wszystkich
innych obiektów.
Właściwość Object.prototype jest jednym z nielicznych obiektów, który nie ma prototypu, tzn.
nie dziedziczy właściwości po żadnym innym obiekcie. Większość wbudowanych i
zdefiniowanych konstruktorów ma prototyp odziedziczony po obiekcie Object.prototype. Jest
nim na przykład obiekt Date.prototype. Zatem obiekt utworzony za pomocą instrukcji new
Date() dziedziczy właściwości zarówno po obiekcie Date.prototype, jak i Object.prototype.
Seria połączonych prototypów nosi nazwę łańcucha prototypów.
Aby utworzyć obiekt bez użycia prototypu, należy wywołać powyższą funkcję z argumentem
null. Jednak utworzony w ten sposób obiekt nie odziedziczy żadnych właściwości, nawet
podstawowych metod takich jak toString(). Oznacza to, że nie będzie go można używać z
operatorem +:
Aby utworzyć zwykły, pusty obiekt, podobny do utworzonego za pomocą literału {} lub
instrukcji new Object(), należy użyć argumentu Object.prototype:
Funkcji Object.create() używa się wtedy, gdy trzeba zabezpieczyć obiekt przed
niezamierzonymi modyfikacjami przez funkcje biblioteczne, nad którymi to modyfikacjami
programista nie ma kontroli. W argumencie takiej funkcji nie umieszcza się właściwego
obiektu, tylko inny, pochodny obiekt. Funkcja „widzi” właściwości oryginalnego obiektu, ale nie
dotyczą go wprowadzone przez tę funkcję zmiany:
Aby utworzyć lub ustawić właściwość, należy użyć kropki lub nawiasów kwadratowych,
podobnie jak w celu jej odpytania. Wyrażenie należy jednak umieścić po lewej stronie operatora
przypisania:
Mówiąc ściślej, wartością wyrażenia wewnątrz nawiasów kwadratowych nie musi być ciąg
znaków, tylko wartości, które można przekształcić w ciąg lub symbol (patrz punkt 6.10.3). W
rozdziale 7. dowiesz się, że często wewnątrz nawiasów umieszcza się liczby.
obiekt.właściwość
obiekt["właściwość"]
}
Ten kod odczytuje i łączy ze sobą właściwości address0, address1, address2 i address3
obiektu customer.
Powyższy krótki przykład pokazuje, jak elastyczna jest składnia odwołania do właściwości,
wykorzystująca wyrażenie tekstowe. Powyższy kod można zmienić tak, aby wykorzystywał
składnię z kropką, ale zdarzają się sytuacje, w których można stosować wyłącznie składnię z
nawiasami. Załóżmy, że mamy kod wykorzystujący zasoby sieciowe do wyliczania bieżącej
wartości inwestycji w akcje. Program umożliwia użytkownikowi wprowadzenie symboli i liczby
posiadanych akcji. Do przechowywania tych informacji wykorzystuje obiekt o nazwie
portfolio, posiadający po jednej właściwości na każdą akcję. Nazwą każdej właściwości jest
symbol akcji, a jej wartością liczba akcji. Jeżeli na przykład użytkownik posiada 50 akcji firmy
IBM, to właściwość portfolio.ibm ma wartość 50.
Fragment programu, w którym dodawane są nowe akcje do portfela, może mieć następującą
postać:
function addstock(portfolio, stockname, shares) {
portfolio[stockname] = shares;
Ponieważ użytkownik wprowadza symbole akcji w trakcie działania programu, nie sposób ich
przewidzieć zawczasu. Dlatego w odwołaniach do właściwości obiektu portfolio nie można
użyć składni z kropką. Można za to zastosować składnię z nawiasami kwadratowymi, ponieważ
wykorzystywany jest w niej ciąg znaków. Ciąg jest dynamiczny i może się zmieniać w trakcie
działania kodu. Tym się różni od identyfikatora, który jest statyczny i musi być w kodzie
wpisany na stałe.
W rozdziale 5. poznałeś pętlę for/in (wkrótce spotkasz się z nią ponownie w podrozdziale 6.6),
która użyta z tablicą asocjacyjną w pełni ujawnia swoją siłę. Poniżej pokazany jest przykład
wykorzystania tej pętli do wyliczenia całkowitej wartości portfela:
function computeValue(portfolio) {
6.3.2. Dziedziczenie
Obiekt w języku JavaScript ma zestaw właściwości własnych, jak również odziedziczonych po
prototypie. Aby zrozumieć tę cechę, trzeba dokładniej poznać mechanizm dostępu do
właściwości. W przykładach opisanych w tym punkcie obiekty są tworzone za pomocą funkcji
Object.create() i określonych prototypów. W rozdziale 9. dowiesz się, że tworząc instancję
klasy za pomocą operatora new, tworzy się obiekt, który dziedziczy właściwości po prototypie.
Załóżmy, że mamy kod, w którym odpytywana jest właściwość x obiektu o. Jeżeli obiekt ten nie
ma własnej właściwości o tej nazwie, odpytywana jest właściwość x jego prototypu[1]. Jeżeli
prototyp również nie ma własnej właściwości o tej nazwie, ale ma prototyp, wówczas
odpytywana jest właściwość tego prototypu. Ten proces powtarza się do momentu, aż zostanie
znaleziona właściwość x lub obiekt, którego właściwość prototype ma wartość null. Jak widać,
właściwości prototype tworzą łańcuch, czyli połączoną listę obiektów, po których dziedziczone
są właściwości:
Teraz załóżmy, że właściwości x w obiekcie o została przepisana jakaś wartość. Jeżeli obiekt ten
miał wcześniej własną, tj. nieodziedziczoną właściwość x, zostanie po prostu zmieniona jej
wartość. W przeciwnym razie zostanie utworzona nowa właściwość o nazwie x. Jeżeli obiekt
wcześniej miał odziedziczoną właściwość x, zostanie ona przesłonięta przez nową właściwość o
takiej samej nazwie.
Podczas przypisywania wartości właściwościom jest przeglądany łańcuch prototypów, ale
wyłącznie w celu sprawdzenia, czy można wykonać to przypisanie. Jeżeli na przykład obiekt o
dziedziczy właściwość x przeznaczoną tylko do odczytu, wówczas przypisanie wartości nie jest
możliwe (szczegółowe informacje o tym, kiedy właściwościom można przypisywać wartości,
znajdziesz w punkcie 6.3.3). Jeżeli natomiast przypisanie jest możliwe, jest tworzona lub
modyfikowana właściwość zdefiniowana tylko w danym obiekcie. Żaden prototyp w łańcuchu
nie jest modyfikowany. Dziedziczenie właściwości podczas ich odpytywania, a nie ustawiania
jest kluczową funkcjonalnością języka JavaScript, umożliwiającą selektywne nadpisywanie
dziedziczonych właściwości:
Jest jeden wyjątek od reguły określającej, kiedy przypisanie wartości właściwości kończy się
niepowodzeniem i kiedy tworzona i ustawiana jest właściwość w oryginalnym obiekcie. Jeżeli
obiekt o dziedziczy właściwość x, która jest metodą dostępową (setterem, patrz punkt 6.10.6),
wówczas nie jest tworzona właściwość x, tylko wywoływana jest powyższa metoda. Zwróć
jednak uwagę, że wywoływany jest setter obiektu o, a nie jego prototypu, w którym metoda jest
zdefiniowana. Jeżeli więc setter definiuje nowe właściwości, są one tworzone w obiekcie o.
Żaden prototyp w łańcuchu nie jest zmieniany.
Błędem jest natomiast próba odpytania właściwości nieistniejącego obiektu. Wartości null i
undefined nie mają właściwości, więc próba ich odpytania też jest błędem. Kontynuujmy
poprzedni przykład:
Wyrażenie odwołujące się do właściwości nie zostanie wyliczone, jeżeli po lewej stronie kropki
będzie znajdowała się wartość null lub undefined. Należy więc zachować ostrożność, wpisując
wyrażenia takie jak book.author.surname, jeżeli nie ma pewności, że obiekty book i
book.author są zdefiniowane. Poniżej opisanie są dwa sposoby uniknięcia tego problemu:
if (book) {
if (book.author) {
surname = book.author.surname;
}
}
Operator delete usuwa tylko własne właściwości obiektu (nie usuwa odziedziczonych). Aby
usunąć odziedziczoną właściwość, należy użyć operatora delete z prototypem, w którym dana
właściwość jest zdefiniowana. Operacja ta wpływa na funkcjonowanie wszystkich obiektów
dziedziczących właściwości po danym prototypie.
Operator delete zwraca wartość true, jeżeli właściwość została pomyślnie usunięta, nie dała
żadnego efektu (na przykład właściwość nie istniała) lub wyrażenie nie reprezentowało
właściwości:
// wartość true.
delete o.toString // => true: nic się nie dzieje (właściwość toString jest
odziedziczona).
Operator delete nie usuwa również właściwości, których atrybut „konfigurowalna” ma wartość
false. Niektóre właściwości wbudowanych obiektów nie są konfigurowalne. Są to na przykład
właściwości obiektu globalnego tworzone podczas deklarowania zmiennych i funkcji. W trybie
ścisłym próba usunięcia niekonfigurowalnej właściwości skutkuje zgłoszeniem wyjątku
TypeError. W zwykłym trybie operator delete zwraca w takim wypadku wartość false.
Ilustrują to poniższe przykłady:
// W trybie ścisłym wszystkie poniższe instrukcje powodują zgłoszenie wyjątku
TypeError, a nie zwrócenie
// wartości false.
delete globalThis.f // => false: tej właściwości też nie można usunąć.
W trybie ścisłym w instrukcji usuwającej konfigurowalną właściwość obiektu globalnego można
pominąć jego referencję i po operatorze delete umieścić po prostu nazwę właściwości:
Po lewej stronie operatora in musi znajdować się nazwa właściwości, a po prawej obiekt. Jeżeli
obiekt ma podaną właściwość, własną lub odziedziczoną, zwracana jest wartość true:
let o = { x: 1 };
let o = { x: 1 };
o.hasOwnProperty("x") // => true: obiekt o ma własną właściwość o
nazwie "x".
o.hasOwnProperty("y") // => false: obiekt o nie ma własnej właściwości
o nazwie "y".
o.hasOwnProperty("toString") // => false: toString jest właściwością
odziedziczoną.
let o = { x: 1 };
o.x !== undefined // => true: obiekt o ma właściwość o nazwie "x".
Aby wyliczyć tylko odziedziczone właściwości, należy w ciele pętli jawnie sprawdzać ich źródło:
for(let p in o) {
for(let p in o) {
if (typeof o[p] === "function") continue; // Pominięcie wszystkich metod.
}
Zamiast użycia pętli for/in prostszym rozwiązaniem jest utworzenie tablicy zawierającej
nazwy wszystkich właściwości, a następnie przeanalizowanie jej za pomocą pętli for/of. Nazwy
właściwości można uzyskać za pomocą jednej z czterech funkcji:
W podrozdziale 6.7 będą opisane przykłady użycia funkcji Object.keys() z pętlą for/of.
Pętla for/in nie wylicza właściwości w tak ściśle określonej kolejności jak powyższe funkcje.
Zazwyczaj najpierw wylicza w opisanej kolejności własne właściwości danego obiektu, a po nich
w takiej samej kolejności właściwości poszczególnych prototypów w łańcuchu. Należy
pamiętać, że nie są wyliczane właściwości prototypów, które mają takie same nazwy jak
wyliczone wcześniej właściwości obiektu, a nawet jak właściwości niewyliczalne.
}
target // => {x: 1, y: 2, z: 3}
Ponieważ jest to często wykonywana operacja, różne platformy JavaScript oferują służące do
tego celu funkcje pomocnicze, zazwyczaj o nazwie extend(). Począwszy od wersji języka ES6
można też używać wbudowanej funkcji Object.assign(). Jej argumentami są dwa obiekty lub
ich większa liczba. Funkcja modyfikuje i zwraca pierwszy argument, który jest docelowym
obiektem, ale nie modyfikuje pozostałych, źródłowych obiektów. Ze wszystkich źródłowych
obiektów kopiuje ich własne wyliczalne właściwości, również te, których nazwami są symbole, i
umieszcza je w obiekcie docelowym. Obiekty źródłowe są przetwarzane w takiej kolejności, w
jakiej są umieszczone w argumentach. Oznacza to, że właściwości pierwszego źródłowego
obiektu nadpisują właściwości obiektu docelowego o takich samych nazwach, jak również
właściwości kolejnych obiektów źródłowych (jeżeli zostały podane) nadpisują właściwości o
takich samych nazwach pierwszego obiektu źródłowego.
Funkcja Object.assign() kopiuje właściwości zawierające zwykłe metody get i set. Oznacza
to, że podczas kopiowania wywoływane są gettery obiektu źródłowego i settery obiektu
docelowego. Same metody nie są kopiowane.
Często pojawia się potrzeba kopiowania domyślnych wartości wielu właściwości obiektu
źródłowego, których nie ma w obiekcie docelowym. Za pomocą funkcji Object.assign() użytej
w zwykły sposób nie można tego osiągnąć:
Object.assign(o, defaults); // Wszystkie właściwości obiektu o są
nadpisywane właściwościami obiektu defaults.
Zamiast tego należy utworzyć nowy obiekt, skopiować do niego właściwości obiektu defaults,
a następnie nadpisać je właściwościami obiektu o:
o = Object.assign({}, defaults, o);
W punkcie 6.10.4 dowiesz się, że tę samą operację kopiowania i nadpisywania właściwości
można wykonać za pomocą operatora rozciągania ...:
o = {...defaults, ...o};
Można uniknąć dodatkowego obciążenia wywoływanego tworzeniem i kopiowaniem obiektu.
W tym celu należy napisać odmianę funkcji Object.assign(), która kopiuje tylko brakujące
właściwości:
// Funkcja podobna do Object.assign(), która nie nadpisuje istniejących
właściwości,
// jak również nie obsługuje tych, których nazwami są symbole.
function merge(target, ...sources) {
}
}
}
return target;
}
Object.assign({x: 1}, {x: 2, y: 2}, {y: 3, z: 4}) // => {x: 2, y: 3, z: 4}
merge({x: 1}, {x: 2, y: 2}, {y: 3, z: 4}) // => {x: 1, y: 2, z: 4}
W podobny sposób można łatwo napisać inne funkcje, które tak jak merge() operują na
właściwościach. Może to być na przykład funkcja restrict(), usuwająca z danego obiektu
właściwości, których nie ma w innym obiekcie. Innym przykładem może być funkcja
subtract() usuwająca z danego obiektu wszystkie właściwości, które posiada inny obiekt.
let point = {
x: 1,
y: 2,
toString: function() { return `(${this.x}, ${this.y})`; }
};
String(point) // => "(1, 2)": metoda toString() służąca do przekształcania
ciągów znaków.
toLocaleString: function() {
return `(${this.x.toLocaleString()}, ${this.y.toLocaleString()})`;
}
};
let point = {
x: 3,
y: 4,
valueOf: function() { return Math.hypot(this.x, this.y); }
};
Number(point) // => 5: metoda valueOf() przekształca współrzędne w liczbę.
point > 4 // => true
point > 5 // => false
point < 6 // => true
y: 2,
toString: function() { return `(${this.x}, ${this.y})`; },
toJSON: function() { return this.toString(); }
};
JSON.stringify([point]) // => '["(1, 2)"]'
let x = 1, y = 2;
let o = {
x: x,
y: y
};
Począwszy od wersji języka ES6 można pominąć pierwszy identyfikator i dwukropek, dzięki
czemu kod jest znacznie prostszy:
let x = 1, y = 2;
let o = { x, y };
o.x + o.y // => 3
o[computePropertyName()] = 2;
Znacznie prościej można utworzyć taki obiekt za pomocą nowej funkcjonalności zwanej
wyliczaną właściwością, dzięki której można nawiasy kwadratowe stosować bezpośrednio w
literale:
const PROPERTY_NAME = "p1";
};
p.p1 + p.p2 // => 3
W nowej składni wewnątrz nawiasów kwadratowych można umieszczać dowolne wyrażenia,
których wyniki, po przekształceniu w razie potrzeby w ciągi znaków, będą użyte jako nazwy
właściwości.
Wyliczane właściwości przydają się na przykład w funkcjach bibliotecznych, w których
argumentach umieszcza się obiekty zawierające właściwości o nazwach zdefiniowanych w
bibliotece. Podczas pisania kodu definiującego tego rodzaju obiekty i wpisywania nazw
właściwości na stałe pojawia się ryzyko popełnienia błędu. Ponadto jeżeli pojawi się nowa
wersja biblioteki, wymagane nazwy właściwości mogą być inne. Dlatego kod będzie bardziej
stabilny, jeżeli zastosuje się nową składnię i zdefiniowane w bibliotece stałe zawierające nazwy
właściwości.
let p = { x: 0, ...o };
p.x // => 1: wartość właściwości obiektu o nadpisuje początkową wartość.
let q = { ...o, x: 2 };
q.x // => 2: liczba 2 nadpisuje poprzednią wartość właściwości obiektu o.
Należy również zwrócić uwagę, że operator rozciąga tylko własne właściwości obiektu (bez
odziedziczonych):
let o = Object.create({x: 1}); // Obiekt o dziedziczy właściwość x.
let p = { ...o };
p.x // => undefined
Na koniec warto wspomnieć, że operator rozciągania, będący trzema skromnymi kropkami, w
rzeczywistości przysparza wiele pracy interpreterowi JavaScript. Złożoność obliczeniowa
procesu rozciągania jest równa O(n), gdzie n oznacza liczbę właściwości. Zatem operator ten
użyty wewnątrz pętli lub rekurencyjnej funkcji do gromadzenia danych w dużym obiekcie
powoduje, że złożoność obliczeniowa algorytmu jest równa O(n2), przez co kod przy dużych
wartościach n staje się nieefektywny i nieskalowalny.
};
square.area() // => 100
W wersji ES6 składnia literału obiektowego (i definicji klasy, którą poznasz w rozdziale 9.)
została udoskonalona i można pominąć dwukropek wraz ze słowem kluczowym function. W
rezultacie uzyskuje się następujący kod:
let square = {
area() { return this.side * this.side; },
side: 10
};
square.area() // => 100
Obie formy kodu są równorzędne. W literale obiektu zdefiniowana jest właściwość o nazwie
area, a jej wartością jest specjalna funkcja. Dzięki uproszczonej składni widać wyraźnie, że
area() jest metodą, a nie właściwością zawierającą dane, jak side.
W uproszczonej składni nazwa właściwości może mieć dowolną formę dopuszczalną w literale
obiektowym. Może to być nie tylko zwykły identyfikator, jak area, ale również literał tekstowy
lub wyliczona nazwa, w tym symbol:
[METHOD_NAME](x) { return x + 2; },
[symbol](x) { return x + 3; }
};
weirdMethods["metoda ze spacjami"](1) // => 2
weirdMethods[METHOD_NAME](1) // => 3
weirdMethods[symbol](1) // => 4
Stosowanie symboli jako nazw metod nie jest takie niezwykłe, jak się na pozór wydaje. Aby
obiekt był iterowalny, tj. aby można go było użyć w pętli for/of, należy zdefiniować w nim
metodę o symbolicznej nazwie Symbol.iterator. W rozdziale 12. poznasz przykłady, w których
będzie wykorzystana ta technika.
dataProp: value,
// Właściwość dostępowa zdefiniowana jako para funkcji.
get accessorProp() { return this.dataProp; },
set accessorProp(value) { this.dataProp = value; }
};
Właściwość dostępową definiuje się jako jedną lub dwie metody o takich samych nazwach.
Metody te, w odróżnieniu od zwykłych metod definiowanych przy użyciu uproszonej składni
wprowadzonej w wersji ES6, poprzedza się słowami get i set. W wersji ES6 można definiować
gettery i settery za pomocą wyliczanych nazw właściwości. W tym celu trzeba po prostu nazwę
właściwości umieszczoną po słowie get lub set zastąpić wyrażeniem ujętym w nawiasy
kwadratowe.
let p = {
// Właściwości x i y są zwykłymi właściwościami typu "odczyt/zapis".
x: 1.0,
y: 1.0,
// Właściwość r jest właściwością dostępową typu "odczyt/zapis",
posiadającą gettera i settera.
// Na końcu definicji metody dostępowej należy umieścić przecinek.
get r() { return Math.hypot(this.x, this.y); },
set r(newvalue) {
Właściwości dostępowe, podobnie jak zwykłe, mogą być dziedziczone. Można więc obiekt p z
powyższego przykładu wykorzystać jako prototyp innych obiektów. Nowe obiekty mogą mieć
właściwości własne x i y, jak również odziedziczone r i theta:
let q = Object.create(p); // Nowy obiekt dziedziczący gettery i settery.
q.x = 3; q.y = 4; // Utworzenie własnych właściwości obiektu q.
6.11. Podsumowanie
W tym rozdziale zostały szczegółowo opisane obiekty i związane z nimi następujące
zagadnienia:
Wszystkie wartości inne niż prymitywne są obiektami. Obiektami są również tablice i funkcje,
którym poświęcono dwa następne rozdziały.
[1] Pamiętaj, że niemal wszystkie obiekty mają swoje prototypy, ale większość z nich nie ma
właściwości o nazwie prototype. Dziedziczenie w języku JavaScript funkcjonuje nawet wtedy,
gdy bezpośredni dostęp do prototypu nie jest możliwy. Aby dowiedzieć się więcej na ten temat,
zajrzyj do podrozdziału 14.3.
Rozdział 7.
Tablice
W tym rozdziale opisane są tablice — fundamentalny typ danych w języku JavaScript i
większości języków programowania. Tablica jest uporządkowaną kolekcją wartości, czyli
elementów. Każdy element ma swoje miejsce w tablicy oznaczone numerem, czyli indeksem.
Tablice w języku JavaScript nie są typowane. Oznacza to, że elementy tablicy mogą być różnych
typów, a nawet obiektami i innymi tablicami. Dzięki temu można tworzyć skomplikowane
struktury danych, na przykład tablice obiektów lub tablice tablic. Elementy tablicy są
indeksowane od zera liczbami 32-bitowymi. Pierwszy element ma indeks równy 0, a największy
możliwy indeks jest równy 4 294 967 294 (232–2). Oznacza to, że tablica może się składać z
maksymalnie 4 294 967 295 elementów. Tablice są dynamiczne, tj. można je powiększać i
pomniejszać odpowiednio do potrzeb. Dlatego nie trzeba deklarować tablicy o ustalonej
wielkości ani realokować jej w przypadku zmiany. Tablica może być rozrzedzona, tj. jej
elementy nie muszą mieć kolejnych indeksów (pomiędzy nimi mogą być przerwy). Każda tablica
ma właściwość length. Jeżeli tablica nie jest rozrzedzona, właściwość ta zawiera liczbę
elementów. W przypadku tablicy rozrzedzonej wartość tej właściwości jest większa od
największego indeksu.
Tablica w języku JavaScript jest specjalną odmianą obiektu. Indeksy są w rzeczywistości czymś
nieco więcej niż właściwościami, których nazwami są liczby całkowite. Więcej na temat
specyfiki tablicy dowiesz się w dalszej części rozdziału. Implementacje języka JavaScript
zazwyczaj optymalizują tablice, dzięki czemu dostęp do elementów indeksowanych za pomocą
liczb jest zwykle znacznie szybszy niż do zwykłych właściwości obiektów.
Tablice dziedziczą właściwości po prototypie Array.prototype, który definiuje bogaty zestaw
metod do manipulowania elementami (patrz podrozdział 7.8). Większość z nich to metody
generyczne, tj. obsługujące nie tylko dowolne tablice, ale również obiekty „tablicopodobne”,
które będą opisane w podrozdziale 7.10.
W wersji języka ES6 został wprowadzony zestaw nowych klas tablicowych, zwany ogólnie
„tablicami typowanymi”. Tablica typowana, w odróżnieniu zwykłej, ma ustaloną długość, a jej
elementy określony, liczbowy typ. Są też bardziej wydajne i umożliwiają odwoływanie się do
danych binarnych na poziomie bajtów. Tego rodzaju tablice będą opisane w podrozdziale 11.2.
literału tablicowego,
operatora rozciągania (...) i iterowalnego obiektu,
konstruktora Array(),
metod fabrycznych Array.of() i Array.from().
7.1.1. Literały tablicowe
Najprościej tablicę tworzy się za pomocą literału tablicowego, którym jest po prostu
umieszczona wewnątrz nawiasów klamrowych lista elementów oddzielonych przecinkami, na
przykład:
Wartości użyte w literale nie muszą być stałymi. Mogą to być dowolne wyrażenia:
let base = 1024;
Jeżeli w literale umieści się kilka przecinków z rzędu (bez wartości pomiędzy nimi), powstanie
tablica rozrzedzona (patrz podrozdział 7.3). Elementy, które zostały pominięte, nie istnieją, a
próba ich odpytania skutkuje uzyskaniem wartości undefined:
let count = [1,,3]; // Istnieją elementy o indeksach 0 i 2. Nie ma elementu o
indeksie 1.
let undefs = [,,]; // Tablica bez elementów, ale o długości 2.
W literale tablicowym można na końcu umieścić opcjonalny przecinek. Na przykład tablica [,,]
ma długość 2, a nie 3.
Wielokropek „rozciąga” tablicę a i umieszcza jej elementy w literale tworzonej tablicy. Można
sobie wyobrazić, że fraza ...a jest zastępowana elementami tablicy a i staje się częścią literału.
Pamiętaj jednak, że operator rozciągania pomimo swojej nazwy nie jest operatorem w ścisłym
znaczeniu tego słowa, ponieważ można go stosować tylko w literałach tablicowych i w
wywołaniach funkcji, o czym się dowiesz w dalszej części książki.
Operator rozciągania można stosować z każdym iterowalnym obiektem, tj. takim, który można
iterować za pomocą pętli for/of. Z tego rodzaju obiektami spotkałeś się w punkcie 5.4.4, a
znacznie więcej dowiesz się o nich w rozdziale 12. Ciągi znaków są iterowalne, zatem za
pomocą operatora rozciągania można je przekształcać w tablice złożone z pojedynczych
znaków:
let digits = [..."0123456789ABCDEF"];
digits // =>
["0","1","2","3","4","5","6","7","8","9","A","B","C","D","E","F"]
Zbiory również są iterowalne (patrz punkt 11.1.1), a więc prostym sposobem usunięcia z tablicy
powtarzających się elementów jest przekształcenie jej w zbiór i z powrotem w tablicę za
pomocą operatora rozciągania:
[...new Set(letters)] // => [ "W", "i", "t", "a", "j", ",", " ", "ś", "w",
"e", "c", "!" ]
Bez argumentów:
Jawnie umieszczając w argumentach dwa lub więcej elementów lub jeden element
nieliczbowy:
Problem można rozwiązać, stosując funkcję Array.of() wprowadzoną w wersji języka ES6. Jest
to metoda fabryczna, która tworzy tablicę na podstawie podanych argumentów (niezależnie od
ich liczby) i zwraca ją:
Array.of() // => []; funkcja bez argumentów zwracająca pustą tablicę.
Array.of(10) // => [10]; utworzenie tablicy z jednym liczbowym
elementem.
Array.of(1,2,3) // => [1, 2, 3]
Funkcja Array.from() jest ważna również z tego powodu, że pozwala utworzyć tablicową kopię
obiektu podobnego do tablicy, tj. takiego, który ma liczbową właściwość length, a inne,
odpowiednie właściwości o nazwach będących liczbami całkowitymi. Tego rodzaju obiekty
zwracają niektóre metody JavaScriptu stosowane w przeglądarkach. Przekształcając obiekty
podobne do tablic w zwykłe tablice, można ułatwić sobie dalszą pracę:
let i = 2;
a.length // => 4
To, że indeksy tablicy są specjalnego rodzaju właściwościami obiektu, oznacza, że nie istnieje
problem przekroczenia granicy tablicy. Przy próbie odczytania nieistniejącej właściwości
obiektu nie jest zgłaszany błąd, tylko zwracana wartość undefined. Dotyczy to również tablic:
W dalszej części rozdziału dowiesz się, że tablicę można rozrzedzić za pomocą operatora
delete.
Zwróć uwagę, że jeżeli w literale tablicowym nie umieści się wartości (wpisze się ciąg
przecinków, na przykład [1,,3]), zostanie utworzona tablica rozrzedzona, w której pominiętych
elementów po prostu nie będzie:
Znajomość tablic rozrzedzonych jest ważna dla zrozumienia natury tablic w języku JavaScript.
Jednak w praktyce większość tablic nie jest rozrzedzona. Co więcej, tablice rozrzedzone są
często traktowane jak zwykłe tablice zawierające elementy z wartościami undefined.
Właściwość length tablicy rozrzedzonej jest większa niż liczba jej elementów. O jej wartości
można powiedzieć tylko tyle, że jest większa od indeksu ostatniego elementu. Innymi słowy,
żaden indeks tablicy, niezależnie od tego, czy jest rozrzedzona, czy nie, nie może być większy
ani równy właściwości length. Aby ta zasada była zawsze spełniona, każda tablica ma dwie
specjalne cechy. Pierwsza była opisana wcześniej: jeżeli elementowi o indeksie i zostanie
przypisana wartość większa lub równa wartości właściwości length, wówczas tej właściwości
jest przypisywana wartość i+1. Druga cecha polega na tym, że jeżeli właściwości length
przypisze się liczbę całkowitą nieujemną n mniejszą od bieżącej wartości, to wszystkie elementy
o indeksach mniejszych lub równych n są usuwane. Ilustrują to poniższe przykłady:
Właściwości length można przypisać wartość większą od bieżącej. W ten sposób nie dodaje się
jednak nowych elementów, tylko tworzy na jej końcu rozrzedzony obszar.
Aby dodać na końcu tablicy jedną lub więcej wartości, można również użyć metody push():
let a = []; // Początkowa pusta tablica.
a.push("zero"); // Dodanie wartości na końcu tablicy. Teraz a =
["zero"].
a.push("jeden", "dwa"); // Dodanie dwóch kolejnych wartości. Teraz a =
["zero", "jeden", "dwa"].
Dodanie wartości na końcu tablicy a jest taką samą operacją jak przypisanie wartości
elementowi a[a.length]. Aby wstawić wartość na początku tablicy, należy użyć metody
unshift(), która będzie opisana w podrozdziale 7.8. Istniejące elementy uzyskają wtedy
wyższe indeksy. Metoda pop() działa odwrotnie do push(), tj. usuwa i zwraca ostatni element,
jednocześnie zmniejszając wartość właściwości length o 1. Podobnie metoda shift() usuwa i
zwraca pierwszy element, zmniejsza wartość właściwości length o 1 oraz przesuwa wszystkie
pozostałe elementy na pozycje o indeksach o jeden mniejszych niż aktualne. Więcej na temat
tych metod dowiesz się w podrozdziale 7.8.
Elementy tablicy można usuwać tak jak właściwości obiektu za pomocą operatora delete:
let a = [1,2,3];
delete a[2]; // Teraz tablica a nie ma elementu o indeksie 2.
}
string // => "Witaj, świecie!"; odtworzony początkowy tekst.
Jeżeli w pętli potrzebny jest indeks elementu, należy użyć metody entries() tablicy oraz
przypisania destrukturyzującego, jak niżej:
let everyother = "";
}
everyother // => "Wtj wei!"
Innym dobrym sposobem iterowania tablicy jest użycie metody forEach(). Nie jest to kolejna
forma pętli for, tylko metoda funkcjonująca podobnie jak iterator. W jej argumencie umieszcza
się funkcję, która jest wywoływana dla każdego elementu tablicy:
uppercase += letter.toUpperCase();
});
uppercase // => "WITAJ, ŚWIECIE!"
Zgodnie z oczekiwaniami metoda forEach() iteruje kolejne elementy tablicy. Metoda w drugim
argumencie funkcji umieszcza indeks elementu, który czasami może się przydać. Jednak w
odróżnieniu od pętli for/of metoda ta inaczej traktuje tablicę rozrzedzoną, tj. nie wywołuje
funkcji dla nieistniejącego elementu.
Elementy tablicy można też przetwarzać za pomocą starej dobrej pętli for:
let vowels = "";
}
W powyższych przykładach zostało przyjęte założenie, że wszystkie elementy zawierają
poprawne dane. Jeżeli w rzeczywistości tak nie jest, należy przed przetworzeniem każdego
elementu sprawdzić go. Na przykład kod pomijający nieistniejące elementy może mieć taką
postać:
// Ciało funkcji.
}
7.7. Tablice wielowymiarowe
W języku JavaScript nie są dostępne tablice wielowymiarowe z prawdziwego zdarzenia, ale
można tworzyć tablice tablic. Aby odwołać się do elementu tablicy będącej elementem innej
tablicy, należy dwukrotnie użyć operatora []. Załóżmy, że zmienna matrix zawiera tablicę
złożoną z tablic z liczbami. Każdy element matrix[x] jest tablicą liczb. Aby odwołać się do
określonego elementu takiej tablicy, należy użyć wyrażenia matrix[x][y]. W poniższym
przykładzie jest tworzona tabliczka mnożenia:
// Utworzenie dwuwymiarowej tablicy.
// Zainicjowanie tablicy.
for(let row = 0; row < table.length; row++) {
}
}
// Przykład użycia dwuwymiarowej tablicy do wyliczenia 5*7.
table[5][7] // => 35
metodom iterującym elementy tablicy, w tym wywołującym zadane funkcje dla każdego
elementu;
metodom obsługującym stosy i kolejki, tj. dodającym i usuwającym elementy z początku
lub końca tablicy;
metodom obsługującym podtablice, tj. wyodrębniającym, usuwającym, wstawiającym,
wypełniającym i kopiującym fragmenty innej tablicy;
metodom wyszukującym i sortującym wykorzystywanym do lokalizowania elementów
tablicy oraz ich porządkowania.
Opisane są również statyczne metody klasy Array i kilka dodatkowych metod do łączenia
dwóch tablic oraz do przekształcania ich w ciągi znaków.
7.8.1. Metody iterujące
Metody opisane w tym punkcie iterują tablice, dodatkowo umieszczając każdy element w
argumencie zadanej funkcji. Za ich pomocą można wygodnie przekształcać, mapować,
filtrować, sprawdzać i redukować tablice.
Zanim szczegółowo opiszę te metody, nieco je uogólnię. Przede wszystkim pierwszym
argumentem każdej z nich jest funkcja, którą dana metoda wywołuje dla każdego elementu (lub
niektórych elementów). Jeżeli tablica jest rozrzedzona, zadana funkcja jest wywoływana z
trzema argumentami: wartością elementu, jego indeksem i samą tablicą. Często ważny jest
tylko pierwszy argument, a pozostałe się pomija.
Większość metod iterujących ma drugi opcjonalny argument. Jeżeli jest określony, zadana
funkcja jest traktowana jako metoda tego argumentu. Oznacza to, że drugi argument jest
wartością słowa kluczowego this użytego wewnątrz zadanej funkcji. Zwracany przez funkcję
wynik jest zazwyczaj ważny, ale poszczególne metody traktują go na różne sposoby. Żadna z
opisanych w tym punkcie metod nie modyfikuje tablicy, do której należy, ale może ją oczywiście
modyfikować podana w argumencie funkcja.
Bardzo często funkcję umieszczoną w pierwszym argumencie metody definiuje się bezpośrednio
w wyrażeniu wywołującym tę metodę, zamiast wykorzystywać funkcję zdefiniowaną w innym
miejscu. Szczególnie przydatna jest w takich sytuacjach składnia strzałkowa (patrz punkt
8.1.3), wykorzystana w opisanych przykładach.
Metoda forEach()
Metoda forEach() iteruje tablicę i wywołuje dla każdego jej elementu zadaną funkcję. Jak
wspomniałem, funkcja ta jest pierwszym argumentem metody i jest wywoływana z trzema
argumentami: wartością elementu, jego indeksem i samą tablicą. Jeżeli istotna jest tylko
wartość elementu, definicja funkcji może zawierać tylko jeden argument — pozostałe są wtedy
pomijane:
Zwróć uwagę, że metoda forEach() nie umożliwia przerwania iteracji przed zakończeniem
przetwarzania wszystkich elementów, tj. nie oferuje mechanizmu odpowiadającego instrukcji
break w zwykłej pętli.
Metoda map()
Metoda map() umieszcza w argumencie zadanej funkcji wartość każdego elementu tablicy i
zwraca tablicę złożoną z wyników zwróconych przez tę funkcję. Poniżej pokazany jest przykład:
let a = [1, 2, 3];
a.map(x => x*x) // => [1, 4, 9]: argumentem funkcji jest wartość x, a
zwracanym wynikiem iloczyn x*x.
Funkcja podana w argumencie metody jest wywoływana w taki sam sposób jak w metodzie
forEach(). Jednak w przypadku metody map() funkcja musi zwracać wartość. Pamiętaj, że
metoda ta zwraca nową tablicę i nie modyfikuje tablicy, do której należy. Jeżeli tablica jest
rozrzedzona, funkcja nie jest wywoływana dla nieistniejących elementów, a zwracana tablica
jest tak samo rozrzedzona jak oryginalna, tj. ma taką samą długość i brakuje w niej tych
samych elementów.
Metoda filter()
Metoda filter() zwraca tablicę składającą się z wybranych elementów oryginalnej tablicy.
Funkcja podana w jej argumencie musi być predykatem, tj. zwracać wartość true lub false.
Funkcja ta jest wywoływana tak samo jak w metodach forEach() i map(). Jeżeli zwróconym
wynikiem jest true lub inna wartość, którą można przekształcić w true, to element
umieszczony w argumencie funkcji jest dodawany do wynikowej tablicy. Poniżej
przedstawionych jest kilka przykładów:
let a = [5, 4, 3, 2, 1];
a.filter(x => x < 3) // => [2, 1]; wybieranie wartości mniejszych niż
3.
a.filter((x,i) => i%2 === 0) // => [5, 3, 1]; wybieranie co drugiej wartości.
let a = [1,2,3,4,5];
a.findIndex(x => x === 3) // => 2; wartość 3 ma element o indeksie 2.
a.findIndex(x => x < 0) // => –1; żaden element nie zawiera liczby
ujemnej.
a.find(x => x % 5 === 0) // => 5: wielokrotność liczby 5.
Metoda every() działa jak kwantyfikator matematyczny " („dla każdego”), tj. zwraca wynik
true, jeżeli funkcja predykatu zwróci wartość true dla każdego elementu tablicy:
let a = [1,2,3,4,5];
a.every(x => x < 10) // => true: wszystkie wartości są mniejsze od 10.
a.every(x => x % 2 === 0) // => false: nie wszystkie wartości są parzyste.
Metoda some() działa jak kwantyfikator matematyczny $ („istnieje takie”), tj. zwraca wartość
true, jeżeli przynajmniej dla jednego elementu funkcja predykatu zwróci wartość true.
Natomiast wynik false metoda zwraca wtedy, gdy funkcja predykatu zwróci wynik false dla
każdego elementu:
let a = [1,2,3,4,5];
a.some(x => x%2===0) // => true; tablica a ma parzyste elementy.
Zwróć uwagę, że obie metody przerywają iterowanie tablicy, gdy tylko zostanie określony
wynik. Metoda some() zwraca wartość true, gdy tylko funkcja predykatu zwróci true. Metoda
zwraca wartość false dopiero wtedy, gdy przetworzy całą tablicę i dla każdego elementu
funkcja predykatu zwróci wynik false. Natomiast metoda every() działa odwrotnie: zwraca
wartość false, gdy tylko funkcja predykatu zwróci false, a wartość true zwraca dopiero
wtedy, gdy przetworzy całą tablicę i dla każdego elementu funkcja predykatu zwróci wynik
true. Zauważ również, że zgodnie z przyjętą konwencją matematyczną, jeżeli tablica jest pusta,
metoda every() zwraca wartość true, a metoda some() zwraca false.
let a = [1,2,3,4,5];
a.reduce((x,y) => x+y, 0) // => 15; suma wartości.
Metoda reduce() ma dwa argumenty. Pierwszym jest funkcja wykonująca operację redukcji. Jej
zadaniem jest połączenie, czyli zredukowanie dwóch wartości w jedną i zwrócenie wyniku. W
powyższych przykładach wartości są dodawane i mnożone, jak również wybierana jest większa
wartość. Drugi opcjonalny argument zawiera wartość, która jest przekazywana funkcji jako
wartość inicjująca.
Funkcje wykorzystywane przez metodę reduce() są inne niż w metodach forEach() i map().
Wartość elementu, jego indeks oraz sama tablica są umieszczane w drugim, trzecim i czwartym
argumencie funkcji. Pierwszym argumentem jest zagregowana wartość uzyskana w wyniku
wcześniejszych redukcji. Przy pierwszym wywołaniu funkcji w tym argumencie jest
umieszczana wartość inicjująca podana w drugim argumencie metody. Natomiast w kolejnych
wywołaniach jest to wartość zwrócona w poprzednim wywołaniu funkcji. W pierwszym
powyższym przykładzie funkcja redukująca jest wywoływana z argumentami 0 i 1. Funkcja
dodaje je do siebie i zwraca wynik 1. W następnym wywołaniu argumenty funkcji mają wartości
1 i 2, więc funkcja zwraca wynik 3. Kolejne działania to 3 + 3 = 6, 6 + 4 = 10 i 10 + 5 = 15.
Ostatni wynik, liczba 15, jest wartością zwracaną przez metodę reduce().
Zapewne zauważyłeś, że w trzecim przykładzie metoda reduce() została wywołana z jednym
argumentem, tj. bez wartości inicjującej. W takim wypadku jako wartość inicjująca jest
przyjmowana wartość pierwszego elementu tablicy. Oznacza to, że gdy funkcja jest
wywoływana pierwszy raz, w obu jej argumentach jest umieszczany pierwszy element tablicy.
Zatem w przykładach wyliczających sumę i iloczyn wartości elementów można było pominąć
wartość inicjującą.
Jeżeli tablica jest pusta, to metoda reduce() wywołana bez wartości inicjującej zgłasza wyjątek
TypeError. Jeżeli jest tylko jedna wartość, tj. tablica ma tylko jeden element i nie jest podana
wartość inicjująca, lub tablica jest pusta, a wartość inicjująca jest podana, wówczas metoda
zwraca tę wartość bez wywoływania funkcji redukującej.
Metoda reduceRight() działa podobnie do reduce() z tą różnicą, że przetwarza elementy
tablicy w kolejności od największego do najmniejszego indeksu (od strony prawej do lewej). Jest
to ważne wtedy, gdy operacje redukcji są wiązane prawostronnie, na przykład:
// Wyliczenie wyrażenia 2^(3^4). Potęgowanie jest wiązane prawostronnie.
let a = [2, 3, 4];
a.reduceRight((acc,val) => Math.pow(val,acc)) // => 2.4178516392292583e+24
Zwróć uwagę, że ani metoda reduce(), ani reduceRight() nie ma opcjonalnego argumentu
umożliwiającego określenie wartości identyfikatora this w funkcji redukującej. Nie jest on
potrzebny, ponieważ jego rolę pełni argument inicjujący. Jeżeli funkcja redukująca jest metodą
określonego obiektu, należy użyć metody Function.bind(), która będzie opisana w punkcie
8.7.5.
Dla uproszczenia opisu w przedstawionych wyżej przykładach były stosowane wartości
liczbowe. Jednak metody reduce() i reduceRight() nie są przeznaczone wyłącznie do
wykonywania obliczeń matematycznych. Funkcją redukującą możne być dowolna funkcja
łącząca dwie wartości, na przykład obiekty, w jedną wartość tego samego typu.
Warto wiedzieć, że kod wykorzystujący metody redukujące tablice szybko się komplikuje i robi
nieczytelny. Dlatego czasami łatwiej jest napisać i zrozumieć kod, który przetwarza tablice za
pomocą zwykłych pętli.
Metoda concat() tworzy kopię tablicy, do której należy. W wielu sytuacjach jest to pożądany,
ale kosztowny efekt. Zamiast umieszczać w kodzie wyrażenie a = a.concat(x) tworzące nową
tablicę, lepiej jest użyć metody modyfikującej oryginalną tablicę, na przykład push() lub
splice().
Metody unshift() i shift() działają bardzo podobnie do push() i pop(). Różnią się od nich
jednak tym, że wstawiają i usuwają elementy na początku, a nie na końcu tablicy. Metoda
unshift() wstawia jeden lub więcej elementów, przesuwa istniejące w kierunku wyższych
indeksów i zwraca nową długość tablicy. Natomiast metoda shift() usuwa pierwszy element
tablicy, przesuwa pozostałe w lewo o jedną pozycję i zwraca usunięty element. Za pomocą obu
metod również można zaimplementować stos, ale funkcjonowałby on mniej wydajnie niż w
przypadku użycia metod push() i pop(), ponieważ elementy tablicy musiałyby być przesuwane
w jedną lub drugą stronę przy każdym dodaniu i usunięciu elementu. Można natomiast
zaimplementować kolejkę i za pomocą metody push() dodawać elementy na końcu tablicy, a za
pomocą shift() usuwać je z początku:
let q = []; // q == []
q.push(1,2); // q == [1,2]
q.shift(); // q == [2]; zwracany wynik: 1
q.push(3) // q == [2, 3]
q.shift() // q == [3]; zwracany wynik: 2
q.shift() // q == []; zwracany wynik: 3
Metoda unshift() ma niezwykłą cechę, o której warto tutaj wspomnieć. Wywołana z kilkoma
argumentami, wszystkie umieszcza w wynikowej tablicy jednocześnie. W efekcie uzyskuje się
inną tablicę, niż gdyby argumenty były dodawane pojedynczo:
let a = []; // a == []
a.unshift(1) // a == [1]
a.unshift(2) // a == [2, 1]
a = []; // a == []
a.unshift(1,2) // a == [1, 2]
Metoda slice()
Metoda slice() zwraca wycinek tablicy, czyli podtablicę. Za pomocą jej dwóch argumentów
określa się początek i koniec wycinka. Zwracana tablica zawiera elementy począwszy od
określonego za pomocą pierwszego argumentu do określonego za pomocą drugiego argumentu,
ale z jego wyłączeniem. Jeżeli podany jest tylko jeden argument, zwrócona tablica zawiera
wszystkie elementy od zadanego do ostatniego w oryginalnej tablicy. Jeżeli argument jest liczbą
ujemną, pozycja elementu jest liczona od końca tablicy. Na przykład argument –1 wskazuje
ostatni element, argument –2 przedostatni itd. Zwróć uwagę, że metoda slice() nie
modyfikuje tablicy, do której należy. Poniżej jest przedstawionych kilka przykładów:
let a = [1,2,3,4,5];
a.slice(0,3); // Zwracany wynik: [1,2,3].
Metoda splice()
Metoda splice() jest przeznaczona do wstawiania i usuwania elementów tablicy. W
odróżnieniu od metod slice() i concat() modyfikuje tablicę, do której należy. Zwróć uwagę,
że choć metody splice() i slice() mają bardzo podobne nazwy, wykonują całkowicie
odmienne operacje.
Za pomocą metody splice() można usuwać istniejące elementy, wstawiać nowe lub
wykonywać obie operacje jednocześnie. Indeksy elementów znajdujących się za punktem
wstawienia lub usunięcia są, odpowiednio, zwiększane lub zmniejszane, aby była zachowana ich
ciągłość. Pierwszym argumentem metody jest indeks elementu, od którego będą wstawiane lub
usuwane inne elementy. Drugi argument określa liczbę elementów przeznaczonych do
usunięcia. (Zwróć uwagę, że jest to kolejna różnica w porównaniu z metodą slice(), w której
drugi argument określa końcowy indeks). Jeżeli drugi argument nie jest podany, usuwane są
wszystkie elementy od wskazanego do ostatniego. Metoda splice() zwraca tablicę złożoną z
usuniętych elementów lub pustą tablicę, jeżeli żaden element nie został usunięty. Poniżej
przedstawione są przykłady:
let a = [1,2,3,4,5,6,7,8];
a.splice(4) // => [5,6,7,8]; tablica a ma teraz postać [1,2,3,4].
let a = [1,2,3,4,5];
a.splice(2,0,"a","b") // => []; tablica a ma teraz postać
[1,2,"a","b",3,4,5].
a.splice(2,2,[1,2],3) // => ["a","b"]; tablica a ma teraz postać [1,2,
[1,2],3,3,4,5].
Zwróć uwagę, że metoda splice(), w odróżnieniu od concat(), wstawia całe tablice, a nie ich
elementy.
Metoda fill()
Metoda fill() przypisuje elementom tablicy, czyli jej wycinkowi, określoną wartość.
Modyfikuje więc tablicę, do której należy. Zwracanym wynikiem jest zmieniona tablica:
let a = new Array(5); // Początkowa tablica bez elementów, o długości 5.
a.fill(0) // => [0,0,0,0,0]; wypełnienie tablicy zerami.
Metoda copyWithin()
Metoda copyWithin() kopiuje zadany wycinek tablicy w inne miejsce tej samej tablicy.
Modyfikuje więc tablicę, ale nie zmienia jej długości. Zwracanym wynikiem jest zmieniona
tablica. Pierwszy argument określa początkowy indeks docelowego elementu. Drugi argument
określa początkowy indeks kopiowanego elementu. Jeżeli argument ten nie jest określony,
przyjmowana jest wartość 0. Trzeci argument określa końcowy indeks kopiowanego wycinka.
Jeżeli nie jest określony, przyjmowana jest długość tablicy. Kopiowane są elementy począwszy
od początkowego indeksu do końcowego wyłącznie. W argumentach można umieszczać ujemne
wartości, które określają pozycje elementów względem końca tablicy, podobnie jak w metodzie
slice(). Poniżej przedstawionych jest kilka przykładów:
let a = [1,2,3,4,5];
a.copyWithin(1) // => [1,1,2,3,4]: kopiowanie wszystkich elementów
tablicy do miejsca o indeksie 1.
Metoda includes()
W wersji języka ES2016 została wprowadzona metoda includes(), która ma jeden argument.
Jeżeli tablica zawiera umieszczoną w nim wartość, metoda zwraca wynik true. W przeciwnym
razie zwraca false. Metoda nie informuje o indeksie zadanej wartości, a jedynie o tym, czy
istnieje w tablicy. Jest to w zasadzie test obecności wartości w zbiorze, ale wykonywany na
tablicy. Pamiętaj jednak, że tablica nie jest zbiorem. Dlatego jeżeli trzeba przetwarzać w ten
sposób więcej niż kilka elementów, należy użyć klasy Set (patrz punkt 11.1.1).
Zasadnicza różnica pomiędzy metodami includes() a indexOf() polega na tym, że ta druga
wykorzystuje ten sam algorytm sprawdzania równości co operator ===. Algorytm ten zakłada,
że wartość NaN różni się każdej innej wartości, również NaN. Natomiast metoda includes()
wykorzystuje inną odmianę algorytmu, w którym NaN jest równe NaN. Oznacza to, że metoda
indexOf() nie znajduje wartości NaN w tablicy, za to znajduje ją includes():
let a = [1, true, 3, NaN];
a.includes(true) // => true
a.includes(2) // => false
Metoda sort()
Metoda sort() sortuje elementy bezpośrednio w tablicy i zwraca ją. Jeżeli argumenty nie są
określone, metoda sortuje wiersze w kolejności alfabetycznej (tymczasowo przekształcając je w
razie potrzeby w ciągi znaków):
Metoda reverse()
Metoda reverse() odwraca kolejność elementów i zwraca zmienioną tablicę. Nie tworzy nowej
tablicy z elementami ułożonymi w innej kolejności:
let a = [1,2,3];
a.reverse(); // a == [3,2,1]
Nie są to jednak krytyczne cechy definiujące tablicę. Całkowicie poprawne i uzasadnione jest
traktowanie jako tablicy obiektu, którego właściwość length zawiera liczbę, a inne
odpowiednie właściwości zawierają nieujemne liczby całkowite.
Tego rodzaju obiekty „tablicopodobne” stosuje się w praktyce. Nie mają wprawdzie typowych
metod tablicowych, a ich właściwości length nie funkcjonują tak jak w tablicach. Można je
jednak iterować w taki sam sposób jak zwykłe tablice. Okazuje się, że wiele algorytmów
tablicowych działa poprawnie również w przypadku obiektów podobnych to tablic. Dotyczy do
szczególnie algorytmów, które tylko odczytują zawartości tablic, oraz tych, które nie zmieniają
właściwości length.
W poniższym kodzie do zwykłego obiektu są dodawane właściwości, dzięki którym upodabnia
się on do tablicy. Następnie są iterowane jego „elementy”:
let a = {}; // Początkowy, zwykły, pusty obiekt.
}
a.length = i;
// Iterowanie obiektu tak, jakby był tablicą.
let total = 0;
} else {
return false; // W przeciwnym razie nie jest
podobny.
}
}
W dalszej części rozdziału przekonasz się, że ciągi znaków funkcjonują podobnie jak tablice.
Niemniej jednak funkcje takie jak powyższa zwracają w przypadku ciągu znaków wynik false,
ponieważ najlepiej jest nie traktować go jako tablicy.
Metody tablicowe są w większości generyczne, więc działają poprawnie zarówno z tablicami,
jak i obiektami do nich podobnymi. Ponieważ tego rodzaju obiekty nie dziedziczą właściwości
po prototypie Array.prototype, nie można bezpośrednio wywoływać metod tablicowych. Jest
to możliwe jedynie za pomocą metody Function.call() (szczegółowe informacje znajdziesz w
punkcie 8.7.4):
let a = {"0": "a", "1": "b", "2": "c", length: 3}; // Obiekt podobny do
tablicy.
Array.prototype.join.call(a, "+") // => "a+b+c"
Array.prototype.map.call(a, x => x.toUpperCase()) // => ["A","B","C"]
Operator typeof zwraca oczywiście w przypadku ciągu znaków wynik "string", natomiast
metoda Array.isArray() wywołana z ciągiem w argumencie zwraca wartość false.
Indeksowane ciągi znaków mają tę podstawową zaletę, że zamiast wywoływać metodę
charAt(), można stosować nawiasy kwadratowe, dzięki czemu kod jest bardziej zwięzły,
czytelniejszy i potencjalnie wydajniejszy. Dodatkowo, dzięki temu, że ciągi funkcjonują jak
tablice, można korzystać z metod generycznych, na przykład:
7.11. Podsumowanie
W tym rozdziale zostały szczegółowo opisane tablice, włącznie z przypadkami szczególnymi,
takimi jak tablice rozrzedzone i obiekty podobne do tablic. Poniżej wymienione są kwestie, o
których warto pamiętać:
function printprops(o) {
for(let p in o) {
console.log(`${p}: ${o[p]}\n`);
}
}
// Funkcja wyliczająca odległość pomiędzy dwoma punktami (x1, y1) i (x2, y2)
w kartezjańskim układzie
// współrzędnych.
let dx = x2 - x1;
let dy = y2 - y1;
function factorial(x) {
if (x <= 1) return 1;
return x * factorial(x-1);
}
Deklaracja funkcji ma tę ważną cechę, że użyta w niej nazwa staje się zmienną, której wartością
jest sama funkcja. Deklaracja jest „windowana” na szczyt struktury obejmującego ją skryptu,
bloku lub innej funkcji. Dzięki temu funkcję można wywoływać w kodzie umieszczonym przed
jej definicją. Innymi słowy, interpreter JavaScript definiuje wszystkie funkcje zadeklarowane w
bloku kodu, zanim zacznie wykonywać zawarte w nim instrukcje.
Przedstawione wyżej funkcje distance() i factorial() wyliczają pewne wartości i zwracają je
do wywołującego je kodu za pomocą instrukcji return. Instrukcja ta kończy wykonywanie kodu
funkcji i zwraca wartość umieszczonego po niej wyrażenia. Jeżeli go na ma, instrukcja zwraca
wartość undefined.
Funkcja printprops() jest inna. Jej zadaniem jest wyświetlenie nazw i wartości wszystkich
właściwości obiektu. Ponieważ nie musi zwracać żadnej wartości, nie zawiera instrukcji return.
Wywołanie tej funkcji ma zawsze wartość undefined. Jeżeli funkcja nie zawiera instrukcji
return, wykonywane są po prostu wszystkie instrukcje tworzące jej ciało, a kodowi
wywołującemu jest zwracana wartość undefined.
W starszych wersjach języka niż ES6 deklaracje funkcji trzeba było umieszczać na początku
pliku lub definicji innej funkcji. Nie można było (choć niektóre implementacje języka to
dopuszczały) definiować funkcji wewnątrz pętli, instrukcji warunkowych i innych bloków kodu.
W wersji ES6 w trybie ścisłym można deklarować funkcje wewnątrz bloku kodu. Tak
zdefiniowane funkcje nie są widoczne poza blokiem.
Aby do wyrażenia funkcyjnego można było się odwoływać tak jak w funkcji factorial(), trzeba
mu nadać nazwę. Tak zdefiniowane wyrażenie jest wiązane z obiektem funkcyjnym w lokalnym
zasięgu funkcji. W efekcie powstaje lokalna nazwa. W większości wyrażeń funkcyjnych nazwy
nie są potrzebne, dzięki czemu ich definicje są bardziej zwięzłe (choć nie tak bardzo jak
definicje opisanych niżej funkcji strzałkowych).
Pomiędzy deklaracją funkcji f() a zmienną f zawierającą wyrażenie funkcyjne jest istotna
różnica. W pierwszym przypadku obiekt funkcyjny jest tworzony przed wykonaniem kodu, w
którym funkcja jest wykorzystywana. Definicja jest windowana, a więc funkcję można
wywoływać w kodzie umieszczonym przed nią. Inaczej jest w przypadku wyrażenia funkcyjnego.
Funkcja nie istnieje, dopóki nie rozpocznie się wyliczanie wartości wyrażenia, które ją definiuje.
Co więcej, aby wywołać funkcję, potrzebne jest odwołanie do niej. Zatem nie można odwoływać
się do funkcji zdefiniowanej jako wyrażenie, dopóki nie zostanie ono przypisane zmiennej.
Dlatego funkcji zdefiniowanej jako wyrażenie nie można wywoływać w umieszczonym przed nią
kodzie.
Jednak składnia funkcji strzałkowej może być bardziej zwięzła. Jeżeli ciało funkcji zawiera tylko
instrukcję return, można ją pominąć wraz ze średnikiem i nawiasami klamrowymi. Ciało
funkcji jest wtedy wyrażeniem, którego wartość jest zwracana przez funkcję:
Co więcej, jeżeli funkcja strzałkowa ma tylko jeden parametr, można pominąć nawiasy, w
którym jest on umieszczony:
Zwróć uwagę, że jeżeli funkcja strzałkowa nie ma parametrów, wymagane jest użycie pustej
pary nawiasów:
Oprócz tego pomiędzy parametrami a strzałką nie można wstawiać podziału wiersza, ponieważ
zostanie wtedy zdefiniowane zupełnie inne wyrażenie, na przykład polynomial = x, które samo
w sobie też jest poprawne.
Jeżeli ciało funkcji strzałkowej zawiera jedynie instrukcję return, a zwracanym wyrażeniem jest
literał obiektowy, należy umieścić go wewnątrz dodatkowej pary nawiasów klamrowych, aby
uniknąć niejednoznaczności, czy nawiasy obejmują ciało funkcji, czy literał:
W trzecim wierszu powyższego przykładu funkcja h() jest bardzo niejednoznaczna, dlatego że
kod będący literałem obiektowym może być zinterpretowany jako wyrażenie opatrzone etykietą
i w efekcie może powstać funkcja zwracająca wartość undefined. W ostatnim wierszu bardziej
złożony literał obiektowy nie jest poprawną instrukcją i interpreter zgłosi błąd składniowy.
Funkcje strzałkowe, dzięki swojej zwięzłej składni, doskonale nadają się do umieszczania w
argumentach innych funkcji, szczególnie metod tablicowych, takich jak map(), filter() lub
reduce() (patrz punkt 7.8.1), na przykład:
Pomiędzy funkcją strzałkową a funkcją zdefiniowaną w inny sposób jest jedna krytyczna
różnica: funkcja strzałkowa dziedziczy wartość słowa kluczowego this po środowisku, w
którym jest zdefiniowana. Oznacza to, że nie ma ona własnego kontekstu, tak jak zwykła
funkcja. Jest to ważna i bardzo przydatna cecha funkcji strzałkowej, do której wrócę w dalszej
części rozdziału. Inna, mniej istotna różnica polega na tym, że funkcja strzałkowa nie ma
właściwości prototype, a więc nie może być konstruktorem klasy (patrz podrozdział 9.2).
function hypotenuse(a, b) {
Ciekawą cechą funkcji zagnieżdżonej jest zasięg jej zmiennych. Może się ona odwoływać się do
parametrów i lokalnych zmiennych funkcji, w której jest zagnieżdżona. Na przykład w
powyższym kodzie wewnętrzna funkcja square() może odczytywać i zapisywać wartości
parametrów a i b zewnętrznej funkcji hypotenuse(). Zasada zasięgu zmiennych obowiązująca
w zagnieżdżonych funkcjach jest bardzo ważna, dlatego wrócimy do niej w podrozdziale 8.6.
jako funkcje,
jako metody,
jako konstruktory,
pośrednio, za pomocą metod call() i apply(),
niejawnie, wykorzystując konstrukcje języka, które nie wyglądają tak jak zwykłe funkcje.
W zwykłym wywołaniu funkcji wartość przez nią zwracana staje się wartością wyrażenia
wywołującego. Jeżeli funkcja kończy działanie, ponieważ interpreter osiągnął koniec jej kodu,
zwraca wartość undefined. Jeżeli natomiast interpreter napotkał instrukcję return, zwracanym
wynikiem jest wartość wyrażenia umieszczonego po tej instrukcji. Jeżeli wyrażenia nie ma,
zwracana jest wartość undefined.
Wywołanie warunkowe
Począwszy od wersji języka ES2020 po wyrażeniu funkcyjnym, a przed nawiasem
otwierającym można umieszczać znaki ?., dzięki którym funkcja jest wywoływana tylko
wtedy, gdy wyrażenie funkcyjne jest różne od null i od undefined. Zatem wyrażenie f?.
(x), o ile nie powoduje efektów ubocznych, jest równoważne poniższemu:
W zwykłym trybie kontekst wywołania, czyli wartość słowa kluczowego this, jest globalnym
obiektem. Jednak w trybie ścisłym słowo to ma wartość undefined. Zwróć uwagę, że funkcje
strzałkowe zachowują się wtedy inaczej, tj. dziedziczą wartość this właściwą dla miejsca, w
którym są zdefiniowane.
W funkcjach wywoływanych jako funkcje (nie jako metody) słowo this zazwyczaj w ogóle nie
jest wykorzystywane. Za jego pomocą można jednak sprawdzać, czy obwiązuje tryb ścisły:
o.m = f;
o.m();
o.m(x, y);
Powyższy kod jest wyrażeniem wywołującym. Zawiera wyrażenie funkcyjne o.m i dwa wyrażenia
argumentowe x i y. Wyrażenie funkcyjne jest właściwością dostępową, co oznacza, że funkcję
wywołuje się jak metodę, a nie jak zwykłą funkcję.
Argumenty i zwracany wynik metody wykorzystuje się dokładnie tak samo jak w przypadku
zwykłej funkcji. Jednak pomiędzy wywołaniami metody i funkcji jest ważna różnica: kontekst.
Wyrażenie dostępu do właściwości składa się z dwóch części: nazwy obiektu (w tym przypadku
o) oraz właściwości (m). W wyrażeniu wywołującym metodę, jak w tym przypadku, obiekt o staje
się kontekstem, do którego w ciele funkcji można odwoływać się za pomocą słowa kluczowego
this. Poniżej przedstawiony jest przykład:
};
calculator.add(); // Wywołanie metody wyliczającej sumę 1+1.
calculator.result // => 2
Zazwyczaj w wywołaniu metody stosuje się notację z kropką, ale można też wykorzystywać
nawiasy kwadratowe. Na przykład oba poniższe wiersze przedstawiają wywołania metod:
rect.setSize(width, height);
setRectSize(rect, width, height);
Powyższe funkcje wykonują dokładnie takie same operacje na obiekcie rect. Jednak składnia
wywołania metody zastosowana w pierwszym wierszu czytelniej wyraża to, że w centrum
operacji znajduje się obiekt rect.
Łańcuchowanie metod
Jeżeli metoda zwraca obiekt, można go wykorzystać do wywołania innej metody. W
rezultacie otrzymuje się wyrażenie będące serią (łańcuchem) wywołań metod. Często
tego rodzaju struktury stosuje się w programowaniu asynchronicznych operacji opartych
na promesach (patrz rozdział 13.), jak niżej:
// Wykonanie trzech asynchronicznych operacji z rzędu wraz z obsługą
błędów.
doStepOne().then(doStepTwo).then(doStepThree).catch(handleErrors);
Kodując metodę, która nie zwraca żadnej wartości, warto zastanowić się, czy nie powinna
zwracać wartości this. Konsekwentne stosowanie tej konwencji w całym interfejsie API
jest stylem programowania zwanym łańcuchowaniem metod[1], w którym nazwa
obiektu jest stosowana raz i wywoływane są jego kolejne metody, jak niżej:
new Square().x(100).y(100).size(50).outline("red").fill("blue").draw();
Zwróć uwagę, że this jest słowem kluczowym, a nie zmienną. Składnia języka JavaScript nie
pozwala na przypisywanie wartości temu słowu.
Zasięg słowa kluczowego this jest inny niż zasięg zmiennych. Ponadto funkcje zagnieżdżone (z
wyjątkiem strzałkowych) nie dziedziczą wartości tego słowa po funkcjach nadrzędnych. Jeżeli
zagnieżdżona funkcja jest wywoływana jako metoda, wartością jej słowa this jest obiekt, do
którego funkcja należy. Jeżeli zagnieżdżona funkcja (inna niż strzałkowa) jest wywoływana jako
funkcja, to wartością słowa this jest obiekt globalny (w trybie zwykłym) lub undefined (w
trybie ścisłym). Często popełnianym błędem jest zakładanie, że funkcja zagnieżdżona w
metodzie i wywoływana jako funkcja może za pomocą słowa this uzyskiwać kontekst
wywołania metody. Poniższy kod ilustruje ten problem:
let o = { // Obiekt o.
};
o.m(); // Wywołanie metody m obiektu o.
Słowo this użyte w zagnieżdżonej funkcji nie zawiera obiektu o. Jest to błąd interpretera
języka JavaScript, o czym należy pamiętać. Powyższy kod przedstawia typowy sposób obejścia
problemu. Wewnątrz metody m wartość słowa this jest przypisywana zmiennej self. Natomiast
wewnątrz funkcji f jako odwołanie do zawierającego ją obiektu jest wykorzystywana zmienna
self, a nie słowo this.
const f = () => {
this === o // true, ponieważ funkcja strzałkowa dziedziczy wartość this.
};
Funkcje zdefiniowane jako wyrażenia, a nie instrukcje, nie są windowane. Zatem, aby powyższy
kod działał poprawnie, funkcja f musi być zdefiniowana wewnątrz metody m przed wierszem, w
którym jest wywoływana.
Jeszcze innym rozwiązaniem jest wywołanie metody bind() zagnieżdżonej funkcji w celu
zdefiniowania nowej metody obiektu o i jej niejawnego wywołania:
const f = (function() {
this === o // true, ponieważ funkcja jest powiązana z zewnętrznym
obiektem this.
}).bind(this);
o = new Object();
o = new Object;
Wywołanie konstruktora tworzy nowy, pusty obiekt, który dziedziczy właściwości po obiekcie
(właściwości) prototype konstruktora. Zadaniem funkcji konstruktora jest inicjowanie obiektu.
Nowy obiekt jest wykorzystywany jako kontekst wywołania i w funkcji konstruktora można się
do niego odwoływać za pomocą słowa kluczowego this. Zwróć uwagę, że nowy obiekt jest
wykorzystywany jako kontekst nawet wtedy, gdy wywołanie konstruktora przypomina
wywołanie metody. Oznacza to, że w wyrażeniu new o.m() obiekt o nie jest wykorzystywany
jako kontekst wywołania.
W funkcji konstruktora zazwyczaj nie stosuje się instrukcji return. Konstruktor ma za zadanie
zainicjować obiekt i zakończyć działanie wraz z wykonaniem ostatniej instrukcji. Nowy obiekt
jest wartością wyrażenia wywołania konstruktora. Jeżeli jednak w funkcji konstruktora jawnie
jest użyta instrukcja return, to zwracany przez nią obiekt staje się wartością wyrażenia
wywołania. Jeżeli instrukcja return jest użyta bez wyrażenia lub z wartością prymitywną, to
wartością wyrażenia wywołania jest nowy obiekt.
a = a || [];
Jak pamiętasz z punktu 4.10.2, operator || zwraca wartość pierwszego operandu, jeżeli jest
prawdziwy, lub drugiego w przeciwnym razie. W tym przykładzie, jeżeli w argumencie a
zostanie umieszczony obiekt, funkcja go wykorzysta. Jeżeli natomiast argument zostanie
pominięty lub będzie miał fałszywą wartość, na przykład null, zostanie użyta nowa, pusta
tablica.
Zwróć uwagę, że w definicji funkcji należy argumenty opcjonalne umieścić na końcu listy, aby
można je było pomijać. Nie jest możliwe na przykład pominięcie pierwszego argumentu i
określenie drugiego. Można natomiast w pierwszym argumencie jawnie umieścić wartość
undefined.
Począwszy od wersji języka ES6 można definiować domyślnie wartości wszystkich parametrów
bezpośrednio w ich liście. W tym celu należy po prostu po nazwie parametru wpisać znak
równości i domyślną wartość, która ma być użyta, jeżeli nie zostanie określona wartość tego
parametru:
// Funkcja dołączająca do tablicy a nazwy wyliczalnych właściwości obiektu o
return a;
}
Domyślnie wartości są przypisywane parametrom w chwili wywołania funkcji, a nie w jej
definicji. Zatem za każdym razem, gdy funkcja getPropertyNames() będzie wywoływana z
jednym argumentem, będzie tworzona nowa pusta tablica[2]. Kod funkcji jest najbardziej
czytelny, jeżeli domyślnymi wartościami parametrów są stałe lub literały, na przykład [] lub {}.
Nie jest to jednak bezwzględny wymóg. Można na przykład stosować zmienne lub wywołania
funkcji wyliczające domyślne wartości parametrów. Ciekawym przypadkiem jest funkcja z
wieloma parametrami, w której od wartości jednego parametru zależą domyślnie wartości
innych parametrów:
if (n > maxValue) {
maxValue = n;
}
}
// Zwrócenie największego argumentu.
return maxValue;
}
}
finally {
// Wyświetlenie czasu wykonania kodu przed zwróceniem opakowanej
funkcji.
console.log(`Wyjście z funkcji ${f.name} po ${Date.now()-
startTime}ms`);
}
};
}
// Wyliczenie sumy liczb od 1 do n metodą brutalnej siły.
function benchmark(n) {
let sum = 0;
for(let i = 1; i <= n; i++) sum += i;
return sum;
}
// Testowe wywołanie funkcji i pomiar czasu jej wykonania.
timed(benchmark)(1000000) // => 500000500000; suma wyliczona na podstawie
argumentu.
Powyższy kod będzie bardziej zrozumiały, jeżeli oba argumenty wektorowe rozpakuje się do
parametrów o czytelnych nazwach:
function vectorAdd([x1,y1], [x2,y2]) { // Rozpakowanie dwóch argumentów na
cztery parametry.
return [x1 + x2, y1 + y2];
}
)
{
return { x: x1 + x2, y: y1 + y2 };
}
}
vectorMultiply({x: 1, y: 2}, 2) // => {x: 2, y: 4, z: 0}
W niektórych językach, na przykład w Pythonie, można określać wartości argumentów, stosując
składnię nazwa=wartość, bardzo wygodną, gdy funkcja ma wiele argumentów opcjonalnych lub
ich lista jest tak długa, że trudno jest zapamiętać ich kolejność. W języku JavaScript uzyskanie
podobnego efektu nie jest możliwe wprost, ale można stosować przybliżenie polegające na
destrukturyzowaniu argumentu obiektowego do parametrów. Przeanalizujmy funkcję, która
kopiuje zadaną liczbę elementów jednej tablicy do drugiej i umożliwia określenie indeksów w
obu tablicach, od których ma rozpocząć kopiowanie. Ponieważ argumentów jest pięć, z których
część ma wartości domyślne, trudno jest zapamiętać ich kolejność. Można więc zdefiniować i
wywołać następującą funkcję:
function arraycopy({from, to=from, n=from.length, fromIndex=0, toIndex=0}) {
let valuesToCopy = from.slice(fromIndex, fromIndex + n);
to.splice(toIndex, 0, ...valuesToCopy);
return to;
}
let a = [1,2,3,4,5], b = [9,8,7,6,5];
}
f([1, 2, 3, 4], 5, 6) // => [3, 5, 6, 3, 4]
Począwszy od wersji języka ES2018 można stosować parametr resztowy podczas
destrukturyzowania obiektu. Wartością tak uzyskanego parametru jest obiekt, którego
właściwości nie zostały rozpakowane. Obiektowe parametry resztowe często stosuje się z
operatorem rozciągania, który również został wprowadzony w wersji ES2018:
// Mnożenie wektora {x, y} lub {x, y, z} przez wartość skalarną z zachowaniem
pozostałych właściwości.
function vectorMultiply({x, y, z=0, ...props}, scalar) {
return { x: x*scalar, y: y*scalar, z: z*scalar, ...props };
}
vectorMultiply({x: 1, y: 2, w: -1}, 2) // => {x: 2, y: 4, z: 0, w: –1}
Na koniec pamiętaj, że destrukturyzować można nie tylko obiekty i tablice, ale również tablice
obiektów oraz obiekty, których właściwościami są tablice lub inne obiekty dowolnie głęboko
zagnieżdżone. Przeanalizujmy kod programu graficznego, w którym okręgi są reprezentowane
za pomocą obiektów o właściwościach x, y, radius i color, przy czym ostatnia właściwość jest
tablicą zawierającą składniki koloru: czerwony, zielony i niebieski. Można zdefiniować funkcję z
jednym argumentem reprezentującym okrąg, destrukturyzowanym do sześciu osobnych
parametrów:
function drawCircle({x, y, radius, color: [r, g, b]}) {
// Kod funkcji.
}
let total = 0;
for(let element of a) { // Zgłoszenie wyjątku TypeError, jeżeli argument
a nie jest iterowalny.
if (typeof element !== "number") {
throw new TypeError("sum(): właściwości muszą być liczbami ");
}
total += element;
}
return total;
}
sum([1,2,3]) // => 6
sum(1, 2, 3); // !TypeError: liczba 1 nie jest iterowalna.
sum([1,2,"3"]); // !TypeError: element o indeksie 2 nie jest liczbą.
Funkcje można również przypisywać właściwościom obiektów. Jak już wcześniej pisałem,
funkcje takie jak poniższa są nazywane metodami:
let o = {square: function(x) { return x*x; }}; // Literał obiektowy.
let y = o.square(16); // y == 256
Funkcje nie muszą mieć nazw, na przykład jeżeli są przypisywane elementom tablicy:
}
uniqueInteger() // => 0
uniqueInteger() // => 1
Przeanalizujmy inny przykład. Poniżej jest przedstawiona funkcja factorial(), która
wykorzystuje własne właściwości (traktuje siebie jako tablicę) do zapisywania wyliczonych
wcześniej wyników:
// Funkcja wyliczająca silnię i zapamiętująca wyniki w swoich właściwościach.
function factorial(n) {
if (Number.isInteger(n) && n > 0) { // Dopuszczalne są tylko
dodatnie liczby całkowite.
}
factorial[1] = 1; // Zainicjowanie pamięci podręcznej wartością początkową.
factorial(6) // => 720
factorial[5] // => 120; ta wartość została zapamiętana w powyższym
wywołaniu.
Funkcja stosowana w charakterze przestrzeni nazw okazuje się bardzo przydatna, jeżeli
wewnątrz niej zdefiniowane są inne funkcje wykorzystywane jako zmienne i zwracane jako
wyniki głównej funkcji. Tego rodzaju funkcja nosi nazwę domknięcia i jest tematem następnego
podrozdziału.
8.6. Domknięcia
JavaScript, tak jak każdy nowoczesny język programowania, wykorzystuje zasięgi leksykalne.
Oznacza to, że funkcja jest wywoływana z zasięgiem widoczności zmiennych, w którym została
zdefiniowana, a nie w którym jest wywoływana. Aby zaimplementować zasięg leksykalny,
wewnętrzny stan obiektu funkcyjnego musi oprócz kodu zawierać odwołanie do zasięgu, w
którym znajduje się definicja funkcji. Tego rodzaju kombinacja obiektu funkcyjnego i zasięgu, w
którym wykorzystywane są zmienne funkcji, jest w literaturze informatycznej nazywana
domknięciem (ang. closure).
Z technicznego punktu widzenia wszystkie funkcje są domknięciami. Ponieważ jednak
większość z nich wywołuje się w tym samym zasięgu, w którym zostały zdefiniowane, nie ma
znaczenia, że w rzeczywistości wykorzystywane są domknięcia. Ich przydatność ujawnia się
dopiero wtedy, gdy są wywoływane w innym zasięgu, niż zostały zdefiniowane. Najczęściej ma
to miejsce w przypadku, gdy zagnieżdżony obiekt funkcyjny jest zwracany przez funkcję,
wewnątrz którego jest ona zdefiniowana. Istnieje kilka bardzo przydatnych i często
stosowanych technik programowania, w których wykorzystywane są tego rodzaju zagnieżdżone
domknięcia. Na pierwszy rzut oka domknięcia mogą się wydawać niezrozumiałe, ale kiedy się je
pozna, okazują się bardzo wygodne w użyciu.
Aby zrozumieć domknięcia, należy przede wszystkim poznać zasady funkcjonowania zasięgów
leksykalnych wykorzystywanych w zagnieżdżonych funkcjach. Przeanalizujmy poniższy kod:
let scope = "globalny zasięg"; // Globalna zmienna.
function checkscope() {
function checkscope() {
let scope = "lokalny zasięg"; // Lokalna zmienna.
function f() { return scope; } // Zwrócenie wartości zmiennej scope.
return f;
}
uniqueInteger() // => 1
Aby zrozumieć powyższy kod, trzeba go dokładnie przeczytać. Na pierwszy rzut oka
początkowy wiersz wygląda na przypisanie funkcji zmiennej uniqueInteger. W rzeczywistości
jest to definicja i wywołanie funkcji (o czym świadczy nawias otwierający), której wynik jest
przypisywany powyższej zmiennej. Po przeanalizowaniu ciała funkcji widać, że zwracanym
wynikiem jest inna funkcja. Jest to zagnieżdżony obiekt funkcyjny, przypisywany następnie
zmiennej uniqueInteger. Zagnieżdżona funkcja ma dostęp do zmiennych zdefiniowanych w jej
zasięgu i może wykorzystywać zmienną counter funkcji nadrzędnej. W kodzie wywołującym tę
funkcję zmienna counter nie jest widoczna. Wyłączny dostęp do niej ma wewnętrzna funkcja.
Lokalne zmienne, takie jak counter, nie muszą być dostępne wyłącznie dla jednego
domknięcia. Bez trudu można zdefiniować dwie lub więcej zagnieżdżonych funkcji
współdzielących ten sam zasięg funkcji nadrzędnej. Przeanalizujmy poniższy kod:
function counter() {
let n = 0;
return {
count: function() { return n++; },
reset: function() { n = 0; }
};
}
let c = counter(), d = counter(); // Utworzenie dwóch liczników.
c.count() // => 0
d.count() // => 0: liczniki funkcjonują niezależnie
od siebie.
Funkcja counter() zwraca obiekt „licznika”, który ma dwie metody: count(), zwracającą
następną liczbę całkowitą, i reset(), resetującą wewnętrzny stan obiektu. Zauważ przede
wszystkim, że obie metody mają dostęp do lokalnej zmiennej n. Ponadto każde wywołanie
metody counter() tworzy nowy zasięg, inny niż w poprzednich wywołaniach, z nową lokalną
zmienną. Jeżeli więc metoda ta zostanie wywołana dwukrotnie, powstaną dwa obiekty z
osobnymi lokalnymi zmiennymi. Wywołanie metody count() lub reset() jednego obiektu nie
wpływa na drugi obiekt.
}
};
}
let c = counter(1000);
// dla obu metod i nie może być przypisana ani zmieniona w inny sposób
// jak tylko za pomocą settera.
function addPrivateProperty(o, name, predicate) {
let value; // To jest wartość właściwości.
// Getter po prostu zwraca wartość właściwości.
o[`get${name}`] = function() { return value; };
// Setter zapisuje wartość lub zgłasza wyjątek, jeżeli funkcja predykatu
// odrzuci wartość.
o[`set${name}`] = function(v) {
if (predicate && !predicate(v)) {
throw new TypeError(`set${name}: niepoprawna wartość - ${v}`);
} else {
value = v;
}
};
}
// Poniższy kod demonstruje użycie funkcji addPrivateProperty().
let o = {}; // Pusty obiekt.
// Dodanie metod dostępowych getName() i setName().
// Dopuszczalne są tylko ciągi znaków.
addPrivateProperty(o, "Name", x => typeof x === "string");
o.setName("Jan"); // Przypisanie wartości właściwości.
}
let funcs = constfuncs();
funcs[5]() // => 10; dlaczego ta funkcja nie zwraca wartości 5?
Powyższy kod tworzy 10 domknięć i zapisuje je w tablicy. Wszystkie domknięcia są definiowane
wewnątrz tej samej funkcji, a więc współdzielą dostęp do lokalnej zmiennej i. Gdy funkcja
constfuncs() kończy działanie, zmienna i ma wartość 10, którą współdzielą wszystkie
domknięcia. Dlatego wszystkie funkcje umieszczone w zwracanej tablicy zwracają tę samą
wartość, co nie jest pożądanym efektem. Pamiętaj, że zasięg zmiennych powiązany z
domknięciem „żyje”, tzn. zagnieżdżone funkcje nie tworzą jego prywatnych kopii czy
statycznych migawek zmiennych. Problem polega na tym, że zmienna zadeklarowana za
pomocą słowa kluczowego var jest dostępna w całej funkcji. W pętli for zmienna i jest
zdefiniowana w ten właśnie sposób, zatem jest dostępna w całej funkcji, a nie tylko w ciele
pętli. Powyższy kod demonstruje błąd w wersjach języka ES5 i starszych. Rozwiązało go dopiero
wprowadzenie w wersji ES6 blokowego zasięgu zmiennych. Problem zniknie, gdy słowo
kluczowe var zastąpi się słowem let lub const. Ponieważ oba te słowa mają blokowy zasięg, w
każdej iteracji pętli będzie definiowany nowy zasięg, niezależny od poprzednich iteracji,
w którym zmienna i będzie wiązana na nowo.
Poza tym należy podczas tworzenia domknięć pamiętać, że this jest słowem kluczowym, a nie
zmienną. Jak pisałem wcześniej, funkcja strzałkowa dziedziczy wartość tego słowa po funkcji
nadrzędnej, w odróżnieniu od funkcji zdefiniowanej za pomocą słowa kluczowego function.
Jeżeli więc domknięcie musi wykorzystywać wartość this funkcji nadrzędnej, należy użyć
funkcji strzałkowej lub wywoływać metodę bind() zwracanego domknięcia. Można również
wartość this nadrzędnej funkcji przypisywać zmiennej, którą domknięcie odziedziczy:
const self = this; // Udostępnienie wartości this zagnieżdżonym funkcjom.
f.call(o);
f.apply(o);
Odpowiednikami powyższych wywołań są następujące wiersze (przyjęte zostało założenie, że w
obiekcie o nie została wcześniej utworzona właściwość m):
o.m = f; // Utworzenie tymczasowej metody obiektu o.
o.m(); // Wywołanie metody bez argumentów.
delete o.m; // Usunięcie tymczasowej metody.
Pamiętaj, że funkcja strzałkowa dziedziczy wartość this po funkcji, w której jest zdefiniowana, i
nie można jej zmienić za pomocą metod call() ani apply(). Obie metody funkcji strzałkowej,
gdy zostaną wywołane, pomijają pierwszy argument.
Drugi i kolejne argumenty metody call() są przekazywane wywoływanej funkcji. Nie są też
pomijane przez funkcję strzałkową. Aby na przykład umieścić dwie liczby w argumentach
funkcji f(), a następnie wywołać ją tak, jakby była metodą obiektu o, należy użyć
następującego kodu:
f.call(o, 1, 2);
Metoda apply() jest podobna do call(). Różni się tym, że funkcji, którą wywołuje, przekazuje
argumenty w postaci tablicy:
f.apply(o, [1,2]);
Jeżeli wywoływana funkcja może mieć dowolną liczbę argumentów, za pomocą metody apply()
można ją wywoływać, umieszczając w jej argumencie tablicę o dowolnej długości. W wersjach
języka ES6 i nowszych można użyć w tym celu operatora rozciągania, natomiast w wersjach
ES5 i starszych stosowana była metoda apply(). Na przykład aby znaleźć największą liczbę w
tablicy bez użycia operatora rozciągania, można za pomocą metody apply() umieścić elementy
tablicy w argumentach metody Math.max():
let biggest = Math.max.apply(Math, arrayOfNumbers);
Przedstawiona niżej funkcja trace() jest podobna do funkcji timed() zdefiniowanej w punkcie
8.3.4, ale jest przeznaczona do użycia z metodami, a nie z funkcjami. Nie wykorzystuje
operatora rozciągania, tylko metodę apply(). Dzięki temu może wywoływać opakowaną
metodę z takimi samymi argumentami i wartością this jak metoda opakowująca.
Zwróć uwagę, że w argumentach konstruktora Function() nie umieszcza się nazwy tworzonej
funkcji. Konstruktor ten, tak jak literał funkcyjny, tworzy anonimową funkcję.
Konstruktor Function() ma kilka ważnych cech, o których należy pamiętać:
stddev // => 2
Nowa wersja kodu wygląda zupełnie inaczej niż poprzednia. Ponieważ jednak wywołuje metody
obiektów, wykazuje pewne cechy programowania obiektowego. Zdefiniujmy funkcyjne wersje
metod map() i reduce():
const map = function(a, ...args) { return a.map(...args); };
const reduce = function(a, ...args) { return a.reduce(...args); };
Kod wykorzystujący powyższe wersje funkcji map() i reduce() wygląda jak niżej:
const sum = (x,y) => x+y;
const square = x => x*x;
function not(f) {
return function(...args) { // Zwrócenie nowej funkcji,
let result = f.apply(this, args); // która wywołuje funkcję f
return !result; // i neguje wynik.
};
}
const even = x => x % 2 === 0; // Funkcja sprawdzająca, czy zadana liczba
jest parzysta.
const odd = not(even); // Nowa funkcja wykonująca odwrotną operację.
[1,1,3,5,5].every(odd) // => true: wszystkie elementy tablicy są
liczbami nieparzystymi.
Funkcja not() jest wyższego rzędu, ponieważ jej argumentem i zwracanym wynikiem są inne
funkcje.
Przeanalizujmy poniższy przykład funkcji mapper(). Zwracanym przez nią wynikiem jest
funkcja, która wykorzystuje do mapowania elementów dwóch tablic funkcję podaną w
argumencie. Zastosowana jest tu zdefiniowana wcześniej funkcja map(), dlatego ważne jest
rozumienie różnicy pomiędzy tymi dwiema funkcjami.
// Funkcja zwracająca funkcję, której argumentem jest tablica. Nowa funkcja
// przetwarza każdy element za pomocą funkcji f i zwraca tablicę uzyskanych
// w ten sposób wartości. Porównaj ją z opisaną wcześniej funkcją map().
function mapper(f) {
8.8.4. Memoizacja
W punkcie 8.4.1 została zdefiniowana funkcja, która zapamiętywała zwrócone wcześniej wyniki.
Tego rodzaju zapisywanie wartości jest w programowaniu funkcyjnym określane mianem
memoizacji (ang. memoization). Poniższy kod przedstawia funkcję wyższego rzędu memoize(),
której argumentem jest inna funkcja, a zwracanym wynikiem jej zmemoizowana wersja:
// Funkcja zwracająca zmemoizowaną wersję funkcji f.
// Argumenty funkcji f muszą być ciągami znaków.
function memoize(f) {
const cache = new Map(); // Pamięć podręczna umieszczona w domknięciu.
return function(...args) {
// Utworzenie tekstowych wartości argumentów, aby mogły być kluczami w
pamięci podręcznej.
let key = args.length + args.join("+");
if (cache.has(key)) {
return cache.get(key);
} else {
let result = f.apply(this, args);
cache.set(key, result);
return result;
}
};
}
Powyższa funkcja tworzy obiekt, który wykorzystuje jako swoją pamięć podręczną i przypisuje
go lokalnej zmiennej w zwracanej funkcji. Zwracana funkcja przekształca elementy tablicy w
ciągi znaków i wykorzystuje je jako nazwy właściwości obiektu pełniącego rolę pamięci
podręcznej. Jeżeli pamięć zawiera daną wartość, funkcja zwraca ją wprost. W przeciwnym razie
wywołuje zadaną funkcję, która wylicza wartość na podstawie argumentów, umieszcza ją w
pamięci podręcznej i zwraca jako wynik. Poniższy kod pokazuje przykład użycia funkcji
memoize():
// Funkcja wyliczająca największy wspólny dzielnik dwóch liczb całkowitych
// przy użyciu algorytmu Euklidesa
(http://en.wikipedia.org/wiki/Euclidean_algorithm).
return a;
}
const gcdmemo = memoize(gcd);
gcdmemo(85, 187) // => 17
// Zwróć uwagę, że zazwyczaj wymagane jest rekurencyjne wywoływanie
// funkcji memoizującej, a nie oryginalnej.
const factorial = memoize(function(n) {
return (n <= 1) ? 1 : n * factorial(n-1);
});
8.9. Podsumowanie
Poniżej wymienione są najważniejsze zagadnienia opisane w tym rozdziale:
Funkcje można definiować za pomocą słowa kluczowego function lub składni strzałkowej
(=>), wprowadzonej w wersji języka ES6.
Funkcje można wywoływać jako metody i konstruktory.
Wykorzystując funkcjonalności wprowadzone w wersji języka ES6, można definiować
domyślne wartości opcjonalnych parametrów funkcji, umieszczać za pomocą parametru
resztowego argumenty w tablicy, jak również destrukturyzować obiekty i tablice do
argumentów funkcji.
Za pomocą operatora rozciągania ... można umieszczać elementy tablicy lub innego
iterowalnego obiektu w argumentach wywoływanej funkcji.
Funkcja zdefiniowana wewnątrz innej funkcji i zwracana jako jej wynik zachowuje dostęp
do swojego zasięgu leksykalnego. Może więc odczytywać i zapisywać zmienne
zdefiniowane w zewnętrznej funkcji. Tego rodzaju funkcje są nazywane domknięciami.
Warto znać technikę ich tworzenia i stosowania.
Funkcje są obiektami, które można przetwarzać. Dzięki temu w języku JavaScript można
stosować styl programowania funkcyjnego.
W języku JavaScript klasy można było definiować od zawsze. W wersji języka ES6 została
wprowadzona nowa składnia, wykorzystująca słowo kluczowe class, dzięki której tworzenie
klas stało się jeszcze prostsze. Nowe klasy funkcjonują tak samo jak ich odpowiedniki starszego
typu. Ten rozdział rozpoczyna się od opisu starego sposobu definiowania klas, ponieważ lepiej
demonstruje on zasadę ich działania. Po omówieniu podstaw zajmiemy się nową, prostszą
składnią definiowania klas.
Jeżeli znasz silnie typowany język programowania, na przykład Javę lub C++, stwierdzisz, że
stosowane w nich klasy znacznie różnią się od dostępnych w języku JavaScript. Istnieją
wprawdzie pewne podobieństwa składniowe i można emulować wiele funkcjonalności
„klasycznych” klas, ale trzeba wyraźnie podkreślić, że w języku JavaScript klasy i mechanizm
dziedziczenia prototypów istotnie różnią się od klas i mechanizmów ich dziedziczenia w Javie i
innych językach.
let r = Object.create(range.methods);
// Zapisanie początku i końca zakresu (stanu) nowego obiektu.
r.to = to;
}
// Poniższy prototyp definiuje metody dziedziczone przez wszystkie obiekty.
range.methods = {
// Metoda zwracająca wartość true, jeżeli x zawiera się w zakresie, lub
false w przeciwnym razie.
*[Symbol.iterator]() {
for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
},
this.from = from;
this.to = to;
}
// Wszystkie obiekty będą dziedziczyły cechy obiektu Range.
Range.prototype = {
[Symbol.iterator]: function*() {
},
};
Porównaj uważnie listingi 9.1 i 9.2 i zwróć uwagę na różnice pomiędzy obiema technikami
definiowania klas. Przede wszystkim funkcja fabryczna range() została zamieniona na
konstruktor Range(). Zastosowana została tu popularna konwencja programistyczna.
Konstruktor w rzeczywistości definiuje klasę, a nazwa klasy zaczyna się od wielkiej litery.
Nazwa zwykłej funkcji lub metody rozpoczyna się od małej litery.
Ponadto zauważ, że na końcu listingu konstruktor Range() jest wywoływany za pomocą słowa
kluczowego new. Funkcja fabryczna range() była wywołana bez użycia tego słowa. W listingu
9.1 nowy obiekt jest tworzony poprzez zwykłe wywołanie funkcji (patrz punkt 8.2.1), natomiast
w listingu 9.2 wywoływany jest w tym celu konstruktor (patrz punkt 8.2.3). Ponieważ operacja
ta jest wykonywana za pomocą słowa kluczowego new, nie trzeba stosować funkcji
Object.create(). Obiekt jest tworzony automatycznie przed wywołaniem konstruktora i
można się do niego odwoływać za pomocą słowa this. Zadaniem konstruktora jest jedynie
zainicjowanie obiektu. Konstruktor nie musi go nawet zwracać. Wywołanie konstruktora
powoduje utworzenie obiektu, wykonanie kodu konstruktora i zwrócenie nowego obiektu. Te
ważne różnice pomiędzy wywołaniem konstruktora a zwykłej funkcji są kolejnym powodem, dla
którego nazwa konstruktora rozpoczyna się od wielkiej litery. Konstruktor koduje się tak, aby
można go było wywoływać za pomocą słowa kluczowego new. Wywołany tak jak zwykła funkcja
zazwyczaj nie działa poprawnie. Przyjęta konwencja nazewnictwa pozwala programiście
odróżnić konstruktor od zwykłej funkcji, dzięki czemu „wie” on, że musi użyć słowa new.
Inną ważną różnicą między listingami 9.1 i 9.2 jest nazwa prototypu. W pierwszym przypadku
brzmi ona range.methods. Jest to wygodna w użyciu, opisowa, dowolnie wybrana nazwa. W
drugim listingu prototyp ma ściśle określoną nazwę Range.prototype. W konstruktorze
Range() prototypem nowego obiektu jest zawsze właściwość Range.prototype.
function C() {
// Kod inicjujący.
Na koniec zwróć również uwagę na fragmenty, które się nie zmieniły w obu listingach. Metody
klasy są w obu przypadkach definiowane i wywoływane w taki sam sposób. Ponieważ listing 9.2
demonstruje idiomatyczny sposób tworzenia klas w wersjach języka starszych niż ES6, nie jest
w nim stosowana skrócona składnia definiowania metod prototypu i jawnie jest używane słowo
kluczowe function. Jednak implementacje metod są w obu przypadkach takie same.
Na szczęście nowa składnia wprowadzona w wersji ES6 nie pozwala definiować metod jako
funkcji strzałkowych, więc ich pomyłkowe użycie nie jest możliwe. Słowo kluczowe class
opiszę za chwilę, ale najpierw przedstawię kilka dodatkowych szczegółów dotyczących
konstruktorów.
function Strange() {}
Strange.prototype = Range.prototype;
Choć operator instanceof w rzeczywistości nie weryfikuje użycia konstruktora, należy po jego
prawej stronie umieścić nazwę konstruktora, ponieważ reprezentuje on publiczną tożsamość
klasy.
Aby przetestować łańcuch prototypów obiektu bez użycia konstruktora jako pośrednika, można
zastosować metodę isPrototypeOf(). W listingu 9.1 zdefiniowana jest klasa bez konstruktora,
więc w tym przypadku nie można użyć operatora instanceof. Aby sprawdzić bez użycia
konstruktora, czy obiekt r jest instancją danej klasy, należy użyć następującego kodu:
Rysunek 9.1 przedstawia zależności pomiędzy funkcją konstruktora a jej prototypem oraz
pomiędzy prototypem a konstruktorem. Dodatkowo pokazuje instancje utworzone za pomocą
konstruktora.
Zwróć uwagę, że na rysunku 9.1 konstruktor Range() jest użyty jako przykład. W
rzeczywistości klasa Range zdefiniowana w listingu 9.2 zastępuje obiekt Range.prototype
własnym obiektem. Zdefiniowany w ten sposób nowy prototyp nie ma właściwości constructor,
więc instancje klasy Range też jej nie mają. Problem ten można rozwiązać, jawnie definiując
konstruktor w prototypie:
Range.prototype = {
constructor: Range, // Jawne przypisanie wstecznego odwołania do
konstruktora.
/* Definicje metod */
};
Range.prototype.includes = function(x) {
return this.from <= x && x <= this.to;
};
Range.prototype.toString = function() {
class Range {
constructor(from, to) {
this.from = from;
this.to = to;
}
// Metoda zwracająca wartość true, jeżeli x zawiera się w zakresie, lub
false w przeciwnym razie.
}
// Przykład użycia nowej klasy Range.
Ważne jest, że klasy zdefiniowane w listingach 9.2 i 9.3 działają dokładnie w taki sam sposób.
Wprowadzenie słowa kluczowego class nie zmieniło fundamentów klas opartych na
prototypach. Mimo że w listingu 9.3 jest użyte słowo class, wynikowy obiekt Range jest
konstruktorem tak samo jak jego starsza wersja zdefiniowana w listingu 9.2. Nowy zapis jest
przejrzysty i wygodny w użyciu, ale należy go traktować jako „lukier składniowy” bardziej
ogólnego mechanizmu definiowania klas zastosowanego w listingu 9.2.
Zwróć uwagę na kilka szczegółów składni użytej w listingu 9.3:
Klasa jest zadeklarowana za pomocą słowa kluczowego class, po którym następuje nazwa
klasy i jej ciało umieszczone wewnątrz nawiasów klamrowych.
W ciele klasy wykorzystane są skrócone definicje metod literału obiektowego (podobnie
jak w listingu 9.1), tj. nie jest stosowane słowo kluczowe function. (Pozornie ciało klasy
jest podobne do literału obiektowego, ale nim nie jest. W szczególności nie można w nim
definiować właściwości w postaci par nazwa-wartość).
Konstruktor klasy definiuje się za pomocą słowa kluczowego constructor. Nie jest to
jednak nazwa funkcji. Instrukcja deklarująca klasę definiuje nową zmienną Range i
przypisuje jej wartość tej specjalnej funkcji.
Jeżeli nie trzeba wykonywać żadnych operacji inicjujących obiekt, można pominąć słowo
kluczowe constructor i jego ciało. Niejawnie zostanie utworzony pusty konstruktor.
Aby zdefiniować klasę pochodną od innej klasy, czyli podklasę, należy użyć słów kluczowych
extends i class:
// Klasa Span jest podobna do Range. Nie jest jednak inicjowana za pomocą
wartości początkowej i końcowej,
if (length >= 0) {
super(start, start + length);
} else {
super(start + length, start);
}
}
}
Tworzenie podklas to zupełnie osobny temat. Wrócę do niego w podrozdziale 9.5, w którym
opiszę słowa kluczowe extends i super.
Deklaracja klasy, podobnie jak deklaracja funkcji, może mieć postać instrukcji lub wyrażenia.
Można użyć następującego kodu:
let square = function(x) { return x * x; };
square(3) // => 9
jak również poniższego:
Wyrażenie definiujące klasę, podobnie jak funkcję, może zawierać opcjonalną nazwę klasy. W
takim wypadku będzie ona zdefiniowana tylko w ciele samej klasy.
Wyrażenia funkcyjne są bardzo popularne (szczególnie wykorzystujące skróconą formę funkcji
strzałkowych), natomiast wyrażeń definiujących klasy raczej się nie używa, chyba że podczas
tworzenia funkcji, której argumentem jest klasa, a zwracanym wynikiem podklasa.
Na koniec niniejszego wprowadzenia do słowa kluczowego class przedstawiam dwie cechy
składni klasy, które nie są widoczne, ale należy o nich pamiętać:
W kodzie deklaracji ciała funkcji niejawnie obowiązuje tryb ścisły (patrz punkt 5.6.3),
mimo że nie ma dyrektywy "use strict". Oznacza to na przykład, że w ciele klasy nie
można stosować literałów ósemkowych ani instrukcji with. Ponadto w przypadku użycia
zmiennej bez jej uprzedniego zadeklarowania pojawi się komunikat o błędzie.
Deklaracje klas, w odróżnieniu od deklaracji funkcji, nie są windowane. Jak pamiętasz
z punktu 8.1.1, definicja funkcji jest niejawnie przesuwana na początek pliku lub
obejmującej ją innej funkcji. Oznacza to, że funkcję można wywoływać w kodzie
poprzedzającym jej definicję. Deklaracje klas pod wieloma względami są podobne do
deklaracji funkcji, jednak nie dotyczy to windowania. Nie można utworzyć instancji klasy,
zanim się jej nie zadeklaruje.
Wszystkie odmiany skróconej składni definicji metody w literale obiektowym można również
stosować w ciele klasy. Dotyczy to także metod generatorów (oznaczonych symbolem *) oraz
metod będących wartościami wyrażeń umieszczonych w nawiasach kwadratowych. W listingu
9.3 widziałeś już metodę generatora o wyliczonej nazwie, dzięki której obiekty Range można
iterować:
*[Symbol.iterator]() {
for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;
constructor() {
this.size = 0;
this.capacity = 4096;
this.buffer = new Uint8Array(this.capacity);
}
}
Ponieważ z dużym prawdopodobieństwem nowa składnia pola instancji zostanie
ustandaryzowana, można zamiast powyższego kodu użyć następującego:
class Buffer {
size = 0;
capacity = 4096;
Zanim pojawiła się opisana składnia definicji pól, ciało klasy było bardzo podobne do literału
obiektowego wykorzystującego skróconą składnię definicji metod. Jedyna różnica polegała na
tym, że nie stosowało się przecinków. Dzięki nowej składni, w której zamiast dwukropków i
przecinków wykorzystywane są znaki równości, widać wyraźnie, że ciało klasy nie jest literałem
obiektowym.
Wniosek standaryzacyjny definiujący pola instancji obejmuje również prywatne pola. Jeżeli w
instrukcji inicjującej pole instancji, takiej jak w poprzednim przykładzie, przed nazwą klasy
umieści się znak # (którego nie można stosować w identyfikatorach), wówczas pole to będzie
dostępne wewnątrz klasy, ale pozostanie niewidoczne, a więc niemutowalne, dla kodu poza
klasą. Na przykład aby w powyższej hipotetycznej klasie uniemożliwić użytkownikom
przypadkowe zmodyfikowanie pola size, można użyć prywatnego pola #size i dodatkowo
zdefiniować getter służący wyłącznie do odczytywania wartości tego pola. Ilustruje to poniższy
przykład:
class Buffer {
#size = 0;
get size() { return this.#size; }
}
Zwróć uwagę, że zgodnie z nową składnią, zanim użyje się prywatnego pola, należy je
zdefiniować. Nie można na przykład w konstruktorze wpisać this.#size = 0;, chyba że
bezpośrednio w ciele klasy umieści się „deklarację” pola.
Wspomniany wyżej wniosek standaryzacyjny określa również użycie słowa kluczowego static.
Umieszczenie go przed deklaracją pola publicznego lub prywatnego powoduje, że pole staje się
właściwością konstruktora, a nie instancji. Przeanalizujmy zdefiniowaną wcześniej metodę
Range.parse(). Jest w niej użyte dość skomplikowane wyrażenie regularne, które można
umieścić w statycznym polu. Wykorzystując proponowaną składnię definicji statycznego pola,
można napisać następujący kod:
static integerRangePattern = /^\((\d+)\.\.\.(\d+)\)$/;
static parse(s) {
}
return new Range(parseInt(matches[1]), matches[2]);
}
Jeżeli powyższe statyczne pole ma być dostępne wyłącznie wewnątrz klasy, należy je
zdefiniować, wykorzystując symbol #.
/**
* Instancje klasy Complex reprezentują liczby zespolone. Liczba
*/
class Complex {
// #r = 0;
// #i = 0;
plus(that) {
return new Complex(this.r + that.r, this.i + that.i);
}
times(that) {
Po zdefiniowaniu klasy przedstawionej w listingu 9.4 można używać konstruktora, pól instancji,
metod instancji, pól klasy i metod klasy w następujący sposób:
let c = new Complex(2, 3); // Utworzenie nowego obiektu za pomocą
konstruktora.
let d = new Complex(c.i, c.r); // Użycie pól instancji c.
Poniżej pokazany jest przykładowy kod dodający do klasy Complex z listingu 9.4 metodę
wyliczającą sprzężenie liczby zespolonej.
// Metoda zwracająca sprzężenie zadanej liczby zespolonej.
Complex.prototype.conj = function() { return new Complex(this.r, -this.i); };
Prototyp wbudowanych klas również jest otwarty. Oznacza to, że można dodawać metody do
klas reprezentujących liczby, ciągi znaków, tablice, funkcje itp. W ten sposób można w
starszych wersjach języka implementować nowe funkcjonalności. Ilustruje to poniższy kod:
// Jeżeli klasa String nie posiada metody startsWith()…
if (!String.prototype.startsWith) {
// …można ją zdefiniować jak niżej, wykorzystując metodę indexOf().
String.prototype.startsWith = function(s) {
return this.indexOf(s) === 0;
};
}
Dodawanie metod do prototypów wbudowanych klas jest raczej złą praktyką, ponieważ w
przyszłości, gdy w nowej wersji języka zostanie zdefiniowana metoda o takiej samej nazwie,
może to prowadzić do niejasności i problemów z kompatybilnością kodu. Możliwe jest nawet
dodawanie metod do prototypu Object.prototype. Takie metody są dostępne we wszystkich
klasach. Jest to jednak zdecydowanie niezalecany sposób, ponieważ właściwości dodane do tego
prototypu są widoczne w pętlach for/in. Tego efektu można uniknąć, tworząc niewyliczalną
właściwość za pomocą metody Object.defineProperty() (patrz podrozdział 14.1).
9.5. Podklasy
W programowaniu obiektowym klasa B może rozszerzać klasę A, czyli być jej podklasą. W
takim przypadku A jest nadklasą, a B podklasą. Instancje klasy B dziedziczą metody klasy A.
Klasa B może definiować własne metody, nadpisujące metody klasy A o takich samych nazwach.
Często w metodach nadpisujących w klasie B trzeba wywoływać metody nadpisywane w klasie
A. Analogicznie konstruktor B() podklasy zazwyczaj wywołuje konstruktor A() nadklasy, aby w
pełni zainicjować instancję.
Na początku tego podrozdziału pokażę, jak definiuje się klasy w starszych wersjach języka niż
ES6. Następnie zademonstruję definiowanie podklas za pomocą słów kluczowych class i
extends oraz wywoływanie konstruktora nadklasy za pomocą słowa kluczowego super. W
dalszej części opiszę, jak zapobiegać tworzeniu podklas i wykorzystywać komponowanie, a nie
dziedziczenie obiektów. Na końcu zaprezentuję rozbudowany kod definiujący hierarchię klas
Set oraz klasy abstrakcyjne oddzielające interfejs od implementacji.
if (span >= 0) {
this.from = start;
this.to = start + span;
} else {
this.to = start;
this.from = start + span;
}
}
// Prototyp klasy Span musi dziedziczyć cechy prototypu klasy Range.
Span.prototype = Object.create(Range.prototype);
// Konstruktor Range.prototype.constructor ma nie być dziedziczony,
// dlatego definiujemy własną właściwość constructor.
Span.prototype.constructor = Span;
};
Aby klasa Span była podklasą Range, musi dziedziczyć właściwość Span.prototype po
Range.prototype. Poniżej przedstawiony jest najważniejszy wiersz z powyższego listingu. Jeżeli
jest dla Ciebie jasny, to oznacza, że wiesz już, jak funkcjonują podklasy w języku JavaScript.
Span.prototype = Object.create(Range.prototype);
Przykład podklasy EZArray nie jest reprezentatywny, ponieważ jest bardzo prosty. Listing 9.6
przedstawia bardziej rozbudowany kod. Zdefiniowana jest w nim podklasa TypedMap. Jest ona
rozszerzeniem klasy Map i sprawdza typy danych. Dzięki temu klucze i wartości są zawsze
właściwych typów (według operatora typeof). Ten przykład demonstruje przede wszystkim
użycie słowa kluczowego super do wywoływania konstruktora i metod nadklasy.
Listing 9.6. TypedMap.js: klasa pochodna od Map, sprawdzająca typy kluczy i wartości
}
// Zainicjowanie nadklasy za pomocą argumentu entries (po sprawdzeniu
typu).
super(entries);
// Zainicjowanie podklasy poprzez zapisanie typów.
this.keyType = keyType;
this.valueType = valueType;
}
// Ponownie zdefiniowana metoda set(), sprawdzająca typy
// danych dodawanych do mapy.
set(key, value) {
// Zgłoszenie wyjątku, jeżeli klucz lub wartość jest niewłaściwego typu.
Dwa pierwsze argumenty konstruktora TypedMap() określają wymagane typy klucza i wartości.
Muszą to być ciągi znaków zwracane przez operator typeof, na przykład "number" lub
"boolean". Można również określić trzeci argument. Jest to tablica (lub inny iterowany obiekt),
której elementami są tablice [klucz,wartość], zawierająca początkowe dane mapy. Jeżeli
argument ten zostanie określony, konstruktor w pierwszej kolejności sprawdzi, czy dane są
właściwych typów, a następnie wywoła konstruktor nadklasy. W tym celu użyje słowa
kluczowego super, tak jak gdyby było ono nazwą funkcji. Konstruktor Map() ma opcjonalny
argument — iterowany obiekt zawierający tablice [klucz,wartość]. Zatem trzeci, opcjonalny
argument konstruktora TypedMap() jest używany jako pierwszy argument konstruktora Map().
W tym celu za pomocą instrukcji super(entries) jest wywoływany konstruktor nadklasy.
Konstruktor TypedMap() najpierw wywołuje konstruktor nadklasy, aby zainicjować jej stan,
a następnie inicjuje stan własnej klasy, przypisując właściwościom this.keyType i
this.valueType nazwy wymaganych typów. Dzięki temu właściwości te będzie mogła później
wykorzystać metoda set().
Jeżeli klasa została zdefiniowana za pomocą słowa kluczowego extends, jej konstruktor
musi wywoływać konstruktor nadklasy za pomocą instrukcji super().
Jeżeli podklasa nie ma konstruktora, zostanie on zdefiniowany automatycznie. Można go
wywoływać z dowolnymi argumentami, które będą przekazywane instrukcji super().
Dopóki nie wywoła się konstruktora nadklasy za pomocą instrukcji super(), nie można w
konstruktorze stosować słowa kluczowego this. Jest to zgodne z zasadą, że najpierw musi
być zainicjowana nadklasa, a potem podklasa.
Specjalne wyrażenie new.target nie jest zdefiniowane w funkcjach wywoływanych bez
użycia słowa kluczowego new. Natomiast w konstruktorze wyrażenie to odwołuje się do
wywołanego konstruktora. Jeżeli konstruktor podklasy wywoła za pomocą instrukcji
super() konstruktor nadklasy, to w ciele tego konstruktora wartością wyrażenia
new.target będzie konstruktor podklasy. Dobrze zaprojektowana nadklasa nie musi
„wiedzieć”, że została utworzona jej podklasa, natomiast wyrażenie new.target może się
w niej przydać na przykład do rejestrowania komunikatów.
W dalszej części listingu 9.6, po konstruktorze, jest zdefiniowana metoda set(). W nadklasie
Map jest zdefiniowana metoda o takiej samej nazwie, dodająca do mapy nowy element. Zatem
metoda set() w klasie TypedMap nadpisuje metodę o takiej samej nazwie w nadklasie. Prosta
podklasa TypedMap „nie wie”, jak dodawać do mapy nowe elementy, ale „wie”, jak sprawdzać
ich typy. Dlatego najpierw sprawdza, czy dodawane do mapy klucz i wartość są odpowiednich
typów i ewentualnie zgłasza wyjątek. Metoda set() nie może sama dodać klucza i wartości do
mapy, dlatego wykorzystuje w tym celu metodę set() nadklasy. W tym kontekście słowo
kluczowe super jest podobne do this, tj. odwołuje się do bieżącego obiektu i daje dostęp do
nadpisanych metod nadklasy.
Aby w konstruktorze móc używać słowa kluczowego this i inicjować obiekty, trzeba najpierw
wywołać konstruktor nadklasy. Zasada ta nie obowiązuje jednak w metodach. W metodzie
nadpisującej nie trzeba wywoływać metody nadklasy. Nadpisywaną, jak również każdą inną
metodę nadklasy można wywoływać w dowolnym miejscu, wykorzystując słowo kluczowe
super.
Na koniec opisu klasy TypedMap warto wspomnieć, że w tego rodzaju klasach doskonale
sprawdzają się pola prywatne. W powyższej klasie w obecnej formie użytkownik może zmienić
właściwości keyType i valueType i ominąć kontrolę typów. Gdy pola prywatne będą już
formalnie obsługiwane, można zmienić nazwy tych właściwości na #keyType i #valueType,
dzięki czemu zewnętrzny kod nie będzie mógł ich zmienić.
class Histogram {
// Aby zainicjować obiekt, wystarczy oddelegować go do utworzonej instancji
klasy Map.
constructor() { this.map = new Map(); }
// Metoda count() zwraca liczbę przypisaną zadanemu kluczowi w obiekcie Map
if (count === 1) {
this.map.delete(key);
} else if (count > 1) {
this.map.set(key, count - 1);
}
}
// Iterator zwraca klucze zapisane w obiekcie Histogram.
[Symbol.iterator]() { return this.map.keys(); }
// Inne iteratory są po prostu oddelegowane do obiektu Map.
}
/**
* Range jest klasą pochodną od AbstractSet. Reprezentuje zbiór
* wartości z przedziału określonego za pomocą właściwości
* from oraz to (włącznie). Ponieważ mogą to być liczby
constructor(from, to) {
super();
this.from = from;
this.to = to;
}
}
}
/**
* SingletonSet jest klasą pochodną od AbstractEnumerableSet.
this.member = member;
}
// Zaimplementowane są trzy metody i odziedziczone są
// wykorzystujące je implementacje metod isEmpty, equals()
// oraz toString().
/**
* AbstractWritableSet jest abstrakcyjną podklasą pochodną od
* AbstractEnumerableSet. Definiuje abstrakcyjne metody insert() i remove()
* służące do dodawania i usuwania elementów zbioru. Metody te są
* wykorzystane w implementacjach metod add(), subtract() i intersect().
}
}
subtract(set) {
for(let element of set) {
this.remove(element);
}
}
intersect(set) {
for(let element of this) {
if (!set.has(element)) {
this.remove(element);
}
}
}
}
/**
super();
this.max = max; // Maksymalna liczba całkowita, którą można zapisać.
this.n = 0; // Liczba elementów w zbiorze.
this.numBytes = Math.floor(max / 8) + 1; // Liczba potrzebnych bajtów.
this.data = new Uint8Array(this.numBytes); // Bajty.
}
// Wewnętrzna metoda sprawdzająca, czy zadana wartość jest poprawnym
elementem zbioru.
_valid(x) { return Number.isInteger(x) && x >= 0 && x <= this.max; }
// Sprawdzenie, czy zadany bit zadanego bajtu tablicy danych jest
ustawiony.
// Metoda zwraca wartość true lub false.
_has(byte, bit) { return (this.data[byte] & BitSet.bits[bit]) !== 0; }
// Czy wartość x znajduje się w zbiorze BitSet?
has(x) {
if (this._valid(x)) {
let byte = Math.floor(x / 8);
let bit = x % 8;
return this._has(byte, bit);
} else {
return false;
}
}
// Umieszczenie wartości x w zbiorze BitSet.
insert(x) {
if (this._valid(x)) { // Jeżeli wartość jest poprawna,
let byte = Math.floor(x / 8); // jest zamieniana na bajt i bit.
let bit = x % 8;
if (!this._has(byte, bit)) { // Jeżeli bit nie został
jeszcze ustawiony,
remove(x) {
if (this._valid(x)) { // Jeżeli wartość jest poprawna,
let byte = Math.floor(x / 8); // wyliczany jest bajt i bit.
let bit = x % 8;
if (this._has(byte, bit)) { // Jeżeli bit jest już
ustawiony,
this.data[byte] &= BitSet.masks[bit]; // to jest kasowany
this.n--; // i pomniejszana jest
wielkość zbioru.
}
} else {
throw new TypeError("Niepoprawny element zbioru: " + x );
}
}
// Getter zwracający wielkość zbioru.
get size() { return this.n; }
// Iterowanie zbioru polega na sprawdzaniu kolejnych bitów.
// (Można się postarać i napisać bardziej wydajny kod).
*[Symbol.iterator]() {
9.6. Podsumowanie
W tym rozdziale zostały opisane najważniejsze cechy klas w języku JavaScript:
Obiekty będące instancjami tej samej klasy dziedziczą właściwości tego samego obiektu
prototype. Obiekt ten jest kluczowym elementem klasy. Możliwe jest definiowanie klas
wyłącznie za pomocą metody Object.create().
Aby zdefiniować klasę w wersji języka starszej niż ES6, trzeba było najpierw zdefiniować
konstruktor. Funkcje tworzone za pomocą słowa kluczowego function mają właściwość
prototype. Jej wartością jest obiekt pełniący rolę prototypu dla wszystkich obiektów
tworzonych za pomocą funkcji wywoływanej jako konstruktor przy użyciu słowa
kluczowego new. Inicjując prototyp, definiuje się metody klasy. Prototyp jest kluczowym
elementem klasy, ale jej publiczną tożsamość określa konstruktor.
W wersji języka ES6 zostało wprowadzone słowo kluczowe class ułatwiające definiowanie
klas. Jednak wewnętrzny mechanizm działania konstruktora i prototyp nie zostały
zmienione.
Podklasy definiuje się za pomocą słowa kluczowego extends.
Podklasa może wywoływać konstruktor i nadpisane metody nadklasy, wykorzystując słowo
kluczowe super.
[1] Wyjątkiem są funkcje zwracane przez metodę Function.bind() w wersji języka ES5.
Wiązana funkcja nie ma właściwości prototype, ale może wykorzystywać prototyp podrzędnej
funkcji, jeżeli zostanie wywołana jako konstruktor.
[2] Patrz m.in. Wzorce projektowe Ericha Gammy i in. oraz Java. Efektywne programowanie
Joshui Blocha (wyd. Helion).
Rozdział 10.
Moduły
Ideą programowania modułowego jest składanie większych programów z modułów kodu
napisanych przez różnych programistów i pochodzących z różnych źródeł. Celem jest
zapewnienie poprawnego działania programu wykorzystującego kody, których specyfiki autorzy
modułów nie są w stanie przewidzieć. Z praktycznego punktu widzenia modułowość polega na
opakowywaniu, czyli ukrywaniu prywatnych szczegółów implementacyjnych fragmentów
kodów, mającym na celu porządkowanie przestrzeni nazw i zapobieganie przypadkowym
modyfikacjom zmiennych, funkcji i klas zdefiniowanych w poszczególnych fragmentach.
Jeszcze nie tak dawno język JavaScript nie obsługiwał modułów. Programiści pracujący nad
dużym kodem mieli do dyspozycji namiastkę modułowości w postaci klas, obiektów i domknięć.
Modułowość oparta na domknięciach, wsparta narzędziami do pakowania kodu, przybrała
ostatecznie praktyczną formę w postaci funkcji require() wprowadzonej w środowisku Node.
Tego rodzaju modułowość jest fundamentalną cechą środowiska programistycznego powyższej
platformy, która to cecha jednak nigdy oficjalnie nie objęła języka JavaScript. Zamiast tego,
począwszy od wersji języka ES6, moduły definiuje się za pomocą słów kluczowych import i
export. Słowa te istnieją wprawdzie od lat, ale były zaimplementowane tylko w
przeglądarkach. Dopiero od niedawna można je stosować w środowisku Node. Praktycznie
rzecz biorąc, modułowość w języku JavaScript wciąż jest oparta na narzędziach do tworzenia
pakietów kodu.
W kolejnych podrozdziałach opisane są następujące tematy:
Tworzenie modułów za pomocą klas i obiektów jest często stosowaną i użyteczną techniką,
jednak niezbyt zaawansowaną. Przede wszystkim nie pozwala ona ukrywać wewnętrznych
szczegółów implementacyjnych modułu. Wróćmy jeszcze do listingu 9.8. Gdyby to był moduł,
dobrze byłoby zachować wewnątrz niego kilka wewnętrznych abstrakcyjnych klas i udostępniać
na zewnątrz tylko kilka podklas. Ponadto metody _valid() i _has() w klasie BitSet, będące
wewnętrznymi narzędziami, nie powinny być widoczne na zewnątrz, podobnie jak szczegóły
implementacyjne właściwości BitSet.bits i BitSet.masks.
Jak się dowiedziałeś w podrozdziale 8.6, lokalne zmienne i zagnieżdżone funkcje są prywatnymi
elementami danej funkcji. Oznacza to, że namiastkę modułowości można osiągnąć za pomocą
bezpośrednio wywoływanego wyrażenia funkcyjnego. Szczegóły implementacyjne i funkcje
pomocnicze nie są wtedy ujawniane poza nadrzędną funkcją. Natomiast wartości zwracane
przez funkcję można upubliczniać za pomocą interfejsu API. W przypadku klasy BitSet moduł
mógłby mieć następującą strukturę:
const BitSet = (function() { // Stała BitSet zawiera wynik zwracany przez tę
funkcję.
// prywatne funkcje i stałe. Będą one jednak ukryte przed kodem odwołującym
się do tej klasy,
// Implementacja pominięta.
};
}());
Powyższe podejście modułowe staje się nieco ciekawsze, gdy moduł zawiera kilka elementów.
Na przykład poniższy kod definiuje mały moduł statystyczny eksportujący funkcje mean() i
stddev(), ale ukrywający ich szczegóły implementacyjne:
function stddev(data) {
let m = mean(data);
return Math.sqrt(
);
}
// Eksport publicznych funkcji jako właściwości obiektu.
}());
Wyobraźmy sobie narzędzie przetwarzające zbiór plików, które opakowuje kod zawarty w
każdym pliku w natychmiast wywoływanym wyrażeniu funkcyjnym, rejestruje wartości
zwracane przez każdą funkcję i umieszcza wszystko w jednym dużym pliku. Wynik jego
działania może wyglądać na przykład tak:
return exports;
}());
modules["stats.js"] = (function() {
return exports;
}());
Po scaleniu modułów w jeden plik, jak powyższy, można napisać następujący kod
wykorzystujący moduły:
// Uzyskanie referencji do potrzebnych modułów (lub zawartości modułu).
s.insert(10);
s.insert(20);
s.insert(30);
W środowisku Node każdy plik jest niezależnym modułem zawierającym prywatną przestrzeń
nazw. Stałe, zmienne, funkcje i klasy zdefiniowane w danym pliku są prywatne, chyba że
zostaną wyeksportowane. Ponadto wszystkie symbole eksportowane z danego modułu są
widoczne w innym module dopiero po ich jawnym zaimportowaniu.
W środowisku Node moduły importuje się za pomocą funkcji require(), a publiczny interfejs
API eksportuje się, nadając odpowiednie wartości właściwościom obiektu exports lub
całkowicie zastępując obiekt module.exports.
exports.stddev = function(d) {
let m = exports.mean(d);
};
Często jednak pojawia się potrzeba wyeksportowania z modułu jednej funkcji lub klasy, a nie
obiektu wypełnionego funkcjami i klasami. W takim wypadku wystarczy właściwości
module.exports przypisać jedną, eksportowaną wartość:
module.exports = class BitSet extends AbstractWritableSet {
// Implementacja pominięta.
};
Domyślną wartością właściwości module.exports jest ten sam obiekt, do którego odwołuje się
słowo kluczowe export. W pokazanym wyżej przykładzie modułu stats można funkcję mean()
przypisać właściwości module.exports.mean, a nie exports.mean. Inne podejście w przypadku
modułów takich jak stats polega na eksportowaniu na końcu modułu pojedynczego obiektu, a
nie poszczególnych funkcji:
let m = mean(d);
return Math.sqrt(d.map(x => x - m).map(square).reduce(sum)/(d.length-1));
};
Aby zaimportować wbudowany moduł systemowy środowiska Node lub moduł zainstalowany w
systemie za pomocą menedżera pakietów, wystarczy użyć niekwalifikowanej nazwy modułu, bez
ukośników:
// Moduł ten nie jest częścią środowiska Node, tylko został zainstalowany
lokalnie.
Aby zaimportować moduł do własnego kodu, należy użyć ścieżki pliku zawierającego kod
modułu, względnej wobec położenia bieżącego modułu. Można również stosować pełną ścieżkę
rozpoczynającą się od ukośnika, jednak zazwyczaj nazwy modułów tworzących program
zaczynają się od znaków ./ lub ../ określających położenie pliku względem bieżącego lub
nadrzędnego katalogu, na przykład:
Jeżeli pominie się rozszerzenie .js, środowisko Node i tak znajdzie i zaimportuje plik. Zazwyczaj
jednak jawnie stosuje się rozszerzenie.
Jeżeli moduł eksportuje tylko jedną funkcję lub klasę, wystarczy zaimportować cały moduł.
Jeżeli eksportowany jest obiekt zawierający wiele właściwości, wówczas można go
zaimportować w całości lub tylko niektóre jego właściwości, używając przypisania
destrukturyzującego. Porównajmy oba sposoby:
// Jest to dobry, zwięzły sposób, jednak tracony jest kontekst, gdy nazwy
let sd = stddev(data);
Przede wszystkim należy zwrócić uwagę na istotne różnice pomiędzy modułami w języku ES6
a zwykłymi „skryptami”. Najbardziej widoczna jest sama modułowość: zmienne, funkcje i klasy
zadeklarowane na najwyższym poziomie skryptu mają globalny kontekst, wspólny dla
wszystkich skryptów. Natomiast każdy plik ma własny, prywatny kontekst i można w nim
stosować instrukcje import oraz export. Oprócz tego pomiędzy modułami i skryptami jest kilka
innych różnic. W kodzie zawartym w module (podobnie jak w definicji klasy) domyślnie
obowiązuje tryb ścisły (patrz punkt 5.6.3). Oznacza to, że tworząc moduł, nie trzeba stosować
dyrektywy "use strict", nie można używać instrukcji with, obiektu arguments ani
niezadeklarowanych zmiennych. W rzeczywistości w modułach tryb jest jeszcze bardziej ścisły.
W funkcji wywołanej w zwykły sposób identyfikator this w trybie ścisłym ma wartość
undefined. W module identyfikator ten nie jest zdefiniowany nawet na najwyższym poziomie
kodu (dla porównania w przeglądarce i środowisku Node identyfikator this odwołuje się do
globalnego obiektu).
W ten sposób język JavaScript stał się liderem w modułowości, a środowisko Node
znalazło się w kłopotliwej sytuacji, ponieważ musi obsługiwać dwa nie do końca
kompatybilne ze sobą systemy. Wersja środowiska Node 13 obsługuje wprawdzie
moduły języka ES6, ale wciąż w ogromnej części programów stosowane są moduły
typowe dla tego środowiska.
}
Domyślnie eksportowane symbole importuje się nieco łatwiej. Ponadto w zwykły sposób można
eksportować tylko deklaracje posiadające nazwy. Natomiast instrukcja export default pozwala
eksportować dowolne wyrażenia, w tym funkcje i klasy anonimowe. Oznacza to, że można
eksportować również literały obiektowe. Zatem instrukcja export default użyta z nawiasami
klamrowymi jest w rzeczywistości eksportowanym literałem obiektowym.
Jak pamiętasz, symbol domyślnie eksportowany z modułu nie musi mieć nazwy. Określa się ją
podczas importowania tego symbolu. Natomiast symbole eksportowane w zwykły sposób muszą
mieć nazwy, do których można się odwoływać w module importującym. Spośród wszystkich
eksportowanych symboli można wybierać ich podzbiór przeznaczony do zaimportowania. W tym
celu należy po prostu po instrukcji import umieścić w nawiasach klamrowych listę
importowanych symboli. Użycie nawiasów powoduje, że instrukcja importująca przypomina
przypisanie destrukturyzujące. Jest to dobra analogia, ponieważ odzwierciedla to, co się dzieje
podczas importu. Wszystkie identyfikatory umieszczone wewnątrz nawiasów klamrowych są
windowane na najwyższy poziom modułu i są traktowane jako stałe.
W podręcznikach programowania można spotkać zalecenia, aby wszystkie symbole
wykorzystywane w danym module importować jawnie. Jeżeli jednak moduł eksportuje wiele
symboli, można je wszystkie zaimportować za jednym razem za pomocą następującej instrukcji:
import * as stats from "./stats.js";
Powyższa instrukcja tworzy obiekt i przypisuje go do stałej o nazwie stats. Wszystkie symbole
eksportowane z modułu w zwykły sposób stają się właściwościami tego obiektu. Nazwy symboli
stają się nazwami właściwości obiektu stats. Właściwości te są faktyczne stałymi, tzn. nie
można ich nadpisywać ani usuwać. Symbol wieloznaczny użyty w powyższym przykładzie
powoduje zaimportowanie funkcji mean() i stddev(). Funkcje te są umieszczane w obiekcie
stats i można je wywoływać jako stats.mean() i stats.stddev().
W modułach zazwyczaj definiuje się jeden symbol eksportowany domyślnie lub kilka symboli
eksportowanych w zwykły sposób. Dopuszczalne jest, choć rzadko praktykowane, stosowanie
obu form eksportu. W takim wypadku za pomocą następującej instrukcji można importować
zarówno symbol domyślny, jak i symbole nazwane:
import "./analytics.js";
Tego rodzaju moduł jest uruchamiany tylko przy pierwszym imporcie. Ewentualne kolejne
instrukcje importujące nie wywołują żadnych efektów. Moduł, który jedynie definiuje funkcje,
jest przydatny pod warunkiem, że eksportuje przynajmniej jedną z nich. Natomiast moduł
zawierający dodatkowo zwykły kod można importować nawet wtedy, gdy nie eksportuje
żadnych symboli. Na przykład aplikacja internetowa może importować moduł analityczny
rejestrujący funkcje obsługujące zdarzenia i wysyłające dane pomiarowe do serwera w
określonych momentach. Taki moduł stanowi samodzielny kod i nie musi niczego eksportować.
Trzeba go jednak importować, ponieważ zawiera kod stanowiący w rzeczywistości część
głównego programu.
Zwróć uwagę, że powyższą składnię można stosować również wtedy, gdy moduł eksportuje
symbole. Jeżeli zawiera on zarówno niezależny kod, jak i eksportowane symbole, które nie są
potrzebne w programie, można taki moduł importować tylko w celu uruchomienia zawartego w
nim kodu.
W tym przypadku za pomocą słowa kluczowego default nadawana jest nazwa domyślnie
eksportowanemu symbolowi.
Można również zmieniać nazwy eksportowanych symboli, ale tylko za pomocą instrukcji export
użytej z nawiasami klamrowymi. Taka potrzeba pojawia się dość rzadko, na przykład wtedy, gdy
wewnątrz modułu stosowany jest symbol o zwięzłej nazwie, a trzeba go wyeksportować pod
bardziej opisową, która nie będzie kolidowała z innymi modułami. Podobnie jak w instrukcji
importującej, trzeba w tym celu użyć słowa kluczowego as:
export {
layout as calculateLayout,
render as renderLayout
};
Pamiętaj, że choć nawiasy klamrowe wyglądają jak literał obiektowy, w rzeczywistości nim nie
są. W przypadku eksportu przed słowem kluczowym as umieszcza się identyfikator, a nie
wyrażenie. Oznacza to niestety, że nie można zmieniać nazw w następujący sposób:
Trzeba się jednak liczyć z tym, że w wielu programach będą potrzebne obie funkcje i pożądana
byłaby możliwość wygodnego importowania ich z modułu "./stats.js" za pomocą jednego
wiersza.
Wersja języka ES6 przewiduje takie przypadki i oferuje w tym celu specjalną składnię. Zamiast
importować symbole tylko po to, aby je od razu wyeksportować, można połączyć obie operacje
w jedną za pomocą słów export i from:
Zwróć uwagę, że w powyższym kodzie nazwy mean i stddev nie są nigdzie wykorzystywane.
Jeżeli nie trzeba wybierać konkretnych nazwanych symboli i można ponownie eksportować je
wszystkie, należy użyć symbolu wieloznacznego:
Od początku 2020 r. kod produkcyjny wykorzystujący moduły ES6 wciąż pakuje się przy użyciu
[1]
narzędzi takich jak webpack, które mają wprawdzie swoje zalety i wady , ale w ostatecznym
rozrachunku utworzony dzięki nim kod jest bardziej wydajny. W przyszłości może się to zmienić,
ponieważ sieci są coraz szybsze, a dostawcy przeglądarek nieustannie optymalizują
implementację języka ES6.
Narzędzia pakujące są pożądane w środowiskach produkcyjnych, ale nie w programistycznych,
ponieważ obecnie wszystkie przeglądarki obsługują moduły JavaScript. Jak wiesz, w modułach
domyślnie obowiązuje tryb ścisły, identyfikator this nie odwołuje się do obiektu globalnego, a
deklaracje najwyższego poziomu nie są udostępniane globalnie. Ponieważ kod modułowy musi
być wykonywany inaczej niż kod niemodułowy, niezbędne było wprowadzenie zmian w językach
HTML i JavaScript. Aby natywnie używać instrukcji import w przeglądarkach, należy za
pomocą znacznika <script type="module"> wskazać, że dany kod jest modułem.
Moduły w języku ES6 mają tę ciekawą cechę, że każdy z nich statycznie importuje pewien
zestaw innych modułów. Zatem nawet jeżeli przeglądarka jawnie importuje tylko jeden moduł,
w rzeczywistości importuje wszystkie zagnieżdżone w nim moduły, potem kolejne zagnieżdżone
itd. aż do momentu załadowania kompletnego programu. Wiesz już, że identyfikator modułu
użyty z instrukcją import może być adresem URL. Znacznik <script type="module"> wskazuje
początek programu modułowego. Jednak żadnego importowanego modułu nie należy
umieszczać wewnątrz znaczników <script>. Moduły są ładowane na żądanie tak jak zwykłe
pliki JavaScript i wykonywane w trybie ścisłym, tak jak zwykłe moduły w języku ES6. Zatem
najprostsza definicja punktu wejścia do modułowego programu JavaScript może wyglądać tak:
Inna ważna różnica między skryptami modułowymi a zwykłymi leży w obsłudze odwołań
międzydomenowych. Skrypt umieszczony wewnątrz znacznika <script> może pobierać pliki z
kodami JavaScript z dowolnych serwerów w internecie. Tę cechę wykorzystują serwisy
reklamowe, analityczne i diagnostyczne. Natomiast znacznik <script type="module">
ogranicza tę możliwość i pozwala ładować moduły wyłącznie z tej samej domeny, w której
znajduje się dokument HTML, ewentualnie z innej, jeżeli zostanie użyty odpowiedni nagłówek
CORS zapewniający pobieranie bezpiecznych plików. Niestety to nowe zabezpieczenie utrudnia
diagnozowanie modułów ES6 w trybie programistycznym, gdy adres URL zawiera prefiks
file://. Ten problem można rozwiązać, konfigurując lokalny, testowy serwer WWW.
Niektórzy programiści stosują rozszerzenie .mjs pozwalające odróżniać pliki modułów od
zwykłych plików JavaScript z rozszerzeniem .js. W przeglądarkach i znacznikach <script>
rozszerzenie pliku nie ma znaczenia (ważny jest za to jego typ MIME, dlatego może być
konieczne skonfigurowanie serwera WWW tak, aby plikom .mjs nadawał taki sam identyfikator
MIME jak plikom .js). Natomiast w środowisku Node rozszerzenie stanowi wskazówkę, jaki
system modułów ma być użyty do przetworzenia załadowanego pliku. Zatem, aby moduły języka
ES6 działały poprawnie w środowisku Node, warto dostosować je do konwencji nazewnictwa
plików i rozszerzenia .mjs.
})
Oprócz tego, wykorzystując funkcję asynchroniczną i instrukcję await (tu również może być
wskazana wcześniejsza lektura rozdziału 13.), można uprościć kod w następujący sposób:
async analyzeData(data) {
let stats = await import("./stats.js");
return {
average: stats.mean(data),
stddev: stats.stddev(data)
};
}
Argumentem instrukcji import() jest nazwa modułu taka sama jak stosowana ze statyczną
dyrektywą import. Jednak w przypadku instrukcji nie ma wymogu, aby nazwą był literał
znakowy. Może nią być dowolne wyrażenie, którego wartością jest poprawny ciąg znaków.
Instrukcja import() przypomina funkcję, ale nią nie jest. W rzeczywistości jest to operator, a
nawiasy są częścią jego składni, która jest dość nietypowa dlatego, że nazwą modułu może być
względny adres URL, co wymaga użycia pewnej magii implementacyjnej, niedopuszczalnej w
przypadku funkcji JavaScriptu. W praktyce różnica między funkcją a operatorem rzadko ma
znaczenie. Ujawnia się ona na przykład przy próbie wpisania kodu console.log(import); lub
require = import;.
Na koniec należy zwrócić uwagę, że dynamiczny import przydaje się nie tylko w
przeglądarkach, ale też w narzędziach pakujących kod, na przykład webpack. W najprostszym
przypadku takiemu narzędziu wskazuje się główny punkt wejścia do programu, aby wyszukało
wszystkie dyrektywy statycznego importu i zebrało wszystkie moduły w jeden duży plik.
Natomiast strategicznie stosując instrukcję import(), można zamiast jednego monolitycznego
kodu utworzyć kilka mniejszych i ładować je na żądanie.
10.4. Podsumowanie
Ideą modułowości jest ukrywanie szczegółów implementacyjnych, aby fragmenty kodu
pochodzące z różnych źródeł można było składać w jeden duży program bez obaw o nadpisanie
funkcji lub zmiennych. W tym rozdziale zostały opisane trzy systemy modułów stosowane w
języku JavaScript:
[1] Na przykład aplikacje, które są często aktualizowane i równie często odwiedzane przez tych
samych użytkowników, działają wydajniej, gdy importują małe, a nie duże moduły, ponieważ
efektywniej wtedy wykorzystują pamięci podręczne przeglądarek.
Rozdział 11.
Standardowa biblioteka
JavaScript
Niektóre typy danych, na przykład liczby, ciągi znaków (rozdział 3.), obiekty (rozdział 6.) i
tablice (rozdział 7.) są tak podstawowe, że traktuje się je jako elementy języka JavaScript. W
tym rozdziale są opisane inne ważne, choć nie tak fundamentalne, interfejsy API, które można
traktować jako ciągi „standardowej biblioteki” języka. Są to różne przydatne klasy i funkcje
wbudowane w język i dostępne dla wszystkich programów uruchamianych w przeglądarkach i
środowisku Node[1].
Kolejne podrozdziały są od siebie niezależne i można je czytać w dowolnej kolejności. Opisane
są w nich następujące tematy:
klasy Map i Set reprezentujące zbiory wartości i powiązań pomiędzy parami zbiorów,
obiekty TypedArrays reprezentujące tablice danych binarnych oraz powiązane z nimi
klasy do wyodrębniania wartości z nietablicowych danych binarnych,
wyrażenia regularne i klasa RegExp definiująca wzorce wykorzystywane do przetwarzania
tekstu; szczegółowo jest również opisana składnia wyrażeń regularnych,
klasa Data reprezentująca daty i godziny, umożliwiająca wykonywanie na nich różnych
operacji,
klasa Error i jej różne podklasy, których instancje są tworzone po pojawieniu się błędów
w programie,
obiekt JSON zawierający metody do serializowana i deserializowania struktur danych
złożonych z obiektów, tablic, ciągów znaków, liczb i wartości logicznych,
obiekt Intl i definiowane za jego pomocą klasy umożliwiające dostosowywanie
programów do ustawień regionalnych,
obiekt Console z metodami zwracającymi ciągi znaków, szczególnie przydatnymi przy
diagnozowaniu programów i rejestrowaniu ich działania,
klasa Url upraszczająca parsowanie i przetwarzanie adresów URL; w tym podrozdziale są
również opisane globalne funkcje do kodowania i dekodowania adresów i ich części
składowych,
setTimeout() i inne funkcje do określania fragmentów kodu przeznaczonych do
wykonania po upływie określonego czasu.
W programach JavaScript obiekty rutynowo wykorzystuje się w charakterze map i zbiorów, ale
tylko w odniesieniu do ciągów znaków. Dodatkową komplikacją jest fakt, że obiekty dziedziczą
właściwości o nazwach takich jak toString, których zazwyczaj zbiory i mapy nie powinny
zawierać.
Z powyższych powodów w wersji języka ES6 zostały wprowadzone klasy Set i Map,
reprezentujące zbiory i mapy z prawdziwego zdarzenia. Struktury te są opisane w kolejnych
punktach.
let unique = new Set("Mississippi"); // Cztery elementy: "M", "i", "s" i "p".
Właściwość size obiektu zbioru odpowiada właściwości length tablicy, tj. zawiera liczbę
elementów:
unique.size // => 4
Zbioru nie trzeba inicjować w chwili utworzenia. Elementy można dodawać i usuwać w
dowolnym momencie za pomocą metod add(), delete() i clear(). Pamiętaj, że zbiór nie może
zawierać duplikatów, zatem dodanie wartości, która już jest w zbiorze, nie daje żadnego efektu:
s.size // => 0
s.size // => 2
s.add([1,2,3]); // Dodanie tablicy.
s.size // => 3; do zbioru została dodana cała tablica, a nie jej
osobne elementy.
s.delete(1) // => true: pomyślnie usunięty element o wartości 1.
s.delete("test") // => false: zbiór nie zawiera ciągu "test", więc próba
jego usunięcia nie powiodła się.
s.size // => 0
Metoda add() ma jeden argument. Jeżeli jest nim tablica, jest ona umieszczana w zbiorze
jako całość, a nie jako osobne elementy. Zwracanym wynikiem jest obiekt (zbiór), do
którego metoda należy. Zatem, aby dodać do zbioru kilka elementów, można utworzyć
łańcuch metod, na przykład s.add('a').add('b').add('c');.
Pojedyncze elementy można usuwać również za pomocą metody delete(), która w
odróżnieniu od add() zwraca wartość logiczną. Jeżeli wartość podana w argumencie
należy do zbioru, metoda delete() usuwa ją i zwraca wynik true. W przeciwnym razie
nie usuwa niczego i zwraca false.
Ponadto należy pamiętać o bardzo ważnej kwestii, że sprawdzanie przynależności
wartości do zbioru polega na weryfikacji ścisłej równości obiektów, tak jak to robi
operator ===. Zbiór może zawierać zarówno liczbę 1, jak i ciąg "1", ponieważ są to różne
wartości. Elementy będące obiektami (tablice i funkcje) również są porównywane za
pomocą operatora ===. W powyższym kodzie tablica nie została usunięta ze zbioru,
ponieważ po jej dodaniu została podjęta próba usunięcia innej tablicy (choć z takimi
samymi elementami), podanej w argumencie metody delete(). Aby usunąć tę tablicę,
należało użyć wskazującej ją referencji.
Klasa Set jest iterowalna, co oznacza, że elementy zbioru można wyliczać za pomocą pętli
for/of:
let sum = 0;
Ponieważ obiekt typu Set jest iterowalny, można za pomocą operatora rozciągania (...)
zamieniać go na tablicę lub listę argumentów:
Klasa Set, oprócz tego, że jest iterowalna, implementuje również metodę forEach(), działającą
podobnie jak metoda o tej samej nazwie w tablicy:
let product = 1;
["jeden", 1],
["dwa", 2]
]);
let copy = new Map(n); // Nowa mapa, zawierająca takie same klucze i wartości
jak mapa n.
let o = { x: 1, y: 2}; // Obiekt zawierający dwie właściwości.
Po utworzeniu obiektu Map wartość skojarzoną z zadanym kluczem odczytuje się za pomocą
metody get(), a nową parę klucz-wartość dodaje za pomocą metody set(). Należy jednak
pamiętać, że mapa jest zbiorem kluczy z przypisanymi im wartościami, a nie zbiorem par klucz-
wartość. Wywołując metodę set(), której argumentem jest istniejący w mapie klucz, można
zmieniać przypisaną mu wartość. Do mapy nie jest wtedy dodawana nowa para klucz-wartość.
Klasa Map, oprócz metod get() i set, definiuje metody podobne do dostępnych w klasie Set.
Metoda has() sprawdza, czy mapa zawiera zadany klucz, delete() usuwa klucz i przypisaną
mu wartość, a clear() usuwa wszystkie pary klucz-wartość. Właściwość size zawiera liczbę
kluczy w mapie.
m.size // => 1
m.delete("trzy") // => false: nieudana próba usunięcia nieistniejącego
klucza.
m.size // => 3
m.get("dwa") // => 2
Podobnie jak w zbiorze, kluczem i wartością w mapie może być dowolna wartość, również null,
undefined, NaN, obiekt i tablica. Także porównywanie kluczy polega na sprawdzaniu ich
tożsamości, a nie równości. Jeżeli więc kluczem jest obiekt lub tablica, będzie się różnił od
innych obiektów i tablic zawierających te same właściwości i elementy:
m.get(m) // => undefined: wynik byłby taki sam, gdyby mapa nie
zawierała klucza m.
Obiekty Map są iterowalne. Każdą wyliczaną wartością jest dwuelementowa tablica, której
pierwszym elementem jest klucz, a drugim przypisana mu wartość. Operator rozciągania użyty
z obiektem Map powoduje utworzenie tablicy tablic podobnej do umieszczanej w argumencie
konstruktora Map(). Idiomatycznym przykładem inicjowania mapy za pomocą pętli for/of jest
użycie przypisania destrukturyzującego, a następnie przypisania klucza i wartości osobnym
zmiennym:
Podczas iterowania mapy zachowywana jest kolejność dodanych do niej elementów, podobnie
jak w zbiorze. Pierwsza dodana para klucz-wartość jest odczytywana jako pierwsza, a ostatnio
dodana para jest odczytywana jako ostatnia.
Aby iterować wyłącznie klucze lub powiązane z nimi wartości, należy użyć, odpowiednio,
metody keys() lub values(). Obie zwracają iterowalne obiekty zawierające klucze lub wartości
ułożone w kolejności ich dodania. Metoda entries() zwraca iterowalny obiekt zawierający
pary klucz-wartość, ale zamiast niego można iterować bezpośrednio samą mapę.
[...m.keys()] // => ["x", "y"]: tylko klucze.
});
Może się to wydać dziwne, że w powyższym kodzie wartość znajduje się przed kluczem, choć w
pętli for/of na pierwszym miejscu jest użyty klucz. Jak wspomniałem na początku punktu,
mapę można traktować jako uogólnioną tablicę, w której indeksami nie są liczby, tylko dowolne
wartości. W przypadku tablicy pierwszym argumentem funkcji będącej argumentem metody
forEach() jest element, a drugim indeks, dlatego przez analogię w przypadku mapy pierwszym
argumentem funkcji będącej argumentem metody forEach() jest wartość, a drugim klucz.
Konstruktor WeakMap() jest podobny do Map(), ale pomiędzy nimi istnieje kilka istotnych
różnic:
Klucze w mapie WeakMap muszą być obiektami lub tablicami. Porządkowanie pamięci nie
dotyczy wartości prymitywnych, dlatego nie mogą być one kluczami.
Klasa WeakMap implementuje wyłącznie metody get(), set(), has() i delete(), nie jest
iterowalna i nie definiuje metod keys(), values() oraz forEach(). Gdyby była
iterowalna, jej klucze byłyby dostępne, a więc klasa nie byłaby „słaba”.
Klasa WeakMap nie implementuje właściwości size, ponieważ wielkość mapy może się
zmieniać podczas porządkowania pamięci, czyli w nieprzewidywalnych momentach.
Klasa WeakMap została wprowadzona po to, aby można było kojarzyć wartości z obiektami bez
powodowania wycieków pamięci. Załóżmy, że tworzymy funkcję wykonującą czasochłonne
operacje na obiekcie podanym w argumencie. Aby zwiększyć wydajność obliczeń, funkcja
zapisuje wyliczone wartości w buforze na wypadek ich ponownego użycia w przyszłości. Jeżeli
bufor zostanie zaimplementowany za pomocą klasy Map, nie będzie można odzyskiwać pamięci
zajmowanej przez umieszczone w mapie obiekty. Klasa WeakMap rozwiązuje ten problem.
Podobny efekt można uzyskać, zapisując wyliczoną wartość bezpośrednio w prywatnej
właściwości Symbol obiektu (patrz punkt 6.10.3).
Klasa WeakSet implementuje zbiór obiektów, które są usuwane z pamięci podczas jej
porządkowania. Konstruktor WeakSet() funkcjonuje podobnie jak Set(), ale obiekty
umieszczone w zbiorach WeakSet i Set różnią się od siebie tak jak obiekty umieszczone w
mapach WeakMap i Map:
Typy, których nazwy zaczynają się od Int, oznaczają liczby całkowite ze znakiem, zajmujące 1,
2 lub 4 bajty pamięci (odpowiednio 8, 16 lub 32 bity). Typy, których nazwy zaczynają się od
Uint, oznaczają liczby całkowite bez znaku o wielkościach jak wyżej. Typy BigInt64Array
i BigUint64Array oznaczają liczby całkowite 64-bitowe, reprezentujące wartości BigInt (patrz
punkt 3.2.5). Typy o nazwach rozpoczynających się od Float oznaczają liczby
zmiennoprzecinkowe. Liczby typu Float32Array mają mniejszą precyzję i obejmują mniejszy
przedział wartości (ten typ w języku C i Java nazywa się float), za to zajmują o połowę mniej
miejsca w pamięci niż liczby typu Float64Array.
Typ Uint8ClampedArray jest specjalnym wariantem typu Uint8Array. Każdy z nich oznacza
bajt bez znaku, czyli wartości od 0 do 255. Przy próbie umieszczenia w tablicy typu Uint8Array
liczby większej od 255 lub mniejszej od zera, wartość jest „zawijana” i w efekcie zapisywana
jest inna wartość. Tak funkcjonuje na niskim poziomie pamięć komputera, więc operacja ta jest
bardzo szybka. Typ Uint8ClampedArray dodatkowo sprawdza przypisywaną wartość i jeżeli jest
większa niż 255 lub mniejsza od 0, „przycina” ją do 255 lub 0 (tj. nie „zawija” jej). Jest to
działanie wymagane m.in. przez niskopoziomowy interfejs API elementu <canvas> w języku
HTML do przetwarzania kolorów pikseli.
Konstruktor każdej typowanej klasy posiada właściwość BYTES_PER_ELEMENT zawierającą w
zależności od typu wartość 1, 2, 4 lub 8.
Istnieje jeszcze jeden sposób tworzenia tablic typowanych, polegający na użyciu klasy
ArrayBuffer. Obiekt tego typu zawiera odwołanie do obszaru pamięci, który utworzy się,
wywołując konstruktor z argumentem określającym liczbę alokowanych bajtów pamięci:
Za pomocą klasy ArrayBuffer nie można odczytywać ani zapisywać zaalokowanego obszaru
pamięci. Można za to w jego miejscu utworzyć tablicę typowaną i za jej pośrednictwem
zapisywać i odczytywać bajty. W tym celu należy wywołać konstruktor tablicy typowanej,
umieszczając w jego pierwszym argumencie obiekt ArrayBuffer, w drugim przesunięcie, a w
trzecim wielkość tablicy (liczbę elementów, a nie bajtów). Argumenty drugi i trzeci są
opcjonalne. Jeżeli nie zostaną określone, powstanie tablica zajmująca cały zaalokowany obszar
pamięci. Jeżeli nie zostanie określona wielkość, utworzona tablica będzie zajmować fragment
obszaru od zadanej pozycji początkowej do jego końca. Wywołując konstruktor tablicy
typowanej, należy pamiętać o jeszcze jednej rzeczy: tablica musi być odpowiednio dopasowana,
tj. przesunięcie musi być wielokrotnością wielkości danego typu. Na przykład konstruktor
Int32Array() wymaga, aby była to wielokrotność liczby 4, a Float64Array() — wielokrotność
liczby 8.
Zakładając, że obiekt ArrayBuffer został utworzony wcześniej, tablicę typowaną można
utworzyć na jeden z poniższych sposobów:
let ints2 = new Int32Array(buffer, 1024, 256); // Drugi kilobajt obszaru jako
ciąg 256 liczb całkowitych.
Powyższe cztery tablice typowane oferują różne widoki obszaru pamięci reprezentowanego
przez obiekt ArrayBuffer. Trzeba pamiętać, że wszystkie rodzaje tablic typowanych
wykorzystują obiekt ArrayBuffer, nawet jeżeli nie zostanie on jawnie wskazany. W przypadku
wywołania konstruktora tablicy typowanej bez obiektu bufora w argumencie obiekt ten, o
odpowiedniej wielkości, zostanie automatycznie utworzony. Zgodnie z opisem w dalszej części
rozdziału właściwość buffer typowanej tablicy zawiera odwołanie do obiektu ArrayBuffer.
Obiekt ten czasami wykorzystuje się bezpośrednio, gdy trzeba go stosować z różnymi tablicami
typowanymi.
}
Powyższa funkcja zwraca największą liczbę pierwszą mniejszą od zadanej. Kod wykorzystujący
zwykłą tablicę wyglądałby dokładnie tak samo. Jednak dzięki użyciu klasy Uint8Array()działał
na moim komputerze ponad cztery razy szybciej i zajmował osiem razy mniej pamięci niż jego
odpowiednik wykorzystujący klasę Array().
Pierwszym argumentem metody set() jest tablica zwykła lub typowana, a drugim,
opcjonalnym, jest przesunięcie elementu. Jeżeli argument ten nie zostanie określony,
przyjmowane jest domyślnie przesunięcie równe 0. Operacja kopiowania zawartości jednej
tablicy typowanej do innej jest wykonywana bardzo szybko.
Tablica typowana ma również metodę subarray() zwracającą fragment tablicy, do której
należy:
Metoda subarray() ma takie same argumenty jak slice() i działa podobnie. Pomiędzy obiema
metodami jest jednak ważna różnica. Metoda slice() zwraca zadane elementy w postaci nowej
tablicy, niezależnej od oryginalnej, natomiast subarray() nie kopiuje zawartości pamięci i
zwraca po prostu nowy widok istniejących wartości:
Działanie metody subarray() zwracającej nowy widok istniejącej tablicy sprowadza nas z
powrotem to tematu klasy ArrayBuffer. Każda tablica typowana posiada trzy właściwości
związane z wykorzystywanym przez nią buforem:
Obiekt ArrayBuffer jest po prostu ciągiem bajtów. Dostęp do nich jest możliwy za
pośrednictwem tablicy typowanej, jednak obiekt sam w sobie nie jest tablicą. Należy pamiętać,
że można go indeksować, tak jak każdy obiekt. Takie podejście nie daje dostępu do jego bajtów,
za to może być przyczyną niezrozumiałych błędów:
let bytes = new Uint8Array(8);
bytes[0] = 1; // Ustawienie pierwszego bajtu na 1,
bytes.buffer[0] // => undefined: bufor nie ma indeksu 0.
bytes.byteLength);
let int = view.getInt32(0); // Odczytanie liczby całkowitej ze znakiem,
zapisanej
// w kolejności big-endian, począwszy od
bajtu nr 0.
int = view.getInt32(4, false); // Następna liczba całkowita jest również
zapisana w kolejności
// big-endian.
int = view.getUint32(8, true); // Następna liczba całkowita jest zapisana w
kolejności
Znaki literalne
Wszystkie znaki alfanumeryczne użyte w wyrażeniu regularnym są porównywane literalnie. Za
pomocą odwrotnego ukośnika (\) można również umieszczać w wyrażeniu znaki inne niż
alfanumeryczne. Na przykład sekwencja \n oznacza nowy wiersz w ciągu. Tabela 11.1
przedstawia listę tego rodzaju znaków.
Tabela 11.1 . Znaki literalne stosowane w wyrażeniach regularnych
Znak Opis
\t Tabulator (\u0009).
Klasy znaków
Znaki literalne można łączyć w klasy znaków, umieszczając je wewnątrz nawiasów
kwadratowych. Taka klasa odpowiada dowolnemu zawartemu w niej znakowi. Na przykład
wyrażenie /[abc]/ odpowiada każdemu ze znaków a, b lub c. Można również definiować klasy
wykluczające znaki. Taka klasa odpowiada dowolnemu znakowi oprócz umieszczonych
wewnątrz nawiasów. Klasę wykluczającą definiuje się, umieszczając wewnątrz nawiasów na
pierwszym miejscu znak ^. Na przykład wyrażenie /[^abc]/ odpowiada dowolnemu znakowi
oprócz a, b i c. W definicji klasy można stosować myślniki oznaczające zakresy znaków. Na
przykład wyrażenie odpowiadające małej literze alfabetu łacińskiego ma postać /[a-z]/, a
wyrażenie odpowiadające cyfrze lub dowolnej literze ma postać /[a-zA-Z0-9]/. Aby umieścić
w klasie myślnik, należy go po prostu wpisać na ostatnim miejscu, przed nawiasem
zamykającym.
Sekwencja Opis
Powyższy wiersz tworzy nowy obiekt RegExp i przypisuje go zmiennej pattern. Ten konkretny
obiekt odpowiada każdemu ciągowi znaków kończącemu się literą „s”. Powyższe wyrażenie
regularne można również zdefiniować za pomocą konstruktora RegExp() w następujący sposób:
let pattern = new RegExp("s$");
Zwróć uwagę, że wewnątrz nawiasów kwadratowych można umieszczać sekwencje zawierające
odwrotne ukośniki. Na przykład \s odpowiada dowolnemu białemu znakowi, a \d dowolnej
cyfrze, zatem /[\s\d]/ odpowiada dowolnemu białemu znakowi i dowolnej cyfrze. Jest jeszcze
przypadek szczególny. Jak się przekonasz później, sekwencja \b ma specjalne znaczenie. Użyta
wewnątrz klasy znaków reprezentuje operację usunięcia znaku. Zatem, aby znak ten był
traktowany literalnie, należy użyć klasy zawierającej tylko jeden element: /[\b]/.
Powtarzanie sekwencji
Wykorzystując opisaną wyżej składnię, można liczby dwucyfrowe opisywać za pomocą
wyrażenia /\d\d/, a czterocyfrowe za pomocą wyrażenia /\d\d\d\d/. Nie można jednak w ten
sposób opisać na przykład liczby składającej się z dowolnej liczby cyfr lub ciągu znaków o
zadanej długości i opcjonalnej cyfry. W tego rodzaju bardziej skomplikowanych wzorcach
stosuje się składnię określającą liczbę powtórzeń elementu wyrażenia.
Znaki określające powtórzenia umieszcza się po sekwencji, której mają dotyczyć. Ponieważ
niektóre rodzaje powtórzeń są bardzo często stosowane, reprezentują je specjalne znaki. Na
przykład symbol + oznacza jedno lub więcej wystąpień poprzedzającego go wzorca.
Tabela 11.3 zawiera podsumowanie składni powtórzeń.
Tabela 11.3 . Znaki powtórzeń w wyrażeniach regularnych
Sekwencja Opis
{n,m} Powtórzenie poprzedniego wzorca przynajmniej n razy, ale nie więcej niż m razy.
Powtórzenia niezachłanne
Sekwencje opisane w tabeli 11.3 oznaczają tyle powtórzeń, ile jest to możliwe, a dodatkowo
można za nimi umieszczać inne sekwencje. Są to tzw. „powtórzenia zachłanne” (ang. greedy
repetitions). Można jednak definiować powtórzenia niezachłanne. W tym celu wystarczy po
sekwencji powtórzenia umieścić znak zapytania, na przykład ??, +?, *?, a nawet {1,5}?. Na
przykład wyrażenie /a+/ odpowiada jednemu lub kilku wystąpieniom litery a. Jest więc zgodne
z ciągiem "aaa". Natomiast wyrażenie /a+?/ oznacza jak najmniej wystąpień litery a. Zatem
odpowiada tylko pierwszej literze a powyższego ciągu.
Powtórzenia niezachłanne nie zawsze dają oczekiwany efekt. Przeanalizujmy wyrażenie /a+b/
odpowiadające jednej lub kilku literom a i następującej po nich literze b. Odpowiada ono
całemu ciągowi "aaab". Rozważmy teraz jego niezachłanną wersję /a+?b/. Odpowiada ona
literze b poprzedzonej jak najmniejszą liczbą liter a. Można się więc spodziewać, że w
przypadku powyższego ciągu odpowiada ono tylko jednej literze a i następującej po niej literze
b. W rzeczywistości jednak odpowiada ono całemu ciągowi, podobnie jak zachłanna wersja
wyrażenia. Wynika to stąd, że wyszukiwanie wzorca kończy się na pierwszej pozycji zgodnego z
nim fragmentu ciągu. Ponieważ w tym przypadku wzorzec pasuje do fragmentu
rozpoczynającego się od pierwszego znaku, inne dopasowania nie są brane pod uwagę.
Aby apostrofy lub cudzysłowy były zgodne, należy użyć następującego wyrażenia:
/(['"])[^'"]*\1/
Sekwencja \1 oznacza dowolny ciąg zgodny z pierwszym podwyrażeniem umieszczonym
w nawiasach. W tym przykładzie wzmacnia ona warunek, aby apostrof lub cudzysłów
otwierający był zgodny z apostrofem lub cudzysłowem zamykającym. Wyrażenie to nie
odpowiada ciągowi zawierającemu apostrof umieszczony wewnątrz cudzysłowów i odwrotnie.
Odwołań nie można umieszczać wewnątrz klas znaków. Dlatego wyrażenie /(['"])[^\1]*\1/
jest błędne.
W dalszej części rozdziału poświęconej opisowi interfejsu API klasy RegExp dowiesz się, że
odwołania do podwyrażeń w nawiasach są użyteczną funkcjonalnością wykorzystywaną do
wyszukiwania i zastępowania tekstu.
Elementy wyrażenia regularnego można również grupować bez tworzenia odwołań do nich.
W tym celu początek grupy należy oznaczyć sekwencją (?:, a koniec nawiasem ).
Przeanalizujmy następujące wyrażenie:
/([Jj]ava(?:[Ss]cript)?)\sis\s(fun\w*)/
W tym przykładzie podwyrażenie (?:[Ss]cript) jest grupą elementów, a znak powtórzenia ?
dotyczy całej grupy. Odwołanie nie jest tworzone, więc sekwencja \2 dotyczy tekstu
dopasowanego do podwyrażenia (fun\w*).
Sekwencja Opis
Zwróć uwagę, jak dzięki nazwom grup zrozumiały staje się kontekst wyrażenia
regularnego. W punkcie 11.3.2 poświęconym metodom replace() i match() klasy
String oraz metodzie exec() klasy RegExp dowiesz się, jak za pomocą interfejsu klasy
RegExp można odwoływać się przy użyciu nazw, a nie numerów do tekstów
dopasowanych do poszczególnych grup.
Nazwę grupy można również wykorzystywać do odwołania się do niej wewnątrz
wyrażenia. W poprzednim przykładzie pokazane było wyrażenie sprawdzające, czy
apostrof lub cudzysłów otwierający ciąg znaków jest zgodny z apostrofem lub
cudzysłowem zamykającym. To samo wyrażenie można napisać, wykorzystując nazwane
grupy przechwytujące i odpowiednie odwołania:
/(?<quote>['"])[^'"]*\k<quote>/
Sekwencja \k<quote> jest odwołaniem do nazwanej grupy odpowiadającej
początkowemu apostrofowi lub cudzysłowowi.
Sekwencja Opis
Flagi
Każde wyrażenie regularne może zawierać jedną lub kilka flag modyfikujących jego działanie.
W języku JavaScript zdefiniowanych jest sześć takich flag, każda będąca pojedynczą literą.
Flagi umieszcza się po drugim ukośniku lub w drugim argumencie konstruktora RegExp. Poniżej
opisane są obsługiwane flagi.
g
Asercje wsteczne
W wersji języka ES62018 składnia wyrażeń regularnych została rozszerzona i od tamtej
pory umożliwia definiowanie asercji „wstecznych”. Są one podobne do asercji
wyprzedzających, ale odwołują się do tekstu znajdującego się przed pozycją
dopasowania. Na początku 2020 r. asercje te implementowało środowisko Node i
przeglądarki Node, Chrome i Edge, ale nie Firefox ani Safari.
Zwykłą asercję wsteczną definiuje się za pomocą sekwencji (?<=...), a asercję
wykluczającą za pomocą sekwencji (?<!...). Na przykład wyrażenie:
/(?<= [A-Z]{2} )\d{5}/
odpowiada amerykańskiemu adresowi pocztowemu złożonemu z pięciu cyfr
umieszczonych po dwuliterowym skrócie oznaczającym stan. Natomiast następująca
wykluczająca asercja wsteczna:
/(?<![\p{Currency_Symbol}\d.])\d+(\.\d+)?/u
odpowiada ciągowi cyfr, przed którymi nie ma symbolu Unicode waluty.
Flaga oznaczająca, że wyrażenie jest „lepkie” (ang. sticky), tj. odpowiada początkowi ciągu
lub pierwszemu znakowi następującemu po poprzednim dopasowaniu. Jeżeli wyrażenie jest
skonstruowane tak, że odpowiada tylko jednemu dopasowaniu, flaga ta powoduje, że
wyrażenie funkcjonuje tak, jakby rozpoczynało się od symbolu ^, czyli było zakotwiczone na
początku ciągu. Flaga ta jest bardziej przydatna w przypadku wyrażeń wyszukujących
wszystkie dopasowania w ciągu. W takim wypadku flaga powoduje, że metoda match()
klasy String i metoda exec() klasy RegExp wyszukują kolejne dopasowanie od miejsca, w
którym kończy się poprzednie dopasowanie.
Można stosować dowolne kombinacje powyższych flag w dowolnej kolejności. Na przykład flagi
uig, gui i inne permutacje powodują, że wyrażenie uwzględnia znaki Unicode, nie uwzględnia
wielkości liter i wyszukuje kilka dopasowań.
Metoda search()
Klasa String posiada cztery metody wykorzystujące wyrażenia regularne, z których najprostszą
jest search(). Jej argumentem jest wyrażenie regularne, a zwracanym wynikiem pozycja
pierwszego znaku dopasowania lub liczba –1, jeżeli dopasowanie nie zostanie znalezione.
Ilustruje to poniższy kod:
"JavaScript".search(/script/ui) // => 4
"Python".search(/script/ui) // => –1
Jeżeli argumentem jest ciąg niebędący wyrażeniem regularnym, jest on przekształcany w
wyrażenie za pomocą konstruktora RegExp. Metoda search() nie wyszukuje dopasowań
globalnie. Jeżeli wyrażenie zawiera flagę g, metoda ją pomija.
Metoda replace()
Metoda replace() wyszukuje i zastępuje tekst. Jej pierwszym argumentem jest wyrażenie
regularne, a drugim tekst zastępujący dopasowanie. Metoda wyszukuje dopasowania w ciągu,
do którego należy. Jeżeli wyrażenie zawiera flagę g, metoda zastępuje wszystkie dopasowania
zadanym tekstem. Jeżeli flagi nie ma, zastępuje tylko pierwsze dopasowanie. Jeżeli pierwszym
argumentem jest ciąg niebędący wyrażeniem regularnym, jest on przekształcany w wyrażenie
za pomocą konstruktora RegExp, podobnie jak w metodzie search(). Na przykład w poniższym
kodzie metoda replace() jest wykorzystana do ujednolicenia wielkości liter w każdym słowie
„JavaScript” użytym w zadanym tekście:
// Fragment ciągu, niezależnie od wielkości tworzących go liter, jest
zamieniany na poprawne słowo.
text.replace(/javascript/gi, "JavaScript");
Metoda replace() jest bardziej użyteczna, niż się to wydaje. Jak wiesz, podwyrażenia
umieszczone w nawiasach są numerowane w kolejności od lewej do prawej, a ich dopasowania
są zapamiętywane. Jeżeli zastępujący ciąg znaków zawiera symbol $ i następującą po nim cyfrę,
to metoda replace() zastępuje te znaki tekstem dopasowanym do wskazanego podwyrażenia.
Jest to bardzo użyteczna funkcjonalność, którą można wykorzystać na przykład do
zastępowania cudzysłowów innymi znakami. Ilustruje to poniższy kod:
Metoda match()
Metoda match() jest najbardziej ogólną metodą w klasie String, wykorzystującą wyrażenie
regularne. Jej jedynym argumentem jest wyrażenie (lub ciąg przekształcany w wyrażenie za
pomocą konstruktora RegExp), a zwracanym wynikiem tablica zawierająca wyniki wyszukiwania
lub wartość null, jeżeli żadne dopasowanie nie zostanie znalezione. Jeżeli wyrażenie zawiera
flagę g, metoda zwraca tablicę wszystkich dopasowań w ciągu, na przykład:
"7 plus 8 równa się 15".match(/\d+/g) // => ["7", "8", "15"]
Jeżeli wyrażenie nie zawiera flagi g, metoda match() nie przeszukuje ciągu globalnie, tj.
wyszukuje tylko jedno dopasowanie. Zwracanym wynikiem jest również tablica, ale jej elementy
są wtedy zupełnie inne. Pierwszym jest dopasowany ciąg, a wszystkie pozostałe są ciągami
dopasowanymi do nazwanych grup przechwytujących (jeżeli wyrażenie je zawiera). Jeżeli więc
metoda zwraca tablicę a, to element a[0] zawiera całe dopasowanie, element a[1] zawiera
fragment dopasowany do pierwszej grupy przechwytującej itd. Można powiedzieć, że
odpowiednikiem zapisu a[1] jest sekwencja $1, odpowiednikiem zapisu a[2] jest sekwencja $2
itd.
match.index // => 17
match.groups.protocol // => "http"
match.groups.host // => "www.example.com"
match.groups.path // => "~david"
Jak się przekonałeś, jeżeli wyrażenie zawiera flagę g, to metoda match() działa zupełnie
inaczej. Oprócz tego istotna, choć nie tak radykalna różnica, pojawia się w przypadku użycia
flagi y. Jak pamiętasz, flaga ta powoduje, że wyrażenie staje się „lepkie”, tj. narzuca warunki
dotyczące pozycji, od których mogą zaczynać się dopasowania. Jeżeli wyrażenie zawiera
zarówno flagę g, jak i y, to metoda match() zwraca tablicę zawierającą dopasowane ciągi,
podobnie jak w przypadku braku flagi y. Jednak pierwsze dopasowanie zaczyna się od początku
ciągu, a każde kolejne od pozycji następującej zaraz po poprzednim dopasowaniu.
Jeżeli zostanie użyta flaga y bez flagi g, wówczas metoda match() wyszuka jedno dopasowanie,
domyślnie rozpoczynające się wraz z początkiem ciągu. Można jednak zmienić tę domyślną
pozycję, przypisując właściwości lastIndex obiektu wyrażenia regularnego numer pozycji, od
której ma się rozpoczynać dopasowanie. Po znalezieniu dopasowania właściwości lastIndex
jest automatycznie przypisywana pozycja pierwszego znaku znajdującego się po dopasowaniu.
Jeżeli metoda match() zostanie wywołana ponownie, zacznie wyszukiwać dopasowania
począwszy od tej pozycji. Nazwa lastIndex może się wydawać dziwna, ponieważ właściwość ta
określa pozycję, od której zaczyna się następne dopasowanie. Wrócimy do niej w opisie metody
exec() klasy RegExp, w której nazwa ta jest bardziej uzasadniona.
let vowel = /[aeiouy]/y; // "Lepkie" dopasowanie samogłosek.
Metoda matchAll()
Metoda matchAll() została wprowadzona w wersji języka ES2020 i na początku 2020 r. była
obsługiwana przez środowisko Node oraz wszystkie nowoczesne przeglądarki. Jej argumentem
jest wyrażenie zawierające flagę g. Metoda ta nie zwraca jednak tablicy zawierającej
dopasowania, tak jak match(), tylko iterator zwracający obiekty takie same jak zwracane przez
metodę match() wywołaną z nieglobalnym wyrażeniem w argumencie. Dlatego za pomocą
metody matchAll() najprościej iteruje się wszystkie dopasowania.
Za pomocą metody matchAll() można na przykład iterować poszczególne słowa w tekście, jak
niżej:
Metoda split()
Ostatnią metodą klasy String, wykorzystującą wyrażenie regularne, jest split(). Metoda ta
dzieli ciąg znaków, do którego należy, według podanego separatora i zwraca tablicę podciągów.
Jej argumentem może być zwykły ciąg znaków, na przykład:
"123,456,789".split(",") // => ["123", "456", "789"]
Argumentem metody może być również wyrażenie regularne, dzięki czemu ciągi można dzielić
według bardziej ogólnych reguł. W poniższym kodzie jest wywoływana z separatorem
zawierającym dowolną liczbę poprzedzających go i następujących po nim białych znaków:
lastIndex
Właściwość przeznaczona do odczytu i zapisu, zawierająca liczbę całkowitą. Jeżeli
wyrażenie zawiera flagę g lub y, właściwość ta określa pozycję znaku, od którego
rozpocznie się szukanie dopasowania. Właściwość ta jest wykorzystywana przez metody
exec() i test() opisane w kolejnych punktach.
Metoda test()
Najprościej z wyrażenia regularnego korzysta się za pomocą metody test(). Jej jedynym
argumentem jest ciąg znaków, a zwracanym wynikiem wartość true, jeżeli ciąg ten zawiera
dopasowanie, lub false w przeciwnym razie.
Metoda test() wywołuje po prostu znacznie bardziej złożoną metodę exec(), opisaną w
następnym podpunkcie, i zwraca wartość true, jeżeli metoda exec() zwróci wartość inną niż
null. Dlatego w przypadku użycia wyrażenia zawierającego flagi g lub y działanie metody
test() zależy od wartości właściwości lastIndex, która może się niepostrzeżenie zmieniać.
Więcej informacji na ten temat znajdziesz w ramce „Właściwość lastIndex i powtórne użycie
wyrażenia regularnego” niżej.
Metoda exec()
Wywoływanie metody exec() jest podstawowym i najlepszym sposobem korzystania z wyrażeń
regularnych. Metoda ta wyszukuje dopasowania w ciągu znaków podanym w argumencie. Jeżeli
ich nie znajdzie, zwraca wartość null. W przeciwnym razie zwraca tablicę taką samą jak
metoda match() klasy String wywołana z wyrażeniem globalnym. Element o indeksie 0 tej
tablicy zawiera dopasowanie całego wyrażenia, a kolejne elementy zawierają dopasowania
poszczególnych grup przechwytujących. Zwracana tablica ma również swoje właściwości.
Właściwość index zawiera pozycję znaku, od którego zaczyna się dopasowanie, właściwość
input zawiera przeszukiwany ciąg, a właściwość groups (jeżeli jest) zawiera odwołanie do
obiektu zawierającego dopasowania poszczególnych grup przechwytujących.
Metoda exec(), w odróżnieniu od metody match() klasy String, zwraca taką samą tablicę
niezależnie od tego, czy wyrażenie zawiera flagę g, czy nie. Jak pamiętasz, metoda match()
zwraca tablicę wtedy, gdy wyrażenie jest globalne. Natomiast metoda exec() zawsze zwraca
jedno dopasowanie wraz z kompletem informacji o nim. Jeżeli wyrażenie zawiera flagę g lub y,
metoda rozpoczyna wyszukiwanie dopasowania od pozycji określonej we właściwości
lastIndex obiektu. Dodatkowo, jeżeli ustawiona jest flaga y, szuka dopasowania
rozpoczynającego się od zadanej pozycji. Właściwość lastIndex nowo utworzonego obiektu
RegExp ma wartość 0, więc wyszukiwanie rozpoczyna się od początku ciągu. Za każdym razem,
gdy metoda exec() znajdzie dopasowanie, przypisuje właściwości lastIndex pozycję znaku
znajdującego się zaraz za tym dopasowaniem. Jeżeli metoda nie znajdzie dopasowania,
przypisuje właściwości wartość 0. Zatem metodę tę można wykorzystywać do wielokrotnego
iterowania wszystkich dopasowań. Jednak, jak wspomniałem wcześniej, ten sam efekt można
łatwiej osiągnąć za pomocą wprowadzonej w wersji języka ES2020 metody matchAll() klasy
String. Na przykład pętla w poniższym kodzie jest wykonywana dwukrotnie:
let pattern = /Java/g;
let text = "JavaScript > Java";
let match;
while((match = pattern.exec(text)) !== null) {
console.log(`Matched ${match[0]} at ${match.index}`);
console.log(`Next search begins at ${pattern.lastIndex}`);
}
}
Ten kod nie będzie działał zgodnie z oczekiwaniami. Jeżeli zmienna html będzie zawierała
przynajmniej jeden znacznik <p>, pętla będzie wykonywana w nieskończoność. Problem
polega na tym, że w wyrażeniu warunkowym instrukcji while jest użyty literał wyrażenia
regularnego. W każdej iteracji pętli jest tworzony nowy obiekt RegExp, którego
właściwość lastIndex przyjmuje wartość 0. W efekcie metoda exec() za każdym razem
rozpoczyna przeszukiwanie ciągu od początku i jeżeli znajdzie dopasowanie, wykonuje je
ponownie w następnej iteracji. Rozwiązaniem jest oczywiście jednokrotne zdefiniowanie
obiektu RegExp i zapisanie go w zmiennej. Dzięki temu w każdej iteracji będzie
wykorzystywany ten sam obiekt reprezentujący wyrażenie.
Czasami jednak użycie obiektu RegExp nie jest dobrym podejściem. Załóżmy, że chcemy
wyszukać w słowniku wszystkie słowa zawierające podwójne litery. Poniżej jest
przedstawiony przykładowy kod:
let dictionary = [ "apple", "book", "coffee" ];
let doubleLetterWords = [];
Jeżeli argumentów będzie więcej, zostaną zinterpretowane jako oznaczenia roku, miesiąca,
dnia, godziny, minuty, sekundy i milisekundy w lokalnej strefie czasowej, jak niżej:
let century = new Date(2100, // Rok 2100,
0, // styczeń,
1, // pierwszy dzień miesiąca,
2, 3, 4, 5); // godzina 02:03:04.005 lokalnego czasu.
Osobliwość interfejsu API klasy Date polega na tym, że pierwszy miesiąc roku ma numer 0, a
pierwszy dzień miesiąca ma numer 1. Jeżeli argumenty opisujące czas nie zostaną podane,
konstruktor przyjmie, że mają wartości 0, czyli oznaczają północ.
Zwróć uwagę, że konstruktor interpretuje podane w argumentach liczby jako czas w lokalnej
strefie określonej w ustawieniach systemu operacyjnego. Aby wskazać, że czas ma dotyczyć
strefy UTC (ang. Universal Coordinated Time — uniwersalny czas koordynowany lub inaczej
GMT, ang. Greenwich Mean Time — średni czas Greenwich), należy wywołać metodę
Date.UTC(). Jest to metoda statyczna, której argumenty są takie same jak konstruktora.
Metoda interpretuje je jako oznaczenie czasu UTC i zwraca liczbę milisekund, którą można
umieścić w argumencie konstruktora:
// Północ w Wielkiej Brytanii, 1 stycznia 2100 r.
let century = new Date(Date.UTC(2100, 0, 1));
Domyślnie data jest wyświetlana (na przykład za pomocą metody console.log(century))
zgodnie z lokalną strefą czasową. Aby wyświetlić ją w strefie UTC, należy jawnie przekształcić
ją w ciąg znaków za pomocą metody toUTCString() lub toISOString().
Ponadto jeżeli w argumencie konstruktora Date() umieści się ciąg znaków, zostanie on
potraktowany jako specyfikacja daty i czasu. Konstruktor potrafi analizować daty zapisane w
formatach zwracanych przez metody toString(), toUTCString() lub toISOString():
let century = new Date("2100-01-01T00:00:00Z"); // Data w formacie ISO.
Po utworzeniu obiektu Date można za pomocą jego metod modyfikować pola opisujące rok,
miesiąc, dzień, godziny, minuty, sekundy i milisekundy. Każda metoda ma dwie odmiany
wykonujące operacje na strefie lokalnej i UTC. Na przykład aby odczytać lub ustawić rok,
można użyć metody getFullYear(), getUTCFullYear(), setFullYear() lub
setUTCFullYear():
let d = new Date(); // Początkowa bieżąca data.
toLocaleString()
Metoda formatująca datę i czas zgodnie z lokalną strefą czasową i ustawieniami
regionalnymi.
toDateString()
Metoda formatująca jedynie datę. Wykorzystuje lokalną strefą czasową, ale nie ustawienia
regionalne.
toLocaleDateString()
Metoda formatująca jedynie datę. Wykorzystuje lokalną strefą czasową i ustawienia
regionalne.
toTimeString()
Metoda formatująca jedynie czas. Wykorzystuje lokalną strefą czasową, ale nie ustawienia
regionalne.
toLocaleTimeString()
Metoda formatująca jedynie czas. Wykorzystuje lokalną strefą czasową i ustawienia
regionalne.
Żadna z powyższych metod nie formatuje daty i czasu w sposób umożliwiający prezentowanie
tych informacji użytkownikom. W punkcie 11.7.2 opisane są bardziej ogólne techniki
formatowania daty i czasu, uwzględniające lokalne ustawienia regionalne.
Oprócz opisanych metod przekształcających obiekt Date w tekst jest jeszcze statyczna metoda
Date.parse() analizująca ciąg znaków podany w argumencie i zwracająca odpowiadający mu
znacznik czasu. Metoda analizuje zadany ciąg tak samo jak konstruktor Date(). Ponadto
gwarantuje poprawne przetwarzanie wyników metod toISOString(), toUTCString() i
toString().
Za pomocą opisanych wyżej metod można nie tylko zapisywać dane w pliku i przesyłać je przez
sieć, ale również w dość nieefektywny sposób tworzyć kopie obiektów:
// Utworzenie kopii serializowanego obiektu lub tablicy.
function deepcopy(o) {
return JSON.parse(JSON.stringify(o));
}
Zazwyczaj metody JSON.stringify() i JSON.parse() wywołuje się tylko z jednym
argumentem. Każda z nich ma jednak opcjonalny argument umożliwiający rozszerzanie formatu
JSON, o czym będzie mowa dalej. Ponadto metoda JSON.stringify() ma jeszcze trzeci,
opcjonalny argument. Jeżeli ciąg JSON ma być czytelny dla człowieka, ponieważ na przykład
będzie zawierał treść pliku konfiguracyjnego, wówczas w drugim argumencie powyższej
metody należy umieścić wartość null, a w trzecim liczbę lub ciąg znaków. Trzeci argument
zawiera informację, że ciąg ma się składać z kilku wciętych wierszy. Argument liczbowy określa
liczbę spacji tworzących wcięcie. Jeżeli argumentem jest ciąg białych znaków, na przykład '\t',
jest on wykorzystywany do tworzenia wcięć. Ilustruje to poniższy przykład:
let o = {s: "test", n: 0};
JSON.stringify(o, null, 2) // => '{\n "s": "test",\n "n": 0\n}'
Metoda JSON.parse() pomija białe znaki, więc trzeci argument metody JSON.stringify() nie
wpływa na proces przekształcania ciągu znaków z powrotem w strukturę danych.
/^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d.\d\d\dZ$/.test(value)) {
return new Date(value);
}
// W przeciwnym razie zwracana jest niezmieniona wartość.
return value;
});
Wynik można dostosowywać nie tylko za pomocą opisanej wyżej metody toJSON(), ale również
JSON.stringify(), umieszczając w jej drugim opcjonalnym argumencie tablicę lub funkcję.
Jeżeli drugim argumentem jest tablica ciągów znaków (lub liczb, które zostaną przekształcone
w ciągi), zostaną one użyte jako nazwy właściwości obiektu (lub elementów tablicy).
Właściwości, których nazw nie będzie w tej tablicy, zostaną w procesie serializacji pominięte.
Ponadto zwrócony ciąg będzie zawierał właściwości uporządkowane w tej samej kolejności co
ciągi w tablicy, co może być ważne podczas pisania testów.
Jeżeli argumentem jest funkcja, będzie traktowana jako „zamiennik”, czyli przeciwieństwo
opcjonalnej funkcji odtwarzającej umieszczonej w argumencie metody JSON.parse(). Funkcja
zamiennika jest wywoływana podczas przetwarzania każdej serializowanej wartości. W jej
pierwszym argumencie jest umieszczana nazwa właściwości obiektu lub indeks elementu
tablicy, a w drugim sama wartość. Funkcja zamiennika jest wywoływana jako metoda obiektu
lub tablicy zawierającej serializowaną wartość. Zwracanym przez nią wynikiem jest oryginalna
wartość po serializacji. Jeżeli funkcja zwróci wartość undefined lub nie zwróci niczego,
wówczas dana wartość (jak również element tablicy lub właściwość obiektu) zostanie pominięta
podczas serializacji. Ilustruje to poniższy przykład:
// Określenie pól przeznaczonych do serializacji i ich kolejności.
Właściwość określająca liczbę cyfr tworzących część całkowitą liczby. Jeżeli formatowana
liczba ma mniej cyfr niż wartość tej właściwości, jest dopełniana z lewej strony zerami.
Domyślna wartość tej właściwości to 1, a maksymalna to 21.
minimumFractionDigits, maximumFractionDigits
Te dwie właściwości określają format ułamkowej części liczby. Jeżeli część ta składa się z
mniejszej liczby cyfr niż wartość właściwości minimumFractionDigits, jest dopełniana
zerami z prawej strony. Jeżeli cyfr jest więcej niż wartość właściwości
maximumFractionDigits, część ułamkowa jest zaokrąglana. Obie właściwości mogą
przyjmować wartości od 0 do 20. Domyślnie właściwość minimumFractionDigits ma
wartość 0, a maximumFractionDigits wartość 3. Wyjątkiem są formaty walutowe, w
których część ułamkowa jest różna, zależna od użytej waluty.
minimumSignificantDigits, maximumSignificantDigits
Poniżej opisane są dostępne opcje. Należy określać właściwości wyłącznie tych pól daty i czasu,
które mają być umieszczone w wynikowym ciągu znaków.
year
Wartość "numeric" oznacza czterocyfrowy format roku, a "2-digit" dwucyfrowy.
month
Wartość "numeric" powoduje, że numer miesiąca jest krótki, na przykład "1", a "2-digit",
że składa się z dwóch cyfr, na przykład "01". Wartość "long" powoduje użycie pełnej
nazwy miesiąca, na przykład "stycznia", wartość "short" użycie krótkiej nazwy, na
przykład "sty", a wartość "narrow" jeszcze krótszej, na przykład "s", która nie zawsze jest
unikatowa.
day
Wartość "numeric" powoduje użycie jedno- lub dwucyfrowego numeru dnia miesiąca, a "2-
digit" zawsze numeru dwucyfrowego.
weekday
Wartość "long" powoduje użycie pełnej nazwy dnia tygodnia, np. "poniedziałek", wartość
"short" — użycie nazwy skróconej, na przykład "pon.", a wartość "narrow" — jeszcze
krótszej, na przykład "p", która nie zawsze jest unikatowa.
era
Właściwość określająca, czy podczas porównywania ciągów znaków mają być uwzględniane
wielkości liter i akcenty. Wartość "base" powoduje, że uwzględniana jest jedynie podstawa
znaku. (Należy jednak pamiętać, że w niektórych językach znak z akcentem jest traktowany
jako zupełnie inny znak). Wartość "accent" właściwości powoduje, że uwzględniane są
akcenty, ale nie wielkość liter. Wartość "case" powoduje, że uwzględniana jest wielkość
liter, ale nie akcenty. Domyślnie właściwość ta ma wartość "variant", jeżeli właściwość
usage ma wartość "sort". Jeżeli jest to "search", to domyślna wartość właściwości
sensitivity zależy od ustawień regionalnych.
ignorePunctuation
Jeżeli właściwość ta ma wartość true, wówczas podczas porównywania ciągów znaków nie
są uwzględniane spacje i znaki interpunkcyjne. Na przykład ciągi „nie raz” i „nieraz” są
traktowane jako równe sobie.
numeric
Jeżeli właściwość ta ma wartość true, wówczas ciągi składające się z cyfr lub zawierające
cyfry są traktowane jak liczby i sortowane w kolejności liczbowej, a nie alfabetycznej. Na
przykład ciąg „wersja 9” zostanie umieszczony przed ciągiem „wersja 10”.
caseFirst
Właściwość określająca kolejność wielkich i małych liter. Jeżeli ma wartość "upper", to
litera „A” jest umieszczana przed literą „a”. Jeżeli właściwość ma wartość "lower", to litera
„a” jest umieszczana przed literą „A”. Zwróć uwagę, że niezależnie od wartości właściwości
ta sama litera wielka i mała są podczas sortowania umieszczane obok siebie. Jest to inne
sortowanie niż Unicode (domyślnie stosowane w metodzie sort() w klasie Array), w
którym wszystkie wielkie litery ASCII są umieszczane przed wszystkimi małymi. Domyślna
wartość tej właściwości zależy od lokalnych ustawień regionalnych. Niektóre
implementacje języka mogą pomijać tę właściwość i nie pozwalać na zmianę porządku
sortowania.
Po utworzeniu obiektu Intl.Collator z odpowiednimi ustawieniami regionalnymi i opcjami
można porównywać ciągi znaków za pomocą metody compare(). Metoda ta zwraca liczbę. Jeżeli
jest ona mniejsza od zera, to oznacza, że pierwszy ciąg znajduje się przed drugim. Jeżeli jest
większa od zera, to oznacza, że drugi ciąg znajduje się przed pierwszym. Wartość równa zeru
oznacza, że w dany porządku sortowania oba ciągi są sobie równe.
Metoda compare() ma dwa argumenty i zwraca wynik wymagany w opcjonalnym argumencie
metody sort() w klasie Array. Ponadto klasa Intl.Collator automatycznie wiąże metodę
compare() ze swoją instancją, dzięki czemu metodę tę można umieszczać bezpośrednio w
argumencie metody sort() bez konieczności tworzenia funkcji opakowującej, która
wywoływałaby ją jako metodę obiektu. Poniżej znajduje się kilka przykładów:
// Podstawowy obiekt porównujący ciągi zgodnie z lokalnymi ustawieniami
regionalnymi.
sensitivity: "base",
ignorePunctuation: true
}).compare;
let strings = ["gazeta", "gazela", "gameta"];
strings.findIndex(s => fuzzyMatcher(s, "gaz Ela") === 0) // => 1
W niektórych językach może obowiązywać kilka porządków sortowania. Na przykład w
Niemczech w książkach telefonicznych jest stosowane inne niż w słownikach sortowanie
fonetyczne. W Hiszpanii przed 1994 r. dwuznaki „ch” i „ll” były traktowane jako osobne litery,
dlatego dzisiaj można się spotkać z sortowaniem współczesnym i tradycyjnym. W języku
chińskim znaki mogą być sortowane w zależności od kodowania, od rodzaju bazy i kresek, jak
również od transkrypcji pinyin. Wariantu sortowania nie można wybierać za pomocą
opcjonalnego argumentu konstruktora Intl.Collator(). Określa się go poprzez dopisanie do
oznaczenia języka sekwencji -u-co- i nazwy wariantu. Na przykład ciąg "de-DE-u-co-
phonebk" oznacza porządek stosowany w niemieckich książkach telefonicznych, a "zh-TW-u-
co-pinyin" porządek pinyin stosowany w Tajwanie. Poniżej pokazanych jest kilka przykładów.
// W Hiszpanii przed 1994 r. dwuznaki CH i LL były traktowane jako osobne
litery.
const modernSpanish = Intl.Collator("es-ES").compare;
const traditionalSpanish = Intl.Collator("es-ES-u-co-trad").compare;
let palabras = ["luz", "llama", "como", "chico"];
palabras.sort(modernSpanish) // => ["chico", "como", "llama", "luz"]
palabras.sort(traditionalSpanish) // => ["como", "chico", "luz", "llama"]
console.groupEnd()
Ta funkcja nie ma argumentów. Nie wyświetla żadnych informacji, natomiast anuluje
grupowanie komunikatów zainicjowane za pomocą funkcji console.group() lub
console.groupCollapsed().
console.time()
Funkcja wywoływana z jednym argumentem. Nie wyświetla żadnych informacji, tylko
rejestruje bieżący czas.
console.timeLog()
Pierwszym argumentem tej funkcji jest ciąg znaków. Jeżeli został wcześniej użyty w
argumencie funkcji console.time(), jest wyświetlany w konsoli, a po nim czas, jaki
upłynął od wywołania funkcji console.time(). Dodatkowe argumenty funkcji
console.timeLog() są wyświetlane tak samo jak za pomocą funkcji console.log().
console.timeEnd()
Funkcja wywoływana z jednym argumentem. Jeżeli był on użyty wcześniej z funkcją
console.time(), jest wyświetlany w konsoli, a po nim czas, jaki upłynął od tamtej chwili.
Po wywołaniu funkcji console.timeEnd() nie można używać funkcji console.timeLog(),
chyba że wcześniej ponownie wywoła się funkcję console.time().
%i i %d
Argument jest przekształcany i zaokrąglany do liczby całkowitej.
%f
Argument jest przekształcany w liczbę.
%o i %O
Argument jest traktowany jako obiekt, tj. są wyświetlane nazwy i wartości jego
właściwości. W konsoli przeglądarki wynik jest zazwyczaj interaktywny, tzn. użytkownik
może rozwijać i zwijać zagnieżdżone właściwości. Każda z tych sekwencji powoduje
wyświetlenie szczegółów obiektu. Sekwencja %O wykorzystuje format zależny od
implementacji, uznany jako najbardziej przydatny dla programistów.
%c
Sekwencja ta użyta w konsoli przeglądarki powoduje, że drugi argument flagi jest
interpretowany jako definicja stylu CSS formatującego tekst następujący po tej sekwencji,
ograniczony następną sekwencją %c lub końcem wiersza. W środowisku Node sekwencja %c
i odpowiadający jej argument są pomijane.
Zwróć uwagę, że rzadko pojawia się potrzeba stosowania sekwencji formatujących. Zazwyczaj
ten sam efekt łatwiej można uzyskać, wywołując w zwykły sposób funkcję z kilkoma
argumentami (również obiektami). Na przykład umieszczenie w argumencie funkcji
console.log() obiektu Error powoduje automatyczne wyświetlenie stosu wywołań.
Aby zakodować pary nazwa-wartość w części parametrów adresu URL, wygodniej jest użyć
właściwości searchParams zamiast search. Wartością właściwości search jest ciąg znaków,
który można odczytywać i zapisywać i w ten sposób uzyskiwać lub ustawiać cały fragment
adresu URL zawierający parametry. Właściwość searchParams jest przeznaczona tylko do
odczytu i zawiera odwołanie do obiektu URLSearchParams. Za pomocą interfejsu API tego
obiektu można odczytywać, zapisywać, dodawać, usuwać i sortować parametry zakodowane w
adresie URL:
let url = new URL("https://example.com/search");
url.search // => "": na razie nie ma parametrów.
url.searchParams.append("q", "term"); // Dodanie parametru.
url.search // => "?q=term"
11.10. Czasomierze
Od początku istnienia języka JavaScript w przeglądarkach są zdefiniowane funkcje
setTimeout() i setInterval(), za pomocą których można wywoływać inne funkcje po upływie
określonego czasu lub wywoływać je regularnie w określonych interwałach. Powyższe funkcje
nigdy nie weszły do standardu języka JavaScript, ale są obsługiwane przez wszystkie
przeglądarki i środowisko Node, przez co de facto stanowią część standardowej biblioteki.
Pierwszym argumentem funkcji setTimeout() jest inna funkcja, a drugim jest liczba
milisekund, po upływie których (z ewentualnym niewielkim dodatkowym opóźnieniem, jeżeli
system jest obciążony) ma zostać wywołana zadana funkcja (bez argumentów). W poniższym
kodzie funkcja setTimeout() jest wywoływana trzykrotnie w celu wyświetlenia komunikatu po
upływie jednej, dwóch i trzech sekund:
setTimeout(() => { console.log("Gotowi..."); }, 1000);
setTimeout(() => { console.log("do biegu..."); }, 2000);
setTimeout(() => { console.log("start!"); }, 3000);
Zwróć uwagę, że funkcja setTimeout() nie czeka, aż upłynie zadany czas. Wszystkie powyższe
wiersze są wykonywane niemal jednocześnie, ale w konsoli nic się nie dzieje, dopóki nie minie
1000 milisekund.
Jeżeli drugi argument nie zostanie określony, przyjmie on domyślną wartość 0. Nie oznacza to
jednak, że funkcja podana w pierwszym argumencie zostanie wywołana natychmiast, tylko
najszybciej, jak to będzie możliwe. Jeżeli przeglądarka będzie na przykład zajęta pobieraniem
danych od użytkownika lub obsługiwaniem innych zdarzeń, powyższa funkcja może zostać
wywołana ze zwłoką 10 milisekund lub większą.
Funkcja setTimeout() powoduje, że zadana funkcja jest wywoływana tylko raz. Czasami
zadana funkcja wywołuje funkcję setTimeout() w celu zaplanowania swojego własnego,
kolejnego wywołania. Jeżeli zadana funkcja ma być wywoływana regularnie, prościej jest użyć
funkcji setInterval(), która ma takie same argumenty jak setTimeout(), ale wywołuje
zadaną funkcję regularnie, średnio co określoną w drugim argumencie liczbę milisekund.
Zarówno funkcja setTimeout(), jak i setInterval() zwraca wartość, którą po zapisaniu
w zmiennej można wykorzystać do przerwania ciągu wywołań zadanej funkcji. W tym celu
należy tę wartość umieścić w argumencie funkcji clearTimeout() lub clearInterval().
Zwracaną wartością jest zazwyczaj liczba (w przypadku przeglądarek) lub obiekt (w środowisku
Node). Jej typ nie ma znaczenia i nie należy na niej wykonywać żadnych operacji. Można ją
jedynie umieszczać w argumencie funkcji clearTimeout() w celu anulowania wykonywania
funkcji zarejestrowanej za pomocą setTimeout() (przy założeniu, że nie została jeszcze
wykonana) lub przerwania ciągu wywołań funkcji zarejestrowanej za pomocą setInterval().
Poniższy kod przedstawia przykład użycia funkcji setTimeout(), setInterval() i
clearInterval() z interfejsem API konsoli do wyświetlenia prostego zegara cyfrowego:
// Usuwanie w jednosekundowych odstępach zawartości konsoli i wyświetlanie
bieżącego czasu.
let clock = setInterval(() => {
console.clear();
console.log(new Date().toLocaleTimeString());
}, 1000);
// Przerwanie wykonywania powyższego kodu po upływie 10 sekund.
setTimeout(() => { clearInterval(clock); }, 10000);
Z funkcjami setTimeout() i setInterval() spotkasz się ponownie w rozdziale 13.
poświęconym programowaniu asynchronicznemu.
11.11. Podsumowanie
Nauka języka programowania nie polega jedynie na szlifowaniu jego składni. Równie ważne jest
poznanie standardowej biblioteki i różnych narzędzi oferowanych wraz z językiem. W tym
rozdziale została opisana biblioteka języka JavaScript obejmująca między innymi:
[1] Nie wszystkie opisane tu elementy języka są zdefiniowane w jego specyfikacji. Niektóre
klasy i funkcje były pierwotnie zaimplementowane w przeglądarkach, a później zostały
zaadaptowane w środowisku Node, przez co de facto stały się częściami standardowej biblioteki
języka JavaScript.
[2] Determinizm kolejności iteracji jest cechą zbioru w języku JavaScript, która może być
zaskakująca dla programistów Pythona.
[3] Tablice typowane pojawiły się w klienckiej wersji JavaScriptu, gdy przeglądarki zaczęły
obsługiwać język WebGL. W wersji ES6 zostały wprowadzone do rdzenia języka.
[4] Nie dotyczy to klas znaków, wewnątrz których sekwencja \b oznacza usunięcie znaku.
[5] Analizowanie adresów URL za pomocą wyrażeń regularnych jest złą praktyką. W
podrozdziale 11.9 jest opisane lepsze podejście.
[6] Jeżeli programujesz w języku C, zauważysz, że wiele tych sekwencji jest takich samych jak w
funkcji printf().
Rozdział 12.
Iteratory i generatory
Obiekty iterowalne i powiązane z nimi iteratory to funkcjonalności wersji języka ES6, które
w tej książce widziałeś już kilkakrotnie. Są to obiekty typu Array (również TypedArray), Set,
Map oraz ciągi znaków. Zawartość tych obiektów można przetwarzać za pomocą pętli for/of
w poniższy sposób, opisany w punkcie 5.4.4:
let sum = 0;
for(let i of [1,2,3]) { // Odczytanie w pętli każdej z wartości.
sum += i;
}
sum // => 6
W punkcie 7.1.2 dowiedziałeś się, że iterator stosuje się również z operatorem ..., który
„rozciąga”, czyli rozwija obiekt. Tego operatora używa się w celu zainicjowania tablicy lub
wywołania funkcji, jak niżej:
let chars = [..."abcd"]; // chars == ["a", "b", "c", "d"]
let data = [1, 2, 3, 4, 5];
Math.max(...data) // => 5
Iterator można też stosować z przypisaniem destrukturyzującym:
[...m.keys()] // => ["jeden", " dwa"]: metoda keys() iteruje tylko klucze
mapy.
[...m.values()] // => [1, 2]: metoda values() iteruje tylko wartości mapy.
Ponadto w wersjach języka ES6 i nowszych można w argumentach wielu wbudowanych, często
stosownych z obiektami Array funkcji i konstruktorów umieszczać iteratory. Przykładem jest
konstruktor Set():
// Ciąg znaków jest iterowalny, więc poniższe dwa zbiory są takie same:
Obiekt jest iterowalny, jeżeli posiada specjalną metodę zwracającą obiekt iteratora.
Iteratorem jest obiekt posiadający metodę next() zwracającą wynik iteracji. Natomiast
wynikiem iteracji jest obiekt zawierający właściwości o nazwach value i done. Aby iterować
obiekt, należy najpierw wywołać jego metodę iteratora i uzyskać obiekt iteratora. Następnie
można wielokrotnie wywoływać metodę next() iteratora do momentu, aż właściwość done
zwróconego przez niego obiektu uzyska wartość true. Należy pamiętać, że metoda iteratora nie
ma określonej, konwencjonalnej nazwy. Zamiast niej stosuje się nazwę Symbol.iterator.
Zatem prostą pętlę for/of iterującą obiekt można zakodować w następujący, skomplikowany
sposób:
let iterable = [99];
console.log(result.value) // result.value == 99
Obiekt iteratora wbudowanego typu danych jest iterowalny, tj. posiada metodę
Symbol.iterator, która zwraca ten obiekt. Czasami tę cechę wykorzystuje się do iterowania
„częściowo wykorzystywanego” iteratora, jak niżej:
let list = [1,2,3,4,5];
/*
* Obiekt Range reprezentuje zakres liczb {x: from <= x <= to}.
* Definiuje również metodę has() sprawdzającą, czy dana liczba zawiera się w
zakresie.
class Range {
this.from = from;
this.to = to;
}
has(x) { return typeof x === "number" && this.from <= x && x <= this.to; }
[Symbol.iterator]() {
},
};
}
function map(iterable, f) {
next() {
let v = iterator.next();
if (v.done) {
return v;
} else {
};
}
// Utworzenie mapy liczb całkowitych i ich kwadratów, a następnie
przekształcenie jej w tablicę.
next() {
for(;;) {
let v = iterator.next();
if (v.done || predicate(v.value)) {
return v;
};
}
// Przefiltrowanie zakresu i pozostawienie w nim tylko liczb parzystych.
Obiekty iterowalne i iteratory są z definicji leniwe, tzn. odkładają wyliczenie kolejnej wartości
do momentu, aż będzie ona potrzebna. Załóżmy, że mamy bardzo długi ciąg znaków, który
trzeba podzielić na tokeny według spacji. W tym celu można po prostu użyć metody split(),
ale spowoduje to podzielenie całego ciągu, jeszcze zanim zostanie wykorzystane pierwsze
słowo. Zwrócona przez tę metodę tablica, zawierająca wszystkie podciągi, zajęłaby bardzo dużo
pamięci. Poniżej jest przedstawiona funkcja, która leniwie iteruje poszczególne słowa ciągu, ale
nie umieszcza ich wszystkich w pamięci (w wersji języka ES2020 funkcję tę można
zaimplementować znacznie łatwiej, wykorzystując opisaną w punkcie 11.3.2 metodę
matchAll() zwracającą iterator).
function words(s) {
return this;
},
};
Nawet jeżeli nasz hipotetyczny iterator zwracający słowa zapisane w pliku nigdy nie będzie
działał do końca, i tak będzie musiał zamykać plik, który otworzył. Dlatego oprócz metody
next() musi implementować metodę return(). Jeżeli iteracja zakończy się, zanim metoda
next() zwróci obiekt, w którym właściwość done będzie miała wartość true (zazwyczaj w
efekcie przerwania pętli for/of za pomocą instrukcji break), wówczas interpreter języka
sprawdzi, czy obiekt iteratora posiada metodę return(), i wywoła ją bez argumentów. Dzięki
temu iterator będzie mógł zamknąć plik, zwolnić pamięć lub wykonać inne operacje
porządkujące. Metoda return() musi zwracać wynik iteracji. Właściwości wyniku są
wprawdzie pomijane, ale zwracanie innej wartości niż obiekt jest złą praktyką.
12.3. Generatory
Generator jest rodzajem iteratora zdefiniowanym za pomocą nowej, znacznie lepszej składni
języka ES6. Generatory okazują się szczególnie przydatne w sytuacjach, w których iterowane
wartości nie są elementami struktury danych, tylko wynikami obliczeń.
Aby utworzyć generator, należy najpierw zdefiniować funkcję generatora. Składniowo jest
ona podobna do zwykłej funkcji, ale definiuje się ją za pomocą słowa kluczowego function*,
a nie function. (Z technicznego punktu widzenia nie jest to nowe słowo, tylko symbol *
umieszczony pomiędzy słowem function a nazwą funkcji). Wywołanie funkcji generatora w
rzeczywistości nie powoduje wykonania jej kodu. Zamiast tego zwracany jest obiekt generatora
będący iteratorem. Wywołanie jego metody next() powoduje wykonanie kodu funkcji
generatora od początku (lub od bieżącej pozycji) do instrukcji yield. Instrukcja ta pojawiła się
w wersji języka ES6. Jej wartość staje się wynikiem zwracanym przez metodę next() iteratora.
Wyjaśnia to poniższy przykład:
primes.next().value // => 2
primes.next().value // => 3
primes.next().value // => 5
primes.next().value // => 7
let sum = 0;
for(let prime of oneDigitPrimes()) sum += prime;
sum // => 17
W powyższym przykładzie do zdefiniowania generatora została użyta instrukcja function*.
Generator można też zdefiniować jako wyrażenie. W takim wypadku również należy po słowie
kluczowym function użyć symbolu *:
const seq = function*(from,to) {
x: 1, y: 2, z: 3,
// Generator zwracający klucze zawarte w bieżącym obiekcie.
*g() {
for(let key of Object.keys(this)) {
yield key;
}
}
};
[...o.g()] // => ["x", "y", "z", "g"]
}
Listing 9.3 w rozdziale 9. zawiera przykład użycia zdefiniowanej w ten sposób funkcji
generatora.
function* fibonacciSequence() {
let x = 0, y = 1;
for(;;) {
yield y;
function fibonacci(n) {
for(let f of fibonacciSequence()) {
if (n-- <= 0) return f;
}
}
}
// Tablica zawierająca pięć wyrazów ciągu Fibonacciego.
[...take(5, fibonacciSequence())] // => [1, 1, 2, 3, 5]
}
let item = iterators[index].next(); // Odczytanie następnego elementu
z następnego iteratora.
}
// Ułożenie na przemian elementów zawartych w trzech obiektach.
yield item;
}
}
}
[...sequence("abc",oneDigitPrimes())] // => ["a","b","c",2,3,5,7]
Tego rodzaju proces, w którym zwracane są elementy innego obiektu iterowalnego, jest na tyle
często implementowany, że w wersji języka ES6 została wprowadzona specjalna składnia. Słowo
kluczowe yield* funkcjonuje podobnie jak yield z tą różnicą, że nie zwraca pojedynczej
wartości, tylko iteruje obiekt i zwraca wszystkie wynikowe wartości. Wykorzystując instrukcję
yield*, można przedstawioną wyżej funkcję sequence()uprościć do następującej postaci:
function* sequence(...iterables) {
for(let iterable of iterables) {
yield* iterable;
}
}
}
Funkcja w takiej postaci nie będzie jednak działać poprawnie. Instrukcje yield i yield* można
stosować wyłącznie w generatorach, natomiast zagnieżdżona funkcja strzałkowa jest tutaj
zwykłą funkcją, a nie generatorem zdefiniowanym za pomocą słowa kluczowego function*.
Dlatego nie można w niej użyć słowa yield*.
function *oneAndDone() {
yield 1;
return "done";
}
function* smallNumbers() {
console.log("Metoda next() wywołana pierwszy raz. Argument pominięty.");
}
let g = smallNumbers();
12.5. Podsumowanie
W tym rozdziale dowiedziałeś się, że:
13.1.1. Czasomierze
Najprostszym przykładem asynchroniczności jest wywołanie określonego kodu po upływie
zadanego czasu. Jak się dowiedziałeś w podrozdziale 11.10, można to osiągnąć za pomocą
funkcji setTimeout() w następujący sposób:
setTimeout(checkForUpdates, 60000);
Pierwszym argumentem funkcji setTimeout() jest również funkcja, a drugim jest interwał
czasu wyrażony w milisekundach. W powyższym przykładzie funkcja checkForUpdates() jest
wywoływana po upływie 60 000 milisekund (po 1 minucie) od chwili wywołania funkcji
setTimeout(). Pierwsza z nich jest funkcją zwrotną, która może być zdefiniowana w programie,
a druga jest funkcją rejestrującą funkcję zwrotną i określającą asynchroniczne warunki jej
wywołania.
Funkcja setTimeout()wywołuje zadaną funkcję zwrotną bez argumentów jeden raz, po czym
„zapomina” o niej. Jeżeli funkcja checkForUpdates() ma naprawdę sprawdzać dostępność
aktualizacji, musi być wywoływana regularnie. Dlatego zamiast setTimeout() należy użyć
funkcji setInterval(), jak niżej:
clearInterval(updateIntervalId);
13.1.2. Zdarzenia
Niemal wszystkie programy klienckie napisane w języku JavaScript są sterowane zdarzeniami.
Nie realizują żadnych z góry określonych operacji, tylko czekają, aż użytkownik wykona jakąś
czynność, po czym reagują na nią. Przeglądarka zgłasza zdarzenie, na przykład gdy użytkownik
naciśnie klawisz, przesunie kursor, kliknie przycisk lub dotknie ekranu. W programach
sterowanych zdarzeniami funkcje zwrotne są przypisywane określonym zdarzeniom w
określonych kontekstach, a przeglądarka wywołuje te funkcje w miarę pojawiania się tych
zdarzeń. Tego rodzaju funkcje są nazywane procedurami obsługi zdarzeń (ang. event
handlers). Rejestruje się je za pomocą metody addEventListener():
// Zapytanie przeglądarki o obiekt reprezentujący element <button> zgodny z
zadanym selektorem CSS.
okay.addEventListener('click', applyUpdate);
request.open("GET", "http://www.example.com/api/version");
request.send();
// Zarejestrowanie funkcji zwrotnej, która zostanie wywołana po odebraniu
odpowiedzi.
request.onload = function() {
} else {
versionCallback(response.statusText, null);
}
};
versionCallback(e.type, null);
};
};
if (err) {
} else {
Object.assign(options, JSON.parse(text));
}
startProgram(options);
});
Środowisko Node definiuje również kilka interfejsów API obsługujących zdarzenia. Poniższa
funkcja wysyła zapytanie HTTP na zadany adres URL. Jej asynchroniczny, obsługujący
zdarzenia kod jest dwuwarstwowy. Zwróć uwagę, że do zarejestrowania procedury obsługi
wykorzystywana jest metoda on(), a nie addEventListener().
request = https.get(url);
response.on("end", () => {
// funkcji zwrotnej.
callback(httpStatus, null);
});
});
// Rejestrowana jest również procedura obsługi niskopoziomowych błędów
transmisji sieciowej.
callback(err, null);
});
}
13.2. Promesy
Teraz, po zapoznaniu się z przykładami funkcji zwrotnych i asynchronicznymi klienckimi i
serwerowymi programami obsługującymi zdarzenia, możesz poznać promesy. Jest to
fundamentalna funkcjonalność języka ułatwiająca programowanie asynchroniczne.
Promesa jest obiektem reprezentującym wynik asynchronicznej operacji. Wynik ten może, ale
nie musi, być dostępny w danej chwili, a interfejs API promesy z założenia jest w tej kwestii
niejednoznaczny. Za pomocą promesy nie można synchronicznie odczytać wyniku. Można jej
jedynie zlecić wywołanie funkcji zwrotnej, gdy wynik będzie dostępny. Aby w asynchronicznej
metodzie getText() z poprzedniego przykładu można było użyć promesy, należy usunąć
argument z funkcją zwrotną i dodać instrukcję return zwracającą obiekt promesy. Kod
wywołujący tę metodę może w obiekcie promesy zarejestrować jedną lub kilka funkcji
zwrotnych, które zostaną wywołane po wykonaniu asynchronicznego kodu.
Zatem promesa w najprostszej formie oferuje po prostu inny sposób użycia funkcji zwrotnej.
Jednak jej stosowanie niesie dodatkowe praktyczne korzyści. Jeden z problemów w
programowaniu asynchronicznym polega na tym, że funkcje zwrotne wywołują inne funkcje
zwrotne, które wywołują kolejne funkcje zwrotne itd. Tak zbudowany kod składa się z wielu
głęboko wciętych wierszy, przez co jest nieczytelny. Za pomocą promes można zagnieżdżone
wywołania zaimplementować w postaci bardziej czytelnego łańcucha promes.
Inny problem z funkcjami zwrotnymi polega na tym, że trudno jest za ich pomocą obsługiwać
błędy. Na przykład asynchroniczna funkcja, zwrotna lub zwykła, po zgłoszeniu wyjątku nie jest
w stanie eskalować go do nadrzędnego kodu. Utrudniona obsługa wyjątków jest jednym z
podstawowych mankamentów programowania asynchronicznego. Można wprawdzie
drobiazgowo rejestrować i eskalować błędy za pomocą funkcji zwrotnych i zwracanych przez
nie wyników, ale jest to skomplikowane i pracochłonne rozwiązanie. Promesy ułatwiają je w ten
sposób, że ujednolicają obsługę błędów i umożliwiają ich eskalowanie za pomocą łańcucha
metod.
getJSON(url).then(jsonData => {
// Tutaj umieszczona jest funkcja zwrotna, wywoływana asynchronicznie,
getJSON("/api/user/profile").then(displayUserProfile, handleProfileError);
Promesa reprezentuje wynik operacji asynchronicznej, która zostanie wykonana po utworzeniu
obiektu. Ponieważ operacja ta zostanie wykonana dopiero po zwróceniu obiektu promesy, nie
ma możliwości uzyskania wyniku tej operacji w tradycyjny sposób ani zgłoszenia wyjątku, który
można byłoby przechwycić. Alternatywnym rozwiązaniem jest użycie funkcji umieszczonych w
argumentach metody then(). W przypadku pomyślnie wykonanej operacji synchronicznej jej
wynik jest po prostu zwracany do głównego kodu. Natomiast asynchroniczna promesa w takim
wypadku umieszcza wynik w argumencie funkcji podanej w pierwszym argumencie metody
then().
Jeżeli podczas wykonywania operacji synchronicznej coś pójdzie źle, zostanie zgłoszony
wyjątek, eskalowany następnie w górę stosu wywołań aż do obsługującej go instrukcji catch. W
przypadku operacji asynchronicznej na stosie wywołań nie ma informacji o kodzie, który ją
zainicjował. Dlatego jeżeli pojawi się błąd, nie ma możliwości zgłoszenia i eskalowania wyjątku.
Aby rozwiązać ten problem, w drugim argumencie metody then() umieszcza się funkcję, której
argumentem jest obiekt reprezentujący wyjątek (zazwyczaj jest to obiekt Error lub jego
odmiana, ale nie jest to regułą). Jeżeli więc w powyższym kodzie funkcja getJSON() zakończy
działanie pomyślnie, to umieści wynik w argumencie funkcji displayUserProfile(). Natomiast
jeżeli wystąpi błąd (na przykład użytkownik nie zaloguje się, serwer nie odpowie za zapytanie,
przerwie się połączenie sieciowe, zostanie przekroczony czas oczekiwania itp.), wówczas
funkcja getJSON() wywoła funkcję handleProfileError() z obiektem Error w argumencie.
W praktyce rzadko używa się metody then() z dwoma argumentami, ponieważ jest lepszy i
bardziej idiomatyczny sposób obsługiwania błędów za pomocą promes. Zanim go poznasz,
sprawdźmy, co się stanie, gdy funkcja getJSON()zakończy działanie normalnie, natomiast błąd
pojawi się w funkcji displayUserProfile(). Funkcja zwrotna jest wywoływana
asynchronicznie po zakończeniu działania funkcji getJSON(), więc sama też jest
asynchroniczna i nie może zgłosić wyjątku, ponieważ na stosie wywołań nie ma informacji o
kodzie, który mógłby go obsłużyć. Dlatego bardziej idiomatycznym rozwiązaniem jest użycie
następującego kodu:
getJSON("/api/user/profile").then(displayUserProfile).catch(handleProfileErro
r);
Terminologia promes
Zanim bliżej przyjrzymy się promesom, zatrzymajmy się na chwilę i zdefiniujmy kilka
pojęć. W potocznym, nieprogramistycznym ujęciu promesa oznacza przyrzeczenie, które
może być dotrzymane lub złamane. W języku JavaScript mówi się, że promesa może być
spełniona lub odrzucona. Załóżmy, że została wywołana metoda then() z dwiema
funkcjami w argumentach. Przyjęło się mówić, że promesa jest spełniona, jeżeli została
wywołana pierwsza funkcja. Analogicznie promesa jest odrzucona, jeżeli została
wywołana druga funkcja. Jeżeli promesa nie jest ani spełniona, ani odrzucona, to
oznacza, że jest zawieszona. Promesa spełniona lub odrzucona jest rozstrzygnięta. Zwróć
uwagę, że promesa nie może być jednocześnie spełniona i odrzucona. Po rozstrzygnięciu
nie można jej ponownie spełnić ani odrzucić.
Przypomnij sobie definicję z początku tego podrozdziału: „Promesa jest obiektem
reprezentującym wynik asynchronicznej operacji”. Pamiętaj, że działanie promesy nie
ogranicza się do abstrakcyjnego rejestrowania funkcji zwrotnych wywoływanych po
zakończeniu wykonywania asynchronicznego kodu. Promesa reprezentuje jego wynik.
Jeżeli kod pomyślnie zakończy działanie (promesa zostanie spełniona), to uzyskany wynik
staje się zwracaną przez niego wartością. W przeciwnym razie (promesa zostanie
odrzucona) wynikiem jest obiekt Error lub inna wartość, która w kodzie synchronicznym
mogłaby być zgłoszona jako wyjątek. Z każdą rozstrzygniętą promesą jest skojarzona
wartość, która się nie zmienia. Jeżeli promesa jest spełniona, tą wartością jest wynik
umieszczany w argumencie funkcji zwrotnej umieszczonej w pierwszym argumencie
metody then(). Jeżeli promesa jest odrzucona, tą wartością jest obiekt błędu
umieszczony w argumencie funkcji zwrotnej umieszczonej w argumencie metody
catch() lub drugim argumencie metody then().
Precyzyjne zdefiniowanie pojęć jest niezbędne, ponieważ promesa może być również
zdeterminowana. Pojęcie to można łatwo pomylić ze spełnieniem lub rozstrzygnięciem,
choć nie odpowiada dokładnie żadnemu z nich. Znajomość tego pojęcia jest ważna w
zrozumieniu funkcjonowania promes. Dlatego wrócimy do niego po zapoznaniu się z
łańcuchem promes.
})
.then(rendered => { // Po uzyskaniu gotowego dokumentu
Powyższy kod pokazuje, jak łatwo można za pomocą łańcucha promes wyrazić sekwencję
asynchronicznych operacji. Nie będziemy jednak rozpatrywać tego konkretnego przypadku.
Zamiast tego zajmiemy się ogólną ideą łączenia promes w łańcuch w celu przetwarzania
zapytań HTTP.
Wcześniej w tym rozdziale dowiedziałeś się, jak za pomocą obiektu XMLHttpRequest można
wysyłać zapytania HTTP. Obiekt o tej dziwacznej nazwie posiada stary i niewygodny w użyciu
interfejs API, który w dużej mierze został zastąpiony nowym, opartym na promesach
interfejsem Fetch (patrz punkt 15.11.1). Nowy interfejs w swojej najprostszej formie składa się
z jednej metody fetch(), której argumentem jest adres URL, a zwracanym wynikiem jest
promesa. Promesa jest spełniona, gdy dostępny jest status HTTP odpowiedzi i jej nagłówki.
Ilustruje to poniższy kod:
fetch("/api/user/profile").then(response => {
}
});
Gdy promesa zwrócona przez metodę fetch() zostanie spełniona, obiekt Response jest
umieszczany w argumencie funkcji umieszczonej w argumencie metody then(). Za pomocą
tego obiektu można uzyskać dostęp do statusu zapytania i nagłówków. Oprócz tego obiekt
zawiera metody text() i json() zwracające treść odpowiedzi zapisaną, odpowiednio, w
formacie zwykłego tekstu i JSON. Jednak początkowe spełnienie promesy nie oznacza, że
została odebrana treść odpowiedzi. Dlatego każda z powyższych metod również zwraca
promesę. Poniżej jest przedstawiony prymitywny przykład użycia metod fetch() i
response.json() w celu uzyskania treści odpowiedzi HTTP:
fetch("/api/user/profile").then(response => {
response.json().then(profile => { // Pytanie o treść odpowiedzi
zapisanej w formacie JSON.
displayUserProfile(profile);
});
});
Jest to prymitywny sposób użycia promes, ponieważ są one zagnieżdżane, co przeczy ich idei.
Preferowanym rozwiązaniem jest łączenie promes w łańcuch, jak w poniższym kodzie:
fetch("/api/user/profile")
.then(response => {
return response.json();
})
.then(profile => {
displayUserProfile(profile);
});
Przyjrzyjmy się wywołaniom metod w tym kodzie, nie zwracając uwagi na argumenty:
fetch().then().then()
Kilka metod, wywoływanych w jednym wyrażeniu takim jak powyższe, nosi nazwę łańcucha
metod. Wiadomo, że metoda fetch() zwraca obiekt promesy, a więc pierwszy człon .then()
oznacza wywołanie metody należącej do tego obiektu. Drugi człon .then() oznacza, że
wcześniej wywołana metoda then() również zwraca promesę.
Czasami interfejs API jest z założenia projektowany pod kątem łączenia metod w łańcuch.
Wtedy każda metoda zwraca obiekt, do którego należy. W przypadku promes jest inaczej.
Łańcuch metod then() nie rejestruje kilku funkcji zwrotnych w jednym obiekcie promesy. W
rzeczywistości każda metoda then() zwraca nowy obiekt promesy. Każda nowa promesa
pozostanie niespełniona, dopóki nie zostanie wykonana funkcja umieszczona w argumencie
metody then().
Wróćmy do uproszczonej formy oryginalnego łańcucha. Jeżeli funkcje umieszczone w
argumentach metod then() zostaną zdefiniowane w innym miejscu, kod można zapisać w
następującej postaci:
fetch(theURL) // Zadanie nr 1, zwrócenie promesy nr 1.
Jak wiadomo, metoda fetch() zwraca obiekt promesy. Jeżeli zostanie spełniona, w argumencie
zarejestrowanej funkcji zwrotnej jest umieszczany obiekt Response. Obiekt ten posiada metody
text(), json() i kilka innych umożliwiających odebranie treści odpowiedzi zapisanej w
różnych formatach. Ponieważ jednak treść odpowiedzi jeszcze nie nadeszła, metody te muszą
zwracać obiekt promesy. W opisanym przykładzie zadanie nr 2 polega na wywołaniu metody
json() i zwróceniu uzyskanej wartości. Jest to czwarty obiekt promesy, będący wynikiem
zwracanym przez funkcję zwrotną callback1().
Zmodyfikujmy kod jeszcze raz i nadajmy mu bardziej rozbudowaną, choć mniej idiomatyczną
postać, w której są jawnie wywoływane funkcje zwrotne i zwracane promesy:
function c1(response) { // Funkcja zwrotna nr 1.
let p4 = response.json();
return p4; // Zwrócenie promesy nr 4.
}
function c2(profile) { // Funkcja zwrotna nr 2.
displayUserProfile(profile);
}
Aby łańcuch promes funkcjonował poprawnie, wynik zadania nr 2 musi być wartością
wejściową dla zadania nr 3. Tą wartością jest treść odpowiedzi umieszczona w obiekcie JSON.
Jednak zgodnie z wcześniejszym opisem wartością zwracaną przez funkcję zwrotną c1() nie
jest obiekt JSON, tylko promesa p4. Pozornie jest to sprzeczność, ale w rzeczywistości tak nie
jest. Gdy spełniona jest promesa p1, wywoływana jest funkcja zwrotna c1() i rozpoczyna się
zadanie nr 2. Gdy spełniona jest promesa p2, wywoływana jest funkcja zwrotna c2() i
rozpoczyna się zadanie nr 3. Jednak rozpoczęcie zadania nr 2 z chwilą wywołania funkcji c1()
nie oznacza, że kończy się ono wraz z zakończeniem działania funkcji c1(). Promesy służą
przede wszystkim do zarządzania zadaniami asynchronicznymi i jeżeli zadanie nr 2 jest
asynchroniczne, tak jak w tym przykładzie, to nie kończy się ono w chwili zakończenia działania
funkcji zwrotnej.
Teraz możemy zająć się ostatnim szczegółem, który powinieneś znać, aby móc w pełni korzystać
z promes. Metoda then() wywołana z funkcję zwrotną c() w argumencie zwraca promesę p
i przygotowuje funkcję c() do wywołania w przyszłości. Funkcja ta wykonuje pewne operacje
i zwraca wartość v. W tym momencie promesa p jest zdeterminowana za pomocą wartości v.
Jeżeli promesa jest zdeterminowana wartością inną niż ona sama, jest spełniana za pomocą
uzyskanej wartości. Jeżeli więc funkcja zwrotna c() zwraca wynik inny niż promesa, staje się on
wartością promesy p, promesa jest spełniana i na tym proces się kończy. Jeżeli natomiast
wartość v jest promesą, wówczas promesa p jest zdeterminowana, ale nie jest spełniona. Na
tym etapie promesa p nie może zostać zdeterminowana, dopóki nie będzie zdeterminowana
promesa v. Gdy promesa v zostanie spełniona, wtedy promesa p zostanie spełniona za pomocą
tej samej wartości. Jeżeli promesa v zostanie odrzucona, promesa p też zostanie odrzucona z
tego samego powodu. Takie jest właśnie znaczenie pojęcia „promesa zdeterminowana”: oznacza
powiązanie danej promesy z inną (lub zamknięcie jednej promesy w innej). Nie wiadomo, czy
promesa p zostanie spełniona, czy odrzucona, i funkcja zwrotna c() nie daje nad tym żadnej
kontroli. Promesa p jest zdeterminowana w tym sensie, że jej los zależy od tego, co się stanie z
promesą v.
Wróćmy do przykładu z zapytaniem HTTP. Gdy funkcja zwrotna c1() zwróci promesę p4, wtedy
promesa p2 zostanie zdeterminowana. Nie oznacza to jednak, że zostanie spełniona, ponieważ
nie rozpoczęło się jeszcze zadanie nr 3. Gdy będzie dostępna pełna treść odpowiedzi HTTP,
metoda json() ją przeanalizuje, a zwrócony przez nią wynik zostanie wykorzystany do
spełnienia promesy p4. W tym momencie automatycznie zostanie spełniona promesa p2 za
pomocą tego samego obiektu JSON. Obiekt ten zostanie przekazany funkcji zwrotnej c2() i
rozpocznie się zadanie nr 3.
Opisany proces jest jednym z najtrudniejszych zagadnień języka JavaScript i być może będziesz
musiał kilkakrotnie przeczytać ten punkt, aby je zrozumieć. Rysunek 13.1 przedstawia cały
proces w graficznej formie, która może Ci pomóc w jego zrozumieniu.
Rysunek 13.1. Przetwarzanie zapytania HTTP za pomocą promes
}
// Zbadanie nagłówków i sprawdzenie, czy serwer wysłał odpowiedź w
formacie JSON.
// Jeżeli nie, oznacza to poważny problem, że serwer działa źle.
let type = response.headers.get("content-type");
return response.json();
})
.then(profile => { // Funkcja wywoływana z przeanalizowaną treścią
odpowiedzi lub wartością
// null w argumencie.
if (profile) {
displayUserProfile(profile);
}
else { // W tym miejscu status ma kod 404 lub zwróconą
wartością jest null.
displayLoggedOutProfilePage();
}
})
.catch(e => {
if (e instanceof NetworkError) {
// Metoda fetch() może ulec awarii, jeżeli połączenie sieciowe jest
niedostępne.
displayErrorMessage("Sprawdź połączenie z internetem.");
}
else {
// W tym miejscu pojawił się jakiś inny błąd.
console.error(e);
}
});
Przeanalizujmy działanie tego kodu, gdy coś pójdzie źle. Zastosujmy ten sam schemat
nazewnictwa co poprzednio: p1 niech oznacza promesę zwróconą przez metodę fetch(), p2 —
promesę zwróconą przez pierwszą metodę then(), c1() — funkcję zwrotną umieszczoną w
argumencie tej metody, p3 niech oznacza promesę zwróconą przez drugą metodę then(), c2()
— funkcję zwrotną umieszczoną w argumencie tej drugiej metody, a c3() — funkcję zwrotną
umieszczoną w argumencie metody catch(). Ostatnia metoda również zwraca promesę, ale nie
będziemy się do niej odwoływać za pomocą nazwy.
Pierwszą rzeczą, która może pójść źle, jest wysłanie zapytania za pomocą metody fetch().
Jeżeli połączenie sieciowe nie będzie dostępne lub z innego powodu nie będzie można wysłać
zapytania HTTP, wówczas promesa p1 zostanie odrzucona z obiektem NetworkError. W drugim
argumencie metody then() nie została umieszczona funkcja zwrotna, więc promesa p2 również
zostanie odrzucona z tym samym obiektem. Gdyby w argumencie metody then() była
umieszczona funkcja zwrotna, zostałaby wywołana i po jej pomyślnym wykonaniu promesa
zostałaby spełniona lub odrzucona z wartością zwróconą przez tę funkcję. Jednak w tym
przykładzie takiej funkcji nie ma, więc promesa p2 jest odrzucana, a razem z nią promesa p3 z
tego samego powodu. W tym momencie wywoływana jest funkcja zwrotna c3() i wykonywany
kod obsługujący błąd NetworkError.
Obsługa zapytania może się nie powieść, jeżeli serwer zwróci kod 404 lub inny błąd. Będzie to
jednak poprawna odpowiedź, więc metoda fetch() nie potraktuje jej jako błędu. Umieści za to
w obiekcie Response komunikat „404 Not Found”, spełni promesę p1 i wywoła funkcję c1().
Funkcja ta sprawdzi wartość właściwości ok obiektu, stwierdzi, że nie została odebrana
normalna odpowiedź HTTP, i zwróci wartość null. Ponieważ wartość ta nie jest promesą,
promesa p2 zostanie spełniona i zostanie wywołana funkcja c2() z tą wartością w argumencie.
Kod tej funkcji sprawdzi wprost, czy wartość jest fałszywa, i wyświetli odpowiedni komunikat.
Jest to przypadek, w którym anomalia nie jest traktowana jako błąd i obsługiwana bez użycia
specjalnego kodu.
Poważniejszy błąd może pojawić się w funkcji c1(), gdy zostanie odebrana poprawna
odpowiedź HTTP, ale nagłówek Content-Type nie będzie zawierał oczekiwanej wartości. Kod
żąda, aby odpowiedź była zapisana w formacie JSON, więc jeżeli serwer zwróci odpowiedź w
formacie HTTP, XML lub zwykłym tekstowym, pojawi się problem. Funkcja c1() zawiera kod
sprawdzający zawartość tego nagłówka. Jeżeli jest nieprawidłowa, funkcja uznaje, że błąd jest
nienaprawialny i zgłasza wyjątek TypeError. Jeżeli funkcja zwrotna umieszczona w argumencie
metody then() (lub catch()) zwraca wartość, wówczas promesa zwrócona przez tę metodę jest
odrzucana z wygenerowaną wartością. W tym przykładzie funkcja c1() zgłasza wyjątek
TypeError, przez co promesa p2 jest odrzucana z tym obiektem. Ponieważ procedura obsługi
błędów w promesie p2 nie została określona, odrzucana jest również promesa p3. Funkcja c2()
nie jest wywoływana, a obiekt TypeError jest w efekcie umieszczany w argumencie funkcji
c3(), której kod sprawdza rodzaj błędu i odpowiednio go obsługuje.
W opisywanym kodzie należy zwrócić uwagę na kilka szczegółów. Wyjątek jest zgłaszany za
pomocą zwykłej, synchronicznej instrukcji throw i jest asynchronicznie obsługiwany za pomocą
metody catch() w łańcuchu promes. To wyjaśnia, dlaczego lepiej jest stosować powyższą
metodę zamiast then() z dwoma argumentami oraz dlaczego idiomatycznym rozwiązaniem jest
umieszczanie na końcu łańcucha metody catch().
startAsyncOperation()
.then(doStageTwo)
.catch(recoverFromStageTwoError)
.then(doStageThree)
.then(doStageFour)
.catch(logStageThreeAndFourErrors);
Jak pamiętasz z rozdziału 8., w funkcji strzałkowej można stosować wiele uproszczeń.
Ponieważ tutaj funkcja ma tylko jeden argument, można pominąć zwykłe nawiasy.
Dodatkowo ciało funkcji składa się z jednego wyrażenia, więc można pominąć nawiasy
klamrowe. Zwracanym wynikiem jest wartość wyrażenia. Uproszczenia te sprawiają, że
powyższy kod jest poprawny. Przyjrzyjmy się jednak następującej, niewinnie wyglądającej
zmianie:
Napiszmy inną funkcję zwracającą promesę. Tym razem punktem wyjścia niech będzie funkcja
getJSON():
function getHighScore() {
return getJSON("/api/user/profile").then(profile => profile.highScore);
}
W tym przykładzie zostało przyjęte założenie, że odpowiedź na zapytanie wysłane na adres URL
/api/user/profile zawiera wartość właściwości highScore zapisaną w formacie JSON.
});
}
Zwróć uwagę, że dwie funkcje, od których zależy los promesy utworzonej za pomocą
konstruktora Promise(), mają nazwy resolve() (zdeterminuj) i reject() (odrzuć), a nie
fulfill() (spełnij) i reject() (odrzuć). Promesa umieszczona w argumencie funkcji
resolve() będzie użyta do zdeterminowania zwróconej promesy. Zazwyczaj jednak w
argumencie umieszcza się wartość inną niż promesę, która powoduje spełnienie lub odrzucenie
zwracanej promesy.
Listing 13.1 przedstawia inny przykład użycia konstruktora Promise() w celu przystosowania
funkcji getJSON() do środowiska Node, w którym nie ma wbudowanej metody fetch(). Jak
pamiętasz, ten rozdział zaczął się od opisu asynchronicznych funkcji zwrotnych i zdarzeń. W
tym przykładzie zastosowane są obie funkcjonalności, więc stanowi on dobrą ilustrację
implementacji opartego na promesach interfejsu API z użyciem innych technik programowania
asynchronicznego.
Listing 13.1. Asynchroniczna wersja funkcji getJSON()
const http = require("http");
function getJSON(url) {
// Utworzenie i zwrócenie nowej promesy.
response.on("end", () => {
// Po odebraniu całej odpowiedzi analizujemy ją.
try {
let parsed = JSON.parse(body);
// Po pomyślnym przeanalizowaniu odpowiedzi spełniamy promesę.
resolve(parsed);
} catch(e) {
// W przeciwnym razie odrzucamy ją.
reject(e);
}
});
}
});
// Odrzucamy promesę również wtedy, gdy nie uda się wysłać zapytania,
});
}
.then(body => {
// Zapisujemy odpowiedź w tablicy i świadomie pomijamy instrukcję
return
// (funkcja zwróci wartość undefined).
bodies.push(body);
});
}
// Uruchomienie promesy, która zostanie natychmiast spełniona z wartością
undefined.
let p = Promise.resolve(undefined);
}
// Po spełnieniu ostatniej promesy w łańcuchu tablica odpowiedzi będzie
gotowa.
// Można więc będzie zwrócić promesę z tą tablicą. Zwróć uwagę, że nie
obsługujemy błędów,
if (inputs.length === 0) {
// Jeżeli nie ma więcej wartości wejściowych, zwracana jest tablica
wyników i spełniana ostatnia promesa
// wraz ze wszystkimi wcześniejszymi zdeterminowanymi, ale jeszcze
niespełnionymi promesami.
return outputs;
} else {
// Jeżeli zostały jeszcze wartości do przetworzenia, zwracany jest
obiekt promesy
// i determinowana bieżąca promesa za pomocą wartości uzyskanej z
poprzedniej promesy.
}
}
// Utworzenie pierwszej promesy, którą spełnia pusta tablica.
// Jej funkcją zwrotną jest funkcja zdefiniowana wyżej.
return Promise.resolve([]).then(handleNextInput);
}
Funkcja promiseSequence() jest z założenia generyczna. Można jej użyć do wysłania zapytań
HTTP w następujący sposób:
// Funkcja zwracająca promesę, którą spełnia treść o zadanym adresie URL.
Załóżmy również, że za pomocą powyższej funkcji wysyłamy dwa zapytania o dane JSON:
let value1 = await getJSON(url1);
let value2 = await getJSON(url2);
Problem w powyższym kodzie polega na tym, że jest on niepotrzebnie sekwencyjny. Zapytanie
na drugi adres URL nie zostanie wysłane, dopóki nie zostanie wykonane pierwsze zapytanie.
Jeżeli drugi adres nie jest uzależniony od treści odczytanej z pierwszego adresu, warto
spróbować wysłać oba zapytania jednocześnie. Jest to przypadek, w którym ujawnia się
promesowa natura funkcji asynchronicznych. Aby zaczekać na wykonanie wszystkich funkcji,
należy użyć metody Promise.all(), tak samo jak w przypadku bezpośredniego użycia promes:
let [value1, value2] = await Promise.all([getJSON(url1), getJSON(url2)]);
catch(e) {
reject(e);
}
});
}
Jednak wyrazić słowo await za pomocą tak przekształconej składni jest nieco trudniej. Można
za to traktować je jako znacznik dzielący ciało funkcji na osobne, synchroniczne bloki.
Interpreter dzieli funkcję na dwie podfunkcje i każdą z nich umieszcza w argumencie metody
then() promesy oznaczonej słowem await.
W powyższym przykładowym kodzie wykorzystana jest pętla for/of i zwykły iterator. Ponieważ
iterator ten zwraca promesę, można użyć pętli for/await i uzyskać w ten sposób nieco prostszy
kod:
for await (const response of promises) {
handle(response);
}
W tym przypadku pętla for/await wywołuje promesę za pomocą słowa await, dzięki czemu
kod jest nieco bardziej zwięzły. Niemniej jednak oba powyższe kody realizują dokładnie te same
operacje. Co ważne, oba można stosować wyłącznie wewnątrz funkcji zadeklarowanej za
pomocą słowa async. Pętla for/await pod tym względem niczym się nie różni od zwykłego
wyrażenia await.
}
}
// Funkcja testowa, wykorzystująca generator asynchroniczny i pętlę
for/await.
async function test() { // Funkcja asynchroniczna, więc można użyć
wewnątrz niej pętli for/await.
for await (let tick of clock(300, 100)) { // Pętla powtarzana 100 razy co
300 ms.
console.log(tick);
}
if (this.values.length > 0) {
// Jeżeli w kolejce znajduje się wartość, zwracamy zdeterminowaną
promesę.
const value = this.values.shift();
return Promise.resolve(value);
}
else if (this.closed) {
// Jeżeli kolejka nie zawiera wartości i jest zamknięta, zwracamy
// promesę zdeterminowaną znacznikiem końca strumienia.
return Promise.resolve(AsyncQueue.EOS);
}
else {
// W przeciwnym razie zwracamy niezdeterminowaną promesę.
// Funkcję determinującą umieszczamy w kolejce do późniejszego
wykorzystania.
return new Promise((resolve) => { this.resolvers.push(resolve); });
}
}
close() {
// Po zamknięciu kolejki nie można w niej umieszczać elementów.
}
// Wartość kontrolna zwracana przez metodę dequeue(), oznaczająca koniec
strumienia, gdy kolejka
// zostanie zamknięta.
AsyncQueue.EOS = Symbol("end-of-stream");
Ponieważ klasa AsyncQueue stanowi podstawę asynchronicznych iteracji, można za jej pomocą
definiować własne, ciekawsze iteratory asynchroniczne, po prostu umieszczając elementy w
kolejce. W poniższym przykładzie klasa AsyncQueue jest wykorzystana do utworzenia
strumienia zdarzeń przeglądarki, który może być obsługiwany za pomocą pętli for/await.
// Funkcja umieszczająca w obiekcie AsyncQueue serię zdarzeń określonego typu
console.log(event.key);
}
}
13.5. Podsumowanie
W tym rozdziale zostały opisane następujące tematy:
[2] Słowo await w kodzie najwyższego poziomu zazwyczaj stosuje się w konsoli przeglądarki.
Została zgłoszona propozycja, aby w przyszłej wersji języka JavaScript można było tego słowa
używać również na najwyższym poziomie.
[3] O takim sposobie implementowania asynchronicznego iteratora dowiedziałem się z blogu
Axela Rauschmayera (https://2ality.com).
Rozdział 14.
Metaprogramowanie
W tym rozdziale opisanych jest kilka zaawansowanych, lecz rzadziej stosowanych w
codziennym programowaniu, funkcjonalności języka JavaScript, które mogą być cenne dla
piszących uniwersalne biblioteki i chcących eksperymentować ze szczegółowymi ustawieniami
wpływającymi na działanie obiektów.
Wiele opisanych tutaj technik można ogólnie określić mianem „metaprogramowania”. Jeżeli
przyjmiemy, że „zwykłe” programowanie oznacza pisanie kodu przetwarzającego dane, to
metaprogramowanie polega na pisaniu kodu przetwarzającego inny kod. W językach
dynamicznych, jakim jest m.in. JavaScript, granica między programowaniem a
metaprogramowaniem jest zatarta — nawet prostemu iterowaniu właściwości obiektu za
pomocą pętli for/in programiści używający bardziej statycznych języków mogą przypisać
przedrostek „meta”.
Ten rozdział obejmuje następujące aspekty metaprogramowania:
Jak pamiętasz z punktu 6.10.6, właściwość danych posiada wartość, natomiast właściwość
dostępowa ma getter i setter. W tym podrozdziale gettery i settery będziemy traktować jako
atrybuty właściwości. Zgodnie z tą logiką można nawet powiedzieć, że wartość właściwości jest
jej atrybutem. Zatem właściwość ma nazwę i cztery atrybuty. Cztery atrybuty właściwości
danych to value, writable, enumerable i configurable. Z kolei właściwość dostępowa nie ma
atrybutów value i writable — jej zapisywalność zależy od tego, czy zdefiniowany jest jej setter.
Zatem cztery atrybuty właściwości dostępowej to get, set, enumerable i configurable.
const random = {
Object.defineProperty(o, "x", {
value: 1,
writable: true,
enumerable: false,
configurable: true
});
Object.keys(o) // => []
o.x // => 1
o.x // => 0
Aby za jednym razem utworzyć lub zmienić kilka właściwości, należy użyć metody
Object.defineProperties(), której pierwszym argumentem jest modyfikowany obiekt, a
drugim obiekt wiążący nazwy tworzonych lub zmienianych właściwości z ich deskryptorami.
Poniżej jest przedstawiony przykład:
let p = Object.defineProperties({}, {
x: { value: 1, writable: true, enumerable: true, configurable: true },
r: {
configurable: true
});
p.r // => Math.SQRT2
Powyższy kod tworzy najpierw pusty obiekt, następnie dodaje do niego dwie właściwości
danych i jedną właściwość dostępową przeznaczoną tylko do odczytu. Wykorzystuje przy tym
fakt, że metoda Object.defineProperties() zwraca zmodyfikowany obiekt (podobnie jak
metoda Object.defineProperty()).
W podrozdziale 6.2 została opisana metoda Object.create(), której pierwszym argumentem
jest prototyp tworzonego obiektu. Metoda ta ma jeszcze drugi, opcjonalny argument, taki sam
jak drugi argument metody Object.defineProperties(). Umieszcza się w nim zbiór
deskryptorów wykorzystywanych do tworzenia właściwości nowego obiektu.
Jeżeli obiekt jest nierozszerzalny, można modyfikować jego własne właściwości, ale nie
można dodawać nowych.
Jeżeli właściwość jest niekonfigurowalna, nie można zmieniać jej atrybutów configurable
i enumerable.
Jeżeli właściwość dostępowa jest niekonfigurowalna, nie można zmieniać jej gettera ani
settera, jak również nie można jej przekształcać we właściwość danych.
Jeżeli właściwość danych jest niekonfigurowalna, nie można jej przekształcać we
właściwość dostępową.
Jeżeli właściwość danych jest niekonfigurowalna, nie można zmieniać jej atrybutu
writable z false na true, natomiast można z true na false.
Jeżeli właściwość danych jest niekonfigurowalna i niezapisywalna, nie można zmieniać jej
wartości. Można natomiast zmieniać wartość właściwości konfigurowalnej i
niezapisywalnej (ponieważ jest to równoważne przekształceniu jej we właściwość
zapisywalną, zmodyfikowaniu jej wartości, a następnie przekształceniu z powrotem we
właściwość niezapisywalną).
/*
* Definicja funkcji Object.assignDescriptors() działającej podobnie jak
Object.assign() z tą różnicą,
* wywoływać.
*/
Object.defineProperty(Object, "assignDescriptors", {
writable: true,
enumerable: false,
configurable: true,
return target;
}
});
Aby sprawdzić, czy obiekt jest rozszerzalny, należy umieścić go w argumencie funkcji
Object.isExtensible(). Natomiast aby przekształcić go w nierozszerzalny, należy użyć funkcji
Object.preventExtensions(). Po jej wywołaniu w trybie ścisłym próba dodania do obiektu
nowej właściwości spowoduje zgłoszenie wyjątku TypeError. W zwykłym trybie operacja ta po
prostu się nie powiedzie bez żadnych oznak. Ponadto powyższy wyjątek zostanie zgłoszony przy
próbie zmiany prototypu obiektu (patrz podrozdział 14.3).
let o = {
x: 1,
y: 2,
__proto__: p
};
14.4.2. Symbol.hasInstance
W punkcie 4.9.4 poświęconym operatorowi instanceof napisałem, że operand znajdujący się
po jego prawej stronie musi być konstruktorem, a wyliczanie wartości wyrażenia o instanceof
f polega na wyszukiwaniu wartości właściwości f.prototype w łańcuchu prototypów obiektu o.
Począwszy od wersji języka ES6 alternatywą dla powyższego wyrażenia jest symbol
Symbol.hasInstance. Jeżeli po prawej stronie operatora instanceof znajduje się obiekt
zawierający metodę [Symbol.hasInstance](), w jej argumencie jest umieszczana wartość
znajdująca się po lewej stronie operatora, a zwrócony przez metodę wynik, po przekształceniu
w wartość logiczną, staje się wartością wyrażenia zawierającego operator instanceof.
Oczywiście, jeżeli obiekt po prawej stronie jest funkcją, która nie posiada metody
[Symbol.hasInstance](), wówczas operator instanceof działa w zwykły sposób.
Obecność symbolu Symbol.hasInstance oznacza, że za pomocą operatora instanceof można
sprawdzać, czy obiekt jest instancją odpowiednio zdefiniowanego pseudotypu, na przykład:
// Definicja obiektu jako "typu", który można stosować z operatorem
instanceof.
let uint8 = {
[Symbol.hasInstance](x) {
};
128 instanceof uint8 // => true
14.4.3. Symbol.toStringTag
Wywołując metodę toString() należącą do zwykłego obiektu, uzyskuje się ciąg znaków "
[object Object]":
{}.toString() // => "[object Object]"
return Object.prototype.toString.call(o).slice(8,-1);
}
14.4.4. Symbol.species
W wersjach języka starszych niż ES6 nie było prostego sposobu tworzenia klas pochodnych od
wbudowanych klas, na przykład Array. Począwszy od wersji ES6 każdą wbudowaną klasę
można łatwo rozszerzać za pomocą słów kluczowych class i extends. W punkcie 9.5.2 został
opisany przykład utworzenia prostej klasy pochodnej od Array:
Klasa Array definiuje metody concat(), filter(), map(), slice() i splice(), z których każda
zwraca tablicę. Pojawia się pytanie, czy powyższe, odziedziczone przez klasę EZArray metody,
powinny zwracać instancje klasy Array czy EZArray. Przekonujące argumenty można znaleźć
dla obu przypadków, jednak specyfikacja języka ES6 stanowi, że domyślnie pięć powyższych
metod ma zwracać instancje podklasy.
Mechanizm działania jest następujący:
Czasami powyższe domyślne działanie nie jest pożądane. Jeżeli metody klasy EZArray mają
zwracać zwykłe obiekty typu Array, należy właściwości EZArray[Symbol.species] przypisać
wartość Array. Ponieważ jednak odziedziczoną właściwość można jedynie odczytywać, nie
można jej nadać wartości za pomocą zwykłego operatora przypisania. Można jednak użyć
funkcji defineProperty():
EZArray[Symbol.species] = Array; // Próba przypisania wartości właściwości
przeznaczonej
}
let e = new EZArray(1,2,3);
let arraylike = {
length: 1,
0: 1,
[Symbol.isConcatSpreadable]: true
};
Klasy pochodne od Array są domyślnie rozciągalne. Jeżeli definiowana podklasa nie może
po umieszczeniu w argumencie metody concat() zachowywać się jak tablica, można w
niej zdefiniować następujący getter[1]:
}
}
let pattern = new Glob("docs/*.txt");
"docs/js.txt".search(pattern) // => 0: dopasowanie zaczynające się od znaku
na pozycji 0.
"docs/js.htm".search(pattern) // => –1: brak dopasowania.
let match = "docs/js.txt".match(pattern);
14.4.7. Symbol.toPrimitive
W punkcie 3.9.3 opisałem trzy nieznacznie różniące się od siebie algorytmy konwersji obiektów
na właściwości prymitywne. Podsumowując: jeżeli oczekiwanym lub preferowanym wynikiem
konwersji jest ciąg znaków, najpierw jest podejmowana próba wywoływania metody toString()
obiektu. Jeżeli metoda ta nie jest zdefiniowana lub nie zwraca wartości prymitywnej,
wywoływana jest metoda valueOf(). Jeżeli preferowanym wynikiem konwersji jest liczba,
najpierw jest podejmowana próba wywoływania metody valueOf(). Jeżeli nie jest ona
zdefiniowana lub nie zwraca wartości prymitywnej, wywoływana jest metoda toString(). I
wreszcie, jeżeli nie ma preferencji co do wyniku, decyzję o rodzaju konwersji podejmuje klasa.
W przypadku obiektu typu Date najpierw jest wywoływana metoda toString(), a w obiektach
innych typów metoda valueOf().
Wprowadzony w wersji języka ES6 symbol Symbol.toPrimitive pozwala zmienić ten domyślny
proces konwersji obiektu na wartość prymitywną i uzyskać nad nim pełną kontrolę. Aby to było
możliwe, należy zdefiniować metodę o powyższej symbolicznej nazwie. Metoda ta musi zwracać
wartość prymitywną reprezentującą dany obiekt. Musi mieć jeden argument, w którym będzie
umieszczana wartość opisująca rodzaj oczekiwanej konwersji:
Zazwyczaj nie trzeba uwzględniać tego argumentu i w wyniku można zwracać wartość
prymitywną zawsze tego samego typu. Gdyby jednak instancje klasy miały być porównywane
lub sortowane za pomocą operatorów < i >, warto zdefiniować metodę [Symbol.toPrimitive]
().
14.4.8. Symbol.unscopables
Ostatni opisany w tym rozdziale symbol jest mniej znany. Został wprowadzony w celu
rozwiązania problemów z kompatybilnością kodu wynikających ze stosowania niezalecanej
instrukcji with. Jak wiesz, instrukcja ta jest stosowana z obiektem i w zakresie swego działania
pozwala odwoływać się do właściwości obiektu tak jak do zmiennych. Z tego powodu zaistniały
problemy z kompatybilnością kodu wykorzystującego nowe metody klasy Array. Dlatego
pojawił się symbol Symbol.unscopables. Począwszy od wersji języka ES6 instrukcja with
funkcjonuje nieco inaczej. Użyta z obiektem o wywołuje funkcję
Object.keys(o[Symbol.unscopables]||{}) i w zasięgu swojego działania pomija właściwości
o nazwach umieszczonych w wynikowej tablicy. Dzięki temu w wersji języka ES6 zostały do
prototypu Array.prototype dodane nowe metody, które nie zakłócały działania istniejących
kodów. Oznacza to, że listę tych metod można uzyskać za pomocą następującego kodu:
let newArrayMethods = Object.keys(Array.prototype[Symbol.unscopables]);
.replace("'", "'"));
// Zwrócenie połączonych ciągów znaków i znaczników.
let result = strings[0];
for(let i = 0; i < escaped.length; i++) {
let s = strings[0];
for(let i = 0; i < values.length; i++) {
s += values[i] + strings[i+1];
}
// Zwrócenie reprezentacji ciągu.
Funkcja wywołującą funkcję f() jako metodę obiektu o (lub jako funkcję obiektu this,
jeżeli argument o ma wartość null). W argumentach metody umieszcza wartości zawarte
w tablicy args. Wywołanie tej funkcji jest równoważne użyciu wyrażenia f.apply(o,
args).
Reflect.construct(c, args, newTarget)
Funkcja wywołująca konstruktor c(), podobnie jak to robi słowo kluczowe new. W
argumencie konstruktora umieszczane są elementy tablicy args. Opcjonalny argument
newTarget jest przypisywany właściwości new.target konstruktora. Jeżeli argument ten
nie zostanie określony, właściwości new.target jest przypisywany konstruktor c.
Reflect.defineProperty(o, name, descriptor)
Funkcja definiująca właściwość obiektu o, której nazwę określa argument name (może to
być ciąg znaków lub symbol). Obiekt umieszczony w argumencie descriptor musi być typu
Descriptor i definiować wartość oraz atrybuty właściwości (lub metody get() i set()).
Funkcja Reflect.defineProperty() jest bardzo podobna do Object.defineProperty().
Różni się od niej tylko tym, że pomyślnie wykonana zwraca wynik true, a w przypadku
niepowodzenia wynik false. (Funkcja Object.defineProperty(), odpowiednio, zwraca
obiekt o lub zgłasza wyjątek TypeError).
Reflect.deleteProperty(o, name)
W ten sam sposób obiekt Proxy wykonuje wszystkie podstawowe operacje. Jeżeli obiekt
obsługujący posiada żądaną metodę, jest ona wywoływana. (Nazwy i sygnatury metod są takie
same jak nazwy i sygnatury funkcji obiektu Reflect opisanych w podrozdziale 14.6). W
przeciwnym razie obiekt Proxy wykonuje operację na obiekcie docelowym. Oznacza to, że
obiekt Proxy może działać tak jak obiekt obsługujący lub docelowy. Jeżeli obiekt obsługujący
jest pusty, obiekt Proxy pełni rolę opakowania dla obiektu docelowego:
let t = { x: 1, y: 2 };
let p = new Proxy(t, {});
p.x // => 1
delete p.y // => true: usunięcie właściwości y obiektu Proxy.
t.y // => undefined: właściwość została usunięta również z obiektu
docelowego.
writable: false,
configurable: false
};
},
});
identity.x // => "x"
identity.toString // => "toString"
identity[0] // => "0"
function readOnlyProxy(o) {
function readonly() { throw new TypeError("Tylko odczyt"); }
return new Proxy(o, {
set: readonly,
defineProperty: readonly,
deleteProperty: readonly,
setPrototypeOf: readonly,
});
}
*/
function loggingProxy(o, objname) {
// Definicje metod obiektu Proxy rejestrującego operacje.
// Każda metoda wyświetla komunikat i kieruje operację do obiektu
docelowego.
const handlers = {
// Ta metoda jest specjalna, ponieważ w przypadku właściwości, której
wartością jest obiekt lub funkcja,
// zwraca obiekt Proxy, a nie wartość tej właściwości.
get(target, property, receiver) {
// Zarejestrowanie operacji.
console.log(`Metoda get(${objname},${property.toString()})`);
// Odczytanie wartości właściwości za pomocą interfejsu API obiektu
Reflect.
let value = Reflect.get(target, property, receiver);
},
// Poniższe trzy metody niczym się nie wyróżniają. Rejestrują operacje i
kierują je do obiektu docelowego.
// Zostały zdefiniowane po to, aby uniknąć nieskończonej rekurencji.
set(target, prop, value, receiver) {
console.log(`Metoda set(${objname},${prop.toString()},${value})`);
return Reflect.set(target, prop, value, receiver);
},
apply(target, receiver, args) {
console.log(`Metoda ${objname}(${args})`);
return Reflect.apply(target, receiver, args);
},
construct(target, args, receiver) {
console.log(`Metoda ${objname}(${args})`);
return Reflect.construct(target, args, receiver);
}
};
// Pozostałe metody są generowane automatycznie.
// Metaprogramowanie rządzi!
Reflect.ownKeys(Reflect).forEach(handlerName => {
if (!(handlerName in handlers)) {
handlers[handlerName] = function(target, ...args) {
// Zarejestrowanie operacji.
console.log(`Metoda ${handlerName}(${objname},${args})`);
// Przekierowanie operacji.
// Metoda get(dane,constructor)
// Metoda has(dane,0)
// Metoda get(dane,0)
// Metoda has(dane,1)
// Metoda get(dane,1)
// Teraz wypróbujmy ją z metodami obiektu Proxy.
// Metoda get(dane,length)
// Metoda get(dane,0)
// Wartość 10
// Metoda get(dane,length)
// Metoda get(dane,1)
// Wartość 20
// Metoda get(dane,length)
Z pierwszego bloku wyników można się dowiedzieć, że metoda Array.map() jawnie sprawdza
istnienie każdego obiektu tablicy (wywołuje metodę has()), zanim odczyta jego wartość (tj.
wywoła metodę get()). Takie działanie jest prawdopodobnie podyktowane tym, że metoda musi
sprawdzić, czy element istnieje i czy jest zdefiniowany.
14.8. Podsumowanie
W tym rozdziale zostały opisane następujące tematy:
[1] Błąd w interpreterze wersji V8 języka JavaScript w środowisku Node 13 powoduje, że kod
ten nie działa poprawnie.
Rozdział 15.
JavaScript w przeglądarkach
Język JavaScript został opracowany w 1994 r. na potrzeby tworzenia dynamicznych
dokumentów wyświetlanych w przeglądarkach internetowych. Od tamtej pory znacznie się
rozwinął, a jednocześnie gwałtownie wzrosły możliwości i spektrum zastosowań platform
internetowych. Dzisiaj programiści używający języka JavaScript traktują internet jako w pełni
funkcjonalną platformę do tworzenia aplikacji. Przeglądarki wyspecjalizowały się w
prezentowaniu sformatowanego tekstu i obrazów, a dodatkowo, podobnie jak systemy
operacyjne, oferują różne usługi, m.in. grafikę, wideo, audio, transmisję sieciową,
magazynowanie i przetwarzanie danych. JavaScript jest językiem, dzięki któremu aplikacje
internetowe mogą korzystać z usług oferowanych przez platformy WWW. Ten rozdział
demonstruje, jak korzystać z najważniejszych usług.
Rozdział rozpoczyna się od opisu modelu programowania platform internetowych. Wyjaśniono
w nim, jak osadza się skrypty w dokumentach HTML (podrozdział 15.1) i asynchronicznie, za
pomocą zdarzeń uruchamia się kod JavaScript (podrozdział 15.2). W kolejnych podrozdziałach
przedstawione są najważniejsze interfejsy API, dzięki którym aplikacja internetowa może:
<title>Cyfrowy zegar</title>
<style> /* Arkusz stylów CSS dla zegara. */
</style>
</head>
<script>
// Definicja funkcji wyświetlającej bieżący czas.
function displayTime() {
</script>
</body>
</html>
Znacznik <script> pozwala bezpośrednio umieszczać wewnątrz niego kod JavaScript, ale
częściej stosuje się go z atrybutem src zawierającym adres URL pliku z kodem. Adres ten może
być bezwzględny lub względny wobec bieżącej lokalizacji dokumentu HTML. Jeżeli kod
JavaScript użyty w powyższym przykładzie zostanie zapisany w pliku scripts/digital_clock.js,
wówczas znacznik <script> będzie zawierał następujące odwołanie:
<script src="scripts/digital_clock.js"></script>
Plik z kodem JavaScript nie może zawierać żadnych znaczników HTML. Ponadto, zgodnie
z przyjętą konwencją, ma on rozszerzenie .js.
Znacznik <script> z atrybutem src jest interpretowany dokładnie tak samo jak zawartość pliku
JavaScript umieszczona pomiędzy znacznikami <script> i </script>. Zwróć uwagę, że nawet
w przypadku zastosowania atrybutu src wymagane jest użycie zamykającego znacznika
</script>, ponieważ język HTML nie obsługuje znacznika <script/>.
Plik HTML jest prostszy, ponieważ nie musi zawierać dużych bloków kodu JavaScript.
Dzięki temu oddziela się też zawartość dokumentu od kodu.
Jeżeli z tego samego kodu JavaScript korzystają różne strony WWW, to ewentualne zmiany
w kodzie wystarczy wprowadzić tylko w jednym pliku, a nie kilku osobnych plikach HTML.
Jeżeli ten sam kod JavaScript jest współdzielony przez kilka stron WWW, wystarczy go
załadować raz przy otwarciu pierwszej z nich. Kolejne strony będą korzystać z kodu
umieszczonego w pamięci podręcznej przeglądarki.
Ponieważ wartością atrybutu src może być dowolny adres URL, program JavaScript lub
strona umieszczona na serwerze WWW może wykorzystywać kod udostępniany przez inne
serwery. Ta możliwość jest wykorzystywana w reklamach w internecie.
Moduły
W podrozdziale 10.3 zostały opisane moduły języka JavaScript wraz z instrukcjami import
i export. Program JavaScript wykorzystujący moduły (tj. taki, który nie został przekształcony w
jeden monolityczny plik JavaScript za pomocą specjalnych narzędzi) musi ładować główny
moduł za pomocą znacznika <script> z atrybutem type="module". W ten sposób ładuje się
główny moduł i kaskadowo wszystkie importowane przez niego inne moduły. Szczegółowe
informacje na ten temat zawiera punkt 10.3.5.
Typ skryptu
We wczesnych latach internetu panowało przekonanie, że przeglądarki w przyszłości będą
obsługiwały nie tylko język JavaScript. Dlatego znacznik <script> może mieć atrybut
language="javascript" lub type="application/javascript". Obecnie żaden z nich nie jest
potrzebny. Atrybut language jest przestarzały, a type jest stosowany tylko do dwóch celów:
Na szczęście domyślnie synchroniczne, blokujące uruchamianie kodu nie jest jedyną dostępną
opcją. Znacznik <script> oferuje atrybuty defer i async, dzięki którym skrypty można
uruchamiać w inny sposób. Są to atrybuty logiczne, które nie mają wartości. Umieszcza się je
po prostu w znaczniku <script>. Należy pamiętać, że funkcjonują one jedynie w połączeniu z
atrybutem src. Ilustruje to poniższy kod:
Oba atrybuty zawierają dla przeglądarki informację, że wskazane skrypty nie wykorzystują
metody document.write() do generowania treści HTML. Dzięki temu przeglądarka może
analizować i wyświetlać dokument w trakcie ładowania skryptu. Atrybut defer powoduje
odłożenie uruchomienia skryptu do chwili pełnego przeanalizowania i załadowania dokumentu,
gdy już będzie gotowy do modyfikacji. Natomiast atrybut async sprawia, że przeglądarka
uruchamia skrypt najszybciej, jak jest to możliwe, bez wstrzymywania analizy dokumentu
podczas ładowania skryptu. Jeżeli znacznik <script> zawiera oba atrybuty, ważniejszy jest
async.
Prostą alternatywą dla użycia atrybutów async i defer jest umieszczenie kodu na końcu pliku,
szczególnie gdy jest on osadzony bezpośrednio w dokumencie HTML. Uzyskuje się w ten
sposób pewność, że w chwili uruchomienia skryptu dokument jest w całości przeanalizowany i
gotowy do modyfikacji.
Jeżeli kod nie wykorzystuje modułów, można zawierający go plik ładować na żądanie,
umieszczając po prostu w żądanym miejscu dokumentu znacznik <script>:
function importScript(url) {
});
Powyższa funkcja tworzy za pomocą interfejsu API modelu DOM (patrz podrozdział 15.3)
element <script> i umieszcza go w znaczniku <head>. Do sprawdzenia, czy skrypt został
załadowany pomyślnie, funkcja wykorzystuje funkcje obsługi zdarzeń (patrz podrozdział 15.2).
<html>
<head>
<title>Przykładowy dokument</title>
</head>
<body>
<h1>Dokument HTML</h1>
</body>
</html>
Główny znacznik <html> zawiera w sobie znaczniki <head> i <body>. Znacznik <head> zawiera
w sobie znacznik <title>. Znacznik <body> zawiera w sobie znaczniki <h1> i <p>. Każdy ze
znaczników <title> i <h1> zawiera ciąg znaków, a <p> zawiera trzy ciągi rozdzielone
znacznikami <i> oraz </i>.
Interfejs API modelu DOM odzwierciedla drzewiastą strukturę dokumentu HTML. Każdemu
znacznikowi w dokumencie odpowiada obiekt Element, a każdemu tekstowi obiekt Text. Klasy
Element i Text, jak również klasa Document pochodzą od bardziej ogólnej klasy Node. Obiekty
Node tworzą drzewiastą strukturę, którą za pomocą interfejsu DOM API można przeglądać i
modyfikować. Przedstawia ją rysunek 15.1.
Rysunek 15.1. Drzewiasta struktura dokumentu HTML
Interfejs DOM API zawiera metody umożliwiające tworzenie nowych węzłów typu Element i
Text oraz umieszczanie ich w dokumencie jako dzieci innych obiektów typu Element. Nie
istnieją metody do przenoszenia elementów wewnątrz dokumentu ani do ich usuwania.
Aplikacja serwerowa może generować dane w postaci zwykłego tekstu za pomocą funkcji
console.log (), natomiast kliencka aplikacja JavaScript może tworzyć sformatowane dane
wyjściowe HTML, budując lub modyfikując drzewo dokumentu za pomocą interfejsu API.
Język JavaScript zawiera klasy odpowiadające wszystkim rodzajom znaczników, a każde
wystąpienie danego znacznika w dokumencie jest reprezentowane za pomocą instancji
odpowiedniej klasy. Na przykład znacznik <body> jest reprezentowany przez instancję klasy
HTMLBodyElement, a znacznik <table> przez instancję klasy HTMLTableElement. Obiekty
reprezentujące znaczniki posiadają właściwości odpowiadające atrybutom tych znaczników. Na
przykład instancja klasy HTMLImageElement, reprezentująca znacznik <img>, posiada
właściwość src odpowiadającą atrybutowi src powyższego znacznika. Początkową wartością tej
właściwości jest wartość atrybutu użytego w znaczniku. Przypisanie wartości tej właściwości w
kodzie JavaScript powoduje zmianę atrybutu znacznika (i w efekcie załadowanie i wyświetlenie
nowego obrazu). Większość klas JavaScript reprezentujących znaczniki posiada właściwości
odzwierciedlające atrybuty, ale niektóre z nich definiują własne metody. Na przykład klasy
HTMLAudioElement i HTMLVideoElement posiadają metody play() i pause() umożliwiające
odtwarzanie plików audio i wideo.
15.1.3. Obiekt globalny w przeglądarce
Dla każdego okna i zakładki przeglądarki jest tworzony osobny obiekt globalny (patrz
podrozdział 3.7). Wszystkie kody uruchomione w danym oknie (z wyjątkiem wątków roboczych
— patrz podrozdział 15.13) współdzielą ten sam obiekt globalny. Zasada ta obowiązuje
niezależnie od liczby skryptów i modułów użytych w dokumencie. Wszystkie skrypty i moduły w
danym dokumencie współdzielą ten sam obiekt globalny. Jeżeli w jednym ze skryptów zostanie
zdefiniowana właściwość tego obiektu, będzie ona dostępna w pozostałych skryptach.
W obiekcie globalnym jest zdefiniowana standardowa biblioteka JavaScript, zawierająca m.in.
funkcję parseInt(), obiekt Math, klasę Set i inne definicje. W przeglądarce obiekt ten jest
głównym punktem wejścia dla różnych interfejsów API. Na przykład właściwość document tego
obiektu reprezentuje aktualnie wyświetlany dokument, funkcja fetch() wysyła zapytania HTTP,
a konstruktor Audio() umożliwia odtwarzanie dźwięków.
W przeglądarce obiekt globalny pełni dwie role. Oprócz tego, że definiuje wbudowane typy i
funkcje, reprezentuje również bieżące okno przeglądarki i definiuje różne właściwości, na
przykład history zawierającą historię przeglądanych stron (patrz punkt 15.10.2) czy
innerWidth zawierającą szerokość okna przeglądarki wyrażoną w pikselach. Jest również
właściwość window, której wartością jest sam obiekt globalny. Oznacza to, że w celu odwołania
się w kodzie klienckim do obiektu globalnego wystarczy wpisać słowo window. Dobrą praktyką
korzystania z funkcjonalności związanych z oknem przeglądarki jest stosowanie tego słowa jako
prefiksu. Na przykład bardziej czytelny jest zapis window.innerWidth niż innerWidth.
Uruchomienie programu JavaScript można podzielić na dwie fazy. W pierwszej ładowany jest
dokument i uruchamiany kod zawarty w elementach <script> (zarówno umieszczony w
dokumencie, jak i ładowany z zewnątrz). Skrypty są zazwyczaj uruchamiane w takiej samej
kolejności, w jakiej zostały umieszczone w dokumencie, ale to domyślne działanie można
zmienić za pomocą opisanych wcześniej atrybutów async i defer. Kod danego skryptu jest
wykonywany od góry do dołu, oczywiście z uwzględnieniem instrukcji warunkowych, pętli i
innych instrukcji sterujących. Niektóre skrypty w pierwszej fazie nie robią niczego
szczególnego poza definiowaniem funkcji i klas wykorzystywanych w drugiej fazie. Inne skrypty
z kolei mogą w pierwszej fazie wykonywać ważne operacje, a w drugiej nie robić nic.
Wyobraźmy sobie skrypt umieszczony na samym końcu dokumentu, który wyszukuje w nim
wszystkie znaczniki <h1> i <h2> oraz modyfikuje dokument, wstawiając na jego początku tabelę
ze spisem treści. Wszystkie te operacje mogą być wykonane w pierwszej fazie. W punkcie
15.3.6 będzie opisany właśnie ten przykład.
Gdy dokument jest załadowany i wszystkie skrypty są uruchomione, następuje druga faza
wykonywania kodu. Jest ona asynchroniczna i sterowana zdarzeniami. Skrypt, który w tej fazie
ma coś do zrobienia, musi w pierwszej fazie między innymi zarejestrować przynajmniej jedną
procedurę obsługi zdarzenia lub funkcję zwrotną, która będzie wywoływana asynchronicznie. W
drugiej fazie przeglądarka wywołuje procedury obsługi zdarzeń i funkcje zwrotne w odpowiedzi
na asynchronicznie pojawiające się zdarzenia. Najczęściej są to reakcje na operacje
wykonywane przez użytkownika (kliknięcia myszy, naciśnięcia klawiszy itp.), ale również na
operacje sieciowe, załadowanie dokumentu i innych zasobów, na upływ określonego czasu i
błędy pojawiające się w kodzie. Związane z nimi zdarzenia i procedury ich obsługi będą
szczegółowo opisane w podrozdziale 15.2.
Wśród pierwszych zdarzeń pojawiających się w drugiej fazie są "DOMContentLoaded" i "load".
Pierwsze występuje po załadowaniu i przeanalizowaniu dokumentu HTML, natomiast drugie po
załadowaniu wszystkich zewnętrznych zasobów wykorzystywanych w dokumencie, na przykład
obrazów. Te zdarzenia są często wykorzystywane w skryptach jak sygnały startowe. Często
będziesz miał do czynienia ze skryptami zawierającymi funkcje, które nie robią niczego więcej
poza rejestrowaniem procedury obsługi zdarzenia "load" zgłaszanego na początku drugiej fazy.
Za to procedura obsługi tego zdarzenia przetwarza dokument lub wykonuje inne potrzebne
operacje. Warto wiedzieć, że często procedura obsługi zdarzenia takiego jak "load" rejestruje
inne procedury obsługi zdarzeń.
Faza ładowania programu JavaScript jest dość krótka, zazwyczaj trwa niecałą sekundę. Faza
sterowania zdarzeniami trwa do momentu wyświetlenia dokumentu w oknie przeglądarki.
Ponieważ jest to faza asynchroniczna i sterowana zdarzeniami, mogą się w niej pojawiać długie
okresy bezczynności przerywane operacjami wykonywanymi w odpowiedzi na zdarzenia
wywoływane przez użytkownika i sieć. Obie fazy będą dokładniej opisane w dalszej części
rozdziału.
Wątkowy model kodu klienckiego
Kod JavaScript jest jednowątkowy, co upraszcza programowanie. Tworząc kod, można mieć
pewność, że dwie procedury obsługi zdarzeń nigdy nie będą wykonywane jednocześnie. Nie
trzeba się obawiać, że w trakcie modyfikowania dokumentu przez jeden wątek inny wątek może
robić to samo, jak również nie trzeba przejmować się blokadami, zakleszczeniami i tzw.
wyścigami.
1. Przeglądarka tworzy obiekt Document i rozpoczyna analizę kodu HTML strony. W trakcie
analizy elementów i tekstu dodaje do dokumentu obiekty Element i Text. Na tym etapie
właściwość document.readyState ma wartość "loading".
2. Gdy przeglądarka napotka znacznik <script>, który nie ma atrybutów async, defer ani
type="module", umieszcza go w dokumencie i na czas ładowania skryptu (jeżeli trzeba) i
jego synchronicznego wykonania wstrzymuje analizę dokumentu HTML. Skrypt może
zawierać na przykład metodę document.write() wysyłającą do strumienia wejściowego
tekst, który zostanie potraktowany jako część dokumentu, gdy analiza zostanie
wznowiona. W tego rodzaju skrypcie często są definiowane wykorzystywane później
funkcje i rejestrowane procedury obsługi zdarzeń, ale też może być przetwarzana i
modyfikowana dostępna w tym czasie drzewiasta struktura dokumentu. Zatem skrypt,
który nie jest modułem i nie ma atrybutów async ani defer, „widzi” własny znacznik
<script> i poprzedzającą go treść dokumentu.
3. Gdy przeglądarka napotka znacznik <script> posiadający atrybut async, rozpoczyna
ładowanie skryptu (jeżeli jest to moduł, kaskadowo ładuje również wszystkie
wykorzystywane w nim zależności), jednocześnie kontynuując analizę dokumentu. Skrypt
uruchamia natychmiast po jego załadowaniu. Tego rodzaju skrypt nie może zawierać
metody document.write(). Skrypt „widzi” swój znacznik <script> oraz poprzedzającą go
treść dokumentu i może mieć dostęp do innych jego części.
4. Gdy dokument zostanie przeanalizowany, wartość właściwości document.readyState
zmienia się na "interactive".
5. Wszystkie skrypty posiadające atrybut defer (oraz skrypty modułów, które nie mają
atrybutu async) są wykonywane w takiej samej kolejności, w jakiej zostały umieszczone w
dokumencie. W tym czasie są również wykonywane skrypty asynchroniczne. Skrypty z
atrybutem defer mają dostęp do całego dokumentu i nie mogą zawierać metody
document.write().
6. Przeglądarka zgłasza w obiekcie Document zdarzenie "DOMContentLoaded". Jest to sygnał
przejścia z fazy synchronicznego wykonywania skryptów do fazy asynchronicznej,
sterowanej zdarzeniami. Należy pamiętać, że w tym momencie niektóre skrypty
asynchroniczne mogą nie być jeszcze uruchomione.
7. Na tym etapie dokument jest w całości przeanalizowany, ale przeglądarka może wciąż
oczekiwać na załadowanie dodatkowych treści, na przykład obrazów. Po zakończeniu tych
operacji i wykonaniu wszystkich asynchronicznych skryptów właściwość
document.readyState zmienia wartość na "complete", a przeglądarka zgłasza w obiekcie
Window zdarzenie "load".
8. Od tego momentu procedury obsługi zdarzeń są wywoływane asynchronicznie w
odpowiedzi na operacje wykonywane przez użytkownika, pojawiające się zdarzenia
sieciowe, upływ czasu itp.
Treść samego dokumentu, do którego kod uzyskuje dostęp za pomocą interfejsu DOM API.
Dane wprowadzane przez użytkownika w postaci zdarzeń, na przykład kliknięcia (lub
dotknięcia) elementu <button> czy wpisania tekstu w elemencie <textarea>. W
podrozdziale 15.2 dowiesz się, jak kod JavaScript reaguje na tego rodzaju zdarzenia.
Adres URL dokumentu zapisany we właściwości document.URL. Wywołując konstruktor
URL() z tym adresem w argumencie (patrz podrozdział 11.9), można uzyskać dostęp do
ścieżki, parametrów i innych części adresu.
Zawartość nagłówka Cookie (ciasteczko) zapytania, zapisana we właściwości
document.cookie. Ciasteczka są zazwyczaj wykorzystywane w kodzie serwerowym do
obsługi sesji użytkownika, ale kod kliencki również może je odczytywać i zapisywać, jeżeli
zachodzi taka potrzeba. Szczegółowe informacje na ten temat znajdziesz w punkcie
15.12.2.
Globalna właściwość navigator zawierająca informacje o przeglądarce, systemie
operacyjnym, w którym działa, oraz funkcjonalności obu komponentów. Na przykład
właściwość navigator.userAgent zawiera ciąg znaków opisujący przeglądarkę,
navigator.language reprezentuje preferowany język użytkownika, a
navigator.hardwareConcurrency zawiera liczbę logicznych procesorów dostępnych dla
przeglądarki. Ponadto globalna właściwość screen daje dostęp do parametrów ekranu.
Zawiera m.in. właściwości screen.width (szerokość) i screen.height (wysokość).
Obiekty zapisane we właściwościach navigator i screen są dla przeglądarki tym, czym
zmienne środowiskowe dla programu uruchomionego w środowisku Node.
Kod kliencki zazwyczaj generuje dane wyjściowe wtedy, kiedy są potrzebne. W tym celu
modyfikuje dokument HTML za pomocą interfejsu DOM API (patrz podrozdział 15.3) lub za
pomocą wysokopoziomowej platformy, na przykład React albo Angular. Dane może również
generować za pomocą metody console.log() i jej podobnych (patrz podrozdział 11.8). Dane te
są jednak widoczne tylko w konsoli przeglądarki. Wykorzystuje się je więc do diagnozowania
kodu, a nie do prezentowania treści użytkownikowi.
15.1.7. Błędy
Programy JavaScript, w odróżnieniu od aplikacji uruchamianych w systemie operacyjnym (w
tym aplikacji Node), tak naprawdę nie ulegają awariom. Jeżeli program JavaScript zgłosi
wyjątek, a kod nie zawiera instrukcji catch, która by go obsłużyła, wówczas w konsoli
przeglądarki pojawia się odpowiedni komunikat. Wszystkie zarejestrowane procedury obsługi
zdarzeń będą działały normalnie.
Aby zdefiniować procedurę obsługi wszelkich błędów, wywoływaną w momencie pojawienia się
nieobsłużonego wyjątku, należy właściwości onerror obiektu globalnego przypisać
odpowiednią funkcję. Gdy nieobsłużony wyjątek zostanie przesłany na sam szczyt stosu
wywołań, wtedy funkcja ta zostanie wywołana z trzema tekstowymi argumentami. Pierwszy
będzie zawierał komunikat opisujący błąd, drugi adres URL skryptu, w którym błąd wystąpił, a
trzeci numer wiersza w dokumencie, którego ten błąd dotyczy. Wynik true zwrócony przez tę
funkcję stanowi dla przeglądarki informację, że błąd został obsłużony i nie trzeba wykonywać
żadnych dodatkowych operacji, tzn. przeglądarka nie musi wyświetlać komunikatu.
Jeżeli zostanie odrzucona promesa, która nie ma zdefiniowanej funkcji catch(), wówczas
sytuacja będzie podobna do pojawienia się nieobsłużonego wyjątku oznaczającego
nieoczekiwany błąd w programie. Ten przypadek można stwierdzić, przypisując odpowiednią
funkcję właściwości window.onunhandledrejection lub rejestrując za pomocą funkcji
window.addEventListener() procedurę obsługi zdarzenia "unhandledrejection". W
argumencie tej funkcji jest umieszczany obiekt reprezentujący zdarzenie. Jego właściwość
promise zawiera odrzuconą promesę, a reason wartość, która byłaby umieszczona w
argumencie funkcji catch(). Wywołanie metody preventDefault() należącej do obiektu
reprezentującego nieobsłużone zdarzenie będzie podobnie jak w opisanym wyżej przypadku
sygnałem dla przeglądarki, że błąd został obsłużony i w konsoli nie trzeba wyświetlać żadnego
komunikatu.
Rzadko pojawia się konieczność przypisywania właściwościom onerror lub
onunhandledrejection funkcji obsługujących zdarzenia. Bardzo się natomiast te funkcje
przydają do wysyłania do serwera (na przykład za pomocą funkcji fetch() i zapytania HTTP
POST) informacji o nieoczekiwanych błędach zgłaszanych przez przeglądarkę.
Ponadto kod kliencki nie obsługuje sieciowych funkcjonalności ogólnego przeznaczenia. Może
natomiast wysyłać zapytania HTTP (patrz punkt 15.11.1). Z kolei protokół WebSocket (punkt
15.11.3) definiuje interfejs API umożliwiający komunikację z wyspecjalizowanymi serwerami.
Żaden interfejs API nie daje jednak bezpośredniego dostępu do szerszej sieci. Za pomocą
klienckiej wersji języka JavaScript nie jest możliwe tworzenie klienckich i serwerowych
programów ogólnego przeznaczenia.
Reguła tego samego pochodzenia dotyczy również zapytań HTTP wysyłanych przez skrypty
(patrz punkt 15.11.1). Skrypt może wysyłać dowolne zapytania do serwera, z którego został
załadowany wraz z dokumentem, ale nie może komunikować się z innymi serwerami (chyba że
serwery te zezwalają na międzydomenowe współdzielenie zasobów, o czym będzie mowa dalej).
Reguła tego samego pochodzenia jest przyczyną problemów dla dużych witryn WWW
odwołujących się do wielu domen. Na przykład skrypt umieszczony w domenie
orders.example.com może potrzebować dostępu do właściwości dokumentów zapisanych w
domenie example.com. Aby skrypt mógł odwoływać się do różnych domen, musi zmienić swoje
pochodzenie i właściwości document.domain przypisać prefiks domeny. Zatem skrypt może
zmienić swoje pochodzenie https://orders.example.com na https://example.com, przypisując
właściwości document.domain ciąg "example.com". Nie może to być jednak "orders.example",
"ample.com" ani "com".
Inna technika rozluźniająca regułę tego samego pochodzenia nosi nazwę CORS (ang. Cross-
Origin Resource Sharing — międzydomenowe współdzielenie zasobów). Dzięki niej serwery
mogą decydować, jakie miejsca pochodzenia zgadzają się obsługiwać. Współdzielenie CORS
polega na dodaniu do zapytania HTTP nagłówka Origin:, a do odpowiedzi na zapytanie —
nagłówka Access-Control-Allow-Origin. W nagłówku serwer może umieścić listę miejsc, z
których mogą pochodzić zapytania o plik. Dozwolone jest stosowanie symboli wieloznacznych
umożliwiających pobieranie treści przez dowolne strony. Przeglądarki uwzględniają nagłówki
CORS i nie znoszą ograniczeń reguły tego samego pochodzenia.
Skrypty międzydomenowe
Skrypty międzydomenowe (ang. Cross-Site Scripting; XSS) są wykorzystywane przez hakerów
do umieszczania w kodzie strony znaczników HTML. Należy o tym pamiętać, tworząc program
kliencki w języku JavaScript i zabezpieczyć go przez skryptami międzydomenowymi.
http://www.example.com/greet.html?name=Dawid
Otwarta w ten sposób strona wyświetla napis „Cześć, Dawid”. Sprawdźmy jednak, co się stanie,
jeżeli w parametrze umieścimy taki ciąg:
name=%3Cimg%20src=%22x.png%22%20onload=%22alert(%27wirus%27)%22/%3E
Po zdekodowaniu sekwencji ucieczki w dokumencie zostanie umieszczony następujący kod:
Podstawowym sposobem uchronienia się przed atakami XSS jest usuwanie znaczników HTML
ze wszystkich niezaufanych danych, zanim zostaną użyte do dynamicznego utworzenia treści
dokumentu. Pokazany wyżej plik greet.html można poprawić, zastępując we wprowadzonym
ciągu wszystkie specjalne sekwencje HTML ich odpowiednikami:
name = name
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'")
.replace(/\//g, "/")
Innym rozwiązaniem jest napisanie aplikacji WWW w taki sposób, aby niezaufana treść była
zawsze wyświetlana wewnątrz znacznika <iframe> zawierającego atrybut sandbox, który
blokuje skrypty międzydomenowe i inne niebezpieczne funkcjonalności.
Skrypty międzydomenowe to poważna luka w bezpieczeństwie, sięgająca głęboko w
architekturę sieci WWW. Warto poznać bliżej to zagrożenie. Jego opis wykracza poza zakres tej
książki, ale w internecie można znaleźć wiele materiałów na ten temat.
15.2. Zdarzenia
Do tworzenia kodu klienckiego jest wykorzystywany sterowany zdarzeniami model
programowania asynchronicznego. Zgodnie z tym modelem przeglądarka generuje zdarzenie,
gdy coś się stanie z nią samą, dokumentem, elementem lub skojarzonym z nim obiektem, na
przykład gdy przeglądarka załaduje dokument, użytkownik umieści kursor myszy nad
odnośnikiem albo naciśnie klawisz. Jeżeli jakieś zdarzenia są dla aplikacji ważne, musi ona
zarejestrować jedną lub kilka funkcji, które będą wywoływane w razie wystąpienia tych
zdarzeń. Należy pamiętać, że podejście to jest stosowane nie tylko w programowaniu stron
internetowych. W ten sposób działają wszystkie aplikacje udostępniające graficzne interfejsy
użytkownika, tj. czekają na wykonanie jakiejś operacji (zgłoszenie zdarzenia) i reagują na nią.
Zdarzenia mogą dotyczyć każdego elementu zawartego w dokumencie HTML. Z tego powodu
model obsługi zdarzeń w przeglądarce jest znacznie bardziej skomplikowany niż w środowisku
Node. Ten podrozdział rozpoczyna się od kilku ważnych definicji, które pomogą zrozumieć
model zdarzeń.
Typ zdarzenia
Ten termin oznacza rodzaj zgłoszonego zdarzenia. Na przykład "mousemove" oznacza
przesunięcie wskaźnika myszy, "keydown" naciśnięcie klawisza przez użytkownika, a
"load" załadowanie dokumentu lub innego zasobu z sieci. Ponieważ typ zdarzenia jest
ciągiem znaków, jest czasami nazywany nazwą zdarzenia. To określenie będzie
wykorzystywane w dalszej części rozdziału.
Cel zdarzenia
Jest to obiekt, którego dotyczy i z którym jest związane zdarzenie. Mówiąc o zdarzeniu,
trzeba określić jego typ i cel. Może to być na przykład zdarzenie "load" dla obiektu Window
lub "click" dla obiektu Element reprezentującego znacznik <button>. Obiekty Window,
Document i Element są najpopularniejszymi celami zdarzeń, ale inne obiekty też mogą
zgłaszać swoje zdarzenia. Na przykład obiekt Worker (rodzaj wątku, opisany w
podrozdziale 15.13) jest celem zdarzenia "message" zgłaszanego w chwili, gdy wątek
roboczy wyśle komunikat do wątku głównego.
Obsługa zdarzenia lub detektor zdarzeń
Własne zdarzenia mają też interfejsy API zdefiniowane w specyfikacji języka HTML i
pokrewnych. Z elementami <video> i <audio> jest związana długa lista zdarzeń, na
przykład "waiting", "playing", "seeking" i "volumechange", które wykorzystuje się do
dostosowywania odtwarzaczy multimediów. Ogólnie można powiedzieć, że własne
zdarzenia definiują asynchroniczne interfejsy API, opracowane przed pojawieniem się
promes. Na przykład interfejs IndexedDB API (patrz punkt 15.12.3) zgłasza zdarzenia
"success" i "error", odpowiednio, po udanym lub nieudanym odpytaniu bazy danych.
Obecnie do wysyłania zapytań HTTP służy oparta na promesie funkcja fetch(), ale będący
jej poprzednikiem interfejs XMLHttpRequest API zgłasza wiele różnego rodzaju
charakterystycznych dla niego zdarzeń.
};
Właściwości obsługujące zdarzenia mają ten mankament, że opierają się na założeniu, że
zdarzenie każdego typu jest obsługiwane tylko przez jedną procedurę. Zazwyczaj lepiej jest
używać metody addEventListener(), ponieważ zarejestrowanie nowej procedury nie powoduje
nadpisania procedury zarejestrowanej wcześniej.
}
W argumencie event jest umieszczany reprezentujący zgłoszone zdarzenie obiekt, do którego
może odwoływać się funkcja. Za pomocą instrukcji with kod może się odwoływać do
właściwości obiektu będącego celem zdarzenia, obiektu reprezentującego znacznik <form>
(jeżeli jest) oraz obiektu Document w taki sam sposób jak do zmiennych. Instrukcji with nie
można stosować w trybie ścisłym (patrz punkt 5.6.3), ale w kodzie JavaScript przypisanym
atrybutowi znacznika HTML ten tryb nie obowiązuje. Zdefiniowana w ten sposób procedura
obsługi zdarzenia jest wywoływana w środowisku, w którym są zdefiniowane nieoczekiwane
zmienne, co może być źródłem trudnych do wykrycia błędów. Dlatego dobrą praktyką jest
unikanie tworzenia tego rodzaju procedur bezpośrednio w kodzie HTML.
Metoda addEventListener()
Każdy obiekt będący celem zdarzenia, w tym obiekty Window, Document i Element, posiada
metodę addEventListener() służącą do rejestrowania procedur obsługi zdarzeń dotyczących
tego obiektu. Metoda ta ma trzy argumenty. Pierwszy zawiera typ, czyli nazwę, zdarzenia, które
będzie obsługiwane przez rejestrowaną procedurę. Jest to ciąg znaków, który w odróżnieniu od
właściwości obsługi zdarzenia nie ma prefiksu "on". Drugim argumentem jest funkcja, która
będzie wywoływana po zgłoszeniu danego zdarzenia. Trzeci argument jest opcjonalny, jego opis
znajduje się w dalszej części punktu.
Poniższy kod rejestruje dwie procedury obsługi zdarzenia "click" dotyczącego elementu
<button>. Zwróć uwagę na różnice w zastosowanych technikach.
<button id="mybutton">Kliknij mnie</button>
<script>
let b = document.querySelector("#mybutton");
b.onclick = function() { console.log("Dziękuję za kliknięcie!"); };
b.addEventListener("click", () => { console.log("Dziękuję jeszcze raz!"); });
</script>
passive: true
});
Jeżeli właściwość capture obiektu Options ma wartość true, procedura obsługi zdarzenia jest
rejestrowana jako procedura przechwytująca. Jeżeli właściwość ma wartość false lub nie ma
jej w obiekcie, procedura jest rejestrowana w zwykłym trybie.
Jeżeli obiekt Options ma właściwość once o wartości true, procedura po obsłużeniu zdarzenia
zostanie automatycznie wyrejestrowana. Jeżeli właściwość ta ma wartość false lub nie ma jej
w obiekcie, procedura nie zostanie wyrejestrowana.
Jeżeli obiekt Options ma właściwość passive o wartości true, procedura nie będzie mogła
anulować domyślnej operacji, wywołując metodę preventDefault() (patrz punkt 15.2.5). Jest
to szczególnie ważne w przypadku zdarzeń dotyku ekranu w urządzeniach przenośnych. Gdyby
procedura obsługi zdarzenia "touchmove" mogła anulować domyślną operację przewijania
ekranu, wówczas przeglądarka nie mogłaby go płynnie przewijać. Właściwość ta służy do
informowania przeglądarki, że rejestrowana jest procedura, która może zakłócić jej domyślne
działanie, na przykład przewijanie ekranu. Płynne przewijanie jest tak ważną operacją dla
uzyskania dobrych efektów wizualnych, że przeglądarki Firefox i Chrome domyślnie rejestrują
procedury obsługi zdarzeń "touchmove" i "mousewheel" w trybie pasywnym. Jeżeli więc
procedura obsługi jednego z tych zdarzeń musi wywoływać metodę preventDefault(), należy
jawnie nadać właściwości passive wartość false.
Procedura obsługi zdarzenia jest wywoływana jako metoda obiektu wskazywanego przez
identyfikator this, nawet jeżeli została zarejestrowana za pomocą metody
addEventListener(). Nie dotyczy to jednak procedur będących funkcjami strzałkowymi. W
takim wypadku identyfikator this ma zawsze wartość właściwą dla zakresu, w którym została
zdefiniowana funkcja.
Kolejność wywołań
W obiekcie docelowym można zarejestrować kilka procedur obsługi zdarzenia określonego
typu. Gdy takie zdarzenie zostanie zgłoszone, przeglądarka wywołuje procedury w takiej
kolejności, w jakiej zostały zarejestrowane. Co ciekawe, dotyczy to procedur zarejestrowanych
na różne sposoby, tj. poprzez wywołanie metody addEventListener() lub nadanie wartości
odpowiedniej właściwości, na przykład onclick.
Przechwytywanie zdarzeń daje możliwość ich podglądania podczas ich dostarczania do obiektu
docelowego. Za pomocą procedury przechwytującej można diagnozować kod albo anulować
i filtrować zdarzenia w sposób opisany w następnym punkcie, aby nie została wywołana
procedura zarejestrowana w obiekcie docelowym. Często przechwytuje się zdarzenia zgłaszane
podczas przeciągania elementów za pomocą myszy. W takim przypadku zdarzenia związane z
ruchem myszy muszą być obsługiwane przez procedury zarejestrowane w elemencie
przeciąganym, a nie w elemencie, nad którym jest on przeciągany.
.finally(() => {
// Po udanym lub nieudanym wykonaniu operacji sieciowej jest zgłaszane
// inne zdarzenie informujące użytkownika, że aplikacja jest już wolna.
document.dispatchEvent(new CustomEvent("busy", { detail: false }));
});
// W innym miejscu programu można zarejestrować procedurę obsługi zdarzenia
"busy"
// i wykorzystać ją do wyświetlenia wirującego lub zwykłego kursora.
document.addEventListener("busy", (e) => {
if (e.detail) {
showSpinner();
} else {
hideSpinner();
}
});
15.3. Przetwarzanie dokumentów
Zadaniem klienckiego kodu JavaScript jest przekształcanie statycznego dokumentu HTML w
interaktywną aplikację internetową. Zatem głównym przeznaczeniem języka JavaScript jest
przetwarzanie stron WWW.
Każdy obiekt Window posiada właściwość document zawierającą obiekt Document. Obiekt ten
reprezentuje zawartość okna przeglądarki i jest przedmiotem niniejszego podrozdziału. Nie jest
wprawdzie samodzielnym obiektem, ale jest najważniejszy w modelu DOM. Reprezentuje treść
dokumentu HTML i wykorzystuje się go do przetwarzania tej treści.
Model DOM został opisany w punkcie 15.1.2. Ten podrozdział szczegółowo przedstawia jego
interfejs API. Dowiesz się teraz:
Selektor CSS opisuje element za pomocą znacznika, wartości atrybutu id lub słów zawartych
w atrybucie class:
div // Dowolny element <div>.
#nav // Element z atrybutem id="nav".
.warning // Dowolny element zawierający w atrybucie class słowo
"warning".
Jeżeli żaden element dokumentu nie jest dopasowany do selektora, to obiekt NodeList
zwrócony przez metodę querySelectorAll() ma właściwość length równą 0.
Metody querySelector() i querySelectorAll() są zaimplementowane w klasach Element i
Document. Wywołane jako metody obiektu reprezentującego element zwracają wyłącznie jego
potomne elementy.
Poniższe funkcje demonstrują, jak można przy użyciu powyższych właściwości rekurencyjnie
przeglądać wszystkie elementy dokumentu i wywoływać zadaną funkcję z danym elementem
w argumencie:
// Funkcja przeglądająca rekurencyjnie obiekty potomne dla obiektu e typu
Document
// lub Element i umieszczająca każdy element w argumencie zadanej funkcji.
function traverse(e, f) {
f(e); // Wywołanie funkcji f() z obiektem e w
argumencie.
for(let child of e.children) { // Iterowanie elementów potomnych.
}
}
nodeValue
Właściwość zawierająca tekst umieszczony w węźle Text lub Comment.
nodeName
Nazwa znacznika HTML złożona z wielkich liter.
Używając powyższych właściwości, można do drugiego węzła potomnego od pierwszego węzła
dokumentu odwołać się w następujący sposób:
document.childNodes[0].childNodes[1]
document.firstChild.firstChild.nextSibling
Załóżmy, że dokument ma następującą strukturę:
<html><head><title>Test</title></head><body>Witaj, świecie!</body></html>
Drugi węzłem potomnym od pierwszego węzła dokumentu jest znacznik <body>. Jego
właściwość nodeType ma wartość 1, a nodeName wartość "BODY".
Zwróć jednak uwagę, że opisany interfejs API jest bardzo wrażliwy na zmiany wprowadzane
w kodzie dokumentu. Jeżeli na przykład pomiędzy znacznikami <body> i <head> zostanie
wprowadzony podział wiersza, to podział ten będzie reprezentowany przez pierwszy węzeł
potomny dla pierwszego węzła dokumentu, natomiast drugim węzłem potomnym będzie
znacznik <head>, a nie <body>.
Poniższa funkcja demonstruje użycie opisanego interfejsu API do przeglądania węzłów. Funkcja
ta zwraca teksty zawarte we wszystkich elementach dokumentu.
// Funkcja zwracająca zawartość elementu e w postaci zwykłego tekstu.
// W tym celu rekurencyjnie przegląda wszystkie elementy potomne.
15.3.3. Atrybuty
Znacznik HTML składa się z nazwy i zestawu par nazwa/wartość, czyli atrybutów. Na przykład
znacznik <a> definiuje odnośnik, którego atrybut href jest adresem docelowej strony.
Klasa Element definiuje ogólne metody getAttribute(), setAttribute(), hasAttribute() i
removeAttribute() służące do odczytywania atrybutów, przypisywania im wartości, testowania
ich i usuwania z elementu. Natomiast wartości wszystkich standardowych atrybutów każdego
standardowego elementu HTML są dostępne w postaci właściwości obiektu HTMLElement
reprezentującego dany element. Zazwyczaj o wiele łatwiej jest używać tych właściwości niż
metody getAttribute() i jej podobnych.
// wysłany formularz.
f.method = "POST"; // Określenie typu zapytania
HTTP.
Atrybuty niektórych znaczników, na przykład <input>, są powiązane z właściwościami o innych
nazwach. Między innymi wartość atrybutu value powyższego znacznika jest powiązana z
właściwością defaultValue. Właściwość value zawiera tekst wprowadzony przez użytkownika,
ale zmiany tej właściwości nie przekładają się na zmiany właściwości defaultValue ani
atrybutu value.
Wielkość liter w nazwach atrybutów nie ma znaczenia w przeciwieństwie do nazw wartości. Aby
przekształcić nazwę atrybutu w nazwę właściwości, należy napisać ją małymi literami. Jeżeli
nazwa atrybutu składa się z kilku słów, należy pierwszą literę każdego słowa, oprócz
pierwszego, zmienić na wielką, na przykład defaultChecked lub tabIndex. Wyjątkiem są
właściwości służące do obsługiwania zdarzeń, których nazwy składają się wyłącznie z małych
liter.
Nazwy niektórych atrybutów są zarezerwowanymi słowami kluczowymi języka JavaScript.
W takim przypadku należy nazwę właściwości poprzedzić prefiksem html. Na przykład
atrybutowi for znacznika <label> odpowiada właściwość htmlFor. Wyjątkiem od tej reguły jest
atrybut class, któremu odpowiada właściwość className.
Wartościami właściwości odpowiadających atrybutom są zazwyczaj ciągi znaków. Jeżeli jednak
atrybut ma wartość logiczną, jak na przykład atrybuty defaultChecked i maxLength znacznika
<input>, wówczas właściwość ma wartość logiczną lub liczbową. Wartością atrybutu
obsługującego zdarzenie jest funkcja lub null.
Zwróć uwagę, że za pomocą zdefiniowanych wyżej właściwości można odczytywać i zapisywać
wartości atrybutów, ale nie można ich usuwać. Co więcej, nie można do tego celu użyć
operatora delete. Aby usunąć atrybut, trzeba zastosować metodę removeAttribute().
Atrybut class
Atrybut class jest szczególnie ważny. Jego wartością jest lista oddzielonych spacjami klas CSS
określających styl znacznika. Ponieważ class jest zarezerwowanym słowem kluczowym w
języku JavaScript, wartość tego atrybutu jest powiązana z właściwością className obiektu
Element. Wartością tej właściwości jest ciąg znaków, który można zmieniać. Nazwa class
atrybutu jest nie najlepsza, ponieważ w rzeczywistości jest to lista klas CSS, a nie pojedyncza
klasa. W kodzie klienckim często trzeba dodawać lub usuwać nazwy klas z tej listy, jednak jej
format jako ciąg znaków nie jest wygodny w użyciu. Dlatego obiekt Element zawiera jeszcze
właściwość classList, dzięki której atrybut class można traktować jako listę. Właściwość ta,
mimo swej nazwy, bardziej przypomina zbiór klas, a jej wartością jest iterowalny obiekt
podobny do tablicy i posiadający metody add(), remove(), contains() i toggle(). Poniższy
kod ilustruje przykład użycia tej właściwości:
// Aby powiadomić użytkownika, że aplikacja jest zajęta, wyświetlamy wirujący
kursor.
// W tym celu usuwamy klasę "hidden" i dodajemy "animated" (zakładając, że
arkusz stylów jest
// odpowiednio przygotowany).
let spinner = document.querySelector("#spinner");
spinner.classList.remove("hidden");
spinner.classList.add("animated");
Właściwość outerHTML obiektu Element jest podobna do innerHTML z tym wyjątkiem, że jej
wartość obejmuje sam element, tj. na początku i na końcu znajdują się, odpowiednio, znaczniki
otwierający i zamykający. Wartość przypisana tej właściwości zastępuje cały element.
Pokrewną metodą obiektu Element jest insertAdjacentHTML(), za pomocą której można
umieszczać dowolny kod HTML obok zadanego znacznika. Znacznik umieszcza się w drugim
argumencie metody. Dokładne znaczenie słowa „obok” zależy od wartości pierwszego
argumentu, którym musi być ciąg "beforebegin", "afterbegin", "beforeend" lub
"afterend". Ciągi te oznaczają miejsca wstawienia kodu. Ilustruje je rysunek 15.2.
Pamiętaj, że dany element można wstawić do dokumentu tylko raz. Jeżeli element już istnieje,
wstawienie go w innym miejscu spowoduje przeniesienie go, a nie powielenie. Ilustruje to
poniższy kod:
// Wcześniej wstawiliśmy akapit za zadanym elementem, a teraz przenosimy go
przed ten element.
greetings.before(paragraph);
Aby skopiować element, należy wywołać jego metodę cloneNode() z argumentem true. W ten
sposób zostanie utworzona kopia całej zawartości elementu:
// Utworzenie kopii akapitu i umieszczenie jej za elementem greetings.
greetings.after(paragraph.cloneNode(true));
Węzeł Element lub Text można usunąć za pomocą metody remove(), a zastąpić go innym
węzłem za pomocą metody replaceWith(). Metoda remove() nie ma argumentów, natomiast w
argumentach metody replaceWith() można umieszczać dowolną liczbę ciągów znaków lub
obiektów, podobnie jak w metodach before() i after():
// Usunięcie z dokumentu elementu greetings i zastąpienie go akapitem.
// Jeżeli wstawiany akapit znajduje się już w dokumencie, zostanie
przeniesiony w inne miejsce.
greetings.replaceWith(paragraph);
// Usunięcie akapitu.
paragraph.remove();
Interfejs API modelu DOM definiuje kilka metod starszej generacji, służących do wstawiania i
usuwania treści. Metody appendChild(), insertBefore(), replaceChild() i removeChild()
są trudniejsze w użyciu niż opisane wyżej i nie należy ich stosować.
*
* Arkusz stylów może mieć następującą postać:
*
* #TOC { border: solid black 1px; margin: 10px; padding: 10px; }
* .TOCEntry { margin: 5px 0px; }
* .TOCEntry a { text-decoration: none; }
* .TOCLevel1 { font-size: 16pt; font-weight: bold; }
* .TOCLevel2 { font-size: 14pt; margin-left: .25in; }
* .TOCLevel3 { font-size: 12pt; margin-left: .5in; }
toc = document.createElement("div");
toc.id = "TOC";
document.body.prepend(toc);
}
// Wyszukanie wszystkich nagłówków. Przyjęte jest założenie, że tytuł
dokumentu znajduje
// się w znaczniku <h1>,
// a tytuły sekcji w znacznikach od <h2> do <h6>.
let headings = document.querySelectorAll("h2,h3,h4,h5,h6");
[1] Poprzednie wydania książki zawierały obszerną sekcję opisującą standardową bibliotekę
JavaScript i internetowe interfejsy API. W siódmym wydaniu została ona usunięta, ponieważ
wraz z pojawieniem się serwisu Mozilla Developer Network przestała być potrzebna. Dzisiaj o
wiele szybciej można znaleźć żądaną informację, przeszukując powyższy serwis niż wertując
książkę. Moi przyjaciele z MDN wykonują dobrą robotę, aktualizując na bieżąco dokumentację
online, co nie jest możliwe w przypadku książki.
[2] W niektórych źródłach, również w specyfikacji języka HTML, rozróżniane są pojęcia obsługi
i detektora zdarzeń w zależności od sposobu rejestracji. W tej książce są one synonimami.
[3] Może Ci się to wydać dziwne, jeżeli tworzysz klienckie interfejsy użytkownika z
wykorzystaniem platformy React. W tej platformie w modelu klienckim zostało wprowadzonych
kilka niewielkich zmian. Jedną z nich są „wielbłądzie” nazwy właściwości wykorzystywane do
obsługi zdarzeń, na przykład onClick lub onMouseOver. Jednak w natywnej platformie
internetowej nazwy tego rodzaju właściwości składają się wyłącznie z małych liter.
Rozdział 16.
Serwery w środowisku Node
Node jest platformą powiązaną z systemem operacyjnym, umożliwiającą tworzenie w języku
JavaScript programów, które mogą odczytywać i zapisywać pliki, uruchamiać procesy potomne
i przesyłać dane przez sieć. Dzięki tym cechom platforma Node jest:
nowoczesną alternatywą dla plików powłoki, wolną od zawiłości składni bash i innych
uniksowych powłok;
językiem programowania do ogólnych zastosowań, umożliwiającym tworzenie
bezpiecznych programów, niepodlegających restrykcjom typowym dla niezaufanego kodu
uruchamianego w przeglądarkach;
popularnym środowiskiem do pisania wydajnych, wielowątkowych programów dla
serwerów WWW.
Nie sposób jest w jednym rozdziale opisać wszystkie interfejsy Node API, ale mam nadzieję, że
wystarczająco dokładnie przedstawiłem w nim podstawy, które pozwolą Ci efektywnie korzystać
z platformy i samodzielnie poznawać wszystkie interfejsy API, jakich będziesz potrzebował
w przyszłości.
16.1.1. Konsola
Jeżeli piszesz programy w języku JavaScript dla przeglądarek, pewną niespodzianką będzie dla
Ciebie fakt, że w środowisku Node metoda console.log() służy nie tylko do diagnozowania
kodu, ale również do wyświetlania informacji (lub mówiąc bardziej ogólnie, wysyłania ich do
strumienia stdout) w najprostszy możliwy sposób. Klasyczny program „Witaj, świecie!” w
środowisku Node wygląda tak:
console.log("Witaj, świecie!");
Są jeszcze inne, niskopoziomowe sposoby wysyłania informacji do strumienia stdout, ale nie
tak eleganckie i oficjalne jak proste wywołanie metody console.log().
W konsoli przeglądarki, po wywołaniu metody console.log(), console.warn() lub
console.error(), zazwyczaj pojawia się obok wyświetlonego komunikatu mała ikona
oznaczająca jego rodzaj. W środowisku Node czegoś takiego nie ma, ale komunikaty
wyświetlane za pomocą metody console.error() można odróżnić od console.log(), ponieważ
są wysyłane do strumienia stderr. Jeżeli komunikaty wysyłane przez program do strumienia
stdout przekieruje się do pliku lub potoku, to informacje wyświetlane za pomocą metody
console.error() będą widoczne w konsoli, w odróżnieniu od wyświetlanych przy użyciu
metody console.log().
W środowisku Node przyjęta jest konwencja stosowana w systemie Unix. Program może
odczytywać argumenty za pośrednictwem tablicy process.argv. Jej pierwszy element zawiera
pełną ścieżkę pliku uruchomieniowego środowiska Node. Drugim elementem jest ścieżka do
pliku zawierającego uruchamiany kod JavaScript. W pozostałych elementach tablicy są
umieszczane argumenty wpisane w wierszu poleceń.
console.log(process.argv);
Za pomocą poniższego polecenia można uruchomić ten program i zobaczyć wynik działania:
$ node --trace-uncaught argv.js --arg1 --arg2 nazwapliku
'/usr/local/bin/node',
'/private/tmp/argv.js',
'--arg1',
'--arg2',
'nazwapliku'
]
$ node -p -e 'process.env'
{
SHELL: '/bin/bash',
USER: 'david',
PATH: '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
PWD: '/tmp',
LANG: 'en_US.UTF-8',
HOME: '/Users/david',
Aby dowiedzieć się, co oznaczają argumenty -p i -e, wpisz polecenie node -h lub node --help.
Podpowiedź: powyższe polecenie możesz wpisać jako node --eval 'process.env' --print.
process.setUncaughtExceptionCaptureCallback(e => {
});
Podobna sytuacja ma miejsce, gdy zostanie odrzucona promesa, ale nie będzie wywoływana
w takim przypadku metoda catch(). W wersji Node 13 nie jest to wprawdzie fatalny błąd
powodujący przerwanie działania programu, ale w konsoli pojawia się długi komunikat.
Prawdopodobnie w kolejnych wersjach brak obsługi odrzuconej promesy będzie traktowany
jako błąd fatalny. Aby zapobiec tego rodzaju sytuacjom, wyświetlaniu komunikatów i
przerywaniu działania programu, należy zarejestrować następującą globalną procedurę obsługi
zdarzenia:
});
16.1.4. Moduły
W rozdziale 10. został opisany system modułów języka JavaScript, obejmujący środowiska Node
i ES6. Ponieważ środowisko Node powstało wcześniej niż system modułów JavaScript, ma ono
własny system. Do importowania symbolu do modułu służy funkcja require(), a do
eksportowania obiekt exports lub właściwość module.exports. Są to fundamentalne elementy
środowiska, szczegółowo opisane w podrozdziale 10.2.
Począwszy od wersji Node 13 obsługiwane są standardowe moduły ES6, jak również ładowane
za pomocą funkcji require(), nazywane „modułami CommonJS”. Oba systemy modułów nie są
w pełni kompatybilne, więc korzystanie z nich jest dość skomplikowane. Środowisko Node
przed załadowaniem modułu musi „wiedzieć”, czy jest w nim wykorzystywana funkcja
require() i właściwość module.exports, czy instrukcje import i export. Podczas ładowania
modułu CommonJS jest automatycznie tworzona funkcja require() oraz identyfikatory exports
i module, które uniemożliwiają korzystanie z instrukcji import i export. Z drugiej strony
środowisko podczas ładowania modułu ES6 musi obsługiwać powyższe instrukcje, więc nie
może definiować identyfikatorów require, module i exports.
Jeżeli plik ma inne rozszerzenie niż .mjs lub .cjs, środowisko Node szuka w bieżącym katalogu,
a następnie w podkatalogach pliku package.json. Jeżeli go znajdzie, szuka w nim głównej
właściwości type. Jeżeli właściwość istnieje i ma wartość "module", plik jest ładowany jako
moduł ES6, natomiast wartość "commonjs" właściwości oznacza moduł CommonJS. Zwróć
uwagę, że plik package.json nie jest niezbędny do uruchomienia programu. Jeżeli go nie ma
(albo jest, ale nie zawiera właściwości type), domyślnie jest przyjmowany typ modułu
CommonJS. Plik package.json jest potrzebny jedynie wtedy, gdy są stosowane moduły ES6, ale
pliki mają rozszerzenia inne niż .mjs.
Ponieważ istnieje mnóstwo modułów napisanych w formacie CommonJS, moduły ES6 mogą je
ładować za pomocą instrukcji import. Odwrotna operacja nie jest możliwa, tj. moduł CommonJS
nie może ładować modułu ES6 za pomocą funkcji require().
W tym rozdziale nie jest jednak opisany program npm (nieco szczegółów znajdziesz w
podrozdziale 17.4). Wspominam o nim, ponieważ jeżeli będziesz tworzył kod odwołujący się do
zewnętrznych bibliotek, niemal na pewno będziesz korzystał z tego programu lub podobnego
narzędzia. Załóżmy, że piszesz oprogramowanie dla serwera WWW i zamierzasz wykorzystać
platformę Express (https://expressjs.com), która ułatwi Ci to zadanie. Na początku musisz
utworzyć katalog projektu, a następnie wpisać w nim polecenie npm init. Program poprosi Cię
o podanie nazwy projektu, numeru wersji itp., po czym na podstawie uzyskanych informacji
utworzy plik package.json.
Następnie, aby móc korzystać z platformy Express, będziesz musiał wpisać polecenie npm
install express. Program npm pobierze bibliotekę Express wraz ze wszystkimi zależnymi
bibliotekami i zainstaluje je w lokalnym katalogu node_modules:
+ express@4.17.1
found 0 vulnerabilities
Podczas instalowania pakietów program npm rejestruje w pliku package.json zależność, czyli
informację, że Twój program jest zależny od biblioteki Express. Tak utworzony plik wraz z
kodem programu możesz przekazać innemu programiście, który wpisze polecenie npm install i
automatycznie pobierze i zainstaluje wszystkie biblioteki wymagane do uruchomienia Twojego
programu.
console.error(err);
callback(null);
return;
}
data = JSON.parse(text);
} catch(e) { // Coś poszło źle podczas analizowania zawartości
pliku.
console.error(e);
}
callback(data);
});
}
Środowisko Node jest starsze niż promesy, ale ponieważ powszechnie są w nim stosowane
funkcje zwrotne obsługujące błędy, można łatwo za pomocą metody util.promisify()
utworzyć odmianę interfejsu API opartą na promesach. Poniżej znajduje się zmieniony kod
funkcji readConfigFile(), która zwraca promesę:
const util = require("util");
function readConfigFile(path) {
return pfs.readFile(path, "utf-8").then(text => {
return JSON.parse(text);
});
}
Powyższą funkcję można uprościć, wykorzystując instrukcje async i await (jak wspomniałem
wcześniej, jeżeli nie przeczytałeś jeszcze rozdziału 13., teraz jest dobry moment, aby to zrobić).
async function readConfigFile(path) {
let text = await pfs.readFile(path, "utf-8");
return JSON.parse(text);
}
Gdy uruchamiany jest program serwera i odczytywany plik konfiguracyjny, zazwyczaj nie są
jeszcze obsługiwane zapytania, więc współbieżność nie jest potrzebna. Nie trzeba zapobiegać
blokowaniu operacji i można bezpiecznie używać takich funkcji jak fs.readFileSync(). Z
poprzedniego przykładu można więc usunąć instrukcje async i await i utworzyć całkowicie
synchroniczną odmianę funkcji readConfigFile(), która nie wywołuje funkcji zwrotnej i nie
zwraca promesy. Zamiast tego zwraca obiekt JSON i ewentualne zgłasza wyjątek.
const fs = require("fs");
function readConfigFileSync(path) {
let text = fs.readFileSync(path, "utf-8");
return JSON.parse(text);
}
Teraz, po zapoznaniu się z nieblokującym w każdym calu interfejsem API wróćmy do tematu
współbieżności. Wbudowane, nieblokujące funkcje wykorzystują systemowe wersje funkcji
zwrotnych i procedur obsługi zdarzeń. Kiedy jest wywoływana tego rodzaju nieblokująca
funkcja, środowisko Node rejestruje procedurę obsługi odpowiedniego zdarzenia. Wywołanie
takiej procedury oznacza zakończenie wykonywania danej operacji. Funkcja zwrotna jest
zapisywana w wewnętrznej pamięci środowiska, dzięki czemu jest wywoływana w chwili
zgłoszenia przez system operacyjny odpowiedniego zdarzenia.
Tego rodzaju działanie jest często nazywane współbieżnością opartą na zdarzeniach. Podstawą
środowiska Node jest pojedynczy wątek obsługujący tzw. pętlę zdarzeń. Środowisko po
uruchomieniu rozpoczyna wykonywanie wskazanego kodu, który zazwyczaj wywołuje
przynajmniej jedną nieblokującą funkcję. W efekcie w systemie operacyjnym jest rejestrowana
funkcja zwrotna lub procedura obsługi zdarzenia. (Jeżeli natomiast program jest synchroniczny,
po wykonaniu jego ostatniej instrukcji środowisko Node kończy działanie). Gdy zostanie
osiągnięty koniec kodu, środowisko Node czeka na zgłoszenie zdarzenia powodującego
wznowienie jego działania w systemie operacyjnym. Środowisko Node kojarzy zdarzenie
systemu z zarejestrowaną funkcją zwrotną JavaScript, a następnie ją wywołuje. Funkcja ta
może wywoływać inne nieblokujące funkcje, co powoduje zarejestrowanie w systemie kolejnych
procedur obsługi zdarzeń. Gdy funkcje zakończą działanie, środowisko ponownie przechodzi w
stan oczekiwania i cykl się powtarza.
16.3. Bufory
Jedną ze struktur, którą prawdopodobnie będziesz często wykorzystywać w programach dla
środowiska Node, w szczególności odczytujących dane z plików i sieci, jest klasa Buffer. Jej
instancja jest bardzo podobna do ciągu znaków, z tą różnicą, że reprezentuje sekwencję bajtów.
Środowisko Node powstało, zanim w języku JavaScript pojawiły się tablice typowane (patrz
rozdział 11.2) i klasa Uint8Array reprezentująca tablicę bajtów bez znaku. Dlatego została
zdefiniowana klasa Buffer, która wypełniła tę lukę. Obecnie klasa Uint8Array jest częścią
języka JavaScript, a klasa Buffer klasą pochodną od Uint8Array.
Klasa Buffer różni się od swej klasy nadrzędnej Uint8Array tym, że można ją wykorzystywać
do przetwarzania ciągów znaków. Bajty w buforze można zainicjować za pomocą ciągu, jak
również przekształcać w znaki. Za pomocą mapy kodowej każdy znak jest wiązany z liczbą
całkowitą. Dzięki temu można zakodować ciąg znaków w postaci sekwencji bajtów, a
odpowiednio zakodowaną sekwencję bajtów można zdekodować do postaci ciągu. Klasa
Buffer posiada odpowiednie metody kodujące i dekodujące. Można je poznać po drugim
argumencie określającym sposób kodowania.
Kodowanie określa się za pomocą nazwy, czyli ciągu znaków. Środowisko Node obsługuje
następujące rodzaje kodowania:
"utf8"
Domyślne, najczęściej stosowane kodowanie Unicode, jeżeli nie zostanie jawnie określone.
"utf16le"
Dwubajtowe kody Unicode zapisane w kolejności little-endian. Kody większe niż \uffff są
zapisywane w postaci pary dwubajtowych sekwencji. Inna nazwa tego kodowania to
"ucs2".
"latin1"
Kodowanie ISO-8859-1 typu „jeden bajt na znak”, definiujące zestaw znaków
wykorzystywanych w wielu językach Europy Zachodniej. Ponieważ każdemu znakowi jest
przypisany jeden bajt, kodowanie ma również nazwę "binary".
"ascii"
"base64"
Kodowanie, w którym sekwencja trzech bajtów jest zamieniana na sekwencję czterech
znaków ASCII.
Poniżej znajduje się kilka przykładów użycia klasy Buffer do przekształcania bajtów na znaki
i odwrotnie.
let b = Buffer.from([0x41, 0x42, 0x43]); // <Buffer 41 42 43>
W środowisku Node obiekty zgłaszające zdarzenia są instancjami klasy EventEmitter lub jej
klasy pochodnej. Poniższy przykład ilustruje użycie tej klasy.
Jeżeli do rejestrowania procedur obsługi zdarzeń wolisz stosować bardziej opisowe nazwy,
używaj metody addListener(). Zarejestrowaną procedurę możesz wyrejestrować za pomocą
metody off() lub removeListener(). Oprócz tego, wywołując metodę once() zamiast on(),
możesz zarejestrować procedurę, która zostanie automatycznie wyrejestrowana po obsłużeniu
zdarzenia.
Gdy zostanie zgłoszone określone zdarzenie dla określonego obiektu EventEmitter, platforma
Node wywoła wszystkie procedury zarejestrowane do obsługi tego zdarzenia. Procedury są
wywoływane sekwencyjnie, w jednym wątku w kolejności, w jakiej zostały zarejestrowane.
Pamiętaj, że w środowisku Node nie ma równoległości. Co więcej, procedury obsługi zdarzeń
nie są wywoływane asynchronicznie, tylko synchronicznie. Oznacza to, że metoda emit() nie
umieszcza procedur obsługi zdarzeń w kolejce w celu ich późniejszego wywołania. Zamiast tego
wywołuje wszystkie procedury jedna po drugiej i kończy działanie dopiero wtedy, gdy zakończy
działanie ostatnia wywołana procedura.
Wszystko to oznacza, że wbudowany interfejs API zgłaszający zdarzenie blokuje obsługę innych
zdarzeń. Jeżeli procedura obsługi wywoła funkcję blokującą, na przykład fs.readFileSync(),
to do czasu wykonania synchronicznej operacji zdarzenia nie są obsługiwane. Jeżeli program,
na przykład serwerowy, musi być responsywny, procedury obsługi zdarzeń nie mogą być
blokujące i muszą działać krótko. Jeżeli obsługa zdarzenia wymaga wykonania długotrwałych
operacji, najlepszym rozwiązaniem jest użycie procedury, która asynchronicznie zaplanuje te
operacje za pomocą funkcji setTimeout() (patrz podrozdział 11.10). Oprócz tego środowisko
Node definiuje funkcję setImmediate(), która wywołuje zadaną funkcję natychmiast po
zakończeniu działania wszystkich funkcji zwrotnych i procedur obsługi zdarzeń.
16.5. Strumienie
Algorytm przetwarzania danych najprościej implementuje się przez umieszczenie wszystkich
danych w pamięci, następnie przetworzenie ich i zapisanie danych z powrotem. Poniższy
przykład pokazuje, jak w środowisku Node może wyglądać funkcja kopiująca plik[1].
const fs = require("fs");
if (err) {
callback(err);
} else {
fs.writeFile(destinationFilename, buffer, callback);
}
});
}
Readable
Strumień Readable jest źródłem danych. Na przykład obiekt zwrócony przez metodę
fs.createReadStream() reprezentuje strumień danych odczytywanych ze wskazanego
pliku. Innym przykładem jest strumień process.stdin dostarczający danych pojawiających
się na standardowym wejściu.
Writable
Strumień Writable jest miejscem docelowym dla danych. Tego rodzaju obiekt zwraca na
przykład metoda fs.createWriteStream(). Dane do strumienia wysyła się małymi
porcjami, które są zapisywane we wskazanym pliku.
Duplex
Strumień Duplex jest połączeniem strumieni Readable i Writable. Przykładem tego
rodzaju strumienia jest obiekt Socket zwracany przez metodę net.connect() i różne
metody sieciowe. Dane zapisywane w strumieniu Socket są przesyłane przez sieć do
klienta podłączonego do gniazda. Natomiast odczytywanie danych ze strumienia Socket
oznacza odbieranie ich od innego klienta.
Transform
Strumień Transform jest odczytywalny i zapisywalny, ale różni się od strumienia Duplex
jedną ważną cechą: zapisywane w nim dane można odczytywać zazwyczaj w przetworzonej
postaci. Na przykład metoda zlib.createGzip() zwraca obiekt strumienia Transform
kompresujący przy użyciu algorytmu gzip zapisywane w nim dane. Innym przykładem jest
metoda crypto.createCipheriv() zwracająca obiekt strumienia szyfrującego i
deszyfrującego dane.
Domyślnie strumienie do odczytywania i zapisywania danych wykorzystują bufory. Metoda
setEncoding() obiektu Readable zwraca zdekodowany ciąg znaków zamiast obiektu Buffer.
Z kolei ciąg znaków zapisany w strumieniu Writable jest automatycznie przekształcany przy
użyciu domyślnego lub wskazanego kodowania. Strumieniowy interfejs API środowiska Node
obsługuje również tzw. tryb obiektowy, w którym obiekty są odczytywane ze strumieni i
zapisywane w nich w bardziej skomplikowany sposób niż sekwencje bajtów i ciągi znaków. Ten
tryb nie jest stosowany w żadnym wbudowanym interfejsie API środowiska Node, ale można się
z nim spotkać w różnych bibliotekach.
Strumień Readable służy do odczytywania danych z różnych źródeł, a Writable do ich
zapisywania w różnych miejscach. Każdy strumień ma dwa końce: wejście i wyjście albo źródło
i przeznaczenie. Problem ze strumieniowymi interfejsami API polega na tym, że niemal zawsze
dane na jednym końcu mogą przepływać z inną prędkością niż na drugim. Na przykład proces
odczytujący dane może je przetwarzać szybciej, niż są one wysyłane do strumienia. Możliwa
jest również odwrotna sytuacja: dane mogą być zapisywane w strumieniu szybciej, niż są z
niego odczytywane na drugim końcu. Implementacja strumienia niemal zawsze zawiera
wewnętrzny bufor, w którym są umieszczane zapisane, ale jeszcze nieodczytane dane. Dzięki
buforowi dane są dostępne do odczytu, gdy są potrzebne, jak również jest miejsce do
przechowywania danych zapisywanych. Jednak żaden z tym przypadków nie jest gwarantowany.
Naturalną sytuacją w programach strumieniowych jest oczekiwanie odbiorcy, aż dane zostaną
wysłane (ponieważ bufor strumienia jest pusty) i oczekiwanie nadawcy, aż dane zostaną
odczytane (ponieważ bufor jest pełny).
16.5.1. Potoki
Czasami trzeba dane odczytane z jednego strumienia po prostu zapisać bez zmian w innym
strumieniu. Załóżmy, że tworzysz prosty serwer WWW udostępniający pliki zapisane w
określonym katalogu. Program musi odczytywać dane z plikowego strumienia wejściowego i
zapisywać je w gnieździe sieciowym. Nie trzeba w tym celu pisać specjalnego kodu
odczytującego i zapisującego dane. Zamiast tego wystarczy po prostu połączyć ze sobą dwa
strumienie za pomocą tzw. potoku, a wszystkimi zawiłościami związanymi z przesyłaniem
danych zajmie się środowisko Node. Obiekt Writable trzeba umieścić w argumencie metody
pipe() dostępnej w obiekcie Readable:
const fs = require("fs");
function pipeFileToSocket(filename, socket) {
fs.createReadStream(filename).pipe(socket);
}
Poniższa funkcja łączy za pomocą potoku dwa strumienie, a po wykonaniu żądanej operacji lub
pojawieniu się błędu wywołuje funkcję zwrotną:
function pipe(readable, writable, callback) {
callback(err);
}
// Następnie definiujemy potok i obsługę normalnego zakończenia operacji.
readable
.on("error", handleError)
.pipe(writable)
.on("error", handleError)
.on("finish", callback);
}
// Utworzenie strumieni.
let source = fs.createReadStream(filename);
let destination = fs.createWriteStream(filename + ".gz");
let gzipper = zlib.createGzip();
// Utworzenie potoku.
source
.on("error", callback) // Wywołanie funkcji zwrotnej w przypadku
błędu odczytu.
.pipe(gzipper)
.pipe(destination)
output += "\n";
}
// Funkcję zwrotną trzeba wywoływać nawet wtedy, gdy nie ma danych
wyjściowych.
callback(null, output);
}
// Funkcja wywoływana tuż przed zamknięciem strumienia.
// Jest to okazja do zapisania w nim ostatnich danych.
_flush(callback) {
}
}
// Przy użyciu powyższej klasy można napisać program działający tak jak
polecenie grep.
let pattern = new RegExp(process.argv[2]); // Odczytanie wyrażenia
regularnego z wiersza poleceń.
}
// Na koniec sprawdzamy, czy pozostały tekst jest dopasowany.
if (pattern.test(incompleteLine)) {
destination.write(incompleteLine + "\n", encoding);
}
}
let pattern = new RegExp(process.argv[2]); // Odczytanie wyrażenia
regularnego z wiersza poleceń.
grep(process.stdin, process.stdout, pattern) // Wywołanie asynchronicznej
funkcji grep().
.catch(err => { // Obsługa asynchronicznego
wyjątku.
console.error(err);
process.exit();
});
});
}
}
// Funkcja kopiująca dane ze strumienia źródłowego do docelowego z
uwzględnieniem nacisku zwrotnego.
// Wywołanie tej funkcji jest podobne do wywołania source.pipe(destination).
async function copy(source, destination) {
// Rejestracja w strumieniu docelowym procedury obsługi zdarzenia "error"
zgłaszanego
// po niespodziewanym zamknięciu strumienia (np. w przypadku skierowania go
do polecenia 'head').
copy(process.stdin, process.stdout);
Zanim zakończymy dyskusję o zapisywaniu danych w strumieniu podkreślę jeszcze raz, że
ignorowanie nacisku zwrotnego może spowodować, że wewnętrzny bufor obiektu Writable
będzie się nieustannie powiększał i w efekcie program będzie zajmował więcej pamięci niż
powinien. Jeżeli jest to program serwerowy, będzie narażony na ataki sieciowe. Załóżmy, że
tworzysz program serwera HTTP, który będzie udostępniał pliki przez sieć. Zamiast metody
pipe() użyłeś write(), ale nie zakodowałeś obsługi nacisku zwrotnego. Haker może użyć
programu klienckiego, który będzie wysyłał zapytania o duże pliki, na przykład obrazy, ale nie
będzie ich odbierał. Ponieważ serwer nie będzie reagował na nacisk zwrotny, bufor będzie się
powiększał aż do momentu przepełnienia pamięci. Jeżeli haker nawiąże odpowiednio dużo
połączeń (przeprowadzi tzw. atak denial of service, blokada usługi), może doprowadzić do
przeciążenia serwera, a nawet jego awarii.
Tryb płynny
W trybie płynnym odbierane dane są natychmiast zgłaszane w formie zdarzeń "data". Aby
móc je odczytywać, trzeba po prostu zarejestrować procedurę obsługi tego zdarzenia. Strumień
będzie wtedy automatycznie wysyłał porcje danych (bajty lub znaki), gdy tylko będą dostępne.
Zwróć uwagę, że w tym trybie nie trzeba używać metody read(). Wystarczy jedynie obsługiwać
zdarzenie "data". Pamiętaj, że nowo utworzony strumień nie działa w trybie płynnym. Ten tryb
jest włączany po zarejestrowaniu obsługi powyższego zdarzenia i dopiero wtedy strumień
zaczyna je zgłaszać.
});
output.on("finish", () => { // Po zapisaniu wszystkich danych…
callback(null); // …wywołujemy funkcję zwrotną bez
informacji o błędzie.
});
}
// Kod prostego narzędzia do kopiowania plików.
let from = process.argv[2], to = process.argv[3];
console.log(`Kopiowanie pliku ${from} do ${to}...`);
copyFile(from, to, err => {
if (err) {
console.error(err);
} else {
console.log("Koniec.");
}
});
Tryb wstrzymywany
Drugim trybem, w którym może funkcjonować strumień Readable, jest tryb wstrzymywany.
Obowiązuje on w nowo utworzonym strumieniu, o ile nie zostanie zarejestrowana procedura
obsługi zdarzenia "data" i nie będzie wywoływana metoda pipe(). W tym trybie dane nie są
udostępniane w formie zdarzeń i trzeba je odczytywać ze strumienia, wywołując metodę
read(). Jest to metoda nieblokująca, która zwraca wartość null, jeżeli w strumieniu nie ma
danych. Ponieważ nie trzeba synchronicznie czekać na dane, interfejs API stosowany w trybie
wstrzymywanym jest oparty na zdarzeniach. W chwili pojawienia się danych w strumieniu
obiekt Readable zgłasza zdarzenie "readable", którego procedura obsługi musi wywoływać
metodę read(). Metoda ta musi być wywoływana wielokrotnie w pętli do chwili, aż zwróci
wynik null. Jest to niezbędne, aby całkowicie opróżnić bufor i aby mogło być później zgłoszone
kolejne zdarzenie "readable". Jeżeli w buforze będą znajdowały się dane, a metoda read() nie
zostanie wywołana, wówczas nie zostanie zgłoszone kolejne zdarzenie i program
prawdopodobnie się „zawiesi”.
Strumień działający w trybie wstrzymywanym zgłasza zdarzenia "end" i "error", podobnie jak
w trybie płynnym. Jednak stosowanie trybu wstrzymywanego w programie odczytującym dane
ze strumienia Readable i zapisującym do Writable nie jest dobrym rozwiązaniem. Aby program
właściwie reagował na nacisk wsteczny, dane ze strumienia wejściowego powinien odczytywać
tylko wtedy, gdy będą w nim dostępne, a zapisywać w strumieniu wyjściowym jedynie wtedy,
gdy bufor strumienia nie będzie przepełniony. Oznacza to, że należy wstrzymywać wywoływanie
metod read() i write(), w chwili gdy pierwsza zwróci wynik null lub druga zwróci wynik
false, i wznawiać je, gdy zostanie zgłoszone zdarzenie "readable" lub "drain". Nie jest to
jednak eleganckie rozwiązanie i łatwiejsze może się okazać zastosowanie potoków lub trybu
płynnego.
Poniższy kod wylicza sumę kontrolną SHA256 zawartości wskazanego pliku. Wykorzystany jest
w nim strumień Readable działający w trybie wstrzymywanym. Odczytywane porcje danych są
umieszczane w argumencie metody wyliczającej sumę kontrolną. Zwróć uwagę, że w
środowisku Node 12 lub nowszym łatwiej jest napisać taką funkcję przy użyciu pętli for/await.
const fs = require("fs");
const crypto = require("crypto");
// Funkcja wyliczająca sumę kontrolną SHA256 zawartości zadanego pliku.
// Suma ta jest umieszczana w postaci ciągu znaków w argumencie funkcji
zwrotnej.
function sha256(filename, callback) {
let input = fs.createReadStream(filename); // Strumień danych.
let hasher = crypto.createHash("sha256"); // Obiekt wyliczający sumę
kontrolną.
// …sumę.
} // Proces powtarzamy, dopóki w
strumieniu są dane.
});
input.on("end", () => { // Gdy zostaną odczytane wszystkie
dane, …
let hash = hasher.digest("hex"); // …wyliczamy sumę kontrolną…
callback(null, hash); // …i umieszczamy ją w argumencie
funkcji zwrotnej.
});
const fs = require("fs");
let buffer = fs.readFileSync("test.data"); // Funkcja synchroniczna
zwracająca bajty danych.
let text = fs.readFileSync("data.csv", "utf8"); // Funkcja synchroniczna
zwracająca ciąg znaków.
// Asynchroniczne odczytanie bajtów.
fs.readFile("test.data", (err, buffer) => {
if (err) {
// Tu jest kod obsługi błędów.
} else {
fs.createReadStream(filename, encoding).pipe(process.stdout);
}
Jeżeli trzeba ściśle kontrolować na niskim poziomie operacje odczytu danych w określonych
momentach, należy otworzyć plik, uzyskać jego deskryptor, a następnie za pomocą funkcji
fs.read(), fs.readSync() lub fs.promises.read() odczytać żądaną liczbę bajtów z
określonego miejsca pliku i umieścić je w określonym miejscu bufora. Ilustruje to poniższy kod.
const fs = require("fs");
// Odczytanie określonego fragmentu pliku.
fs.open("data", (err, fd) => {
if (err) {
// Powiadomienie o błędzie.
return;
}
try {
// Odczytanie bajtów od pozycji 20 do 420 i umieszczenie ich w nowo
utworzonym buforze.
fs.read(fd, Buffer.alloc(400), 0, 400, 20, (err, n, b) => {
// Argument err zawiera obiekt błędu, jeżeli wystąpił.
// Argument n zawiera liczbę odczytanych bajtów.
// Argument b zawiera bufor, w którym są zapisywane odczytane bajty
danych.
});
}
finally { // Dzięki instrukcji finallly…
fs.close(fd); // ...otwarty plik zostanie na pewno zamknięty.
}
});
Korzystanie z funkcja read() wykorzystującej funkcje zwrotne komplikuje się, jeżeli z pliku
trzeba odczytać więcej niż jedną porcję danych. Jeżeli dopuszczalne jest użycie
synchronicznego interfejsu API (lub opartego na promesach i instrukcji await), cała operacja
jest prosta:
const fs = require("fs");
function readData(filename) {
let fd = fs.openSync(filename);
try {
// Odczytanie nagłówka pliku.
let header = Buffer.alloc(12); // 12-bajtowy bufor.
fs.readSync(fd, header, 0, 12, 0);
// Weryfikacja "magicznej liczby" pliku.
let magic = header.readInt32LE(0);
if (magic !== 0xDADAFEED) {
const fs = require("fs");
let output = fs.createWriteStream("liczby.txt");
for(let i = 0; i < 100; i++) {
output.write(`${i}\n`);
}
output.end();
Jeżeli dane mają być zapisywane porcjami i trzeba ściśle kontrolować ich położenie w pliku,
należy otworzyć go za pomocą funkcji fs.open(), fs.openSync() lub fs.promises.open(),
a następnie użyć zwróconego deskryptora z funkcją fs.write() lub fs.writeSync(). Funkcje
te są dostępne w różnych odmianach, zapisujących ciągi znaków i bufory danych. Argumentami
funkcji zapisującej ciąg znaków są: deskryptor, zapisywany ciąg i pozycja, na której ma on być
umieszczony w pliku. Czwartym, opcjonalnym argumentem jest kodowanie. Odmiana funkcji
zapisująca bufor bajtów ma następujące argumenty: bufor, pozycja danych w buforze, ilość
bajtów oraz pozycja w pliku, pod którą mają być zapisane dane. Jeżeli zapisywane dane są
umieszczone w tablicy obiektów Buffer, można je zapisać, wywołując jeden raz funkcję
fs.writev() lub fs.writevSync(). Są również dostępne niskopoziomowe funkcje zapisujące
ciągi znaków i bufory danych, wykorzystujące obiekt FileHandle zwrócony przez funkcję
fs.promises.open().
// tzw. klonem,
// tj. dodatkowe miejsce na dysku nie zostanie zajęte, dopóki nie zostanie
zmodyfikowany plik źródłowy
// lub docelowy (pod warunkiem, że system operacyjny obsługuje tę
funkcjonalność).
fs.promises.copyFile("Ważne dane",
`Ważne dane ${new Date().toISOString()}"
fs.constants.COPYFILE_EXCL |
fs.constants.COPYFILE_FICLONE)
.then(() => {
console.log("Kopia wykonana");
});
.catch(err => {
console.error("Błąd podczas wykonywania kopii", err);
});
Funkcja fs.rename() (oraz jej odmiany synchroniczna i oparta na promesie) przenosi plik w
inne miejsce lub zmienia jego nazwę. W jej argumentach umieszcza się bieżącą i nową ścieżkę
pliku. Nie można określić flag operacji. W jednej z odmian można w trzecim argumencie
umieścić funkcję zwrotną:
fs.renameSync("ch15.bak", "backups/ch15.bak");
Zwróć uwagę, że nie można określić flagi zabezpieczającej przed nadpisaniem istniejącego
pliku.
Funkcje fs.link(), fs.symlink() oraz ich odmiany mają takie same argumenty jak funkcja
fs.rename() i działają podobnie jak fs.copyFile() z tą różnicą, że nie kopiują pliku, tylko
tworzą, odpowiednio, twardy i symboliczny odnośnik.
Ostatnie funkcje to fs.unlink(), fs.unlinkSync() i fs.promises.unlink() służące do
usuwania plików. (Ich nieintuicyjne nazwy są spuścizną po systemie Unix, w którym usunięcie
pliku jest operacją odwrotną do utworzenia twardego odnośnika do niego). Argumentem każdej
z nich jest ciąg znaków, bufor lub adres URL usuwanego pliku. W jednej z odmian można
również określić funkcję zwrotną. Poniżej jest przedstawiony przykład:
fs.unlinkSync("backups/ch15.bak");
Metadane pliku można nie tylko odczytywać za pomocą funkcji fs.stat() i jej odmian, ale
również modyfikować.
Funkcje fs.chmod(), fs.lchmod() i fs.fchmod() (wraz odmianami synchronicznymi i opartymi
na promesach) służą do określania trybu, czyli uprawnień dostępu do pliku lub katalogu. Tryb
definiuje się za pomocą liczby całkowitej, której poszczególne bity mają określone znaczenie.
Najbardziej czytelna jest ósemkowa reprezentacja tej liczby. Na przykład aby właściciel mógł
odczytywać plik, a żaden inny użytkownik nie miał do niego dostępu, należy użyć liczby 0o400:
fs.chmodSync("ch15.md", 0o400); // Zabezpieczenie przed przypadkowym
usunięciem.
Funkcje fs.chown(), fs.lchown() i fs.fchown() (wraz z odmianami synchronicznymi i
opartymi na promesach) wykorzystuje się do ustawiania identyfikatorów użytkownika oraz
grupy użytkowników pliku lub katalogu. Są to ważne ustawienia, ponieważ wpływają na
uprawnienia określone za pomocą funkcji fs.chmod().
Ponadto czas dostępu do pliku lub katalogu oraz jego modyfikacji można zmienić, odpowiednio,
za pomocą funkcji fs.utimes() i fs.futimes() (oraz ich odmian).
} finally {
// Usunięcie katalogu, gdy nie jest już potrzebny.
fs.rmdirSync(tempDirPath);
}
Moduł "fs" oferuje dwa osobne interfejsy API wyświetlające zawartość katalogu. Pierwszy
zawiera funkcje fs.readdir(), fs.readdirSync() i fs.promises.readdir() odczytujące od
razu zawartość całego katalogu i umieszczające ją w tablicy ciągów znaków lub tablicy
obiektów Dirent opisujących nazwy i typy poszczególnych katalogów i plików. Wymienione
wyżej funkcje zwracają krótkie nazwy plików, a nie całe ścieżki. Poniższy kod przedstawia
przykład użycia jednej z powyższych funkcji.
let tempFiles = fs.readdirSync("/tmp"); // Funkcja zwracająca tablicę ciągów
znaków.
// Utworzenie tablicy obiektów Dirent za pomocą funkcji opartej na promesie,
a następnie wyświetlenie
// ścieżek podkatalogów.
fs.promises.readdir("/tmp", {withFileTypes: true})
.then(entries => {
entries.filter(entry => entry.isDirectory())
.map(entry => entry.name)
.forEach(name => console.log(path.join("/tmp/", name)));
})
.catch(console.error);
Jeżeli katalog zawiera tysiące plików i podkatalogów, lepszym podejściem jest użycie
strumieniowej funkcji fs.opendir() lub jej odmiany. Każda z tych funkcji zwraca obiekt Dir
reprezentujący wskazany katalog. Za pomocą metod read() i readSync() można odczytywać
kolejne obiekty Dirent reprezentujące poszczególne pliki i podkatalogi. W argumencie metody
read() można umieścić funkcję zwrotną. Jeżeli się tego nie zrobi, metoda zwróci promesę. Po
odczytaniu ostatniego pliku lub podkatalogu metoda zwraca wynik null.
Z obiektu Dir najłatwiej korzysta się za pomocą iteratora i pętli for/await. Poniżej jest
pokazany przykład funkcji, która wykorzystuje strumieniowy interfejs API do wyświetlenia
zawartości katalogu. Wywołuje funkcję stat(), aby wyświetlić nazwy oraz wielkości plików i
katalogów.
const fs = require("fs");
console.log(String(size).padStart(10), name);
}
}
* w formacie JSON.
*/
function postJSON(host, endpoint, body, port, username, password) {
// Natychmiastowe zwrócenie obiektu promesy, która zostanie spełniona lub
odrzucona,
// odpowiednio, w przypadku pomyślnego lub niepomyślnego wysłania zapytania
HTTPS.
return new Promise((resolve, reject) => {
// Przekształcenie obiektu body w ciąg znaków.
let bodyText = JSON.stringify(body);
// Przygotowanie zapytania HTTPS.
let requestOptions = {
// …JSON…
resolve(JSON.parse(body)); // …i determinujemy promesę.
} catch(e) { // Jeżeli coś pójdzie źle…
reject(e); // …odrzucamy promesę i
zgłaszamy błąd.
}
});
});
});
}
Moduły "http" i "https" służą nie tylko do wysyłania zapytań HTTP i HTTPS, ale też do
tworzenia programów serwerowych, które odpowiadają na tego rodzaju zapytania. Ogólnie
wykonywane są następujące operacje:
// katalogu.
function serve(rootDirectory, port) {
let server = new http.Server(); // Utworzenie obiektu serwera.
server.listen(port); // Określenie portu.
console.log("Nasłuch na porcie ", port);
// Odbierane zapytania są przetwarzane za pomocą poniższej funkcji.
server.on("request", (request, response) => {
// Wyodrębnienie głównej części adresu URL (pomijamy dołączone do niego
parametry).
request.httpVersion
}\r\n`);
// Dodanie do odpowiedzi nagłówków zapytania.
let headers = request.rawHeaders;
for(let i = 0; i < headers.length; i += 2) {
response.write(`${headers[i]}: ${headers[i+1]}\r\n`);
}
// Dopisanie podziału wiersza.
response.write("\r\n");
case ".html":
case ".htm": type = "text/html"; break;
case ".js": type = "text/javascript"; break;
case ".css": type = "text/css"; break;
case ".png": type = "image/png"; break;
case ".txt": type = "text/plain"; break;
default: type = "application/octet-stream"; break;
}
let stream = fs.createReadStream(filename);
stream.once("readable", () => {
// Gdy strumień jest gotowy do odczytu, ustawiamy nagłówek Content-
Type i kod stanu 200 OK.
});
}
// Funkcję serve() wywołuje się w wierszu poleceń w następujący sposób:
serve(process.argv[2] || "/tmp", parseInt(process.argv[3]) || 8000);
Do utworzenia prostego programu serwerowego, wykorzystującego protokół HTTP lub HTTPS,
w zupełności wystarczą wbudowane moduły środowiska Node. Pamiętaj jednak, że programy
produkcyjne zazwyczaj nie wykorzystują tych modułów. Większość poważnych serwerów
implementuje się za pomocą zewnętrznych bibliotek, na przykład Express, oferujących
oprogramowanie pośredniczące i wysokopoziomowe narzędzia, niezbędne dla twórców
serwerów WWW.
output: socket,
prompt: ">> "
});
// Funkcja pomocnicza wyświetlająca wiersz tekstu, a następnie (domyślnie)
znaki zachęty.
function output(text, prompt=true) {
socket.write(`${text}\r\n`);
if (prompt) lineReader.prompt();
}
}
}
}
Prosty serwer tekstowy, taki jak powyższy, zazwyczaj nie wymaga użycia specjalnego programu
klienckiego. Jeżeli w systemie jest zainstalowane narzędzie nc („netcat”), można go użyć w
następujący sposób:
$ nc localhost 6789
Puk, puk!
>> Kto tam?
Damy
>> Jakie damy?
Damy radę!
Z drugiej strony łatwo jest w środowisku napisać własny program kliencki dla serwera takiego
jak powyższy. Wystarczy nawiązać z nim połączenie, skierować za pomocą strumienia wyjście
serwera do wyjścia stdout, jak również wejście stdin do wejścia serwera:
// Nawiązanie połączenia ze wskazanym serwerem i portem.
let socket = require("net").createConnection(6789, process.argv[2]);
socket.pipe(process.stdout); // Przekierowanie danych z gniazda
do wyjścia stdout.
process.stdin.pipe(socket); // Przekierowanie danych z wejścia
stdin do gniazda.
socket.on("close", () => process.exit()); // Wyjście po zamknięciu
połączenia.
Moduł "net" obsługuje nie tylko komunikację sieciową opartą na protokole TCP, ale również
komunikację międzyprocesową, w której zamiast numeru portu jest wykorzystywane gniazdo
domenowe Unix określone za pomocą ścieżki systemowej. Tego rodzaju gniazdo nie jest opisane
w tym rozdziale. Szczegółowe informacje można znaleźć w dokumentacji. Inne funkcjonalności
środowiska Node, które nie zostały tu przedstawione, to moduł "dgram" do komunikacji
pomiędzy serwerem a klientami za pomocą protokołu UDP oraz moduł "tls", który tak ma się
do modułu "net" jak moduł "https" do "http". Za pomocą klas tls.Server i tls.TLSSocket
można tworzyć programy serwerowe wykorzystujące protokół TCP (jak w powyższym
przykładzie) do nawiązywania połączeń szyfrowanych za pomocą protokołu SSL, tak jak to robi
serwer HTTPS.
Funkcje exec() i execFile() działają podobnie jak ich synchroniczne odmiany z tą różnicą, że
każda z nich natychmiast zwraca obiekt ChildProcess reprezentujący uruchomiony proces
potomny. W ostatnim argumencie funkcji można umieścić funkcję zwrotną, wywoływaną w
chwili zakończenia działania procesu potomnego. Funkcja zwrotna ma trzy argumenty. W
pierwszym jest umieszczany obiekt błędu, jeżeli taki się pojawi, lub null, jeżeli proces zakończy
działanie pomyślnie. Argumenty drugi i trzeci zawierają dane wysłane przez proces,
odpowiednio, do standardowego strumienia wyjściowego i strumienia błędów.
Za pomocą obiektu ChildProcess zwróconego przez funkcje exec() i execFile() można
przerwać działanie procesu potomnego lub wysłać do niego dane (które proces może odczytać
ze swojego standardowego wejścia). Obiekt ChildProcess będzie dokładniej opisany w części
poświęconej funkcji child_process.spawn().
W celu jednoczesnego uruchomienia kilku procesów potomnych najlepiej jest użyć opartej na
promesie odmiany funkcji exec(). Funkcja ta zwraca promesę, która w przypadku pomyślnego
zakończenia działania procesu tworzy obiekt zawierający właściwości stdout i stderr. Poniżej
jest przedstawiony przykład funkcji, której argumentem jest tablica zawierająca polecenia
powłoki. Funkcja zwraca promesę tworzącą obiekt zawierający wyniki uruchomienia wszystkich
poleceń.
const child_process = require("child_process");
const util = require("util");
Jeżeli program musi wykonywać więcej operacji, niż jest w stanie obsłużyć jeden procesor,
to za pomocą wątków można te operacje rozłożyć na kilka procesorów. Obecnie jest to
powszechnie stosowane rozwiązanie. Dzięki niemu można na przykład przeznaczyć więcej
mocy na wykonywanie obliczeń naukowych, tworzenie modelu uczenia maszynowego czy
przetwarzanie obrazu.
Nawet jeżeli program nie wykorzystuje całej mocy jednego procesora, warto uruchamiać
dodatkowe wątki, aby zapewnić responsywność kodu działającego w głównym wątku.
Przeanalizujmy przypadek serwera przetwarzającego niewielkie ilości dużych zapytań.
Załóżmy, że zapytania pojawiają się co sekundę, a przetwarzanie każdego z nich zajmuje
pół sekundy. W takim przypadku średnie obciążenie procesora jest równe 50%. Jeżeli
jednak dwa zapytania pojawią się w odstępie kilku milisekund, serwer nie będzie mógł
przetworzyć drugiego zapytania, zanim nie wykona wszystkich operacji związanych z
obsługą pierwszego. Gdyby natomiast serwer mógł uruchamiać kilka wątków, wówczas
oba zapytania mógłby zacząć przetwarzać natychmiast, co przełożyłoby się na lepszą
obsługę klientów. Serwer wyposażony w kilka procesorów mógłby równolegle
przygotowywać treści odpowiedzi na oba zapytania. Jednak nawet w przypadku
dostępności tylko jednego procesora wątki robocze poprawiłyby responsywność
programu.
Ogólnie dzięki wątkom można operacje blokujące wykonywać w sposób nieblokujący.
Jeżeli tworzony program wykorzystuje starszy, synchroniczny kod, można go uruchamiać
w osobnym wątku roboczym, dzięki czemu nie będzie blokowany główny wątek.
Wątki robocze nie obciążają systemu w takim samym stopniu jak procesy potomne, ale też nie
są nieważkie. Dlatego nie należy ich używać do wykonywania prostych operacji. Można przyjąć
ogólną zasadę, że jeżeli program nie korzysta intensywnie z procesora i nie ma problemów z
jego responsywnością, nie należy stosować wątków roboczych.
W powyższym przykładzie jest zdefiniowana klasa Worker reprezentująca wątek roboczy. Nowy
wątek tworzy się za pomocą konstruktora threads.Worker(). Kod demonstruje tworzenie
wątku za pomocą tego konstruktora oraz przesyłanie komunikatów pomiędzy wątkami głównym
i roboczym. Zastosowana jest w nim również sztuczka polegająca na uruchomieniu w obu
wątkach tego samego kodu zapisanego w jednym pliku[2].
const threads = require("worker_threads");
// jeżeli kod jest uruchomiony w głównym wątku, lub false, jeżeli w wątku
roboczym.
// roboczym
// i zwraca promesę determinowaną po zakończeniu jego działania.
reticulator.postMessage(splines);
// Spełnienie promesy po odebraniu komunikatu z wątku roboczego lub
odrzucenie jej w przypadku
reticulator.on("error", reject);
});
};
} else {
}
// Po przeliczeniu wszystkich elementów tablicy splines odsyłamy jej
kopię do wątku głównego.
threads.parentPort.postMessage(splines);
});
}
Pierwszym argumentem konstruktora Worker() jest ścieżka pliku zawierającego kod JavaScript
przeznaczony do uruchomienia w wątku. W powyższym przykładzie do utworzenia wątku
roboczego ładującego i uruchamiającego ten sam kod co wątek główny jest użyty
predefiniowany identyfikator __filename. Ogólnie jednak w argumencie umieszcza się ścieżkę
pliku. Pamiętaj, że względna ścieżka jest interpretowana w odniesieniu do katalogu zwracanego
przez metodę process.cwd(), a nie katalogu, w którym znajduje się wykonywany kod. Jeżeli
względna ścieżka ma być interpretowana względem tego katalogu, należy użyć na przykład
funkcji path.resolve(__dirname, 'workers/reticulator.js').
Oprócz tego konstruktor Worker() ma drugi argument, w którym można umieścić obiekt
zawierający opcje konfiguracyjne wątku roboczego. Opcje te będą opisane w dalszej części
rozdziału. Na razie zwróć uwagę, że jeżeli w drugim argumencie konstruktora zostanie
umieszczony obiekt {eval: true}, to pierwszy argument zostanie zinterpretowany jako kod
JavaScript, a nie nazwa pliku:
new threads.Worker(`
const threads = require("worker_threads");
threads.parentPort.postMessage(threads.isMainThread);
`, {eval: true}).on("message", console.log); // Pojawi się napis "false".
Jak już wiesz, właściwość threads.isMainThread przyjmuje wartość true, jeżeli kod
działa w wątku głównym, lub false, jeżeli w wątku roboczym.
W wątku roboczym komunikaty do wątku głównego można wysyłać za pomocą funkcji
threads.parentPort.postMessage(), a odbierać za pomocą funkcji przypisanej
właściwości threads.parentPort.on. W głównym wątku właściwość ta ma wartość null.
W wątku roboczym właściwość threads.workerData zawiera kopię właściwości
workerData obiektu umieszczonego w drugim argumencie konstruktora Worker(). W
głównym widoku właściwość ta ma wartość null. Właściwość tę można wykorzystać do
wysłania komunikatu, który wątek roboczy będzie mógł odebrać zaraz po uruchomieniu,
nie czekając na zdarzenie "message".
Domyślnie właściwość process.env w wątku roboczym zawiera kopię właściwości
process.env z wątku głównego. Jednak wątek główny może za pomocą obiektu
umieszczonego w drugim argumencie konstruktora Worker() określić niestandardowy
zestaw zmiennych środowiskowych. Specjalnym (i niebezpiecznym) przypadkiem jest
przypisanie właściwości env wartości threads.SHARE_ENV powodującej współdzielenie
przez oba wątki tych samych zmiennych. Zmiany wprowadzone przez jeden wątek będą
widoczne w drugim wątku.
Domyślnie strumień process.stdin nie zawiera danych, które mógłby odczytać wątek
roboczy. Można to jednak zmienić, umieszczając w drugim argumencie konstruktora
Worker() obiekt zawierający właściwość stdin: true. W takim przypadku właściwość
stdin obiektu Worker będzie zawierała obiekt Writable i dane wysłane przez wątek
główny do strumienia worker.stdin będą dostępne dla wątku roboczego w strumieniu
process.stdin.
Domyślnie strumienie process.stdout i process.stderr w widoku roboczym są po
prostu skierowane do odpowiednich strumieni wątku głównego. Oznacza to, że na
przykład funkcje console.log() i console.error(), niezależnie od tego, w jakim wątku
są wywoływane, działają tak samo. To domyślne działanie można zmienić, umieszczając w
drugim argumencie konstruktora Worker() obiekt zawierający właściwość stdout:true
lub stderr:true. Wtedy dane wysłane przez wątek roboczy do tych strumieni są dostępne
w widoku głównym w strumieniach worker.stdout i worker.stderr. Jest to myląca
inwersja kierunków strumieni, tak jak w przypadku opisanych wcześniej procesów
potomnych. Strumień wyjściowy wątku roboczego staje się strumieniem wejściowym
wątku nadrzędnego, a strumień wejściowy wątku roboczego staje się strumieniem
wyjściowym wątku nadrzędnego.
Jeżeli w wątku roboczym zostanie wywołana funkcja process.exit(), działanie zakończy
tylko wątek, a nie cały proces.
Wątek roboczy nie może zmieniać stanu procesu, którego jest częścią. Wywołanie w
wątku roboczym funkcji process.chdir() lub process.setuid() skutkuje zgłoszeniem
wyjątku.
Sygnały wysyłane przez system operacyjny, na przykład SIGINT lub SIGTERM, są
dostarczane tylko do głównego wątku i nie można ich odbierać i obsługiwać w wątku
roboczym.
Zwróć uwagę, że w powyższym kodzie tworzona jest para obiektów MessagePort, które są
wykorzystywane do przesyłania komunikatów w ramach głównego wątku. Aby utworzyć
niestandardowy kanał komunikacyjny dla wątków roboczych, należy przenieść jeden z obiektów
z głównego wątku, w którym został utworzony, do wątku roboczego, w którym będzie
stosowany. Następny punkt opisuje, jak to zrobić.
Poniższy przykład pokazuje, jak tworzy się obiekt MessageChannel i przenosi jeden z obiektów
MessagePorts do wątku roboczego.
// komunikatów.
worker.postMessage({ command: "changeChannel", data: channel.port1 },
[ channel.port1 ]);
// Przesłanie komunikatu do wątku roboczego za pomocą niestandardowego
kanału.
channel.port2.postMessage("Czy mnie słyszysz?");
Przenosić można nie tylko obiekty MessagePort. Jeżeli w argumencie metody postMessage()
umieści się typowaną tablicę (lub obiekt zawierający jedną lub kilka dowolnie głęboko
zagnieżdżonych tablic), zostanie ona po prostu skopiowana za pomocą algorytmu
strukturalnego klonowania. Jednak tablica może być duża, na przykład gdy wątek roboczy jest
wykorzystywany do przetwarzania obrazów składających się z milionów pikseli. Dlatego metoda
postMessage() pozwala przenosić tablice bez kopiowania. Wątki domyślnie współdzielą
pamięć, ale wątki robocze w kodzie JavaScript zazwyczaj jej nie współdzielą i jeżeli proces
przenoszenia danych jest odpowiednio kontrolowany, okazują się bardzo wydajne. Operacja jest
bezpieczna, ponieważ tablica po przeniesieniu do innego wątku nie jest już dostępna w wątku
źródłowym. W przypadku przetwarzania obrazu główny wątek może przenosić piksele do wątku
roboczego, a wątek roboczy po ich przetworzeniu może je z powrotem przenosić do wątku
głównego. Pamięć nie będzie wtedy kopiowana, ale nie będzie też dostępna dla obu wątków
jednocześnie.
Aby przenieść typowaną tablicę bez jej kopiowania, należy w drugim argumencie metody
postMessage() umieścić obiekt ArrayBuffer:
// obiekt Buffer.
worker.postMessage(pixels, [ pixels.buffer ]);
Podobnie jak w przypadku obiektu MessagePort, typowana tablica po przeniesieniu nie jest
dostępna. W przypadku odwołania się do obiektu lub tablicy po jej przeniesieniu nie jest
zgłaszany wyjątek. Tego rodzaju operacje po prostu nie są wykonywane.
Należy jednak unikać stosowania tego podejścia, ponieważ język JavaScript z założenia nie jest
wątkowo bezpieczny, a pisanie poprawnego wielowątkowego kodu jest bardzo trudne. (Obiekt
SharedArrayBuffer nie został opisany w podrozdziale 11.2, ponieważ trudno jest umiejętnie
korzystać z jego nietypowej funkcjonalności). Nawet prosty operator ++ nie jest bezpieczny
wątkowo, ponieważ odczytuje wartość, zwiększa ją i zapisuje z powrotem. Jeżeli te operacje
będą wielokrotnie wykonywać dwa wątki jednocześnie, w większości przypadków wartość
będzie zwiększana tylko raz. Ilustruje to poniższy kod.
worker.on("online", () => {
for(let i = 0; i < 10_000_000; i++) sharedArray[0]++;
});
});
} else {
// W wątku roboczym odczytujemy współdzieloną tablicę z właściwości
workerData i zwiększamy jej
// element 10 milionów razy.
threads.parentPort.postMessage("done");
}
Użycie obiektu SharedArrayBuffer może być uzasadnione na przykład w sytuacji, gdy dwa
wątki operują na zupełnie osobnych obszarach współdzielonej pamięci. Efekt ten można
uzyskać, tworząc dwie typowane tablice w nienakładających się na siebie obszarach
współdzielonego bufora. Dwa wątki mogą wtedy przetwarzać osobne tablice. W ten sposób
można na przykład równolegle sortować dane. Jeden wątek może sortować pierwszą połowę
bufora, a drugi wątek drugą połowę. Takie podejście można również stosować przy
przetwarzaniu obrazów. Poszczególne wątki mogą operować na oddzielnych fragmentach tego
samego obrazu.
Jeżeli kilka wątków musi mieć jednoczesny dostęp do tego samego obszaru współdzielonej
tablicy, należy użyć zdefiniowanych w obiekcie Atomics funkcji zapewniających bezpieczeństwo
wątków. Obiekt ten został wprowadzony do języka JavaScript, aby umożliwić wykonywanie za
pomocą obiektu SharedArrayBuffer atomicznych operacji na elementach współdzielonej
tablicy. Na przykład metoda Atomics.add() odczytuje zawartość wskazanego elementu, dodaje
do niego zadaną wartość i zapisuje wynik w elemencie. Operacje te są wykonywane atomicznie,
tj. tak jakby była to jedna operacja. Metodę Atomics.add() można wykorzystać w
przedstawionym wyżej kodzie, dzięki czemu element tablicy zostanie poprawnie zwiększony
20 000 000 razy.
const threads = require("worker_threads");
if (threads.isMainThread) {
let sharedBuffer = new SharedArrayBuffer(4);
worker.on("online", () => {
console.log(Atomics.load(sharedArray, 0));
});
});
} else {
let sharedArray = threads.workerData;
threads.parentPort.postMessage("done");
}
Kod w nowej wersji wyświetla poprawną wartość 20 000 000, ale działa około 9 razy wolniej niż
jego błędny oryginał. Dlatego o wiele prostszym i szybszym rozwiązaniem jest zwiększenie
elementu 20 000 000 razy w jednym wątku. Zwróć również uwagę, że operacje atomiczne
umożliwiają implementowanie bezpiecznych wątkowo algorytmów przetwarzania obrazów, pod
warunkiem, że poszczególne elementy tablicy są od siebie całkowicie niezależne. Jednak w
większości praktycznych programów elementy są ze sobą powiązane i wymagana jest
wysokopoziomowa synchronizacja wątków. Można ją osiągnąć za pomocą niskopoziomowych
funkcji Atomics.wait() i Atomics.notify(), ale jest to temat wykraczający poza zakres tej
książki.
16.12. Podsumowanie
Język JavaScript powstał z myślą o stosowaniu go w przeglądarkach, ale dzięki środowisku
Node jest językiem ogólnego przeznaczenia. Jest szczególnie często wykorzystywany do
tworzenia programów serwerowych, a dzięki ścisłym powiązaniom z systemem operacyjnym
stanowi dobrą alternatywę dla skryptów powłoki.
[1] W praktyce do tego celu wykorzystuje się funkcję fs.copyFile() środowiska Node.
[2] Często prostszym i bardziej czytelnym podejściem jest umieszczenie kodu wątku roboczego
w osobnym pliku. Jednak sztuczka polegająca na uruchomieniu w dwóch wątkach tego samego
kodu zawiodła mnie, gdy musiałem za pomocą funkcji fork() wykonywać polecenia systemu
Unix. Uznałem jednak, że warto o niej wspomnieć ze względu na jej nietypową elegancję.
Rozdział 17.
Narzędzia i rozszerzenia
Gratulacje! Dotarłeś do ostatniego rozdziału książki. Jeżeli przeczytałeś wszystkie wcześniejsze,
posiadasz już rozległą wiedzę o języku JavaScript oraz jego zastosowaniu w środowisku Node
i przeglądarkach. Ten rozdział jest swego rodzaju nagrodą na zakończenie szkoły. Opisuje kilka
ważnych narzędzi, z których korzysta wielu programistów, oraz dwa ważne rozszerzenia języka.
Niezależnie od tego, czy będziesz korzystał z tych narzędzi i rozszerzeń, czy nie, możesz być
prawie pewien, że spotkasz się z nimi w innych projektach. Dlatego warto przynajmniej
wiedzieć, że są.
W tym rozdziale opisano następujące narzędzia i rozszerzenia języka JavaScript:
if (x == 1) {
return 1;
} else {
return x * factorial(x-1)
}
}
Po przetworzeniu tego kodu za pomocą narzędzia ESLint pojawią się następujące komunikaty:
$ eslint code/ch17/linty.js
code/ch17/linty.js
Narzędzie ESLint zawiera wiele predefiniowanych reguł i dostępnych jest wiele wtyczek
implementujących kolejne reguły. Jest elastycznie konfigurowalne, a w specjalnym pliku można
precyzyjnie określić, które reguły mają być stosowane.
Nowoczesną alternatywą dla wprowadzania przy użyciu lintera dyscypliny formatowania kodu
jest automatyczne analizowanie i formatowanie kodu za pomocą narzędzia takiego jak Prettier
(https://prettier.io).
Przeanalizujmy kod poniższej funkcji, który jest poprawny, ale nietypowo sformatowany:
function factorial(x)
if(x===1){return 1}
else{return x*factorial(x-1)}
}
Narzędzie Prettier między innymi poprawia wcięcia, uzupełnia średniki, umieszcza spacje przed
operatorami dwuargumentowymi i po nich, łamie wiersze po nawiasach klamrowych { i }. W
efekcie powstaje kod o bardziej konwencjonalnym wyglądzie:
$ prettier factorial.js
function factorial(x) {
if (x === 1) {
return 1;
} else {
}
Narzędzie Prettier użyte z argumentem --write zamiast wyświetlać sformatowaną wersję
kodu, modyfikuje go w pliku. Jeżeli używasz narzędzia git do zarządzania kodem źródłowym,
możesz w fazie zatwierdzania uruchamiać narzędzie Prettier z argumentem --write, dzięki
czemu kod będzie automatycznie formatowany przed umieszczeniem go w repozytorium.
Narzędzie Prettier jest szczególnie użyteczne, gdy automatycznie uruchamia je edytor przed
zapisaniem pliku. W moim odczuciu przyzwolenie na bałaganiarskie pisanie kodu, który jest
automatycznie poprawiany, jest całkiem przyjemne.
Narzędzie jest konfigurowalne, ale oferuje tylko kilka opcji, za pomocą których można m.in.
określić maksymalną długość wiersza, głębokości wcięć, stosowanie średników oraz apostrofy
lub cudzysłowy dla ciągów znaków. Zazwyczaj najbardziej odpowiednie są domyślne opcje.
Narzędzie powstało po to, aby można było raz zdefiniować zasady formatowania kodu i więcej o
nich nie myśleć.
Lubię stosować program Prettier w swoich projektach. Nie używałem go jednak z przykładami
prezentowanymi w tej książce, ponieważ umieściłem w nich wiele komentarzy, które ręcznie
wyrównałem, a Prettier zaburzałby ten porządek.
/**
*/
`https://globaltemps.example.com/api/city/${city.toLowerCase()}`
);
};
Dobry zestaw testów w tym przypadku powinien weryfikować, czy funkcja getTemperature()
wysyła zapytanie na właściwy adres URL oraz czy poprawnie przelicza skale temperatur.
Poniżej jest przedstawiony odpowiedni test przygotowany za pomocą narzędzia Jest. Zawiera on
atrapę funkcji getJSON(), dzięki której zapytanie musi być wysyłane do serwisu. Ponieważ
getTemperature() jest funkcją asynchroniczną, test również taki jest. Testowanie kodu
asynchronicznego jest trudnym zadaniem, ale narzędzie Jest bardzo je ułatwia.
jest.mock("./getJSON");
getJSON.mockResolvedValue(0);
describe("getTemperature()", () => {
let t = await(getTemperature("Vancouver"));
expect(getJSON).toHaveBeenCalledWith(expectedURL);
});
});
});
Po przygotowaniu testu wpisujemy polecenie jest. Okazuje się, że jeden test wypadł
niepomyślnie.
$ jest getTemperature
FAIL ch17/getTemperature.test.js
getTemperature()
Expected: 212
Received: 87.55555555555556
| ^
32 | });
33 | });
34 |
at Object.<anonymous> (ch17/getTemperature.test.js:31:43)
Snapshots: 0 total
Time: 1.403s
PASS ch17/getTemperature.test.js
getTemperature()
------------------|--------|---------|---------|---------|------------------|
------------------|--------|---------|---------|---------|------------------|
Snapshots: 0 total
Time: 1.508s
Ran all test suites matching /getTemperature/i.
W tym przypadku pokrycie naszego modułu testami jest równe 100%, co jest pożądanym
wynikiem. Funkcja getJSON() jest pokryta częściowo, jednak w teście została użyta jej atrapa,
która nie jest objęta testami. Zatem uzyskany wynik jest zgodny z oczekiwaniami.
W punkcie 16.1.5 został opisany menedżer pakietów npm dostarczany razem ze środowiskiem
Node. Jest to program wykorzystywany zarówno podczas tworzenia kodu klienckiego dla
przeglądarek, jak i serwerowego dla środowiska Node.
Gdy będziesz chciał się zapoznać z projektem JavaScriptu utworzonym przez innego
programistę, jedną z pierwszych czynności, jakie będziesz wykonywał po pobraniu kodu, będzie
wpisanie polecenia npm install. Program npm odczyta z pliku package.json listę zewnętrznych
pakietów wykorzystywanych w projekcie i zapisze je w katalogu node_modules.
Czasami narzędzie programistyczne trzeba zainstalować globalnie, aby można je było stosować
nie tylko z kodami zarejestrowanymi w pliku package.json i zapisanymi w katalogu
node_modules. W tym celu należy użyć argumentu -g (od global):
+ jest@24.9.0
+ eslint@6.7.2
/usr/local/bin/eslint
$ which jest
/usr/local/bin/jest
Program npm oferuje oprócz install polecenia uninstall i update służące, odpowiednio, do
odinstalowywania i aktualizowania pakietów. Jest jeszcze ciekawe polecenie audit wyszukujące
luki w bezpieczeństwie zewnętrznych pakietów:
$ npm audit --fix
=== npm audit security report ===
found 0 vulnerabilities
in 876354 scanned packages
Niektóre programy mogą mieć kilka punktów wejścia. Na przykład aplikacja internetowa
składająca się z wielu stron może mieć na każdej z nich inny punkt wejścia. Narzędzie
pakujące zazwyczaj tworzy osobne pakiety dla poszczególnych punktów lub jeden pakiet
obsługujący wiele punktów.
W kodzie może być stosowana funkcja import() (patrz punkt 10.3.6), która dynamicznie
ładuje moduły wtedy, gdy są potrzebne, w odróżnieniu od instrukcji import ładującej
moduł od razu po uruchomieniu programu. Jest to dobry sposób na szybkie uruchamianie
kodu. Narzędzia pakujące, które obsługują funkcję import(), tworzą kilka pakietów:
główny, ładowany w chwili uruchomienia programu oraz dodatkowe, ładowane
dynamicznie w razie potrzeby. Takie rozwiązanie sprawdza się, gdy funkcja import() jest
rzadko wywoływana w kodzie, a moduły wykorzystują niezależne od siebie zbiory
zależności. Jeżeli jednak moduły współdzielą zależności, narzędziu trudno jest określić, ile
pakietów jest potrzebnych. W takim przypadku najlepszym rozwiązaniem jest jego ręczna
konfiguracja.
Narzędzia pakujące zazwyczaj tworzą mapę źródłową, czyli plik zawierający powiązania
pomiędzy wierszami spakowanego kodu a odpowiadającymi im wierszami w oryginalnych
plikach źródłowych. Dzięki temu narzędzia programistyczne w przypadku pojawienia się
błędu pokazują jego miejsce w oryginalnym pliku.
Czasami program wykorzystuje nieliczne funkcjonalności importowanego modułu. Dobre
narzędzie pakujące określa nieużywane fragmenty modułu i pomija je podczas tworzenia
pakietu. Operacja ta nosi wymyślną nazwę „potrząsanie drzewem”.
Narzędzia pakujące zazwyczaj mają architekturę umożliwiającą oddawanie wtyczek.
Dzięki wtyczkom można importować i pakować „moduły”, które nie są plikami JavaScript.
Załóżmy, że program wykorzystuje dużą strukturę danych kompatybilną z formatem JSON.
Narzędzie pakujące można skonfigurować tak, aby umieściło tę strukturę w osobnym
pliku, a następnie zaimportowało ją za pomocą instrukcji na przykład import widgets
from "./big-widget-list.json". Są również wtyczki umożliwiające importowanie do
programu plików CSS za pomocą instrukcji import. Należy jednak pamiętać, że
importując za pomocą takich wtyczek inne pliki niż JavaScript, uzależnia się kod od
konkretnego narzędzia pakującego.
Pakowanie kodu interpretowanego, takiego jak JavaScript, można porównać do jego
kompilacji. Uruchamianie narzędzia za każdym razem, gdy zostanie wprowadzona zmiana
w kodzie, aby można go było otworzyć w przeglądarce, może być frustrujące. Dlatego
narzędzia zazwyczaj wykorzystują systemowe sensory wykrywające zmiany w plikach
umieszczonych w wybranym katalogu projektu, dzięki czemu mogą automatycznie
generować pakiety. Kod jest wtedy automatycznie zapisywany, a strona w przeglądarce
natychmiast odświeżana, aby można było sprawdzić efekty.
Niektóre narzędzia pakujące oferują tryb „gorącej wymiany modułów”, w którym pakiet
po utworzeniu jest automatycznie ładowany do przeglądarki. Może to się wydawać magią,
ale w rzeczywistości wykorzystywane są tu pewne wewnętrzne mechanizmy systemowe.
Dlatego ten tryb może nie być dostępny w każdym projekcie.
Niektóre funkcjonalności języka, takie jak operator potęgowania ** czy funkcje strzałkowe,
można dość łatwo przekształcić w funkcję Math.pow() lub wyrażenie funkcyjne. Jednak inne
funkcjonalności, na przykład słowo kluczowe class, wymaga bardziej skomplikowanych
przekształceń, przez co kod uzyskany za pomocą narzędzia Babel nie jest czytelny dla
człowieka. Jednak podobnie jak w przypadku narzędzi pakujących tworzona jest mapa źródłowa
wiążąca wiersze kodu wynikowego z oryginalnym, co znakomicie ułatwia pracę.
Twórcy przeglądarek coraz lepiej sobie radzą z przystosowywaniem swoich produktów do
nieustannie rozwijającego się języka JavaScript i obecnie coraz rzadziej pojawia się potrzeba
przekształcania funkcji strzałkowych i deklaracji klas. Narzędzie Babel przydaje się wtedy, gdy
w kodzie są wykorzystywane najnowsze funkcjonalności języka, na przykład znak podkreślenia
oddzielający rzędy wielkości w literałach liczbowych.
Narzędzie Babel, podobnie jak inne narzędzia opisane w tym rozdziale, instaluje się za pomocą
menedżera npm, a uruchamia za pomocą polecenia npx. Narzędzie odczytuje plik
konfiguracyjny zawierający opcje przekształcania kodu JavaScript. Oprócz tego zdefiniowane są
gotowe zestawy opcji, które można stosować w zależności tego, jakie rozszerzenia języka mogą
być wykorzystywane i jak agresywnie mają być przekształcane standardowe funkcjonalności.
Jeden z ciekawych zestawów służy do „minifikowania” kodu, czyli kompresowania go poprzez
usunięcie komentarzy i białych znaków, skrócenie nazw zmiennych itp.
Jeżeli jest stosowany program do pakowania kodu, można go skonfigurować tak, aby
automatycznie uruchamiał narzędzie Babel podczas tworzenia pakietu. Jest to wygodne
rozwiązanie, upraszczające proces tworzenia wynikowego kodu. Na przykład narzędzie
webpack obsługuje moduł "babel-loader", który można zainstalować i skonfigurować tak, aby
narzędzie Babel przekształcało każdy pakowany moduł.
Obecnie coraz rzadziej pojawia się potrzeba przekształcania kodu JavaScript, ale narzędzie
Babel jest wciąż powszechnie stosowane razem z niestandardowymi rozszerzeniami języka.
Dwa z nich są opisane w kolejnych podrozdziałach.
Element JSX można traktować jako nowy typ wyrażenia. W języku JavaScript literały tekstowe
ujmuje się w apostrofy lub cudzysłowy, a wyrażenia regularne w ukośniki. Natomiast literały
JSX ujmuje się w znaki < i >. Poniżej jest przedstawiony prosty przykład:
src: "logo.png",
alt: "Logo JSX",
hidden: true
});
Elementy JSX, podobnie jak HTML, mogą zawierać elementy potomne, na przykład ciągi
znaków. Można je również dowolnie głęboko zagnieżdżać i tworzyć drzewa elementów:
let sidebar = (
<div className="sidebar">
<h1>Tytuł</h1>
<hr/>
<p>To jest pasek boczny</p>
</div>
);
Zwykłe funkcje można w języku JavaScript zagnieżdżać dowolnie głęboko, dlatego złożone
wyrażenia JSX są przekształcane na zagnieżdżone wywołania funkcji createElement().
Elementy potomne (zazwyczaj ciągi znaków i inne elementy JSX) są umieszczane w trzecim i
kolejnych argumentach funkcji:
<div className={className}>
<h1>{title}</h1>
{ drawLine && <hr/> }
<p>{content}</p>
</div>
);
}
}
Tak utworzony kod jest prosty i czytelny. Nawiasy klamrowe zostały usunięte, a wynikowy kod
umieszczony w zwykły sposób w argumentach funkcji React.createElement(). Zwróć uwagę
na zastosowaną tu ciekawą sztuczkę z argumentem drawLine i operatorem &&. Jeżeli funkcję
sidebar() wywoła się z trzema argumentami, argument drawLine uzyska domyślną wartość
true. W czwartym argumencie zewnętrznej funkcji createElement() zostanie wtedy
umieszczony element <hr/>. Jeżeli jednak w czwartym argumencie funkcji sidebar() umieści
się wartość false, to czwartym argumentem zewnętrznej funkcji createElement() też będzie
false i element <hr/> nie zostanie utworzony. Operator && jest często stosowany w
rozszerzeniu JSX do dołączania lub wykluczania elementów potomnych w zależności od
wartości określonego wyrażenia. Ten sposób sprawdza się w platformie React, która po prostu
pomija elementy potomne będące wartościami false lub null i nie generuje dla nich żadnego
kodu.
Wyrażenie JavaScript osadzone w wyrażeniu JSX nie musi być zwykłym ciągiem znaków lub
wartością logiczną, jak w powyższym przykładzie. Może to być dowolna wartość. W praktyce
bardzo często wykorzystuje się platformę React z obiektami, tablicami i funkcjami.
Przeanalizujmy następujący przykład:
return (
<ul style={ {padding:10, border:"solid red 4px"} }>
{items.map((item,index) => {
<li onClick={() => callback(index)} key={index}>{item}</li>
})}
</ul>
);
}
Powyższa funkcja wykorzystuje literał obiektowy jako wartość atrybutu style elementu <ul>
(zwróć uwagę na niezbędne nawiasy klamrowe). Element ten ma jeden element potomny,
którego wartością jest tablica utworzona za pomocą metody map() na podstawie tablicy
wejściowej użytej do utworzenia elementów <li>. Platforma React dopuszcza takie
rozwiązanie, ponieważ spłaszcza elementy pochodne podczas ich wyświetlania. Zwróć również
uwagę, że każdy z zagnieżdżonych elementów <li> posiada atrybut onClick, którego wartością
jest funkcja strzałkowa obsługująca zdarzenie. Powyższy kod JSX jest przekształcany na
pokazany niżej czysty kod JavaScript (który dodatkowo został sformatowany za pomocą
narzędzia Prettier):
"ul",
{ style: { padding: 10, border: "solid red 4px" } },
"li",
{ onClick: () => callback(index), key: index },
item
)
)
);
}
Oprócz tego rozszerzenie JSX pozwala definiować kilka atrybutów jednocześnie za pomocą
wyrażenia obiektowego i operatora rozciągania (patrz punkt 6.10.4). Załóżmy, że tworzymy
dużo wyrażeń JSX, w których wykorzystywany jest ten sam zestaw atrybutów. Pracę można
sobie uprościć, definiując argumenty jako właściwości obiektu, a następnie rozciągając je na
elementy JSX:
"\u05E9\u05DC\u05D5\u05DD");
Jest jeszcze jedna ważna funkcjonalność rozszerzenia JSX, która nie została tu opisana. Jak
zapewne zauważyłeś, element JSX definiuje się za pomocą identyfikatora umieszczanego zaraz
za znakiem <. Jeżeli pierwsza litera identyfikatora jest mała (jak we wszystkich
zaprezentowanych przykładach), to taki identyfikator jest umieszczany w argumencie funkcji
createElement() jako ciąg znaków. Jeżeli natomiast litera jest wielka, identyfikator jest
traktowany w zwykły sposób, tj. w argumencie funkcji createElement() jest umieszczana jego
wartość. Oznacza to, że wyrażenie JSX <Math/> jest przekształcane w kod JavaScript, w którym
w argumencie funkcji React.createElement() jest umieszczany globalny obiekt Math.
Opisana funkcjonalność, umożliwiająca umieszczanie w pierwszym argumencie funkcji
createElement() wartości innych niż ciągi znaków, pozwala tworzyć komponenty za pomocą
platformy React. Komponent, którego nazwa rozpoczyna się wielką literą, jest prostym
wyrażeniem JSX, reprezentującym bardziej złożone wyrażenie wykorzystujące znaczniki HTML
zapisane małymi literami.
W platformie React nowy komponent najprościej definiuje się, tworząc funkcję, której
argumentem jest „obiekt właściwości”, a zwracanym wynikiem wyrażenie JSX. Obiekt
właściwości jest zwykłym obiektem reprezentującym wartości atrybutów, podobnie jak obiekt
umieszczany w drugim argumencie funkcji createElement(). Poniżej jest przedstawiona
kolejna odmiana funkcji sidebar():
function Sidebar(props) {
return (
<div>
<h1>{props.title}</h1>
{ props.drawLine && <hr/> }
<p>{props.content}</p>
</div>
);
}
Funkcja Sidebar() jest bardzo podobna do przedstawionej wcześniej funkcji sidebar(). Jednak
w tej wersji jej nazwa rozpoczyna się wielką literą, a argument jest jeden, a nie kilka. Funkcja w
takiej postaci reprezentuje komponent React i w wyrażeniu JSX można jej użyć w miejscu
nazwy znacznika HTML:
let sidebar = <Sidebar title="Tytuł paska" content="Zawartość paska"/>;
Element <Sidebar/> zostanie przekształcony w następujący kod:
Niniejszy podrozdział jest przewodnikiem i nie opisuje wyczerpująco całego rozszerzenia. Jeżeli
zdecydujesz się z niego korzystać, niemal na pewno będziesz musiał poświęcić wiele czasu na
przeczytanie dokumentacji dostępnej na stronie https://flow.org. Jednak aby zacząć stosować
rozszerzenie w praktyce, nie musisz zagłębiać się w szczegóły systemu typów. W tę daleką
drogę zabiorą Cię opisane tu proste przykłady.
Aby rozszerzenie Flow mogło wyszukiwać błędy w kodzie, wystarczy umieścić w nim komentarz
// @flow. Dzięki temu, nawet jeżeli w kodzie nie umieści się adnotacji typów, rozszerzenie
będzie w stanie wywnioskować typy danych na podstawie wartości i powiadomić o
ewentualnych niedogodnościach.
Przeanalizujmy następujący komunikat o błędzie:
Error ------------------------------------------------------
variableReassignment.js:6:3
Cannot assign 1 to i.r because:
• property r is missing in number [1].
2│ let i = { r: 0, i: 1 }; // Liczba zespolona 0+1i.
[1] 3│ for(i = 0; i < 10; i++) { // Błąd! Zmienna w pętli nadpisuje liczbę
i.
4│ console.log(i);
5│ }
6│ i.r = 1; // Rozszerzenie Flow wykrywa w tym miejscu
błąd.
W tym przykładzie jest zadeklarowana zmienna i, której jest przypisywany obiekt. Ta zmienna
jest następnie wykorzystywana w pętli, co powoduje nadpisanie obiektu. Rozszerzenie Flow
wykrywa ten błąd i przy próbie ponownego użycia zmiennej jako obiektu wyświetla komunikat.
Prostym rozwiązaniem jest w takim przypadku wprowadzenie zmiany for (let i = 0; ...),
dzięki której i stanie się lokalną zmienną w pętli.
Poniżej jest przedstawiony przykład innego błędu, wykrytego w kodzie bez adnotacji:
Error -------------------------------------------------------------------
size.js:3:14
Cannot get x.length because property length is missing in Number [1].
1│ // @flow
2│ function size(x) {
3│ return x.length;
4│ }
[1] 5│ let s = size(1000);
Rozszerzenie Flow rejestruje, że funkcja size() ma jeden argument. Nie potrafi określić jego
typu, ale „widzi”, że jest wykorzystywana jego właściwość length. Następnie zauważa, że
funkcja size() jest wywoływana z liczbą w argumencie. Wyświetla uzasadniony komunikat o
błędzie, ponieważ liczba nie ma właściwości length.
Error -------------------------------------------------------------------
size2.js:5:18
Cannot call size with array literal bound to s because array literal [1]
is incompatible with string [2].
│
[2] 2│ function size(s: string): number {
3│ return s.length;
4│ }
[1] 5│ console.log(size([1,2,3]));
Adnotacjami można również opatrywać funkcje strzałkowe. Powoduje to jednak rozbudowanie
ich zwięzłej składni:
1│ // @flow
[2] 2│ const size = (s: string): number => s.length;
[1] 3│ console.log(size(null));
Aby móc wartość null lub undefined przypisywać zmiennej lub umieszczać ją w argumencie
funkcji, należy oznaczenie typu poprzedzić znakiem zapytania. Na przykład zamiast string lub
number należy wpisać, odpowiednio, ?string lub ?number. Jeżeli argument naszej funkcji
size() zostanie opatrzony adnotacją ?string, to w przypadku umieszczenia w nim wartości
null rozszerzenie nie zgłosi błędu, ale znajdzie inny:
Error -------------------------------------------------------------------
size4.js:3:14
Cannot get s.length because property length is missing in null or
undefined [1].
1│ // @flow
[1] 2│ function size(s: ?string): number {
3│ return s.length;
4│ }
5│ console.log(size(null));
W tym przypadku rozszerzenie informuje, że użycie odwołania s.length nie jest bezpieczne,
ponieważ argument s może przyjmować wartości null lub undefined, które nie posiadają
właściwości length. W ten właśnie sposób rozszerzenie Flow nie pozwala iść na skróty. Jeżeli
zmienna może przyjmować wartość null, rozszerzenie wymaga, aby sprawdzać taki przypadek,
zanim zostanie wykonana jakakolwiek operacja wymagająca użycia innej wartości.
W tym przypadku błąd można poprawić, zmieniając kod funkcji w następujący sposób:
function size(s: ?string): number {
// W tym miejscu argument s może zawierać ciąg znaków, wartość null lub
undefined.
if (s === null || s === undefined) {
// Rozszerzenie Flow "wie", że w tym miejscu argument s ma wartość null
lub undefined.
return -1;
} else {
// Natomiast w tym miejscu "wie", że argument s zawiera ciąg znaków.
return s.length;
}
}
W argumencie powyższej funkcji można umieszczać wartości kilku typów. Jednak rozszerzenie
Flow „widzi”, że argument użyty po instrukcji sprawdzającej jego typ może zawierać wyłącznie
ciąg znaków, dlatego nie zgłasza błędu w przypadku użycia odwołania s.length. Zwróć uwagę,
że nie trzeba w tym celu pisać tak rozbudowanego kodu jak w tym przykładzie. Wystarczy ciało
funkcji zastąpić wierszem return s ? s.length : -1;.
Rozszerzenie Flow pozwala stosować znak zapytania z dowolnym oznaczeniem typu. Znak ten
zawiera informację, że oprócz wartości danego typu można stosować null i undefined. Oprócz
tego znak zapytania można umieszczać po nazwie argumentu. Jest to wtedy wskazówka, że
argument jest opcjonalny. Zatem po zmianie deklaracji z s: ?string na s? : string można
wywoływać funkcję bez argumentu (lub z wartością undefined równoznaczną pominięciu go),
ale jeżeli się go użyje, musi to być ciąg znaków. Nie można na przykład umieścić w nim wartości
null.
W opisanych przykładach w deklaracjach zmiennych, argumentów funkcji i zwracanych
wyników były stosowane oznaczenia typów prymitywnych string, number, boolean, null i
void. W następnym punkcie opisane są bardziej złożone typy, które obsługuje rozszerzenie
Flow.
17.8.3. Klasy
Rozszerzenie Flow obsługuje nie tylko typy prymitywne, ale też wszystkie wbudowane klasy
JavaScriptu. Jako oznaczenie typu stosuje się nazwę klasy. Na przykład adnotacje użyte w
poniższej funkcji zawierają informację, że w pierwszym argumencie należy umieścić obiekt
Date, a w drugim RegExp.
// @flow
// Funkcja zwracająca wartość true, jeżeli data zapisana w formacie ISO jest
zgodna z zadanym szablonem.
}
Klasa zdefiniowana za pomocą słowa kluczowego class automatycznie staje się dla
rozszerzenia Flow typem danych. Jednak aby móc go stosować, należy w definicji klasy
umieścić adnotacje. W szczególności dotyczy to właściwości. Ilustruje to poniższy przykład
prostej klasy reprezentującej liczbę zespoloną:
// @flow
export default class Complex {
// Rozszerzenie Flow wymaga stosowania rozszerzonej składni klasy, w której
wszystkie właściwości są
// opatrzone adnotacjami.
i: number;
r: number;
static i: Complex;
constructor(r: number, i:number) {
add(that: Complex) {
return new Complex(this.r + that.r, this.i + that.i);
}
}
// Gdyby właściwość i nie była opatrzona adnotacją, rozszerzenie Flow nie
pozwoliłoby na poniższe przypisanie.
Complex.i = new Complex(0,1);
17.8.4. Obiekty
Oznaczenie typu obiektowego jest bardzo podobne do literału obiektowego. Różni się od
literału jedynie tym, że zamiast wartości właściwości są w nim stosowane oznaczenia ich typów.
Poniżej przedstawiona jest przykładowa funkcja, której argumentem jest obiekt zawierający
liczbowe właściwości x i y:
// @flow
// Argumentem funkcji jest obiekt zawierający liczbowe właściwości x i y,
// a zwracanym wynikiem liczba opisująca odległość punktu (x, y) od początku
układu współrzędnych.
x: number,
y: number
};
// Argumentem funkcji jest obiekt Point, a zwracanym wynikiem odległość
punktu od początku
// układu współrzędnych.
export default function distance(point: Point): number {
return Math.hypot(point.x, point.y);
}
Zwróć uwagę, że powyższy kod eksportuje funkcję distance(), jak również typ Point. Typ ten
można zaimportować do innego modułu za pomocą instrukcji import type Point from
'./distance.js'. Pamiętaj jednak, że instrukcja import type jest rozszerzeniem języka
JavaScript, a nie jego dyrektywą. Słowa kluczowe import i export type są wykorzystywane
jedynie przez rozszerzenie Flow i trzeba je usunąć z kodu przed jego uruchomieniem.
Na koniec warto wspomnieć, że zamiast definiowania nazwy typu obiektowego
reprezentującego punkt prostszym i bardziej czytelnym rozwiązaniem jest zdefiniowanie klasy
Point i stosowanie jej jako oznaczenie typu.
17.8.6. Tablice
Oznaczenie typu tablicowego w rozszerzeniu Flow składa się z oznaczeń typów elementów tej
tablicy. Poniżej jest przedstawiony przykład funkcji, której argumentem jest tablica zawierająca
liczby. Jeżeli umieści się w nim tablicę złożoną z elementów innych typów, rozszerzenie zgłosi
błąd:
Error -----------------------------------------------------------------
average.js:8:16
Cannot call average with array literal bound to data because string [1]
is incompatible with number [2] in array element.
[2] 2│ function average(data: Array<number>) {
3│ let sum = 0;
Aby oznaczyć typ tablicowy, należy wpisać słowo Array, po nim znak <, następnie oznaczenie
typu elementu i znak >. Zamiast tego można również wpisać oznaczenie typu elementu, a po
nim parę nawiasów kwadratowych. Zatem w powyższym przykładzie zamiast zapisu
Array<number> można użyć number[]. Preferuję drugą wersję, ponieważ — jak się przekonasz
— parę nawiasów stosuje się również w oznaczeniach innych typów.
Oznaczenie Array można stosować z tablicami składającymi się z dowolnej liczby elementów
tego samego typu. Natomiast typ krotki oznacza się w inny sposób, tj. jako tablicę składającą
się ze stałej liczby elementów różnych typów. Oznaczenie typu krotki składa się z nawiasów
kwadratowych z umieszczonymi wewnątrz nich oznaczeniami typów elementów, oddzielonych
przecinkami.
Na przykład funkcja zwracająca kod stanu HTTP i komunikat może być oznaczona w
następujący sposób:
}
let [r, g, b, a] = fade(gray(75), 3);
Wiedząc, jak oznacza się typ tablicowy, możemy wrócić zmodyfikować opisaną wcześniej
funkcję size() tak, aby jej argumentem była tablica, a nie ciąg znaków. Tablica może mieć
dowolną wielkość, więc typ krotki nie będzie tu odpowiedni. Aby nie ograniczać funkcji
wyłącznie do tablic złożonych z elementów tego samego typu, można użyć oznaczenia
Array<mixed>:
// @flow
function size(s: Array<mixed>): number {
return s.length;
}
console.log(size([1, true, "trzy"]));
Oznaczenie type mixed informuje, że elementy mogą być różnych typów. Jeżeli funkcja
indeksuje elementy i wykonuje operacje na różnych typach, rozszerzenie Flow wymaga, aby
stosować operator typeof. W ten sposób chroni przed wykonaniem potencjalnie niedozwolonej
operacji na elemencie. Aby całkowicie zrezygnować ze sprawdzania typów, należy zamiast
słowa mixed użyć any. Można wtedy wykonywać dowolne operacje bez uprzedniego
sprawdzania, czy wartość jest odpowiedniego typu.
}
console.log(double(new Set([1,2,3]))); // Wynik: "Set {2, 4, 6}"
Innym parametryzowanym typem jest mapa. W tym przypadku trzeba określić dwa parametry
typu, tj. klucza i wartości:
// @flow
]);
Za pomocą rozszerzenia Flow można również definiować parametry typów własnych klas. W
poniższym kodzie jest zdefiniowana klasa Result z parametrami typów Error i Value.
Parametry te są oznaczone literami E i V. W deklaracji instancji klasy Result należy w miejscu
tych liter umieścić konkretne oznaczenia typów. Deklaracja instancji może wyglądać tak:
let result: Result<TypeError, Set<string>>;
this.value = value;
}
threw(): ?E { return this.error; }
returned(): ?V { return this.value; }
get():V {
if (this.error) {
throw this.error;
} else if (this.value === null || this.value === undefined) {
throw new TypeError("Błąd i wartość nie mogą być jednocześnie równe
null");
} else {
return this.value;
}
}
}
Parametry typów można nawet definiować w funkcjach:
// @flow
// Funkcja łącząca elementy dwóch tablic w tablicę par elementów.
function zip<A,B>(a:Array<A>, b:Array<B>): Array<[?A,?B]> {
let result:Array<[?A,?B]> = [];
}
// Utworzenie tablicy [[1,'a'], [2,'b'], [3,'c'], [4,undefined]].
let pairs: Array<[?number,?string]> = zip([1,2,3,4], ['a','b','c'])
let sum = 0;
for(let i = 0; i < data.length; i++) sum += data[i];
return sum/data.length;
}
let data: Array<number> = [1,2,3,4,5];
average(data) // => 3
17.8.9. Funkcje
Wiesz już, jak dodawać oznaczenia typów parametrów funkcji i zwracanych przez nie wyników.
Jeżeli jednak argumentem jest inna funkcja, trzeba użyć oznaczenia typu funkcyjnego.
Aby oznaczyć typ funkcyjny przy użyciu rozszerzenia Flow, należy wpisać listę typów
parametrów oddzielonych przecinkami, ująć ją w nawiasy, następnie wpisać strzałkę i
oznaczenie typu wyniku.
Poniżej jest przedstawiony przykład funkcji, której argumentem jest funkcja zwrotna. Zwróć
uwagę, że użyty jest alias typu funkcji zwrotnej:
// @flow
// Typ funkcji zwrotnej wykorzystany w definicji funkcji fetchText() niżej.
export type FetchTextCallback = (?Error, ?number, ?string) => void;
export default function fetchText(url: string, callback: FetchTextCallback) {
let status = null;
fetch(url)
.then(response => {
status = response.status;
return response.text()
})
.then(body => {
callback(null, status, body);
})
.catch(error => {
callback(error, status, null);
});
}
17.8.10. Unie
Wróćmy jeszcze raz do funkcji size(). Nie ma żadnego sensu definiowanie funkcji, która
jedynie zwraca wielkość tablicy, ponieważ każda tablica posiada właściwość length zawierającą
tę informację. Jednak funkcja size() byłaby przydatna, gdyby w jej argumencie można było
umieszczać dowolne kolekcje obiektów (tablicę, zbiór lub mapę), a zwracanym wynikiem byłaby
wielkość tej kolekcji. Przy użyciu czystego języka JavaScript można łatwo napisać taką funkcję,
ale za pomocą rozszerzenia Flow trzeba określić w jakiś sposób, że argumentem funkcji nie
może być wartość innego typu niż tablica, zbiór lub mapa.
W nomenklaturze rozszerzenia Flow tego rodzaju typy noszą nazwę unii. Unię wyraża się,
wpisując wymagane oznaczenia typów rozdzielone pionowymi kreskami:
// @flow
function size(collection: Array<mixed>|Set<mixed>|Map<mixed,mixed>): number {
if (Array.isArray(collection)) {
return collection.length;
} else {
return collection.size;
}
}
size([1,true,"three"]) + size(new Set([true,false])) // => 5
Typy unijne czyta się, używając słowa „lub”, na przykład „tablica lub zbiór, lub mapa”. Zatem
użycie w oznaczeniu typu pionowej kreski, takiej samej jak w operatorze logicznym lub, nie jest
przypadkowe.
Jak już wiesz, znak zapytania umieszczony przed oznaczeniem typu informuje, że dopuszczalne
jest stosowanie wartości null i undefined. Zauważ, że znak zapytania jest po prostu skróconą
formą prefiksu |null|.
Ogólnie, po oznaczeniu wartości typem unijnym, rozszerzenie Flow nie pozwoli jej użyć, jeżeli
kod nie będzie zawierał niezbędnych instrukcji sprawdzających faktyczne typy tej wartości.
W przedstawionym wyżej przykładzie funkcji size() przed użyciem właściwości length jest
użyta instrukcja sprawdzająca, czy argument jest tablicą. Zwróć uwagę, że nie są osobno
sprawdzane typy Set i Map, ponieważ obie klasy posiadają właściwość size. Zatem kod objęty
klauzulą else jest bezpieczny, jeżeli argument nie jest tablicą.
| 403 // Zabronione
| 404; // Nie znaleziono
Jedną z rad, jakie często słyszy początkujący programista, jest unikanie literałów i definiowanie
zamiast nich stałych reprezentujących używane wartości. Ma to racjonalne uzasadnienie. Jeżeli
na przykład pomylisz się, wpisując ciąg „trefl”, nie pojawi się żaden komunikat, ale kod może
nie działać poprawnie. Natomiast błąd w identyfikatorze powoduje zgłoszenie wyjątku. Jeżeli
stosowane jest rozszerzenie Flow, powyższa rada jest zbędna. Jeżeli zmiennej oznaczonej typem
Suit zostanie przypisany błędny ciąg znaków, rozszerzenie Flow zgłosi błąd.
Innym ważnym zastosowaniem typów literałowych jest tworzenie dyskryminowanych unii. Jeżeli
stosowany jest typ unijny (złożony z oznaczeń różnych typów, a nie literałów), trzeba wpisywać
kod rozróżniający poszczególne typy. W poprzednim punkcie funkcja, której argumentem mogła
być tablica, zbiór lub mapa, zawierała kod odróżniający tablicę od zasobu i mapy. Jeżeli w unii
są stosowane typy obiektowe, można je łatwo rozróżnić za pomocą literałów. Najlepiej wyjaśnić
to na przykładzie. Załóżmy, że wątek roboczy (patrz podrozdział 16.11) działający w środowisku
Node wysyła za pomocą funkcji postMessage() i zdarzenia "message" komunikaty obiektowe
do głównego wątku. Jest wiele rodzajów tych komunikatów i potrzebny jest typ unijny opisujący
je wszystkie. Można to zrobić tak jak w poniższym kodzie:
// @flow
// Wątek roboczy wysyła komunikat poniższego typu, gdy skończy przetwarzać
przesłane do niego dane.
export type ResultMessage = {
messageType: "result",
result: Array<ReticulatedSpline>, // Zakładamy, że ten typ jest
zdefiniowany w innym miejscu.
};
// Wątek roboczy wysyła komunikat poniższego typu, gdy zostanie zgłoszony
wyjątek.
export type ErrorMessage = {
messageType: "error",
error: Error,
};
// Wątek roboczy wysyła komunikat poniższego typu zawierający informacje o
wykonanej pracy.
export type StatisticsMessage = {
messageType: "stats",
splinesReticulated: number,
splinesPerSecond: number
};
// Komunikat wysłany przez wątek roboczy musi być typu WorkerMessage.
export type WorkerMessage = ResultMessage | ErrorMessage | StatisticsMessage;
// W głównym wątku jest zdefiniowana obsługująca zdarzenie funkcja, w której
argumencie
// jest umieszczany obiekt typu WorkerMessage. Ponieważ każdy typ komunikatu
ma zdefiniowaną
// właściwość messageType typu literałowego, w kodzie funkcji można łatwo
rozróżnić poszczególne typy
// komunikatów.
function handleMessageFromReticulator(message: WorkerMessage) {
if (message.messageType === "result") {
// Tylko typ ResultMessage ma właściwość messageType zawierającą podaną
wartość.
// Zatem rozszerzenie Flow "wie", że w tym miejscu można bezpiecznie
używać właściwości
// message.result.
// Jeżeli zostanie użyta inna właściwość, rozszerzenie zgłosi błąd.
console.log(message.result);
} else if (message.messageType === "error") {
// Tylko typ ErrorMessage ma właściwość messageType zawierającą wartość
"error",
// dlatego w tym miejscu można bezpiecznie użyć właściwości
message.error.
throw message.error;
} else if (message.messageType === "stats") {
// Tylko typ StatisticsMessage ma właściwość messageType zawierającą
wartość "stats",
// dlatego w tym miejscu można bezpiecznie użyć właściwości
message.splinesPerSecond.
console.info(message.splinesPerSecond);
}
}
17.9. Podsumowanie
JavaScript jest dzisiaj najczęściej stosowanym językiem programowania na świecie. Jest
nieustannie rozwijany, ulepszany i wzbogacany nieprzebranymi ilościami bibliotek, narzędzi i
rozszerzeń. W tym rozdziale zostały opisane wybrane narzędzia i rozszerzenia, ale oprócz nich
jest wiele innych, które warto znać. Ekosystem języka JavaScript wciąż się rozwija dzięki
aktywnej i dynamicznej społeczności programistów dzielących się swoją wiedzą za pomocą
blogów, filmów i prezentacji. Gdy zamkniesz tę książkę i dołączysz do społeczności, z pewnością
nie zabraknie Ci źródeł, dzięki którym będziesz mógł aktywnie poznawać JavaScript.
Powodzenia!
David Flanagan, marzec 2020 r.
[1] Jeżeli używasz języka Java, na pewno doświadczyłeś czegoś podobnego, gdy napisałeś
pierwszy interfejs API wykorzystujący parametry typowane. W moim przypadku uczenie się
rozszerzenia Flow bardzo przypominało doświadczenia z 2004 r., gdy w Javie pojawiły się typy
generyczne.
O autorze
David Flanagan programuje w języku JavaScript i pisze o nim od 1995 r. Mieszka razem z żoną
i dziećmi na północno-zachodnim wybrzeżu Stanów Zjednoczonych, gdzieś pomiędzy Seattle a
kanadyjskim Vancouver. Ukończył studia informatyczne i inżynierskie na uniwersytecie
Massachusetts Institute of Technology, pracuje jako inżynier oprogramowania w Vmware.
Kolofon
Okładka książki JavaScript. Przewodnik. Poznaj język mistrzów programowania. Wydanie VII
przedstawia nosorożca jawajskiego (Rhinoceros sondaicus). Każdy z pięciu gatunków wyróżnia
się dużymi rozmiarami, grubą skórą przypominającą zbroję, trójpalczastymi łapami i
pojedynczym lub podwójnym rogiem. Nosorożec jawajski jest podobny do spokrewnionego z
nim nosorożca indyjskiego. Tak jak w tamtym gatunku samce mają pojedynczy róg, ale osobniki
są mniejsze i mają unikalną teksturę skóry. Kiedyś występowały w całej południowo-wschodniej
Azji, jednak obecnie można je spotkać tylko w Indonezji. Żyją na obszarach lasów deszczowych,
gdzie odżywiają się dorodnymi liśćmi i trawami. Aby uchronić się przed szkodnikami, m.in.
krwiożerczymi muchami, zanurzają się po pyski w wodzie lub błocie.
Nosorożec jawajski mierzy średnio 1,8 m wysokości i 3 m długości, dorosły osobnik waży około
1400 kg. Podobnie jak u nosorożca indyjskiego, szara skóra wygląda, jakby składała się z płyt, z
których niektóre mają teksturę. Nosorożce dożywają średnio 40 – 50 lat. Samice rodzą młode
co 3 – 5 lat, ciąża trwa 16 miesięcy. Młode ważą około 45 kg i pozostają pod opieką matek przez
2 lata.
Ogólnie nosorożce są gatunkiem dość licznym, umiejącym się przystosować do różnych
warunków. W wieku dorosłym nie mają naturalnych drapieżników. Jednak polowania
doprowadziły do ich niemal całkowitego wytępienia. Z powodu tradycji głoszącej, że proszek z
ich rogu posiada magiczną moc i jest afrodyzjakiem, nosorożce są głównym celem kłusowników.
Populacja nosorożca jawajskiego jest najbardziej zagrożona. Od 2020 r. około 70 ocalałych
osobników żyje w Parku Narodowym Ujung Kulon na Jawie w Indonezji. Ochrona wydaje się
pomagać w przetrwaniu tego gatunku, ponieważ według spisu z 1967 r. żyło tylko 25 jego
przedstawicieli.
Kolorową ilustrację na okładce wykonała Karen Montgomery na podstawie czarno-białej ryciny
z publikacji Dover Animals.
Spis treści
1. Opinie o książce JavaScript. Przewodnik. Poznaj język mistrzów programowania. Wydanie
VII
2. Wstęp
1. Konwencje typograficzne
2. Przykłady kodów
3. Podziękowania
3. Rozdział 1. Wprowadzenie do języka JavaScript
1. 1.1. Poznawanie JavaScriptu
2. 1.2. Witaj, świecie!
3. 1.3. Wycieczka po języku JavaScript
4. 1.4. Przykład: histogram częstości użycia znaków
5. 1.5. Podsumowanie
4. Rozdział 2. Struktura leksykalna
1. 2.1. Tekst programu
2. 2.2. Komentarze
3. 2.3. Literały
4. 2.4. Identyfikatory i zarezerwowane słowa
1. 2.4.1. Zarezerwowane słowa
5. 2.5. Unicode
1. 2.5.1. Sekwencje ucieczki Unicode
2. 2.5.2. Normalizacja Unicode
6. 2.6. Opcjonalne średniki
7. 2.7. Podsumowanie
5. Rozdział 3. Typy, wartości i zmienne
1. 3.1. Informacje ogólne i definicje
2. 3.2. Liczby
1. 3.2.1. Literały całkowite
2. 3.2.2 Literały zmiennoprzecinkowe
3. 3.2.3. Działania arytmetyczne
4. 3.2.4. Format zmiennoprzecinkowy i błędy zaokrąglenia
5. 3.2.5. Typ BigInt — dowolnie duże liczby całkowite
6. 3.2.6. Daty i czas
3. 3.3. Tekst
1. 3.3.1. Literały znakowe
2. 3.3.2. Sekwencje ucieczki w literałach znakowych
3. 3.3.3. Operacje na ciągach znaków
4. 3.3.4. Literały szablonowe
1. Oznakowane literały szablonowe
5. 3.3.5. Porównywanie ciągu znaków ze wzorcem
4. 3.4. Wartości logiczne
5. 3.5. Wartości null i undefined
6. 3.6. Symbole
7. 3.7. Obiekt globalny
8. 3.8. Niemutowalne prymitywne wartości i mutowalne odwołania do obiektu
9. 3.9. Konwersje typów
1. 3.9.1. Konwersje i równość wartości
2. 3.9.2. Jawna konwersja
3. 3.9.3. Konwersja obiektu na wartość prymitywną
1. Konwersja obiektu na wartość logiczną
2. Konwersja obiektu na ciąg znaków
3. Konwersja obiektu na liczbę
4. Operatory i szczególne przypadki konwersji
5. Metody toString() i valueOf()
6. Algorytmy konwersji obiektów na wartości prymitywne
10. 3.10. Deklarowanie zmiennych i przypisywanie wartości
1. 3.10.1. Deklaracje z użyciem słów let i const
1. Zasięgi zmiennych i stałych
2. Wielokrotne deklaracje
3. Deklaracje i typy
2. 3.10.2. Deklarowanie zmiennych za pomocą instrukcji var
3. 3.10.3. Przypisania destrukturyzujące
11. 3.11. Podsumowanie
6. Rozdział 4. Wyrażenia i operatory
1. 4.1. Wyrażenia podstawowe
2. 4.2. Inicjatory obiektów i tablic
3. 4.3. Wyrażenia definiujące funkcje
4. 4.4. Wyrażenia dostępu do właściwości
1. 4.4.1. Warunkowy dostęp do właściwości
5. 4.5. Wyrażenia wywołujące
1. 4.5.1. Wywołania warunkowe
6. 4.6. Wyrażenia tworzące obiekty
7. 4.7. Przegląd operatorów
1. 4.7.1. Liczba operandów
2. 4.7.2. Typy operandów i wyników
3. 4.7.3. Efekty uboczne operatorów
4. 4.7.4. Priorytety operatorów
5. 4.7.5. Wiązanie operatorów
6. 4.7.6. Kolejność przetwarzania
8. 4.8. Operatory arytmetyczne
1. 4.8.1. Operator +
2. 4.8.2. Jednoargumentowe operatory arytmetyczne
3. 4.8.3. Operatory bitowe
9. 4.9. Wyrażenia relacyjne
1. 4.9.1. Operatory równości i nierówności
1. Ścisła równość
2. Równość z konwersją typów
2. 4.9.2. Operatory porównania
3. 4.9.3. Operator in
4. 4.9.4. Operator instanceof
10. 4.10. Wyrażenia logiczne
1. 4.10.1. Operator logiczny ORAZ (&&)
2. 4.10.2. Operator logiczny LUB (||)
3. 4.10.3. Operator logiczny NIE (!)
11. 4.11. Wyrażenia przypisujące
1. 4.11.1. Przypisanie z działaniem
12. 4.12. Wyrażenia interpretujące
1. 4.12.1. Funkcja eval()
2. 4.12.2. Globalne wywołanie funkcji eval()
3. 4.12.3. Ścisłe wywołanie funkcji eval()
13. 4.13. Inne operatory
1. 4.13.1. Operator warunkowy (?:)
2. 4.13.2. Pierwszy zdefiniowany (??)
3. 4.13.3. Operator typeof
4. 4.13.4. Operator delete
5. 4.13.5. Operator await
6. 4.13.6. Operator void
7. 4.13.7. Operator przecinek (,)
14. 4.14. Podsumowanie
7. Rozdział 5. Instrukcje
1. 5.1. Instrukcje wyrażeniowe
2. 5.2. Instrukcje złożone i puste
3. 5.3. Instrukcje warunkowe
1. 5.3.1. Instrukcja if
2. 5.3.2. Instrukcja else if
3. 5.3.3. Instrukcja switch
4. 5.4. Pętle
1. 5.4.1. Instrukcja while
2. 5.4.3. Instrukcje do/while
3. 5.4.3. Pętla for
4. 5.4.4. Pętla for/of
1. Pętla for/of z obiektami
2. Pętla for/of z ciągami znaków
3. Pętla for/of ze zbiorami i mapami
4. Iterowanie asynchroniczne za pomocą pętli for/await
5. 5.4.5. Pętla for/in
5. 5.5. Skoki
1. 5.5.1. Instrukcje z etykietami
2. 5.5.2. Instrukcja break
3. 5.5.3. Instrukcja continue
4. 5.5.4. Instrukcja return
5. 5.5.5. Instrukcja yield
6. 5.5.6. Instrukcja throw
7. 5.5.7. Instrukcje try/catch/finally
6. 5.6. Inne instrukcje
1. 5.6.1. Instrukcja with
2. 5.6.2. Instrukcja debugger
3. 5.6.3. Dyrektywa "use strict"
7. 5.7. Deklaracje
1. 5.7.1. Deklaracje const, let i var
2. 5.7.2. Deklaracja function
3. 5.7.3. Deklaracja class
4. 5.7.4. Deklaracje import i export
8. 5.8. Podsumowanie instrukcji
8. Rozdział 6. Obiekty
1. 6.1. Wprowadzenie do obiektów
2. 6.2. Tworzenie obiektów
1. 6.2.1. Literały obiektowe
2. 6.2.2. Tworzenie obiektów za pomocą operatora new
3. 6.2.3. Prototypy
4. 6.2.4. Funkcja Object.create()
3. 6.3. Odpytywanie i ustawianie właściwości
1. 6.3.1. Obiekty jako tablice asocjacyjne
2. 6.3.2. Dziedziczenie
3. 6.3.3. Błędy dostępu do właściwości
4. 6.4. Usuwanie właściwości
5. 6.5. Sprawdzanie właściwości
6. 6.6. Wyliczanie właściwości
1. 6.6.1. Kolejność wyliczania właściwości
7. 6.7. Rozszerzanie obiektów
8. 6.8. Serializacja obiektów
9. 6.9. Metody obiektów
1. 6.9.1. Metoda toString()
2. 6.9.2. Metoda toLocaleString()
3. 6.9.3. Metoda valueOf()
4. 6.9.4. Metoda toJSON()
10. 6.10. Udoskonalona składnia literału obiektowego
1. 6.10.1. Uproszczone definiowanie właściwości
2. 6.10.2. Wyliczane nazwy właściwości
3. 6.10.3. Symbole jako nazwy właściwości
4. 6.10.4. Operator rozciągania
5. 6.10.5. Uproszczone definiowanie metod
6. 6.10.6. Gettery i settery
11. 6.11. Podsumowanie
9. Rozdział 7. Tablice
1. 7.1. Tworzenie tablic
1. 7.1.1. Literały tablicowe
2. 7.1.2. Operator rozciągania
3. 7.1.3. Konstruktor Array()
4. 7.1.4. Metoda Array.of()
5. 7.1.5. Funkcja Array.from()
2. 7.2. Odczytywanie i zapisywanie elementów tablicy
3. 7.3. Rozrzedzone tablice
4. 7.4. Długość tablicy
5. 7.5. Dodawanie i usuwanie elementów tablicy
6. 7.6. Iterowanie tablic
7. 7.7. Tablice wielowymiarowe
8. 7.8. Metody tablicowe
1. 7.8.1. Metody iterujące
1. Metoda forEach()
2. Metoda map()
3. Metoda filter()
4. Metody find() i findIndex()
5. Metody every() i some()
6. Metody reduce() i reduceRight()
2. 7.8.2. Spłaszczanie tablic za pomocą metod flat() i flatMap()
3. 7.8.3. Łączenie tablic za pomocą metody concat()
4. 7.8.4. Stosy i kolejki, czyli metody push(), pop(), shift() i unshift()
5. 7.8.5. Podtablice, czyli metody slice(), splice(), fill() i copyWithin()
1. Metoda slice()
2. Metoda splice()
3. Metoda fill()
4. Metoda copyWithin()
6. 7.8.6. Metody przeszukujące i sortujące tablice
1. Metody indexOf() i lastIndexOf()
2. Metoda includes()
3. Metoda sort()
4. Metoda reverse()
7. 7.8.7. Konwersja tablicy na ciąg znaków
8. 7.8.8. Statyczne funkcje tablicowe
9. 7.9. Obiekty podobne do tablic
10. 7.10. Ciągi znaków jako tablice
11. 7.11. Podsumowanie
10. Rozdział 8. Funkcje
1. 8.1. Definiowanie funkcji
1. 8.1.1. Deklaracje funkcji
2. 8.1.2. Wyrażenia funkcyjne
3. 8.1.3. Funkcje strzałkowe
4. 8.1.4. Zagnieżdżone funkcje
2. 8.2. Wywoływanie funkcji
1. 8.2.1. Wywołanie funkcji
2. 8.2.2. Wywołanie metody
3. 8.2.3. Wywołanie konstruktora
4. 8.2.4. Wywołanie pośrednie
5. 8.2.5. Niejawne wywołanie funkcji
3. 8.3. Argumenty i parametry funkcji
1. 8.3.1. Parametry opcjonalne i domyślne
2. 8.3.2. Parametry resztowe i lista argumentów o zmiennej długości
3. 8.3.3. Obiekt Arguments
4. 8.3.4. Operator rozciągania w wywołaniach funkcji
5. 8.3.5. Destrukturyzacja argumentów funkcji do jej parametrów
6. 8.3.6. Typy argumentów
4. 8.4. Funkcje jako wartości
1. 8.4.1. Definiowanie własnych właściwości funkcji
5. 8.5. Funkcje jako przestrzenie nazw
6. 8.6. Domknięcia
7. 8.7. Właściwości, metody i konstruktory funkcji
1. 8.7.1. Właściwość length
2. 8.7.2. Właściwość name
3. 8.7.3. Właściwość prototype
4. 8.7.4. Metody call() i apply()
5. 8.7.5. Metoda bind()
6. 8.7.6. Metoda toString()
7. 8.7.7. Konstruktor Function()
8. 8.8. Programowanie funkcyjne
1. 8.8.1. Przetwarzanie tablic za pomocą funkcji
2. 8.8.2. Funkcje wyższego rzędu
3. 8.8.3. Częściowe stosowanie funkcji
4. 8.8.4. Memoizacja
9. 8.9. Podsumowanie
11. Rozdział 9. Klasy
1. 9.1. Klasy i prototypy
2. 9.2. Klasy i konstruktory
1. 9.2.1. Konstruktory, tożsamość klas i operator instanceof
2. 9.2.2. Właściwość constructor
3. 9.3. Słowo kluczowe class
1. 9.3.1. Metody statyczne
2. 9.3.2. Gettery, settery i inne rodzaje metod
3. 9.3.3. Pola publiczne, prywatne i statyczne
4. 9.3.4. Przykład: klasa reprezentująca liczby zespolone
4. 9.4. Dodawanie metod do istniejących klas
5. 9.5. Podklasy
1. 9.5.1. Podklasy i prototypy
2. 9.5.2. Tworzenie podklas za pomocą słów extends i super
3. 9.5.3. Delegowanie zamiast dziedziczenia
4. 9.5.4. Hierarchie klas i klasy abstrakcyjne
6. 9.6. Podsumowanie
12. Rozdział 10. Moduły
1. 10.1. Tworzenie modułów za pomocą klas, obiektów i domknięć
1. 10.1.1. Automatyzacja modułowości opartej na domknięciach
2. 10.2. Moduły w środowisku Node
1. 10.2.1. Eksport symboli w środowisku Node
2. 10.2.2. Import symboli w środowisku Node
3. 10.2.3. Moduły Node w przeglądarkach
3. 10.3. Moduły w języku ES6
1. 10.3.1. Eksport symboli w języku ES6
2. 10.3.2. Import symboli w języku ES6
3. 10.3.3. Importowanie i eksportowanie ze zmianą nazw
4. 10.3.4. Ponowny eksport
5. 10.3.5. Moduły JavaScript w aplikacjach internetowych
6. 10.3.6. Dynamiczny import za pomocą funkcji import()
7. 10.3.7. Właściwość import.meta.url
4. 10.4. Podsumowanie
13. Rozdział 11. Standardowa biblioteka JavaScript
1. 11.1. Zbiory i mapy
1. 11.1.1. Klasa Set
2. 11.1.2. Klasa Map
3. 11.1.3. Klasy WeakMap i WeakSet
2. 11.2. Typowane tablice i dane binarne
1. 11.2.1. Typy elementów tablicy typowanej
2. 11.2.2. Tworzenie tablic typowanych
3. 11.2.3. Korzystanie z tablic typowanych
4. 11.2.4. Metody i właściwości tablicy typowanej
5. 11.2.5. Klasa DataView i kolejność bajtów
3. 11.3. Wyszukiwanie wzorców i wyrażenia regularne
1. 11.3.1. Definiowanie wyrażeń regularnych
1. Znaki literalne
2. Klasy znaków
3. Powtarzanie sekwencji
4. Powtórzenia niezachłanne
5. Alternatywy, grupy i odwołania
6. Określanie pozycji dopasowania
7. Flagi
2. 11.3.2. Metody dopasowujące klasy String
1. Metoda search()
2. Metoda replace()
3. Metoda match()
4. Metoda matchAll()
5. Metoda split()
3. 11.3.3. Klasa RegExp
1. Właściwości obiektu RegExp
2. Metoda test()
3. Metoda exec()