JavaScript. Przewodnik - David Flanagan

You might also like

Download as pdf or txt
Download as pdf or txt
You are on page 1of 526

David Flanagan

JavaScript
Przewodnik
Poznaj język mistrzów
programowania
Wydanie VII

Przekład: Andrzej Watrak


Tytuł oryginału: JavaScript: The Definitive Guide: Master the World's Most-Used Programming
Language, 7th Edition
Tłumaczenie: Andrzej Watrak
ISBN: 978-83-283-7309-9

© 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)

Drogi Czytelniku! Jeżeli chcesz ocenić tę książkę, zajrzyj pod adres


http://helion.pl/user/opinie/jsppm7_ebook Możesz tam wpisać swoje uwagi, spostrzeżenia,
recenzję.
Pliki z przykładami omawianymi w książce można znaleźć pod adresem:
ftp://ftp.helion.pl/przyklady/jsppm7.zip

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:

Czcionka pogrubiona oznacza nowe pojęcia.


Czcionka pochyła oznacza adresy e-mailowe, nazwy plików i ich rozszerzenia oraz komentarze
do kodu.
Czcionka o stałej szerokości jest zastosowana w listingach, jak również w odwołaniach do
elementów kodu, na przykład w nazwach zmiennych, funkcji, baz danych, typów, zmiennych
środowiskowych, instrukcjach i słowach kluczowych.

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.

Zawiera ogólną informację.

Oznacza przestrogę lub zalecenie zachowania ostrożności.


Przykłady kodów
Pliki z przykładami kodów można pobrać ze strony https://ftp.helion.pl/przyklady/jsppm7.zip.

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.

Dziękuję również redaktorom, recenzentom i współautorom poprzednich wydań. Są to: Andrew


Schulman, Angelo Sirigos, Aristotle Pagaltzis, Brendan Eich, Christian Heilmann, Dan Shafer,
Dave C. Mitchell, Deb Cameron, Douglas Crockford, Dr Tankred Hirschmann, Dylan
Schiemann, Frank Willison, Geoff Stearns, Herman Venter, Jay Hodges, Jeff Yates, Joseph
Kesselman, Ken Cooper, Larry Sullivan, Lynn Rollins, Neil Berkman, Mike Loukides, Nick
Thompson, Norris Boyd, Paula Ferguson, Peter-Paul Koch, Philippe Le Hegaret, Raffaele Cecco,
Richard Yaker, Sanders Kleinfeld, Scott Furman, Scott Isaacs, Shon Katzenberger, Terry Allen,
Todd Ditchendorf, Vidur Apparao, Waldemar Horwat i Zachary Kessin.
Pisanie siódmego wydania oderwało mnie od mojej rodziny na wiele wieczorów. Proszę,
przyjmijcie wyrazy wdzięczności za to, że tolerowaliście moją nieobecność.

— David Flanagan, marzec 2020 r.


Rozdział 1.
Wprowadzenie do języka
JavaScript
JavaScript jest językiem programowania sieci WWW. Jest stosowany w ogromnej większości
stron internetowych, a wszystkie nowoczesne przeglądarki, wykorzystywane na komputerach,
tabletach i smartfonach, posiadają interpreter tego języka. Wszystko to sprawia, że JavaScript
jest najbardziej rozpowszechnionym językiem w historii programowania. W ostatniej dekadzie,
od kiedy pojawiła się platforma Node.js, JavaScript jest stosowany nie tylko w przeglądarkach,
ponieważ niebywały sukces tej platformy sprawił, że najwięcej programistów używa obecnie
tego właśnie języka. Niezależnie od tego, czy zamierzasz poznać go od podstaw, czy
wykorzystujesz zawodowo, niniejsza książka pomoże Ci go dogłębnie opanować.
Jeżeli znasz inne języki programowania, przekonasz się, że JavaScript jest wysokopoziomowym,
dynamicznym, interpretowanym językiem, przygotowanym do obiektowego i funkcjonalnego
kodowania. Typy zmiennych nie są w nim określone. Jego składnia luźno nawiązuje do języka
Java, ale oba języki nie są ze sobą powiązane. Stosowane w nim pierwszoklasowe funkcje
wywodzą się z języka Scheme, a dziedziczenie prototypów z mało znanego języka Self. Jednak
aby w pełni skorzystać z tej książki i poznać JavaScript, nie musisz znać tych języków ani
stosowanych w nich pojęć.
Nazwa „JavaScript” jest nieco myląca. Język ten, oprócz podobnej składni, nie ma nic
wspólnego z Javą. Już dawno wyrósł ze swoich skryptowych korzeni i stał się uniwersalnym,
wydajnym językiem ogólnego przeznaczenia, wykorzystywanym w poważnych projektach
informatycznych obejmujących obszerny kod.
Każdy język, aby można było go używać, musi posiadać platformę, czyli standardową bibliotekę,
umożliwiającą kodowanie podstawowych operacji, takich jak pobieranie i zwracanie danych.
Rdzeń języka JavaScript definiuje podstawowy interfejs API przeznaczony do wykonywania
operacji na liczbach, testach, tablicach, zbiorach, mapach itp., ale nie obejmuje żadnych
funkcjonalności związanych z pobieraniem i zwracaniem danych. Za tego rodzaju operacje (jak
również realizację bardziej zaawansowanych funkcjonalności, na przykład obsługę sieci,
dysków i grafiki) jest odpowiedzialne „środowisko gospodarza”, w którym stosowany jest
JavaScript.
Od początku takim środowiskiem była przeglądarka, która do dziś jest najczęściej stosowanym
środowiskiem uruchomieniowym kodu napisanego w JavaScripcie. Poprzez przeglądarkę kod
odbiera dane od użytkownika przekazywane za pomocą myszy i klawiatury, a od serwera — za
pomocą zapytań HTTP. Zwracanie danych polega na wyświetlaniu informacji zakodowanych w
językach HTML i CSS.

JavaScript — nazwy, wersje i tryby


Język JavaScript powstał w firmie Netscape na początku ery sieci WWW. Nazwa
„JavaScript” jest zastrzeżonym przez Sun Microsystems (dzisiaj Oracle) znakiem
handlowym, reprezentującym implementację stosowaną w przeglądarce Netscape
(dzisiaj Mozilla). Autorzy przekazali specyfikację języka stowarzyszeniu ECMA (ang.
European Computer Manufacturers Association, Europejskie Stowarzyszenie Producentów
Komputerów) w celu jej standaryzacji, ale z powodu problemów ze znakiem handlowym
oficjalna wersja otrzymała dziwną nazwę ECMAScript. Jednak w praktyce każdy mówi na
ten język po prostu JavaScript. W tej książce nazwa ECMAScript lub skrót ES oznacza
standard języka i jego wersje.
W drugiej dekadzie XXI wieku wszystkie przeglądarki obsługiwały głównie wersję nr 5
języka ECMAScript. W tej książce stanowi ona wzorzec kompatybilności, a wcześniejsze
wersje nie są omawiane. W 2015 r. pojawiła się wersja ES6 zawierająca ważne nowe
funkcjonalności, m.in. klasy i moduły, dzięki którym JavaScript przekształcił się ze
zwykłego języka skryptowego w poważny język programowania, przeznaczony do
wszelkich zastosowań na szeroką skalę. Kadencja wersji ES6 trwała niecały rok, a kolejne
wersje są oznaczane rokiem wydania, tj. ES2016, ES2017, ES2018, ES2019 i ES2020.
Twórcy języka, w miarę jego rozwoju, starali się usuwać błędy znalezione we
wcześniejszych wersjach niż ES5. Jednak ze względu na konieczność zachowania
wstecznej kompatybilności, nie można było z niego usunąć przestarzałych
funkcjonalności, również tych obarczonych błędami. Mimo to począwszy od wersji ES5
kod można pisać w tzw. ścisłym trybie JavaScript, wolnym od wielu wcześniejszych
błędów. Wymagane jest w tym celu użycie dyrektywy use strict opisanej w specyfikacji
w sekcji §5.6.3. Sekcja ta opisuje również różnice pomiędzy starym a ścisłym trybem
języka. W wersjach ES6 i nowszych samo użycie nowych funkcjonalności powoduje
niejawne zastosowanie trybu ścisłego. Na przykład wpisanie słowa kluczowego class lub
zdefiniowanie modułu sprawia, że kod jest automatycznie przełączany w tryb ścisły i nie
można w nim stosować starych, wadliwych funkcjonalności. W niniejszej książce opisane
są te funkcjonalności z wyraźnym zaznaczeniem, że nie są dostępne w trybie ścisłym.

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.

Na początku książki opisane są niskopoziomowe podstawy języka, a w dalszej części oparte na


nich bardziej zaawansowane abstrakcje wyższych poziomów. Kolejne rozdziały należy czytać
mniej więcej zgodnie z ich kolejnością. Jednak nauka nowego języka programowania nie jest
procesem liniowym, więc jego opis też taki nie jest. Wszystkie funkcjonalności są ze sobą
wzajemnie powiązane i wszędzie w książce znajdują się odwołania do informacji zawartych w
następnych lub wcześniejszych rozdziałach. Niniejszy wprowadzający rozdział zawiera ogólny
przegląd języka i opisuje jego najważniejsze funkcjonalności, których poznanie ułatwi Ci
zrozumienie bardziej skomplikowanych treści zawartych w kolejnych rozdziałach. Możesz go
pominąć, jeżeli jesteś praktykującym programistą JavaScriptu, aczkolwiek może Ci się
spodobać zamieszczony na końcu listing 1.1.

1.1. Poznawanie JavaScriptu


Podczas nauki nowego języka programowania trzeba samodzielnie wypróbowywać
prezentowane w książce przykłady, modyfikować je, ponownie wypróbowywać i weryfikować w
ten sposób swoją wiedzę. W przypadku języka JavaScript potrzebny jest do tego celu
interpreter.

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.

Rysunek 1.1. Konsola narzędzi programistycznych, umożliwiająca wpisywanie kodu JavaScript

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

Welcome to Node.js v12.18.2

Type ".help" for more information.


> .help

.break Sometimes you get stuck, this gets you out

.clear Alias for .break

.editor Enter editor mode

.exit Exit the repl


.help Print this help message

.load Load JS from a file into the REPL session

.save Save all evaluated commands in this REPL session to a file

Press ^C to abort current expression, ^D to exit the repl


> let x = 2, y = 3;
undefined
> x + y

> (x === 2) && (y === 3)

true

> (x > 3) || (y < 3)


false

1.2. Witaj, świecie!


Gdy będziesz gotów zacząć eksperymentować z dłuższymi fragmentami kodu, interaktywne
środowisko do wpisywania pojedynczych wierszy może okazać się niewystarczające i potrzebny
Ci będzie edytor tekstowy. Napisany w nim kod będziesz mógł kopiować i wklejać do konsoli
przeglądarki lub terminala z otwartą sesją Node. Oprócz tego możesz zapisywać kod w pliku
(któremu zazwyczaj nadaje się rozszerzenie .js) i uruchamiać w środowisku Node:

$ node kod.js

Podczas nieinteraktywnego korzystania ze środowiska Node, jak wyżej, wartości uzyskiwane


w poszczególnych wierszach kodu nie są automatycznie wyświetlane i trzeba te operacje
kodować samodzielnie. Do wyświetlania tekstu i innych wartości w terminalu lub konsoli
przeglądarki służy funkcja console.log(). Jeżeli więc utworzysz plik witaj.js, zawierający
następujący wiersz kodu:

console.log("Witaj, świecie!");

i wpiszesz polecenie node witaj.js, pojawi się napis 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>

Następnie załaduj plik do przeglądarki, wpisując w polu adresu następujący wiersz:

file:///Users/username/javascript/witaj.html

Otwórz konsolę zawartą w narzędziach dla programistów i sprawdź, co się w niej pojawiło.

1.3. Wycieczka po języku JavaScript


Ten podrozdział stanowi krótkie wprowadzenie do JavaScriptu za pomocą przykładów. Później
zajmiemy się poznawaniem języka na najniższym poziomie. W rozdziale 2. poznasz komentarze,
średniki i znaki Unicode. Rozdział 3. będzie bardziej ciekawy — opisane będą w nim zmienne
oraz wartości, które można im przypisywać.

Poniżej przedstawiony jest przykładowy kod ilustrujący treść wymienionych rozdziałów:

// Wszystko, co znajduje się po dwóch ukośnikach jest komentarzem.

// Czytaj komentarze uważnie, ponieważ wyjaśniają kod.


// Zmienna to symboliczna nazwa wartości.

// Deklaruje się ją za pomocą słowa kluczowego let:

let x; // Deklaracja zmiennej o nazwie x.

// Wartości przypisuje się zmiennym za pomocą znaku =.


x = 0; // Teraz zmienna x ma wartość 0.

x // => 0: wynikiem użycia zmiennej jest jej


wartość.

// W języku JavaScript dostępnych jest kilka typów wartości:

x = 1; // Liczby.

x = 0.01; // Liczby mogą być całkowite lub


zmiennoprzecinkowe.

x = "hello world"; // Ciągi znaków umieszcza się w cudzysłowach.

x = 'JavaScript'; // Można również stosować apostrofy.

x = true; // Wartość logiczna.

x = false; // Druga wartość logiczna.

x = null; // Null jest specjalną wartością oznaczającą brak


wartości.

x = undefined; // Undefined jest kolejną specjalną wartością,


podobnie jak null.

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:

// Najważniejszym typem danych jest obiekt.

// Jest to kolekcja par nazwa/wartość lub ciągów z przypisanymi im


wartościami.

let book = { // Obiekty umieszcza się w nawiasach klamrowych.

topic: "JavaScript", // Właściwość "topic" ma wartość "JavaScript".

edition: 7 // Właściwość "edition" ma wartość 7.

}; // Zamykający nawias klamrowy oznacza koniec


obiektu.

// Do właściwości obiektu można odwoływać się za pomocą kropki lub nawiasów


kwadratowych:

book.topic // => "JavaScript"


book["edition"] // => 7: inny sposób uzyskiwania wartości
właściwości.

book.author = "Flanagan"; // Utworzenie nowej właściwości poprzez


przypisanie wartości.

book.contents = {}; // {} oznacza pusty obiekt bez właściwości.

// Warunkowe odwołanie do właściwości za pomocą znaków ?. (ES2020):


book.contents?.ch01?.sect1 // => undefined: obiekt book.contents nie ma
właściwości ch01.
// W JavaScripcie dostępne są również tablice, czyli indeksowane listy
wartości:

let primes = [2, 3, 5, 7]; // Tablica zawierająca cztery wartości ujęte w


nawiasy [ i ].

primes[0] // => 2: pierwszy element tablicy o indeksie 0.

primes.length // => 4: liczba elementów tablicy.

primes[primes.length-1] // => 7: ostatni element tablicy.

primes[4] = 9; // Dodanie nowego elementu poprzez przypisanie


wartości.

primes[4] = 11; // Zmiana istniejącego obiektu poprzez przypisanie


wartości.

let empty = []; // Znaki [] reprezentują pusta tablicę (bez


elementów).

empty.length // => 0

// Tablica może zawierać inne tablice lub obiekty:

let points = [ // Tablica zawierająca dwa elementy,

{x: 0, y: 0}, // z których każdy jest obiektem.

{x: 1, y: 1}

];
let data = { // Obiekt zawierający dwie właściwości.

trial1: [[1,2], [3,4]], // Wartość każdej właściwości jest tablicą.

trial2: [[2,3], [4,5]] // Elementami tablicy są tablice.

};

Składnia komentarzy w przykładowych kodach


Na pewno zauważyłeś, że niektóre komentarze w powyższym kodzie rozpoczynają się
strzałką (=>). Oznacza ona wartość zwracaną w danym wierszu i stanowi próbę
emulowania w drukowanej książce interaktywnego środowiska, na przykład konsoli
przeglądarki.
Komentarz // => pełni również rolę asercji. Utworzyłem narzędzie, które analizuje kod i
sprawdza, czy dany wiersz zwraca wartość podaną w komentarzu. Mam nadzieję, że
dzięki temu popełniłem mniej błędów.
Są dwa style wpisywania asercji w komentarzach. Na przykład zapis // a == 42 oznacza,
że w danym wierszu zmienna a powinna mieć wartość 42. Natomiast komentarz
rozpoczynający się od // ! oznacza, że kod w danym wierszu zgłasza wyjątek (komentarz
po wykrzykniku zazwyczaj zawiera opis wyjątku).
Tego rodzaju komentarze są stosowane w całej książce.

Stosowany w powyższym kodzie zapis zawierający zamknięte w nawiasach kwadratowych


elementy tablicy lub zamknięte w nawiasach klamrowych nazwy właściwości z przypisanymi im
wartościami nosi nazwę wyrażenia inicjującego (jest to jeden z tematów rozdziału 4.).
Wyrażenie jest frazą, którą można przetworzyć w celu uzyskania wartości.
Wyrażenia najczęściej tworzy się za pomocą operatorów:

// Operatory działają na wartościach (operandach) i tworzą nową wartość.

// Najprostsze są operatory arytmetyczne:

3 + 2 // => 5: dodawanie

3 - 2 // => 1: odejmowanie
3 * 2 // => 6: mnożenie

3 / 2 // => 1.5: dzielenie

points[1].x - points[0].x // => 1: można również stosować bardziej


skomplikowane operatory.

"3" + "2" // => "32": symbol + oznacza dodawanie liczb lub


łączenie ciągów znaków.

// JavaScript zawiera kilka skróconych operatorów arytmetycznych.

let count = 0; // Zdefiniowanie zmiennej.


count++; // Zwiększenie wartości zmiennej.

count--; // Zmniejszenie wartości zmiennej.


count += 2; // Dodanie 2. To samo co count = count + 2;

count *= 3; // Mnożenie przez 3. To samo co count = count * 3;


count // => 6: nazwy zmiennych też są wyrażeniami.

// Operatory równości i relacji sprawdzają, czy dwie wartości są równe,


nierówne,
// czy jedna jest mniejsza lub większa itp. Wynikiem jest wartość true lub
false.
let x = 2, y = 3; // Symbol = oznacza przypisanie, a nie sprawdzenie
równości.

x === y // => false: równość


x !== y // => true: nierówność

x < y // => true: mniejsze niż


x <= y // => true: mniejsze lub równe

x > y // => false: większe niż


x >= y // => false: większe lub równe
"two" === "three" // => false: te dwa ciągi są różne

"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:

// Funkcja jest blokiem kodu posiadającym parametry, który można wywoływać.


function plus1(x) { // Definicja funkcji o nazwie "plus1" z parametrem
"x".
return x + 1; // Zwrócenie liczby o 1 większej od podanej w
parametrze.

} // Funkcje są zamknięte w nawiasach klamrowych.


plus1(y) // => 4: zmienna y ma wartość 3, więc funkcja
zwraca wartość 3 + 1.

let square = function(x) { // Funkcja posiada wartość, którą można przypisać


zmiennej.

return x * x; // Wyliczenie wartości funkcji.


}; // Średnik oznacza koniec przypisania.
square(plus1(y)) // => 16: wywołanie dwóch funkcji w jednym
wyrażeniu.
W wersjach ES6 i nowszych można definiować funkcje, stosując skróconą składnię. Za pomocą
symboli => oddziela się listę argumentów od ciała funkcji. W ten sposób definiuje się funkcje
strzałkowe, które często stosuje się w celu umieszczenia nienazwanej funkcji w argumencie
innej funkcji. Jeżeli powyższy kod zmienimy tak, aby wykorzystywał funkcje strzałkowe, będzie
miał taką postać:

const plus1 = x => x + 1; // Wartość wejściowa x jest powiązana z wartością


wyjściową x + 1.
const square = x => x * x; // Wartość wejściowa x jest powiązana z wartością
wyjściową x * x.
plus1(y) // => 4: wywołanie funkcji wygląda tak samo.

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.

let a = []; // Utworzenie pustej tablicy.


a.push(1,2,3); // Metoda push() dodaje element do tablicy.
a.reverse(); // Inna metoda, odwracająca kolejność elementów.

// Można również definiować własne metody. Słowo kluczowe "this" oznacza


obiekt,

// w którym definiowana jest metoda. W tym przypadku jest to użyta wcześniej


tablica.
points.dist = function() { // Definicja metody wyliczającej odległość między
dwoma punktami.
let p1 = this[0]; // Wywołanie pierwszego elementu tablicy.
let p2 = this[1]; // Drugi element obiektu "this".

let a = p2.x-p1.x; // Różnica pomiędzy współrzędnymi x.


let b = p2.y-p1.y; // Różnica pomiędzy współrzędnymi y.

return Math.sqrt(a*a + // Twierdzenie Pitagorasa.


b*b); // Metoda Math.sqrt() wyliczająca pierwiastek
kwadratowy.

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

// Składnia instrukcji warunkowych i pętli w JavaScripcie jest podobna


// do stosowanej w C, C++, Javie i innych językach.

function abs(x) { // Funkcja wyliczająca wartość bezwzględną.


if (x >= 0) { // Instrukcja if wywołuje poniższy kod,

return x; // jeżeli porównanie jest prawdziwe.


} // Koniec klauzuli if.
else { // Opcjonalna klauzula else wywołuje poniższy kod,

return -x; // jeżeli porównanie jest fałszywe.


} // Jeżeli klauzula zawiera tylko jedną instrukcję,
nawiasy klamrowe
// nie są wymagane.
} // Zwróć uwagę na instrukcję return wewnątrz
instrukcji if/else.
abs(-10) === abs(10) // => true

function sum(array) { // Funkcja wyliczająca sumę elementów tablicy.


let sum = 0; // Na początku zmienna sum ma wartość 0.
for(let x of array) { // Iterowanie tablicy. Każdy element jest
przypisywany zmiennej x.
sum += x; // Dodanie wartości elementu do zmiennej sum.

} // Koniec pętli.
return sum; // Zwrócenie sumy.

}
sum(primes) // => 28: suma pięciu początkowych liczb
pierwszych: 2+3+5+7+11.

function factorial(n) { // Funkcja wyliczająca silnię.


let product = 1; // Na początku zmienna product ma wartość 1.

while(n > 1) { // Powtarzanie kodu wewnątrz {}, dopóki wyrażenie


wewnątrz ()
// ma wartość true.

product *= n; // Skrót instrukcji product = product * n;


n--; // Skrót instrukcji n = n – 1

} // Koniec pętli.
return product; // Zwrócenie iloczynu.

}
factorial(4) // => 24: 1*4*3*2

function factorial2(n) { // Kolejna wersja funkcji wykorzystująca inną


pętlę.
let i, product = 1; // Początkowa wartość 1.

for(i=2; i <= n; i++) // Automatyczne zwiększanie zmiennej i od 2 do n.


product *= i; // Powtarzana instrukcja. Jeżeli jest tylko jedna,
nawiasy {} nie są potrzebne.

return product; // Zwrócenie silni.


}

factorial2(5) // => 120: 1*2*3*4*5


JavaScript jest językiem obiektowym, ale istotnie różni się od innych, „klasycznych” języków
tego rodzaju. Rozdział 9. szczegółowo opisuje programowanie obiektowe w JavaScripcie i
zawiera wiele przykładów. Poniżej pokazany jest prosty przykład ilustrujący definiowanie klasy
reprezentującej punkt na dwuwymiarowej płaszczyźnie. Obiekty będące instancjami tej klasy
posiadają jedną metodę distance(), wyliczającą odległość punktu od początku układu
współrzędnych.

class Point { // Zgodnie z przyjętą konwencją nazwa klasy


zaczyna się od wielkiej litery.

constructor(x, y) { // Konstruktor inicjujący nową instancję.


this.x = x; // Słowo kluczowe this oznacza nowy, inicjowany
obiekt.
this.y = y; // Zapisanie argumentów funkcji we właściwościach
obiektu.

} // Konstruktor nie musi zwracać wartości.


distance() { // Metoda wyliczająca odległość punktu od początku
układu współrzędnych.
return Math.sqrt( // Zwrócenie pierwiastka kwadratowego z x² + y².
this.x * this.x + // Słowo this oznacza obiekt Point,

this.y * this.y // którego metoda distance() jest wywoływana.


);

}
}

// Aby utworzyć obiekt Point, należy wywołać konstruktor za pomocą słowa


kluczowego new.
let p = new Point(1, 1); // Punkt o współrzędnych (1, 1).

// Przykład użycia obiektu p typu Point.


p.distance() // => Math.SQRT2

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.

Rozdział 11. „Standardowa biblioteka JavaScript”


Rozdział opisujący wbudowane funkcje i klasy, dostępne we wszystkich programach. Są to
m.in. ważne struktury danych, takie jak mapy i zbiory, klasy implementujące wyrażenia
regularne stosowane do porównywania ciągów znaków ze wzorcami, funkcje do
serializowania danych i wiele innych.
Rozdział 12. „Iteratory i generatory”

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.

Rozdział 13. „Asynchroniczność w języku JavaScript”


W tym rozdziale szczegółowo opisano programowanie asynchroniczne, funkcje zwrotne,
zdarzenia, interfejsy API oparte na promesach oraz słowa kluczowe async i await. Choć
język JavaScript sam w sobie nie jest asynchroniczny, interfejsy API tego rodzaju są
domyślnie dostępne zarówno w przeglądarkach, jak i platformie Node. Ten rozdział opisuje
techniki wykorzystujące powyższe interfejsy.

Rozdział 14. „Metaprogramowanie”


Wprowadzenie w kilka zaawansowanych funkcjonalności języka JavaScript, które mogą
zainteresować programistów tworzących biblioteki dla innych programistów.

Rozdział 15. „JavaScript w przeglądarkach”


Rozdział jest wprowadzeniem do środowiska gospodarza, jakim jest przeglądarka,
wyjaśniono w nim, w jaki sposób jest w niej wykonywany kod, i opisano kilka
najważniejszych spośród wielu interfejsów API oferowanych przez przeglądarki. Jest to
najdłuższy rozdział.

Rozdział 16. „Serwery w środowisku Node”


Wprowadzenie do środowiska gospodarza, jakim jest środowisko Node. Rozdział opisuje
podstawowy model programowania, struktury danych i interfejsy API, które warto znać.
Rozdział 17. „Narzędzia i rozszerzenia”

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.

1.4. Przykład: histogram częstości użycia


znaków
Niniejszy rozdział wieńczy krótki, ale niebanalny program. Listing 1.1 przedstawia kod
odczytujący znaki ze standardowego wejścia, wyliczający częstości ich występowania i
wyświetlający histogram. Aby go uruchomić w celu wyliczenia częstości znaków zawartych w
samym kodzie źródłowym, wpisz następujące polecenie:
$ node charfreq.js < charfreq.js

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

* standardowego wejścia, wylicza częstości występowania poszczególnych


* znaków i wyświetla histogram dla tych, które są wykorzystywane
najczęściej.
* Do uruchomienia niezbędna jest platforma Node w wersji 12 lub nowszej.
*/

// Ta klasa rozszerza klasę Map tak, aby metoda get() zwracała określoną
// wartość, a nie null, gdy mapa nie zawiera klucza.

class DefaultMap extends Map {


constructor(defaultValue) {

super(); // Wywołanie konstruktora klasy


nadrzędnej.
this.defaultValue = defaultValue; // Zapamiętanie domyślnej wartości.

}
get(key) {

if (this.has(key)) { // Jeżeli mapa zawiera klucz,


return super.get(key); // zwracana jest jego wartość z klasy
nadrzędnej.

}
else {

return this.defaultValue; // W przeciwnym razie zwracana jest


domyślna wartość.
}

}
}

// Ta klasa wylicza i wyświetla histogram częstości znaków.


class Histogram {
constructor() {

this.letterCounts = new DefaultMap(0); // Mapa wiążąca znaki z liczbą


wystąpień.
this.totalLetters = 0; // Całkowita liczba znaków.
}
// Ta funkcja aktualizuje informacje o nowych znakach w histogramie.

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

// Przekształcenie histogramu w ciąg zawierający grafikę ASCII.


toString() {
// Przekształcenie mapy w tablicę zawierającą tablice [klucz, wartość].
let entries = [...this.letterCounts];

// Posortowanie tablicy najpierw wg liczby wystąpień, a potem


alfabetycznie.
entries.sort((a,b) => { // Funkcja definiująca kolejność
sortowania.
if (a[1] === b[1]) { // Jeżeli liczby są równe,
return a[0] < b[0] ? -1 : 1; // sortuj alfabetycznie.

} else { // W przeciwnym razie


return b[1] - a[1]; // sortuj według wartości.
}
});

// Przekształcenie liczb w procenty.


for(let entry of entries) {
entry[1] = entry[1] / this.totalLetters*100;
}
// Pominięcie wartości mniejszych niż 1%.

entries = entries.filter(entry => entry[1] >= 1);


// Przekształcenie każdej wartości w wiersz tekstu.
let lines = entries.map(
([l,n]) => `${l}: ${"#".repeat(Math.round(n))} ${n.toFixed(2)}%`

);
// Zwrócenie wierszy połączonych za pomocą znaku końca wiersza.
return lines.join("\n");
}
}

// Poniższa asynchroniczna funkcja (zwracająca promesę) tworzy obiekt


Histogram,
// asynchronicznie odczytuje porcje tekstu ze standardowego wejścia i dodaje
je
// do histogramu. Zwraca histogram, gdy osiągnie koniec strumienia.
async function histogramFromStdin() {
process.stdin.setEncoding("utf-8"); // Odczytanie znaków Unicode, a nie
bajtów.
let histogram = new Histogram();
for await (let chunk of process.stdin) {
histogram.add(chunk);
}

return histogram;
}
// Ostatni wiersz stanowi główne ciało programu.
// Tworzy obiekt Histogram na podstawie danych uzyskanych

// ze standardowego wejścia i wyświetla histogram.


histogramFromStdin().then(histogram => { console.log(histogram.toString());
});

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:

wielkości liter, spacje, podziały wierszy,


komentarze,
literały,
identyfikatory i zarezerwowane słowa,
standard Unicode,
opcjonalne średniki.

2.1. Tekst programu


JavaScript jest językiem, w którym istotna jest wielkość liter. Oznacza to, że słowa kluczowe,
zmienne, nazwy funkcji i inne identyfikatory muszą być spójne pod względem wielkości liter. Na
przykład słowo while musi być wpisywane w takiej właśnie formie, a nie „While” czy „WHILE”.
Ponadto nazwy online, Online, OnLine i ONLINE reprezentują różne zmienne.
W JavaScripcie spacje umieszczane pomiędzy tokenami kodu są pomijane. W większości
przypadków pomijane są również podziały wierszy (z wyjątkiem sytuacji opisanych w
podrozdziale 2.1). Dzięki swobodzie stosowania spacji, wcięć i podziałów wierszy można
formatować kod w czytelny i spójny sposób.
Białymi znakami, oprócz zwykłej spacji (\u0020), są również tabulator oraz niektóre znaki
kontrolne ASCII i Unicode. Podziałem wiersza może być znak nowego wiersza, powrót karetki
(ang. carriage return) oraz para znaków powrót karetki/przejście do następnego wiersza (ang.
line feed).

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

* nie jest wymagany, ale dzięki niemu komentarz ładnie wygląda.


*/

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.

"Witaj, świecie!" // Ciąg znaków.

'Cześć' // Inny ciąg znaków.


true // Wartość logiczna.

false // Druga wartość logiczna.


null // Brak obiektu.

Pełną listę literałów liczbowych i tekstowych znajdziesz w rozdziale 3.

2.4. Identyfikatory i zarezerwowane


słowa
Identyfikator to po prostu nazwa. W języku JavaScript identyfikatorami są nazwy stałych,
zmiennych, właściwości, funkcji, klas oraz etykiet w pewnego rodzaju pętlach. Pierwszym
znakiem identyfikatora musi być litera, symbol podkreślenia (_) lub dolara ($). Po nim mogą
następować litery, cyfry, symbole podkreślenia i dolara. Pierwszym znakiem nie może być cyfra.
Dzięki temu ograniczeniu można łatwo odróżniać identyfikatory od liczb. Poniższe przykłady są
poprawnymi identyfikatorami:

nazwa_mojej_zmiennej

v13
_dummy

$str

JavaScript, podobnie jak inne języki, zawiera zastrzeżone identyfikatory, wykorzystywane na


wewnętrzne potrzeby. Są to wymienione w następnym punkcie zarezerwowane słowa, których
nie można używać w charakterze identyfikatorów.

2.4.1. Zarezerwowane słowa


Wymienione w tym punkcie słowa są częścią języka JavaScript. Wiele z nich, m.in. if, while i
for, jest zastrzeżonych, tj. nie można ich używać w charakterze nazw stałych, zmiennych,
funkcji i klas (mogą być jednak nazwami właściwości obiektu). Inne słowa, na przykład from,
of, get i set, stosowane w pewnych kontekstach są poprawnymi identyfikatorami, które nie
wprowadzają niejednoznaczności składniowych. Niektóre słowa kluczowe, na przykład let, nie
mogą być w pełni zastrzeżone ze względu na konieczność zachowania wstecznej
kompatybilności ze starszymi wersjami języka. Istnieją skomplikowane reguły określające,
kiedy tego rodzaju słowa można stosować jako identyfikatory, a kiedy nie można. Na przykład
słowo let może być nazwą zmiennej zadeklarowanej za pomocą słowa var poza klasą (ale nie
wewnątrz niej), ale nie może być nazwą stałej zadeklarowanej za pomocą słowa const.
Najlepiej jest unikać stosowania wszelkich zarezerwowanych słów w charakterze
identyfikatorów, z wyjątkiem from, set i target, które są bezpieczne i powszechnie używane.

as const export get null target void

async continue extends if of this while

await debugger false import return throw with


break default finally in set true yield

case delete for instanceof static try

catch do from let super typeof

class else function new switch var


Oprócz tego w JavaScripcie są pewne słowa, które nie są częścią języka, ale są zarezerwowane
lub ich stosowanie jest ograniczone, ponieważ mogą pojawić się w przyszłych wersjach:
enum implements interface package private protected public

Ze względów historycznych nie można w pewnych przypadkach stosować jako identyfikatorów


słów arguments i eval. Dlatego najlepiej w ogóle ich nie używać.

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;

2.5.1. Sekwencje ucieczki Unicode


W niektórych programach i urządzeniach nie można poprawnie wprowadzać, wyświetlać i
przetwarzać wszystkich znaków Unicode. Aby można było stosować język JavaScript ze
starszymi technologiami i systemami, są w nim zdefiniowane tzw. sekwencje ucieczki (ang.
escape sequence) umożliwiające kodowanie znaków Unicode wyłącznie za pomocą znaków
ASCII. Tego rodzaju sekwencja składa się ze znaków \u i następujących po nich dokładnie
czterech cyfr szesnastkowych (dopuszczalne są małe i wielkie litery A – F) lub od jednej do
sześciu cyfr szesnastkowych zamkniętych w nawiasach klamrowych. Sekwencje ucieczki można
stosować w literałach znakowych, literałach wyrażeń regularnych i identyfikatorach, ale nie w
słowach kluczowych. Na przykład znakowi „é” odpowiada sekwencja \u00E9. Poniżej
przedstawione są trzy sposoby użycia nazwy zmiennej zawierającej powyższy znak:

let café = 1; // Definicja zmiennej zawierającej w nazwie znak Unicode.


caf\u00e9 // => 1; odwołanie do zmiennej z wykorzystaniem sekwencji
ucieczki.

caf\u{E9} // => 1; inna postać tej samej sekwencji ucieczki.

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:

console.log("\u{1F600}"); // Wyświetlenie uśmiechniętej buźki.

Sekwencje ucieczki można również stosować w komentarzach. Ponieważ komentarze są


pomijane, są one interpretowane jak znaki ASCII, a nie Unicode.

2.5.2. Normalizacja Unicode


Jeżeli w kodzie JavaScript są stosowane nie tylko znaki ASCII, należy pamiętać, że ten sam znak
Unicode można zapisywać na więcej niż dwa sposoby. Na przykład ciąg „é” można zapisać jako
pojedynczy znak Unicode \u00E9 lub jako zwykły znak ASCII „e” z następującym po nim kodem
\u0301 dołączającym symbol akcentu. W obu przypadkach znaki wyświetlane na ekranie
wyglądają tak samo, ale ich kody binarne są różne. Oznacza to, że z perspektywy JavaScriptu są
to różne znaki, co może wprowadzać niemałe zamieszanie:

const café = 1; // Stała o nazwie "caf\u{e9}"

const café = 2; // Inna stała o nazwie "cafe\u{301}"

café // => 1: ta stała ma wartość 1.

café // => 2: ta nieodróżnialna stała ma inną wartość.

Standard Unicode zawiera proponowany sposób kodowania wszystkich znaków i procedurę


normalizacyjną przekształcającą tekst w kanoniczną postać umożliwiającą porównywanie
ciągów. W JavaScripcie jest przyjęte założenie, że kod źródłowy jest już znormalizowany. Jeżeli
zamierzasz stosować znaki Unicode w swoich programach, sprawdź, czy edytor i inne narzędzia
przeprowadzają normalizację. Dzięki temu unikniesz stosowania tak samo wyglądających, ale
różnych identyfikatorów.

2.6. Opcjonalne średniki


W JavaScripcie, podobnie jak w innych językach, do oddzielania instrukcji służy średnik (patrz
rozdział 5.). Jest to ważny znak, ponieważ poprawia czytelność kodu. Gdyby go nie było, koniec
jednej instrukcji mógłby stanowić początek następnej. W JavaScripcie nie trzeba stosować
średnika, jeżeli poszczególne instrukcje są umieszczone w osobnych wierszach. Nie trzeba go
również umieszczać na końcu programu ani w miejscu, w którym następnym tokenem jest
zamykający nawias klamrowy (}). Często programiści umieszczają średniki, aby wyraźnie
oznaczyć koniec instrukcji (tak jak w tej książce), choć nie jest to konieczne. Inny styl polega na
pomijaniu średników wszędzie, gdzie jest to dopuszczalne, i stosowaniu ich tylko w nielicznych
sytuacjach, w których są wymagane. Niezależnie od przyjętego stylu jest kilka niuansów
dotyczących stosowania opcjonalnych średników, o których to niuansach należy pamiętać.
Przeanalizujmy poniższy kod. Ponieważ obie instrukcje są umieszone w osobnych wierszach,
średniki są niepotrzebne:

a = 3;

b = 4;

Jednak w poniższym przypadku pierwszy średnik jest niezbędny:

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)

Powyższy kod jest interpretowany w następujący sposób:

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:

let x = 0 // Średnik pominięty.

;[x,x+1,x+2].forEach(console.log) // Zabezpieczający średnik oddzielający


instrukcje.

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;

zostanie on zinterpretowany jako:

return; true;

choć prawdopodobnie miałeś na myśli:

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.

Drugi wyjątek dotyczy stosowania operatorów ++ i –- (patrz podrozdział 4.8). Operatory te


mogą być prefiksami lub sufiksami umieszczonymi, odpowiednio, przed lub za wyrażeniami. W
drugim przypadku trzeba je umieszczać w tym samym wierszu, w którym znajduje się
wyrażenie. Trzeci wyjątek jest związany z funkcjami zdefiniowanymi przy użyciu zwięzłej
składni „strzałkowej”. Strzałka => musi być umieszczona w jednym wierszu z listą argumentów.

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.

3.1. Informacje ogólne i definicje


Dostępne w języku JavaScript typy danych można podzielić na dwie kategorie: prymitywne
i obiektowe. Typy prymitywne obejmują liczby, teksty (inaczej ciągi znaków) i wartości
logiczne. Niniejszy rozdział w dużej części szczegółowo opisuje liczby (podrozdział 3.2) i ciągi
znaków (podrozdział 3.3). Wartościom logicznym jest poświęcony podrozdział 3.4.
Specjalne wartości null i undefined to wartości prymitywne, które nie są liczbami, ciągami ani
wartościami logicznymi. Każda wartość jest zazwyczaj ściśle określonego typu. Więcej o null i
undefined dowiesz się w podrozdziale 3.5. W wersji ES6 języka pojawił się nowy typ
specjalnego przeznaczenia o nazwie Symbol, umożliwiający definiowanie rozszerzeń języka bez
naruszania wstecznej kompatybilności ze starszymi wersjami. Typ ten będzie krótko opisany w
podrozdziale 3.6.
W języku JavaScript każda wartość inna niż liczba, ciąg znaków, wartość logiczna, symbol, null
i undefined jest obiektem. Obiekt, tj. wartość typu obiektowego, jest kolekcją właściwości
posiadających nazwy i wartości (prymitywne lub obiektowe). W podrozdziale 3.7 zostanie
opisany specjalny obiekt globalny, natomiast szerzej i dokładniej obiekty zostaną
przedstawione w rozdziale 6.

Zwykły obiekt w języku JavaScript jest nieuporządkowaną kolekcją nazwanych wartości.


Zdefiniowany jest również obiekt specjalnego typu, zwany tablicą, będący uporządkowaną
kolekcją ponumerowanych wartości. Operacje na tablicach wykonuje się, stosując specjalną
składnię. Ponadto tablice zawierają specjalne, wbudowane funkcjonalności odróżniające je od
zwykłych obiektów. Tablice będą tematem rozdziału 7.

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:

a.sort(); // Obiektowa wersja funkcji sort(a).


Definiowanie metod zostanie opisane w rozdziale 9. Z technicznego punktu widzenia metody
mogą mieć tylko obiekty. Jednak liczby, ciągi, wartości logiczne i symbole funkcjonują tak, jakby
również je posiadały. Jedynie wartość null nie ma żadnej metody, którą można by wywołać.
Typy obiektowe w JavaScripcie są mutowalne, a prymitywne niemutowalne. Wartość typu
mutowalnego można modyfikować, na przykład można zmieniać wartości właściwości lub
elementów tablicy. Natomiast liczby, wartości logiczne, symbole, wartości null i undefined są
niemutowalne. W ogóle nie ma sensu mówić na przykład o zmienianiu liczby. Ciąg znaków
można traktować jako tablicę znaków, a więc wydawałoby się, że jest to typ mutowalny. W
rzeczywistości ciąg jest jednak niemutowalny. Do jego poszczególnych znaków można
odwoływać się za pomocą indeksu, ale nie można ich zmieniać. Różnice pomiędzy typami
mutowalnymi a niemutowalnymi zostaną szczegółowo opisane w podrozdziale 3.8.

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.

W JavaScripcie w powyższym formacie są również precyzyjnie zapisywane liczby całkowite z


zakresu od −9 007 199 254 740 992 (−253) do 9 007 199 254 740 992 (253) włącznie. W
przypadku użycia wartości wykraczających poza ten zakres tracona jest precyzja ostatnich cyfr.
Pamiętaj jednak, że niektóre operacje, opisane w rozdziale 4., są wykonywane na liczbach
całkowitych 32-bitowych. Jeżeli musisz dokładnie zapisywać duże liczby całkowite, zajrzyj do
punktu 3.2.5.

Liczba umieszczona bezpośrednio w kodzie JavaScript nosi nazwę literału liczbowego.


Literały liczbowe można przedstawiać w różnych formatach, opisanych w kolejnych punktach.
Zwróć uwagę, że każdy z nich można poprzedzić symbolem – (minus) oznaczającym liczbę
ujemną.

3.2.1. Literały całkowite


W języku JavaScript liczby dziesiętne zapisuje się w postaci sekwencji cyfr, na przykład:

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:

0xff // => 255:(15*16+15)

0xBADCAFE // => 195939070

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):

0b10101 // => 21:(1*16+0*8+1*4+0*2+1*1)


0o377 // => 255:(3*64+7*8+7*1)

3.2.2 Literały zmiennoprzecinkowe


Literały zmiennoprzecinkowe zawierają symbol dziesiętny, do ich zapisywania wykorzystuje się
tradycyjną składnię liczb rzeczywistych, obejmującą część całkowitą, następujący po niej
symbol dziesiętny i część ułamkową.

Literały zmiennoprzecinkowe można również zapisywać, stosując format wykładniczy. Po liczbie


rzeczywistej umieszcza się literę e (lub E), następnie opcjonalny symbol + lub -, a po nim
całkowity wykładnik. Jest to zapis reprezentujący mnożenie liczby rzeczywistej przez liczbę 10
podniesioną do zadanej potęgi. Składnię tę można zwięźle opisać w następujący sposób:

[cyfry][.cyfry][(E|e)[(+|-)]cyfry]
Przykłady:

3.14

2345.6789
.333333333333333333
6.02e23 // 6,02×10²³

1.4738223E-32 // 1,4738223×10–³²

Separatory w literałach liczbowych


Aby poprawić czytelność długiego literału liczbowego, można umieścić w nim znaki
podkreślenia rozdzielające grupy cyfr, na przykład:

let billion = 1_000_000_000; // Podkreślenie jako separator tysięcy,

let bytes = 0x89_AB_CD_EF; // jako separator bajtów

let bits = 0b0001_1101_0111; // lub półbajtów.

let fraction = 0.123_456_789; // Można go stosować również w części


ułamkowej.

Na początku 2020 r., gdy pisałem tę książkę, stosowanie znaków podkreślenia w


literałach liczbowych nie było jeszcze sformalizowane w specyfikacji języka JavaScript.
Proces standaryzacji jest jednak na zaawansowanym etapie, a format ten jest już
zaimplementowany we wszystkich najważniejszych przeglądarkach i w środowisku Node.

3.2.3. Działania arytmetyczne


W języku JavaScript operacje na liczbach wykonuje się za pomocą operatorów arytmetycznych.
Operator + oznacza dodawanie, - odejmowanie, * mnożenie, / dzielenie, a % dzielenie z resztą
(modulo). W wersji ES2016 języka został wprowadzony operator ** oznaczający potęgowanie.
Szczegółowe informacje o tych i innych operatorach znajdziesz w rozdziale 4.
Oprócz podstawowych operatorów arytmetycznych dostępne są bardziej zaawansowane
operatory matematyczne. Są to funkcje i stałe zdefiniowane jako właściwości obiektu Math:

Math.pow(2,53) // => 9007199254740992: liczba 2 podniesiona do


potęgi 53.

Math.round(.6) // => 1.0: zaokrąglenie do najbliższej liczby


całkowitej.

Math.ceil(.6) // => 1.0: zaokrąglenie w górę do najbliższej liczby


całkowitej.

Math.floor(.6) // => 0.0: zaokrąglenie w dół do najbliższej liczby


całkowitej.

Math.abs(-5) // => 5: wartość bezwzględna.


Math.max(x,y,z) // Wybranie największej wartości.

Math.min(x,y,z) // Wybranie najmniejszej wartości.

Math.random() // Liczba pseudolosowa z zakresu 0 <= x < 1.0.

Math.PI // π: stosunek obwodu do średnicy koła.

Math.E // e: podstawa logarytmu naturalnego.

Math.sqrt(3) // => 3**0.5: pierwiastek kwadratowy liczby 3.

Math.pow(3, 1/3) // => 3**(1/3): pierwiastek sześcienny liczby 3.


Math.sin(0) // Funkcja trygonometryczna (są również Math.cos,
Math.atan i inne funkcje).

Math.log(10) // Logarytm naturalny z 10.

Math.log(100)/Math.LN10 // Logarytm liczby 100 przy podstawie 10.

Math.log(512)/Math.LN2 // Logarytm liczby 512 przy podstawie 2.

Math.exp(3) // Sześcian stałej e.

Począwszy od wersji ES6 obiekt Math zawiera jeszcze więcej funkcji:

Math.cbrt(27) // => 3: pierwiastek sześcienny.

Math.hypot(3, 4) // => 5: pierwiastek kwadratowy sumy kwadratów argumentów.

Math.log10(100) // => 2: logarytm przy podstawie 10.


Math.log2(1024) // => 10: logarytm przy podstawie 2.

Math.log1p(x) // Logarytm naturalny (1+x); dokładny w przypadku bardzo


małych wartości x.

Math.expm1(x) // Math.exp(x)–1; odwrotność Math.log1p().

Math.sign(x) // –1, 0 lub 1, jeżeli argument jest, odpowiednio, mniejszy,


równy lub większy od zera.

Math.imul(2,3) // => 6: zoptymalizowane mnożenie 32-bitowych liczb


całkowitych.
Math.clz32(0xf) // => 28: liczba wiodących zerowych bitów liczby całkowitej
32-bitowej.

Math.trunc(3.9) // => 3: zamiana na liczbę całkowitą poprzez usunięcie


części ułamkowej.

Math.fround(x) // Zaokrąglenie do najbliższej 32-bitowej liczby


zmiennoprzecinkowej.

Math.sinh(x) // Sinus hiperboliczny (są również Math.cosh(), Math.tanh()


i inne funkcje).

Math.asinh(x) // Arcus sinus hiperboliczny (są również Math.acosh(),


Math.atanh() i inne funkcje).

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:

Infinity // Liczba dodatnia, zbyt duża, aby można ją było


wyrazić.

Number.POSITIVE_INFINITY // Jak wyżej.

1/0 // => Nieskończoność.

Number.MAX_VALUE * 2 // => Infinity; przepełnienie


-Infinity // Liczba ujemna, zbyt duża, aby można ją było
wyrazić.

Number.NEGATIVE_INFINITY // Jak wyżej.

-1/0 // => –Infinity

-Number.MAX_VALUE * 2 // => –Infinity

NaN // Wartość nieliczbowa.


Number.NaN // Wartość nieliczbowa zapisana w inny sposób.

0/0 // => NaN

Infinity/Infinity // => NaN

Number.MIN_VALUE/2 // => 0: niedomiar.

-Number.MIN_VALUE/2 // => –0: "ujemne zero".

-1/Infinity // -> –0: również "ujemne zero".

-0

// Właściwości i metody obiektu Number zdefiniowane w wersji ES6:

Number.parseInt() // Odpowiednik globalnej funkcji parseInt().


Number.parseFloat() // Odpowiednik globalnej funkcji parseFloat().

Number.isNaN(x) // Czy x ma wartość NaN?

Number.isFinite(x) // Czy x jest liczbą skończoną?

Number.isInteger(x) // Czy x jest liczbą całkowitą?

Number.isSafeInteger(x) // Czy x jest liczbą całkowitą z zakresu –(2**53) < x


< 2**53?

Number.MIN_SAFE_INTEGER // => –(2**53–1)

Number.MAX_SAFE_INTEGER // => 2**53–1

Number.EPSILON // => 2**–52: najmniejsza różnica między liczbami.

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:

let zero = 0; // "Zwykłe" zero.

let negz = -0; // "Ujemne" zero.

zero === negz // => true: "zwykłe" zero jest równe "ujemnemu" zeru.

1/zero === 1/negz // => false: Infinity i –Infinity nie są sobie równe.

3.2.4. Format zmiennoprzecinkowy i błędy


zaokrąglenia
Liczb rzeczywistych jest nieskończenie wiele, ale w języku JavaScript można w formacie
zmiennoprzecinkowym wyrazić ich skończoną liczbę (dokładnie 18 437 736 874 454 810 627).
Oznacza to, że w kodzie liczby rzeczywiste są często przybliżeniami faktycznych wartości.
Stosując zmiennoprzecinkowy format, zdefiniowany w normie IEEE-754 i wykorzystywany
w języku JavaScript i wielu innych nowoczesnych językach programowania, można dokładnie
wyrażać ułamki 1/2, 1/8, 1/1024 itp. Niestety powszechnie, szczególnie w operacjach
finansowych, stosowane są ułamki dziesiętne, takie jak 1/10, 1/100 itp., których nie można
dokładnie wyrazić za pomocą zmiennoprzecinkowego formatu binarnego.
Liczby w języku JavaScript można wyrażać z ogromną precyzją i ułamek 0,1 można przybliżyć
bardzo dokładnie. Mimo to brak ścisłej reprezentacji może być przyczyną problemów.
Przeanalizujmy poniższy kod:
let x = .3 - .2; // Trzydzieści groszy minus dwadzieścia groszy.
let y = .2 - .1; // Dwadzieścia groszy minus dziesięć groszy.

x === y // => false: obie wartości są różne!


x === .1 // => false: .3–.2 nie jest równe .1

y === .1 // => true: .2–.1 jest równe .1


Z powodu błędów zaokrąglenia różnica między przybliżeniami liczb 0,3 i 0,2 nie jest dokładnie
taka sama jak między przybliżeniami 0,2 i 0,1. Należy pamiętać, że ten problem występuje nie
tylko w języku JavaScript, ale we wszystkich językach, w których stosowany jest
zmiennoprzecinkowy format binarny. Ponadto wartości zmiennych x i y w powyższym kodzie są
bardzo zbliżone do siebie i poprawnej wartości. Uzyskiwane w ten sposób wyniki są w
większości przypadków akceptowalne. Problem pojawia się wtedy, gdy trzeba je ze sobą
porównywać.
Jeżeli przybliżenie zmiennoprzecinkowe jest źródłem problemów w kodzie, należy stosować
skalowalne liczby całkowite. Na przykład wartości monetarne należy wyrażać w groszach, a nie
w ułamkach złotego.
3.2.5. Typ BigInt — dowolnie duże liczby całkowite
Jedną z najnowszych funkcjonalności języka JavaScript, zdefiniowaną w specyfikacji ES2020,
jest typ liczbowy BigInt. Na początku 2020 r. obsługiwały go przeglądarki Chrome, Firefox
i Edge oraz platforma Node, natomiast w Safari typ ten był na etapie implementacji. Jak
sugeruje nazwa, BigInt jest typem liczb całkowitych. Został wprowadzony do języka JavaScript
głównie po to, aby można było wyrażać całkowite liczby 64-bitowe, niezbędne do uzyskania
kompatybilności z innymi językami i interfejsami API. Jednak duże liczby tego typu mogą
składać się z tysięcy, a nawet milionów cyfr. (Należy pamiętać, że typ BigInt nie nadaje się do
zastosowań kryptograficznych, ponieważ nie można za jego pomocą zapobiegać atakom
czasowym).

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:

1234n // Nie taki znów ogromny literał BigInt.


0b111111n // Binarny literał BigInt.

0o7777n // Ósemkowy literał BigInt.


0x8000000000000000n // => 2n**63n: 64-bitowy literał BigInt.

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

let string = "1" + "0".repeat(100); // Jedynka i 100 zer.


BigInt(string) // => 10n**100n: jeden googol.

Działania arytmetyczna na liczbach BigInt wykonywane są tak samo jak na zwykłych, z


wyjątkiem dzielenia, w którym część ułamkowa jest odrzucana, tj. wynik jest zaokrąglany w dół:
1000n + 2000n // => 3000n

3000n - 2000n // => 1000n


2000n * 3000n // => 6000000n

3000n / 997n // => 3n: iloraz jest równy 3.


3000n % 997n // => 9n: reszta jest równa 9.

(2n ** 131071n) - 1n // Liczba Mersenne'a składająca się z 39457 cyfr.


Standardowe operatory +, -, *, /, % i ** można stosować z liczbami BigInt, jednak nie można
mieszać operandów typu BigInt i zwykłych typów liczbowych. Na pierwszy rzut okna może się
to wydawać dziwne, ale są ku temu uzasadnione powody. Jeżeli jeden typ liczbowy jest bardziej
ogólny od drugiego, można łatwo zdefiniować działanie zwracające wynik bardziej ogólnego
typu. Jednak żaden typ nie jest bardziej ogólny. Typ BigInt pozwala wyrażać wyjątkowo duże
liczby całkowite, a więc jest bardziej ogólny niż zwykły typ liczbowy. Z drugiej strony jednak
BigInt jest wyłącznie typem całkowitym, a więc bardziej ogólny jest zwykły typ liczbowy.
Ponieważ nie ma możliwości obejścia tego problemu, w języku JavaScript została przyjęta
zasada, że w działaniach arytmetycznych nie można mieszać operandów.

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

2 > 1n // => true


0 == 0n // => true

0 === 0n // => false: operator == sprawdza również zgodność typów.


Operatory bitowe (które zostaną opisane w punkcie 4.8.3) zazwyczaj poprawnie działają z
operandami typu BigInt. Jednak żadnej funkcji obiektu Math nie można stosować z liczbami
typu BigInt.

3.2.6. Daty i czas


W języku JavaScript zdefiniowana jest klasa Date, służąca do wyrażania i wykonywania działań
na liczbach reprezentujących datę i czas. Wartość typu Date jest obiektem posiadającym
liczbową reprezentację wyrażającą liczbę milisekund, jakie upłynęły od 1 stycznia 1970 r.:
let timestamp = Date.now(); // Aktualny czas jako znacznik (liczba).

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.

Znaki, kody i ciągi w JavaScripcie


W języku JavaScript stosowany jest zestaw znaków Unicode kodowanych w standardzie
UTF-16, a ciągi znaków są sekwencjami 16-bitowych liczb bez znaku. Kody najczęściej
stosowanych znaków Unicode (tworzących tzw. podstawową płaszczyznę wielojęzykową)
zapisuje się za pomocą 16 bitów i można je reprezentować w postaci pojedynczych
elementów ciągu. Inne znaki Unicode, które nie mieszczą się w 16 bitach, koduje się,
wykorzystując reguły standardu UTF-16 w postaci sekwencji par 16-bitowych (tzw. par
zastępczych — ang. surrogate pair). Oznacza to, że pojedynczy znak Unicode może być
reprezentowany przez ciąg znaków od długości 2 (dwie wartości 16-bitowe):

let euro = "€";


let love = "♥";

euro.length // => 1: ten znak składa się z jednego 16-bitowego elementu.


love.length // => 2: kod UTF-16 znaku ♥ to "\ud83d\udc99”.

Większość metod do przetwarzania ciągów znaków operuje na wartościach 16-bitowych,


a nie na znakach, nie traktuje w specjalny sposób par zastępczych, nie normalizuje
ciągów, a nawet nie sprawdza, czy ciągi są zakodowane zgodnie ze standardem UTF-16.
Począwszy od wersji ES6 ciągi znaków są iterowalne. Za pomocą pętli for/of lub
operatora ... można iterować poszczególne znaki ciągu, ale nie 16-bitowe wartości.

3.3.1. Literały znakowe


Aby umieścić ciąg znaków w kodzie JavaScript, należy po prostu ująć go w apostrofy,
cudzysłowy lub grawisy (' lub " lub `). Cudzysłowy i grawisy można umieszczać wewnątrz
ciągów ujętych w apostrofy, z kolei cudzysłowy i apostrofy w ciągach ujętych w grawisy itd.
Poniżej znajduje się kilka przykładów literałów znakowych:
"" // Pusty ciąg (o zerowej liczbie znaków).

'test'
"3.14"

'name="myform"'
"Lubisz książki wydawnictwa Helion?"

"τ oznacza stosunek obwodu koła do jego promienia"


`Powiedział: "powiedziała mi 'cześć'".`
Ciągi ujęte w grawisy pojawiły się w wersji ES6 języka. Dzięki nim można w literałach
znakowych umieszczać (interpolować) wyrażenia. Składnia interpolacyjna zostanie opisana w
punkcie 3.3.4.
W starszych wersjach języka JavaScript literał znakowy musiał być umieszczany w jednym
wierszu. Dlatego w celu utworzenia długiego ciągu trzeba było za pomocą operatora + łączyć
wiele jednowierszowych ciągów. Począwszy od wersji ES5 literał znakowy może zajmować kilka
wierszy, przy czym na końcu każdego poza ostatnim należy umieścić lewy ukośnik (\). Znaki
umieszczone po ukośnikach, jak również podziały wierszy nie stanowią literału znakowego. Aby
w literale ujętym w apostrofy lub cudzysłowy umieścić podział wiersza, należy użyć sekwencji
\n (która zostanie opisana w następnym punkcie). W przypadku użycia grawisów
wprowadzonych w wersji ES6 podziały wierszy wchodzą w skład literału:

// Ciąg reprezentujący dwa wiersze zapisane w jednym wierszu:


'Dwa\nwiersze.'

// Jednowierszowy ciąg zapisany w trzech wierszach:


"Jeden\
długi\

wiersz."
// Dwuwierszowy ciąg zapisany w dwóch wierszach:

`Znak podziału umieszczony na końcu tego wiersza


jest częścią tego ciągu znaków.`

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>

3.3.2. Sekwencje ucieczki w literałach znakowych


Lewy ukośnik (\) ma w ciągach znaków w JavaScripcie specjalne znaczenie. W połączeniu z
następującym po nim znakiem reprezentuje znak, którego nie można wyrazić w inny sposób. Na
przykład \n jest sekwencją ucieczki reprezentującą podział wiersza.
Innym wspomnianym wcześniej przykładem jest sekwencja \' reprezentująca apostrof. Stosuje
się ją wtedy, gdy w literale ujętym w apostrofy trzeba umieścić inny apostrof. Teraz widać, skąd
się wzięła nazwa „sekwencja ucieczki”: za pomocą lewego ukośnika można „uciec” od zwykłej
interpretacji apostrofu, aby nie oznaczał on końca ciągu:
'McDonald's, Domino's Pizza, Dunkin' Donuts'

Tabela 3.1 przedstawia sekwencje ucieczki stosowane w języku JavaScript i reprezentowane


przez nie znaki. Trzy z nich to sekwencje generyczne, które w połączeniu z liczbą szesnastkową
umożliwiają kodowanie dowolnych znaków Unicode. Na przykład sekwencja \xA9 reprezentuje
symbol praw autorskich, oznaczony w standardzie Unicode liczbą szesnastkową A9. Ponadto
sekwencja \u wraz z czterema cyframi szesnastkowymi lub z jedną do sześciu cyfr
szesnastkowych ujętych w nawiasy klamrowe służy do wyrażania dowolnego znaku Unicode. Na
przykład zapis \u03c0 reprezentuje znak π, a \u{1f600} znak emoji uśmiechniętej buźki.

Tabela 3.1 . Sekwencje ucieczki w JavaScripcie

Sekwencja Reprezentowany znak

\0 Znak NUL (\u0000)

\b Usunięcie znaku (\u0008)

\t Tabulator poziomy (\u0009)

\n Nowy wiersz (\u000A)

\v Tabulator pionowy (\u000B)

\f Wysunięcie arkusza papieru (\u000C)

\r Powrót karetki (\u000D)

\" Cudzysłów (\u0022)

\' Apostrof (\u0027)

\\ Lewy ukośnik (\u005C)

\xnn Znak Unicode zapisany za pomocą dwóch cyfr szesnastkowych nn

\unnnn Znak Unicode zapisany za pomocą czterech cyfr szesnastkowych nn

\u{n} Znak Unicode zapisany za pomocą od jednej do sześciu cyfr szesnastkowych nn


(z zakresu od 0 do 10FFFF w wersji ES6 lub nowszej)

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.

3.3.3. Operacje na ciągach znaków


Jedną z wbudowanych funkcjonalności języka JavaScript jest łączenie ciągów znaków. Operator
+ użyty z liczbami powoduje dodanie ich, natomiast użyty z ciągami powoduje dołączenie
drugiego do pierwszego, na przykład:
let msg = "Witaj, " + "świecie@"; // Tworzy ciąg "Witaj, świecie!".

let greeting = "Witaj na moim blogu," + " " + name;


Ciągi znaków można porównywać ze sobą za pomocą standardowych operatorów równości ===
i nierówności !==. Dwa ciągi są sobie równe, jeżeli tworzą dokładnie takie same sekwencje 16-
bitowych wartości. Ciągi można również porównywać ze sobą za pomocą operatorów <, <=, > i
>=. W punkcie 11.7.3 znajdziesz więcej informacji na temat porównywania i sortowania ciągów
z uwzględnieniem ustawień regionalnych.

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:

let s = "Witaj, świecie!"; // Początkowy tekst.


// Wyodrębnianie fragmentów ciągu.
s.substring(1,4) // => "ita": znaki 2., 3. i 4.

s.slice(1,4) // => "ita": ten sam wynik.


s.slice(-3) // => "ie!": trzy ostatnie znaki

s.split(", ") // => [ 'Witaj', 'świecie!' ]: podział ciągu według


zadanego ciągu.

// Przeszukiwanie ciągu.
s.indexOf("i") // => 1: pozycja pierwszej litery "i".
s.indexOf("i", 3) // => 9: pozycja pierwszej litery "i", większa niż 3.

s.indexOf("zz") // => –1: ciąg s nie zawiera ciągu "zz".


s.lastIndexOf("i") // => 12: pozycja ostatniej litery "i".

// Logiczne metody przeszukujące, dostępne w wersjach ES6 i nowszych.


s.startsWith("Wit") // => true: ciąg s zaczyna się od zadanego ciągu.

s.endsWith(".") // => false: ciąg s nie kończy się zadanym ciągiem.


s.includes("aj") // => true: ciąg s zawiera ciąg "aj".
// Tworzenie zmienionej wersji zadanego ciągu.

s.replace("j", "my") // => "Witamy, świecie!"


s.toLowerCase() // => "Witamy, świecie!"

s.toUpperCase() // => "WITAMY, ŚWIECIE!"


s.normalize() // Normalizacja Unicode NFC: dostępna w wersji ES6.
s.normalize("NFD") // Normalizacja NFD. Inne rodzaje: "NFKC" i "NFKD".

// Odczytywanie pojedynczych (16-bitowych) znaków ciągu.


s.charAt(0) // => "W": pierwszy znak ciągu.

s.charAt(s.length-1) // => "!": ostatni znak ciągu.


s.charCodeAt(0) // => 87: 16-bitowy kod znaku na zadanej pozycji.

s.codePointAt(0) // => 87: metoda dostępna w wersji ES6, działa z


kodami większymi niż 16 bitów.
// Dopełnianie ciągu w wersjach ES2017 i nowszych.

"x".padStart(3) // => " x": dodanie spacji z lewej strony w celu


uzyskania ciągu o długości

// 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).

" test ".trim() // => "test": usunięcie spacji z początku i końca


ciągu.
" test ".trimStart() // => "test ": usunięcie spacji z początku ciągu
(również trimLeft()).
" test ".trimEnd() // => " test": usunięcie spacji z końca ciągu
(również trimRight ()).

// Różne metody tekstowe.


s.concat("!!") // => "Witaj, świecie!!!": zamiast tego można użyć
operatora +.
"<>".repeat(5) // => "<><><><><>": powielenie ciągu (od wersji ES5).
Pamiętaj, że w języku JavaScript ciągi znaków są niemutowalne. Metody takie jak replace()
i toUpperCase() nie modyfikują oryginalnych ciągów, tylko zwracają ich nowe wersje.
Ciągi można również traktować jako tablice przeznaczone wyłącznie do odczytu. Do
poszczególnych znaków (16-bitowych wartości ) można się odwoływać za pomocą nawiasów
kwadratowych zamiast za pomocą metody charAt():
let s = "Witaj, świecie!";
s[0] // => "W"
s[s.length-1] // => "!"

3.3.4. Literały szablonowe


Począwszy od wersji ES6 literały znakowe można definiować za pomocą grawisów:
let s = `Witaj, świecie!`;
Jest to o wiele bardziej przydatny sposób od dotychczasowych, ponieważ w zdefiniowanym w
ten sposób literale szablonowym można umieszczać dowolne wyrażenia. Wynikowy literał jest
tworzony po wyliczeniu wszystkich zawartych w nim wyrażeń, przekształceniu ich w ciągi
znaków i umieszczeniu ich w zadanym ciągu ujętym w grawisy:

let name = "Andrzej";


let greeting = `Cześć, ${ name }.`; // greeting == "Cześć, Andrzej."
Wszystko, co znajduje się między znakami ${ i }, jest interpretowane jako wyrażenie JavaScript,
a wszystko poza nawiasami klamrowymi jako zwykły tekst. Interpreter wylicza wynik wyrażenia
zawartego w nawiasach, przekształca go w ciąg znaków i umieszcza w szablonie. Znak dolara,
nawiasy i wszystko, co się wewnątrz nich znajduje, jest usuwane.
Szablon może zawierać dowolną liczbę wyrażeń, może zawierać sekwencje ucieczki, podobnie
jak zwykły ciąg, jak również można go zapisywać w wielu wierszach bez stosowania specjalnych
znaków. Poniższy literał szablonowy zawiera cztery wyrażenia, sekwencję ucieczki Unicode i
przynajmniej cztery podziały wiersza (wyrażenia również mogą zawierać tego rodzaju znaki):
let errorMessage = `\

\u2718 Test pliku ${filename}:${linenumber}:


${exception.message}
Ślad stosu:
${exception.stack}
`;

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.

Oznakowane literały szablonowe


Bardzo przydatną, choć mniej znaną funkcjonalnością literału szablonowego, jest możliwość
umieszczania przed otwierającym grawisem funkcji (czyli „znacznika”), której jest
przekazywany test wraz z zawartymi w nim wyrażeniami. Zawartością takiego „oznakowanego”
literału szablonowego jest wynik zwrócony przez daną funkcję. Funkcjonalność tę wykorzystuje
się na przykład w celu zastosowania sekwencji ucieczki HTML i SQL przed umieszczeniem
wartości w tekście.
Wersja ES6 języka oferuje jeden wbudowany znacznik: funkcję String.raw(), która zwraca
tekst umieszczony wewnątrz grawisów, zawierający nieprzetworzone sekwencje ucieczki:
`\n`.length // => 1: ten ciąg składa się z jednego znaku podziału
wiersza.
String.raw`\n`.length // => 2: ciąg składający się z lewego ukośnika i
litery n.
Zwróć uwagę, że w części zawierającej funkcję nie są stosowane nawiasy. W tym szczególnym
przypadku grawisy zastępują parę nawiasów.

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.

3.3.5. Porównywanie ciągu znaków ze wzorcem


W języku JavaScript dostępny jest typ danych zwany wyrażeniem regularnym. Typ ten służy
do definiowania wzorca i porównywania z nim ciągów znaków. Nie jest to podstawowy typ
danych, ale ze względu na podobieństwo jego składni do liczb i tekstów jest często traktowany
jako typ podstawowy. Składnia wyrażenia regularnego jest skomplikowana, a definiowany przez
nie interfejs API nietrywialny. Wyrażenia regularne zostaną szczegółowo opisane w
podrozdziale 11.3. Ponieważ są one bardzo przydatne i powszechnie stosowane do
przetwarzania tekstu, ogólnie opiszę je w tym punkcie.
Tekst umieszczony pomiędzy ukośnikami definiuje literał wyrażenia regularnego. Po drugim
ukośniku można umieścić jeden lub więcej znaków modyfikujących znaczenie wyrażenia, na
przykład:
/^HTML/; // Wyrażenie sprawdzające, czy pierwszym znakiem ciągu
jest litera H, T, M lub L.
/[1-9][0-9]*/; // Wyrażenie sprawdzające, czy ciąg zawiera podciąg
składający się

// z przynajmniej jednej cyfry.


/\bjavascript\b/i; // Wyrażenie sprawdzające, czy ciąg zawiera podciąg
"javascript". Wielkość
// liter nie ma znaczenia.
Obiekt wyrażenia regularnego zawiera kilka przydatnych metod. Oprócz tego obiekt ciągu
zawiera metody, w których argumentach można umieszczać wyrażenia regularne, na przykład:

let text = "test: 1, 2, 3"; // Przykładowy tekst.


let pattern = /\d+/g; // Sprawdzenie, czy ciąg zawiera
przynajmniej jedną cyfrę.
pattern.test(text) // => true: ciąg jest zgodny ze wzorcem.
text.search(pattern) // => 6: pozycja pierwszego zgodnego
podciągu.
text.match(pattern) // => ["1", "2", "3"]: tablica zawierająca
wszystkie zgodne podciągi.
text.replace(pattern, "#") // => "test: #, #, #"
text.split(/\D+/) // => ["","1","2","3"]: podział ciągu wg
znaków innych niż cyfry.
3.4. Wartości logiczne
Typ logiczny reprezentuje prawdę lub fałsz, włączenie lub wyłączenie, potwierdzenie lub
zaprzeczenie. Są tylko dwie wartości tego typu, reprezentowane za pomocą zarezerwowanych
słów true i false.
Wartość logiczna jest zazwyczaj wynikiem operacji porównania, na przykład:
a === 4
Powyższy kod sprawdza, czy wartość zmiennej a jest równa liczbie 4. Jeżeli jest, wynikiem
porównania jest logiczna wartość true. Jeżeli wartość jest inna niż 4, wynikiem porównania jest
wartość false.
Wartości logiczne są powszechnie stosowane w strukturach sterujących. Na przykład instrukcja
if/else wykonuje jedną z dwóch operacji w zależności od tego, czy wartość logiczna jest
równa true, czy false. Zazwyczaj porównanie zwracające wartość logiczną umieszcza się
bezpośrednio w instrukcji, która ją wykorzystuje, na przykład:
if (a === 4) {

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:

if (o !== null) ...


Operator nierówności !== porównuje zmienną o z wartością null, a wynikiem wyrażenia jest
wartość true lub false. Operator można jednak całkowicie pominąć i wykorzystać fakt, że null
jest wartością fałszywą, a obiekt prawdziwą:
if (o) ...
W pierwszym przypadku ciało instrukcji if zostanie wykonane tylko wtedy, gdy zmienna o
będzie miała wartość inną niż null. Drugi przypadek jest mniej ścisły: ciało instrukcji zostanie
wykonane wtedy, gdy wartość zmiennej o nie będzie fałszywa, tj. będzie różna od null i
undefined. Wybór właściwej wersji instrukcji zależy od wartości, jakie może przyjmować
zmienna o. Jeżeli trzeba odróżniać null od zera i pustego ciągu znaków, należy stosować
operator ścisłego porównania.
Wartość logiczna posiada metodę toString() zwracającą ciąg "true" lub "false". Interfejs
API jest trywialny, ale są trzy ważne operatory logiczne.
Operator && wykonuje operację logiczną ORAZ. Zwraca wartość prawdziwą tylko wtedy, gdy
oba operandy są prawdziwe. W przeciwnym razie zwraca wartość fałszywą. Operator ||
wykonuje operację logiczną LUB. Zwraca wartość prawdziwą wtedy, gdy przynajmniej jeden
operand jest prawdziwy. W przeciwnym razie zwraca wartość fałszywą. Jednoargumentowy
operator ! wykonuje operację logiczną NIE. Zwraca wartość true, jeżeli operand ma wartość
fałszywą, lub false, jeżeli ma wartość prawdziwą. Ilustruje to poniższy przykład:
if ((x === 0 && y === 0) || !(z === 0)) {

// Zmienne x i y są równe zeru, a z jest różna od zera.


}
Szczegółowe informacje o powyższych operatorach znajdziesz w podrozdziale 4.10.

3.5. Wartości null i undefined


Słowo kluczowe null jest zazwyczaj wykorzystywane do sygnalizowania braku wartości.
Operator typeof użyty z tym słowem zwraca ciąg "object", co oznacza, że null można
traktować jako specjalny obiekt oznaczający brak obiektu. Jednak w praktyce null jest
traktowana jako jedyna wartość osobnego typu, oznaczająca brak liczby, ciągu znaków i
obiektu. Wiele języków programowania zawiera odpowiednik null — zapewne spotkałeś się z
wartościami NULL, nil lub None.
W języku JavaScript jest jeszcze jedno podobne słowo, undefined, oznaczające bardziej
dotkliwy brak wartości. Tę wartość przyjmuje zmienna, która nie została zainicjowana, jak
również zwraca je zapytanie o nieistniejącą właściwość obiektu lub nieistniejący element
tablicy. Jest to też wynik funkcji, która nie zwraca żadnej określonej wartości, jak również
wartość nieokreślonego parametru funkcji. Wartość undefined jest globalną stałą (a nie
słowem kluczowym, jak null, choć w praktyce różnica ta nie jest istotna), przypisywaną
niezainicjowanej zmiennej. Operator typeof użyty z wartością undefined zwraca ciąg
"undefined" oznaczający, że jest to wartość osobnego typu.
Wartości null i undefined, pomimo opisanych różnic, oznaczają brak wartości i są często
stosowane wymiennie. Operator == traktuje je jako równe wartości w przeciwieństwie do
operatora ścisłej równości ===, który je rozróżnia. Obie wartości są fałszywe i w miejscach,
gdzie wymagana jest wartość logiczna, są traktowane jako wartość false. Żadna z nich nie ma
właściwości ani metod. Próba odwołania się do metody lub właściwości za pomocą kropki lub
nawiasów kwadratowych skutkuje zgłoszeniem błędu TypeError.
Według mnie wartość undefined sygnalizuje systemowy, nieoczekiwany lub świadczący o
błędzie brak wartości, natomiast null — programowy, normalny lub oczekiwany brak wartości.
Staram się w miarę możliwości unikać stosowania wartości null i undefined. Jeżeli jednak
muszę przypisać jedną z nich zmiennej lub właściwości, umieścić ją w argumencie funkcji lub
zwrócić w wyniku, zazwyczaj stosuję null. Inni programiści z kolei unikają null i tam, gdzie
jest to możliwe, stosują undefined.
3.6. Symbole
Symbole zostały wprowadzone w wersji ES6 języka, aby można było stosować nietekstowe
nazwy właściwości. Aby zrozumieć symbole, trzeba wcześniej poznać fundamentalny typ
Object, będący nieuporządkowaną kolekcją właściwości, z których każda ma nazwę i wartość.
Nazwami właściwości są zazwyczaj ciągi znaków, a przed pojawieniem się wersji ES6 były to
wyłącznie ciągi. Począwszy od wersji ES6 nazwami mogą być również symbole, jak niżej:

let strname = "string name"; // Ciąg pełniący rolę nazwy właściwości.


let symname = Symbol("propname"); // Symbol pełniący rolę nazwy właściwości.
typeof strname // => "string": jest ciągiem znaków.
typeof symname // => "symbol": symname jest symbolem.

let o = {}; // Utworzenie nowego obiektu.


o[strname] = 1; // Zdefiniowanie właściwości o nazwie
określonej za pomocą
// ciągu znaków.
o[symname] = 2; // Zdefiniowanie właściwości o nazwie
określonej za pomocą

// 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"

3.7. Obiekt globalny


W poprzednich podrozdziałach opisałem prymitywne typy i wartości stosowane w języku
JavaScript. Typy obiektowe — obiekty, tablice i funkcje — opiszę w osobnych rozdziałach w
dalszej części książki. Jest jednak pewna ważna wartość, którą muszę omówić teraz: obiekt
globalny. Jest to zwykły obiekt mający bardzo ważną cechę: jego właściwości są globalnymi
identyfikatorami. Interpreter JavaScriptu zaraz po uruchomieniu (lub przeglądarka po
załadowaniu strony) tworzy nowy obiekt globalny z początkowym zestawem właściwości
definiujących:

globalne stałe, m.in. undefined, Infinity i NaN;


globalne funkcje, m.in. isNaN(), parseInt() (punkt 3.9.2) i eval() (podrozdział 4.12);
funkcje konstruktorów klas, m.in. Date(), RegExp(), String(), Object() i Array()
(punkt 3.9.2);
globalne obiekty, m.in. Math i JSON (podrozdział 6.8).

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.

3.8. Niemutowalne prymitywne wartości i


mutowalne odwołania do obiektu
Pomiędzy prymitywnymi wartościami (undefined, null, wartościami logicznymi, liczbami i
ciągami) a obiektami istnieje fundamentalna różnica. Wartości prymitywne są niemutowalne, tj.
nie można ich zmieniać („mutować”). W przypadku liczb i wartości logicznych jest to oczywiste,
ponieważ zmienianie wartości liczby nie ma sensu, ale w przypadku ciągów znaków już takie
nie jest. Ponieważ ciągi znaków są podobne do tablic, mogłoby się wydawać, że powinna istnieć
możliwość zmieniania znaku o zadanym indeksie. Jednak nie jest to możliwe. Wszystkie metody
zwracające pozornie zmieniony ciąg tak naprawdę zwracają nowy, na przykład:
let s = "cześć"; // Tekst składający się z małych liter.
s.toUpperCase(); // Metoda zwraca wynik "CZEŚĆ", ale nie zmienia wartości
zmiennej s.

s // => "cześć": oryginalny ciąg nie jest zmieniany.


Zmienne prymitywne są porównywane na podstawie wartości. Dwie zmienne są traktowane
jako równe wtedy, gdy ich wartości są równe. Wydaje się to oczywiste dla liczb, wartości
logicznych, null i undefined, ponieważ nie ma innego sposobu porównywania takich wartości.
Jednak, jak poprzednio, nie jest to już takie oczywiste w przypadku ciągów znaków. Dwa ciągi
są traktowane jako równe wtedy, gdy mają takie same długości i identyczne znaki na wszystkich
pozycjach.

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.

o.y = 3; // Kolejna modyfikacja polegająca na dodaniu nowej


właściwości.
let a = [1,2,3]; // Tablice też są mutowalne.
a[0] = 0; // Zmiana elementu tablicy.
a[3] = 4; // Dodanie nowego elementu tablicy.

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.

Obiekty są czasami nazywane typami referencyjnymi w celu odróżnienia ich od typów


prymitywnych. Zgodnie z tą terminologią wartości obiektów są referencjami. Zatem obiekty
porównuje się na podstawie referencji. Dwie wartości obiektowe są równe tylko wtedy, gdy są
referencjami do tego samego obiektu.
let a = []; // Zmienna zawiera referencję do pustej tablicy.

let b = a; // Teraz zmienna b zawiera referencję do tej samej tablicy.


b[0] = 1; // Modyfikacja tablicy, do której referencję zawiera zmienna b.
a[0] // => 1: wprowadzona zmiana dotyczy również zmiennej a.
a === b // => true: zmienne a i b zawierają referencje do tej samej
tablicy, więc są równe.

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.

for(let i = 0; i < a.length; i++) { // Pętla iterująca indeksy tablicy a.


b[i] = a[i]; // Skopiowanie elementu tablicy a do b.
}
let c = Array.from(b); // Począwszy od wersji ES6 tablice można kopiować za
pomocą metody Array.from().

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.

for(let i = 0; i < a.length; i++) { // Iterowanie wszystkich


elementów tablicy.
if (a[i] !== b[i]) return false; // Jeżeli elementy różnią się,
tablice nie są równe.
}

return true; // W przeciwnym razie są równe.


}

3.9. Konwersje typów


Język JavaScript jest bardzo elastyczny pod względem stosowanych typów wartości.
Przekonałeś się o tym przy okazji wartości logicznych: w miejscu, gdzie spodziewana jest
wartość typu logicznego, można umieścić wartość dowolnego innego typu, która zostanie
odpowiednio przekształcona. Niektóre wartości („prawdziwe”) są przekształcane na true, a
inne („fałszywe”) na false. Ta sama zasada dotyczy innych typów. Jeżeli oczekiwany jest ciąg
znaków, dowolna zadana wartość zostanie przekształcona w ciąg. Jeżeli oczekiwana jest liczba,
wartość zostanie przekształcona w liczbę lub — jeżeli nie będzie to możliwe — w NaN.

Poniżej przedstawionych jest kilka przykładów:


10 + " obiektów" // => "10 obiektów": liczba 10 jest przekształcana w ciąg
znaków.
"7" * "4" // => 28: oba ciągi są przekształcane w liczby.
let n = 1 - "x"; // n == NaN; ciągu "x" nie można przekształcić w liczbę.

n + " obiektów" // => "NaN obiektów": wartość NaN jest przekształcana w


ciąg "NaN".
Tabela 3.2 podsumowuje przekształcanie wartości jednego typu w inny. Pogrubioną czcionką są
wyróżnione konwersje, które mogą się wydawać nietypowe. Puste komórki tabeli oznaczają, że
konwersja nie jest potrzebna lub nie jest wykonywana.
Tabela 3.2 . Konwersje typów w JavaScripcie

Konwersja na Konwersja Konwersja na


Wartość
ciąg na liczbę wartość logiczną

undefined "undefined" NaN false

null "null" 0 false

true "true" 1

false "false" 0

"" (pusty ciąg znaków) 0 false

"1.2" (ciąg znaków


1.2 true
zawierający liczbę)

"jeden" (ciąg znaków


NaN true
niezawierający liczby)

0 "0" false

-0 "0" false

1 (wartość skończona, różna


"1" true
od zera)

Infinity "Infinity" true

-Infinity "-Infinity" true

NaN "NaN" false

{} (dowolny obiekt) Patrz punkt 3.9.3. Patrz punkt true


3.9.3.

[] (pusta tablica) "" 0 true

[9] (jeden element


"9" 9 true
zawierający liczbę)

['a'] (element zawierający Należy użyć


NaN true
inną wartość) metody join().

funkcja() {} (dowolna
Patrz punkt 3.9.3. NaN true
funkcja)

Pokazane w powyższej tabeli konwersje pomiędzy wartościami prymitywnymi są zrozumiałe.


Konwersja na wartość logiczną została opisana w podrozdziale 3.4. Dobrze zdefiniowana jest
konwersja każdej prymitywnej wartości na ciąg ciągów. Natomiast konwersja na liczbę jest już
nieco trudniejsza. Ciągi, które można interpretować jako liczby, są przekształcane w liczby.
Dopuszczalne jest stosowanie wiodących i końcowych spacji, ale w przypadku użycia innych
znaków niż cyfry wynikiem konwersji jest wartość NaN. Niektóre liczbowe konwersje mogą
zaskakiwać. Na przykład wartość true jest przekształcana w liczbę 1, a false oraz pusty ciąg
znaków — w liczbę 0.
Konwersja obiektu na wartość prymitywną jest jeszcze bardziej zawiła i będzie tematem punktu
3.9.3.

3.9.1. Konwersje i równość wartości


W języku JavaScript są dwa operatory sprawdzające równość wartości. Dla operatora „ścisłej
równości” === operandy nie są równe, jeżeli są różnych typów. W większości przypadków jest to
właściwy operator, który należy stosować. Ponieważ jednak język JavaScript jest bardzo
elastyczny pod względem konwersji typów, istnieje jeszcze operator ==, bardziej tolerancyjny
pod względem równości wartości. Na przykład wszystkie poniższe porównania zwracają
wartość true:
null == undefined // => true: te dwie wartości są traktowane jako równe.
"0" == 0 // => true: ciąg znaków przed porównaniem jest
przekształcany w liczbę.
0 == false // => true: wartość logiczna przed porównaniem jest
przekształcana w liczbę.
"0" == false // => true: oba operandy przed porównaniem są
przekształcane w liczby!

W punkcie 4.9.1 znajdziesz dokładny opis przekształceń wykonywanych przez operator == w


celu sprawdzenia, czy dwie wartości są sobie równe.
Pamiętaj, że możliwość przekształcenia jednej wartości w inną nie oznacza ich równości. Na
przykład wartość undefined użyta w miejscu, w którym oczekiwana jest wartość logiczna, jest
przekształcana w false, co nie oznacza, że undefined == false. Z operatorami i instrukcjami
można stosować wartości różnych typów, które są odpowiednio przekształcane. Instrukcja if
przekształca wartość undefined w false, ale operator == nigdy nie przekształca operandów w
wartości logiczne.

3.9.2. Jawna konwersja


Choć w wielu przypadkach konwersja typu odbywa się automatycznie, czasami trzeba ją
wykonywać jawnie, choćby po to, aby kod był bardziej czytelny.
Najprościej jawną konwersję wykonuje się za pomocą funkcji Boolean(), Number() i String(),
jak niżej:
Number("3") // => 3
String(false) // => "false": można również użyć false.toString().
Boolean([]) // => true
Każda wartość inna niż null i undefined ma metodę toString(), która zazwyczaj zwraca taki
sam wynik jak funkcja String().
Przy okazji zauważ, że funkcje Boolean(), Number() i String() można wywoływać jak
konstruktory, tj. z użyciem instrukcji new. W ten sposób uzyskuje się obiekt opakowujący,
funkcjonujący jak prymitywna wartość logiczna, liczba lub ciąg znaków. Tego rodzaju obiekt jest
zaszłością historyczną z początków języka JavaScript. Dzisiaj nie ma już uzasadnionych
powodów, aby go używać.

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.

W opcjonalnym argumencie metody toString() zawartej w klasie Number można określić


podstawę konwersji od 2 do 36 (jeżeli się tego nie zrobi, przyjmowana jest podstawa 10). Dzięki
temu można konwertować liczby na różne systemy, na przykład:
let n = 17;
let binary = "0b" + n.toString(2); // Liczba dwójkowa == "0b10001".

let octal = "0o" + n.toString(8); // Liczba ósemkowa == "0o21".


Let hex = "0x" + n.tostring(16); // Liczba szesnastkowa == "0x11".
Podczas przetwarzania danych finansowych lub naukowych często trzeba przekształcać liczby
w ciągi znaków w sposób dający kontrolę nad liczbą cyfr znaczących i dziesiętnych oraz nad
zapisem wykładniczym. Klasa Number ma trzy metody dokonujące tego rodzaju konwersji.
Metoda toFixed() przekształca liczbę w ciąg zawierający zadaną liczbę cyfr po przecinku bez
stosowania notacji wykładniczej. Metoda toExponential() przekształca liczbę w ciąg, stosując
notację wykładniczą, w której przed przecinkiem znajduje się jedna cyfra, a po przecinku
zadana liczba cyfr (oznacza to, że liczba cyfr znaczących jest o jeden większa od zadanej liczby
cyfr). Metoda toPrecision() przekształca liczbę w ciąg składający się z zadanej liczby cyfr
znaczących. Jeżeli liczba cyfr znaczących nie jest wystarczająco duża, aby w pełni wyrazić
liczbę całkowitą, stosowana jest notacja wykładnicza. Pamiętaj, że wszystkie trzy metody
odpowiednio zaokrąglają lub uzupełniają zerami końcowe cyfry. Przeanalizujmy poniższe
przykłady:
let n = 123456.789;

n.toFixed(0) // => "123457"


n.toFixed(2) // => "123456.79"
n.toFixed(5) // => "123456.78900"
n.toExponential(1) // => "1.2e+5"
n.toExponential(3) // => "1.235e+5"

n.toPrecision(4) // => "1.235e+5"


n.toPrecision(7) // => "123456.8"
n.toPrecision(10) // => "123456.7890"
Oprócz wymienionych wyżej metod formatujących liczby dostępna jest bardziej ogólna metoda
klasy Intl.NumberFormat uwzględniająca formaty międzynarodowe. Szczegółowe informacje
na jej temat znajdziesz w punkcie 11.7.1.
Funkcja Number() wywołana z ciągiem znaków w argumencie traktuje ten ciąg jako literał
całkowity lub zmiennoprzecinkowy. Funkcja operuje tylko na liczbach systemu dziesiętnego i
nie przetwarza literałów zawierających prefiksy. Bardziej elastyczne są globalne funkcje
parseInt() i parseFloat() (nie są to metody żadnej klasy). Pierwsza z nich przetwarza tylko
liczby całkowite, natomiast druga całkowite i zmiennoprzecinkowe. Jeżeli ciąg rozpoczyna się
od znaków „0x” lub „0X”, funkcja parseInt() interpretuje go jako liczbę szesnastkową. Obie
funkcje pomijają wiodące spacje, przetwarzają wszystkie następujące po nich znaki numeryczne
i pomijają pozostałe. Jeżeli pierwszy znak inny niż spacja nie jest literałem cyfrowym, funkcja
zwraca wartość NaN. Poniżej przedstawionych jest kilka przykładów:
parseInt("3 blind mice") // => 3
parseFloat(" 3.14 meters") // => 3.14

parseInt("-12.34") // => –12


parseInt("0xFF") // => 255
parseInt("0xff") // => 255
parseInt("-0XFF") // => –255
parseFloat(".1") // => 0.1

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 "$".

Funkcja parseInt() ma drugi, opcjonalny argument określający system liczbowy. Dopuszczalne


są wartości od 2 do 36, na przykład:
parseInt("11", 2) // => 3: (1*2+1)
parseInt("ff", 16) // => 255: (15*16+15)
parseInt("zz", 36) // => 1295: (35*36+35)

parseInt("077", 8) // => 63: (7*8+7)


parseInt("077", 10) // => 77: (7*10+7)
3.9.3. Konwersja obiektu na wartość prymitywną
W poprzednich punktach opisałem, jak można jawnie przekształcać jeden typ wartości w inny
oraz jak niejawnie jest przekształcany jeden typ prymitywny w inny typ prymitywny. W tym
punkcie wyjaśnię skomplikowane reguły konwersji obiektów na wartości prymitywne. Opis jest
długi i zawiły, dlatego jeżeli pierwszy raz czytasz ten punkt, możesz go pominąć i przejść do
podrozdziału 3.10.
Konwersja obiektów na wartości prymitywne jest tak skomplikowana między innymi dlatego, że
niektóre rodzaje obiektów mogą mieć kilka prymitywnych reprezentacji. Na przykład obiekt
Date można przedstawić jako ciąg znaków lub liczbowy znacznik czasu. Specyfikacja języka
JavaScript definiuje trzy podstawowe algorytmy przekształcania obiektów w wartości
prymitywne:
Preferuj ciąg znaków
Algorytm generuje wartość prymitywną i preferuje ciąg znaków, jeżeli jest możliwa
konwersja na ciąg.
Preferuj liczbę
Algorytm generuje wartość prymitywną i preferuje liczbę znaków, jeżeli taka konwersja jest
możliwa.

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.

Konwersja obiektu na wartość logiczną


Konwersja obiektu na wartość logiczną jest trywialna: po prostu każdy obiekt jest
przekształcany na wartość true. Zwróć uwagę, że konwersja ta nie wymaga stosowania
opisanych wcześniej algorytmów. Ponadto dotyczy ona dosłownie wszystkich obiektów, włącznie
z pustymi tablicami, a nawet opakowującym obiektem new Boolean(false).

Konwersja obiektu na ciąg znaków


Jeżeli obiekt musi być przekształcony w ciąg znaków, najpierw jest konwertowany na wartość
prymitywną za pomocą algorytmu preferuj ciąg znaków, a następnie — jeżeli jest to konieczne
— uzyskana prymitywna wartość jest konwertowana na ciąg zgodnie z regułami
przedstawionymi w tabeli 3.2.

Tego rodzaju konwersja ma miejsce na przykład:

po umieszczeniu w argumencie wbudowanej funkcji obiektu zamiast ciągu znaków,


w przypadku użycia String() jako funkcji konwertującej,
podczas interpolacji obiektu w literale szablonowym (patrz punkt 3.3.4).

Konwersja obiektu na liczbę


Jeżeli obiekt musi być przekształcony w liczbę, najpierw jest konwertowany na wartość
prymitywną za pomocą algorytmu preferuj liczbę, a następnie — jeżeli jest to konieczne —
uzyskana prymitywna wartość jest konwertowana na liczbę zgodnie z regułami
przedstawionymi w tabeli 3.2.
W ten sposób funkcje i metody konwertują swoje argumenty, jeżeli zostaną w nich umieszczone
obiekty zamiast liczb. Oprócz tego większość operatorów, z nielicznymi opisanymi niżej
wyjątkami, przekształca w ten sposób swoje operandy.

Operatory i szczególne przypadki konwersji


Operatory dokładne opiszę w rozdziale 4. Teraz wyjaśnię szczególne przypadki, w których nie
jest wykonywana opisana wcześniej konwersja obiektu na ciąg znaków ani na liczbę.
Operator + dodaje liczby i łączy ciągi znaków. Jeżeli operand jest obiektem, jest przekształcany
w wartość prymitywną za pomocą algorytmu bez preferencji. Jeżeli jedna z uzyskanych
wartości prymitywnych jest ciągiem znaków, druga jest przekształcana również w ciąg i oba
ciągi są ze sobą łączone. W przeciwnym razie oba operandy są przekształcane w liczby i
sumowane.
Operatory == i != sprawdzają równość i nierówność wartości w dość luźny sposób,
umożliwiający przeprowadzanie konwersji typów. Jeżeli jeden operand jest obiektem, a drugi
wartością prymitywną, obiekt jest przekształcany w wartość prymitywną za pomocą algorytmu
bez preferencji, po czym obie wartości są porównywane ze sobą.
Operatory <, <=, > i >= porównują operandy ze sobą i można je stosować zarówno z liczbami, jak
i ciągami znaków. Jeżeli operand jest obiektem, jest przekształcany w wartość prymitywną za
pomocą algorytmu preferuj liczbę. Zwróć uwagę, że inaczej niż w przypadku konwersji obiektu
na liczbę wartość zwracana przez zastosowany algorytm nie jest przekształcana w liczbę.
Pamiętaj, że liczbowe reprezentacje obiektów Date, w odróżnieniu od reprezentacji tekstowych,
można poprawnie porównywać za pomocą operatorów < i >. Algorytm bez preferencji
przekształca obiekt typu Date w ciąg znaków, zatem dzięki temu, że powyższe operatory stosują
algorytm preferuj liczbę, można porównywać ze sobą obiekty typu Date.

Metody toString() i valueOf()


Wszystkie obiekty dziedziczą dwie metody, wykorzystywane do przekształcania obiektów w
wartości prymitywne. Przyjrzyjmy się im, zanim zajmiemy się algorytmami preferuj ciąg
znaków, preferuj liczbę i bez preferencji.
Pierwszą z nich jest toString(). Metoda ta zwraca tekstową reprezentację obiektu. Domyślnie
nie są to szczególnie interesujące informacje (choć mogą być przydatne, o czym się przekonasz
w punkcie 14.4.3):
({x: 1, y: 2}).toString() // => "[object Object]"
Wiele klas definiuje bardziej wyspecjalizowane wersje metody toString(). Na przykład w
klasie Array metoda ta przekształca wszystkie elementy tablicy w ciągi, a następnie łączy je ze
sobą za pomocą przecinków. Natomiast w klasie Function metoda toString() przekształca
funkcję zdefiniowaną przez użytkownika w kod źródłowy JavaScript. W klasie Date z kolei
metoda zwraca czytelny dla człowieka zapis daty i czasu, który można również analizować za
pomocą kodu. W klasie RegExp metoda toString() przekształca obiekt typu RegExp w ciąg
podobny do literału wyrażenia regularnego:
[1,2,3].toString() // => "1,2,3"
(function(x) { f(x); }).toString() // => "function(x) { f(x); }"
/\d+/g.toString() // => "/\\d+/g"
let d = new Date(2020,0,1);
d.toString() // => "Date Fri Jan 01 2010 00:00:00 GMT+0100 (czas
środkowoeuropejski standardowy)"
Druga z omawianych metod to valueOf(). Jej zadanie jest ściśle określone: ma przekształcać
obiekt w reprezentującą go wartość prymitywną, jeżeli taka istnieje. Obiekty są zestawami
wartości i większości z nich nie można przedstawić za pomocą jednej wartości prymitywnej.
Dlatego domyślnie metoda valueOf() zwraca po prostu sam obiekt. Klasy opakowujące, takie
jak String, Number lub Boolean, definiują metodę valueOf() zwracającą opakowaną
prymitywną wartość. Klasy tablic, funkcji i wyrażeń regularnych po prostu dziedziczą domyślną
metodę. Wywołując metodę valueOf() instancji tych klas, uzyskuje się po prostu tę instancję.
W klasie Date powyższa metoda zwraca wewnętrzną reprezentację daty, tj. liczbę milisekund,
jakie upłynęły od 1 stycznia 1970 r.:
let d = new Date(2010, 0, 1); // 1 stycznia 2010 r.
d.valueOf() // => 1262332800000

Algorytmy konwersji obiektów na wartości prymitywne


Po zapoznaniu się z metodami toString() i valueOf() możemy się ogólnie przyjrzeć
algorytmom konwersji obiektów na wartości prymitywne (szczegóły działania odłóżmy do
punktu 14.4.7):

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ę.

3.10. Deklarowanie zmiennych


i przypisywanie wartości
Jedną z fundamentalnych zasad programowania jest stosowanie nazw, czyli identyfikatorów,
reprezentujących wartości. Wiążąc nazwę z wartością, można wykorzystywać tę wartość w
tworzonym kodzie. Operacja ta zazwyczaj jest nazywana przypisywaniem wartości zmiennej.
Termin „zmienna” sugeruje, że można przypisywać różne wartości, a więc zawartość zmiennej
zmienia się w trakcie działania programu. Jeżeli nazwie zostanie przypisana wartość na stałe,
wówczas stosuje się pojęcie stałej.
Zmienne i stałe przed użyciem trzeba zadeklarować. W wersjach ES6 języka i nowszych służą
do tego celu słowa kluczowe let i const, które za chwilę opiszę. W starszych wersjach zmienne
deklarowało się za pomocą dość specyficznego słowa var, które wyjaśnię w dalszej części
podrozdziału.

3.10.1. Deklaracje z użyciem słów let i const


W wersjach ES6 i nowszych zmienne deklaruje się za pomocą słowa kluczowego let, jak niżej:
let i;
let sum;
Za pomocą jednej instrukcji let można też deklarować kilka zmiennych:
let i, sum;

Dobrą praktyką jest przypisywanie zmiennym wartości początkowych zaraz po ich


zadeklarowaniu, jeżeli tylko jest to możliwe:
let message = "cześć";
let i = 0, j = 0, k = 0;
let x = 2, y = x*x; // Do inicjowania zmiennych można wykorzystywać inne,
zadeklarowane
// wcześniej zmienne.
Jeżeli za pomocą instrukcji let nie zainicjuje się zmiennej, zostanie ona zadeklarowana, ale do
chwili przypisania wartości będzie zawierała wartość undefined.
Aby zadeklarować stałą, należy zamiast słowa let użyć const. Instrukcja ta działa podobnie jak
let z tą jedyną różnicą, że stałą trzeba zainicjować w miejscu jej zadeklarowania:

const H0 = 74; // Stała Hubble’a (km/s/Mpc).


const C = 299792.458; // Prędkość światła w próżni (km/s).
const AU = 1.496E8; // Jednostka astronomiczna: odległość Ziemi od Słońca
(km).
Jak sugeruje nazwa, stała nie zmienia swojej wartości, a przy próbie jej modyfikacji jest
zgłaszany błąd TypeError.
Powszechnie stosowaną (ale nie uniwersalną) konwencją jest używanie w nazwach stałych
wyłącznie wielkich liter, na przykład H0 lub HTTP_NOT_FOUND, aby można je było odróżniać od
zmiennych.

Kiedy stosować instrukcję const?


Są dwie szkoły stosowania słowa const. Pierwsza głosi, że słowa tego należy
używać tylko w przypadku wartości fundamentalnie niezmiennych, takich jak stałe
fizyczne, numery wersji oprogramowania, sekwencje bajtów opisujące typy plików
itp. Warto jednak zwrócić uwagę, że wiele tzw. „zmiennych” nie jest
modyfikowanych przez cały czas działania programu. Dlatego według innej szkoły
można wszystko deklarować za pomocą słowa const, a jeżeli okaże się, że jakaś
wartość będzie się zmieniać, należy w jej deklaracji zmienić słowo na let. W ten
sposób zapobiega się błędom wynikającym z niezamierzonych modyfikacji
zmiennych.
Zgodnie z pierwszą szkołą słowo const należy stosować tylko z wartościami, które
nie mogą się zmieniać, a z drugą szkołą — ze wszystkimi wartościami, które nie
będą się zmieniać. W swoim kodzie stosuję pierwszą szkołę.

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

Zasięgi zmiennych i stałych


Zasięg zmiennej jest obszarem kodu źródłowego, w którym zmienna jest zdefiniowana.
Zmienne i stałe zadeklarowane za pomocą słów let i const mają zasięg blokowy. Oznacza to,
że ich definicje obowiązują tylko w obrębie bloku kodu, w którym zostały umieszczone. W
języku JavaScript blokami są m.in. klasy, funkcje, ciała instrukcji if/else oraz pętli while i for.
Ogólnie mówiąc, granice zasięgu określają nawiasy klamrowe, wewnątrz których
zadeklarowana jest zmienna lub stała (choć oczywiście nie można odwoływać się do zmiennych
i stałych w wierszach poprzedzających ich deklaracje). Zasięgiem zmiennych i stałych będących
częściami pętli for, for/in i for/of są ciała tych pętli, mimo że deklaracje tych zmiennych
znajdują się poza nawiasami klamrowymi.
Jeżeli deklaracja zostanie użyta na najwyższym poziomie kodu, ponad wszystkimi blokami, to
zmienna lub stała jest globalna i ma globalny zasięg. W środowisku Node i modułach
klienckich języka JavaScript (patrz rozdział 10.) zasięgiem zmiennej globalnej jest plik, w
którym zmienna jest zadeklarowana, a w tradycyjnym skrypcie klienckim jest to dokument
HTML. Oznacza to, że zmienna lub stała zadeklarowana jako globalna wewnątrz elementu
<script> jest dostępna we wszystkich innych elementach <script> (lub przynajmniej we
wszystkich skryptach wywoływanych po wykonaniu instrukcji let lub const).

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ęć";

3.10.2. Deklarowanie zmiennych za pomocą instrukcji


var
W starszych wersjach języka niż ES6 zmienne można było deklarować wyłącznie za pomocą
słowa kluczowego let, a stałych nie można było deklarować w ogóle. Składnia słowa var jest
taka sama jak let:
var x;
var data = [], count = data.length;
for(var i = 0; i < count; i++) console.log(data[i]);
Choć oba słowa mają taką samą składnię, znacząco różnią się działaniem:

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.

Stosowanie niezadeklarowanych zmiennych


Jeżeli w trybie ścisłym (patrz punkt 5.6.3) nastąpi próba użycia niezadeklarowanej
zmiennej, zostanie zgłoszony błąd referencji. Natomiast poza trybem ścisłym
przypisanie wartości do nazwy, która nie została zadeklarowana za pomocą słów
let, const lub var, spowoduje utworzenie nowej zmiennej globalnej, niezależnie od
głębokości zagnieżdżenia funkcji lub bloku kodu. Niemal zawsze jest to niepożądany
efekt, który może być przyczyną błędów. Jest to jeden z głównym powodów, dla
którego należy stosować tryb ścisły.
Utworzona w taki przypadkowy sposób zmienna globalna funkcjonuje tak, jakby
została zadeklarowana za pomocą słowa kluczowego var, tj. jest to właściwość
globalnego obiektu. Jednak w odróżnieniu od zmiennej zadeklarowanej jawnie przy
użyciu var można ją usunąć za pomocą operatora delete (patrz punkt 4.13.4).

3.10.3. Przypisania destrukturyzujące


W wersji ES6 została wprowadzona składnia przypisania destrukturyzującego,
umożliwiającego deklarowanie wielu zmiennych i przypisywanie im wartości. W tego rodzaju
przypisaniu po prawej stronie znaku równości umieszcza się tablicę obiektów (wartość
„strukturalną”), a po lewej jedną lub kilka nazw zmiennych. Wykorzystuje się przy tym składnię
przypominającą literał tablicowy lub obiektowy. Z wartości po prawej stronie są wyodrębniane
(„destrukturyzowane”) składowe wartości, przypisywane następnie zmiennym umieszczonym
po lewej stronie. Przypisanie destrukturyzujące jest najczęściej wykorzystywane do inicjowania
zmiennych w deklaracjach let, const i var, ale stosuje się je też w zwykłych wyrażeniach
przypisujących (ze zmiennymi zadeklarowanymi wcześniej). Jak się przekonasz w punkcie 8.3.5,
tego rodzaju przypisania można również stosować w definicjach parametrów funkcji.
Poniżej przedstawionych jest kilka przykładów destrukturyzujących przypisań tablic:
let [x,y] = [1,2]; // To samo co x = 1, y = 2.
[x,y] = [x+1,y+1]; // To samo co x = x+1, y = y+1.
[x,y] = [y,x]; // Zamiana wartości dwóch zmiennych.
[x,y] // => [3,2]: powiększone i zamienione wartości.

Zwróć uwagę, jak przypisania destrukturyzujące ułatwiają korzystanie z funkcji zwracających


tablice wartości:
// Konwersja współrzędnych kartezjańskich [x, y] na biegunowe [r, theta].
function toPolar(x, y) {
return [Math.sqrt(x*x+y*y), Math.atan2(y,x)];
}
// Konwersja współrzędnych biegunowych na kartezjańskie.
function toCartesian(r, theta) {
return [r*Math.cos(theta), r*Math.sin(theta)];
}
let [r,theta] = toPolar(1.0, 1.0); // r == Math.sqrt(2); theta == Math.PI/4
let [x,y] = toCartesian(r,theta); // [x, y] == [1.0, 1.0]

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ę.

Przypisanie destrukturyzujące można stosować z zagnieżdżonymi tablicami. W takim przypadku


nazwa umieszczona po lewej stronie znaku równości powinna wyglądać jak literał
zagnieżdżonej tablicy:
let [a, [b, c]] = [1, [2,2.5], 3]; // a == 1; b == 2; c == 2.5
Siła przypisania destrukturyzującego polega na tym, że w rzeczywistości tablica wcale nie jest
potrzebna! Po prawej stronie można umieścić dowolny iterowalny obiekt (patrz rozdział 12.),
jak również każdy obiekt, który można wykorzystać w pętli for/of (patrz punkt 5.4.4):
let [first, ...rest] = "Cześć!"; // first == "C"; rest == [ "z", "e", "ś",
"ć", "!" ]
W przypisaniu destrukturyzującym po prawej stronie można również umieścić wartość
obiektową. W takim przypadku zapis po lewej stronie powinien wyglądać jak literał obiektowy,
tj. musi to być para nawiasów klamrowych z umieszczoną wewnątrz niej listą nazw zmiennych
oddzielonych przecinkami:
let transparent = {r: 0.0, g: 0.0, b: 0.0, a: 1.0}; // Kolor zapisany w
formacie RGBA.

let {r, g, b} = transparent; // r == 0.0; g == 0.0; b == 0.0


W poniższym przykładzie globalne funkcje obiektu Math są kopiowane do zmiennych. W ten
sposób można uprościć kod, w którym wykorzystywanych jest dużo funkcji
trygonometrycznych:
// To samo co const sin = Math.sin, cos = Math.cos, tan = Math.tan
const {sin, cos, tan} = Math;
Zwróć uwagę, że obiekt Math ma wiele innych właściwości, oprócz trzech wymienionych,
destrukturyzowanych do poszczególnych zmiennych. Wszystkie niewymienione właściwości są
po prostu pomijane. Gdyby po lewej stronie została umieszczona nazwa nieodpowiadająca
żadnej właściwości obiektu Math, zostałaby jej przypisana wartość undefined.
W każdym z powyższych przykładów nazwy zmiennych odpowiadały właściwościom
destrukturyzowanego obiektu. Choć dzięki temu kod jest prostszy i bardziej czytelny, taka
konwencja nie jest wymagana. Każdy identyfikator umieszczony po lewej stronie znaku
równości może być parą innych identyfikatorów oddzielonych dwukropkiem. Pierwszy
identyfikator określa wtedy nazwę właściwości, której wartość ma być odczytana, a drugi
identyfikator określa zmienną, której ta wartość ma zostać przypisana:
// To samo co const cosine = Math.cos, tangent = Math.tan;
const { cos: cosine, tan: tangent } = Math;
Moim zdaniem składnia destrukturyzująca staje się zbyt skomplikowana i bezużyteczna, jeżeli
nazwy zmiennych i właściwości nie są zgodne. Staram się unikać takich sytuacji. Stosując tego
rodzaju przypisania, pamiętaj, że nazwy właściwości zawsze umieszcza się po lewej stronie
dwukropka w obu literałach obiektowych oraz po lewej stronie znaku równości.
Przypisanie destrukturyzujące jest jeszcze bardziej skomplikowane, gdy stosowane są
zagnieżdżone obiekty lub tablice obiektów:

let points = [{x: 1, y: 2}, {x: 3, y: 4}]; // Tablica złożona z dwóch


obiektów
let [{x: x1, y: y1}, {x: x2, y: y2}] = points; // destrukturyzowana w cztery
zmienne.
(x1 === 1 && y1 === 2 && x2 === 3 && y2 === 4) // => true
Zamiast tablicy obiektów można destrukturyzować obiekt zawierający tablice:
let points = { p1: [1,2], p2: [3,4] }; // Obiekt, którego
właściwościami są tablice
let { p1: [x1, y1], p2: [x2, y2] } = points; // destrukturyzowany w cztery
zmienne.

(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:

zapisywanie i przetwarzanie liczb i ciągów znaków,


operacje na prymitywnych typach danych: wartościach logicznych, symbolach,
wartościach null i undefined,
różnice między niemutowalnymi typami prymitywnymi a mutowalnymi typami
referencyjnymi,
niejawna i jawna konwersja typów,
deklarowanie i inicjowanie stałych i zmiennych (również za pomocą przypisań
destrukturyzujących),
zasięgi zadeklarowanych zmiennych i stałych.

[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.

4.1. Wyrażenia podstawowe


Najprostsze są wyrażenia podstawowe, tzn. takie, które nie składają się z jeszcze prostszych
wyrażeń. Wyrażeniami podstawowymi w języku JavaScript są stałe, literały wartości, niektóre
słowa kluczowe i odwołania do zmiennych.

Literały to stałe wartości wpisane bezpośrednio w kodzie programu, na przykład:


1.23 // Literał liczbowy.

"cześć" // Literał tekstowy.


/szablon/ // Literał wyrażenia regularnego.

Składnia literałów liczbowych została opisana w podrozdziale 3.2, a tekstowych w podrozdziale


3.3. Literały wyrażeń regularnych zostały ogólnie przedstawione w punkcie 3.3.5, natomiast
szczegółowo będą opisane w podrozdziale 11.3.
Wyrażeniami podstawowymi są również niektóre zarezerwowane słowa:

true // Logiczna wartość "prawda".


false // Logiczna wartość "fałsz".

null // "Pusta" wartość.


this // Wartość oznaczająca "bieżący" obiekt.
W podrozdziałach 3.4 i 3.5 poznałeś słowa true, false i null, które w odróżnieniu od innych
słów kluczowych nie są stałymi, ponieważ mogą przyjmować różne wartości w zależności od
miejsca, w którym zostaną użyte. Słowo this jest stosowane w językach obiektowych. Użyte w
ciele metody oznacza obiekt, którego metoda została wywołana. Więcej informacji na ten temat
znajdziesz w podrozdziale 4.5, rozdziale 8. (szczególnie w punkcie 8.2.2) i rozdziale 9.
Trzecim rodzajem wyrażenia podstawowego jest odwołanie do zmiennej, stałej lub do
właściwości obiektu globalnego:
i // Wynikiem jest wartość zmiennej i.

sum // Wynikiem jest wartość zmiennej sum.


undefined // Wynikiem jest wartość właściwości "undefined" obiektu
globalnego.

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.

4.2. Inicjatory obiektów i tablic


Inicjator obiektu lub tablicy jest wyrażeniem, którego wartością jest nowo utworzony obiekt
lub tablica. Tego rodzaju wyrażenie jest czasem nazywane literałem obiektowym lub
tablicowym. W odróżnieniu od prawdziwego literału nie jest to jednak wyrażenie podstawowe,
jako że zawiera w sobie podwyrażenie określające wartość właściwości obiektu lub elementu
tablicy. Ponieważ składnia inicjatora tablicy jest nieco prostsza, przyjrzymy się jej w pierwszej
kolejności.
Inicjator tablicy jest listą wyrażeń oddzielonych przecinkami, umieszczoną wewnątrz nawiasów
kwadratowych. Wynikiem inicjatora jest nowa tablica elementów zainicjowanych wynikami
oddzielonych przecinkami wyrażeń:

[] // Pusta tablica. Brak wyrażeń wewnątrz nawiasów oznacza, że


tablica nie ma elementów.

[1+2,3+4] // Tablica dwuelementowa. Pierwszy element ma wartość 3, a drugi


7.

Poszczególne wyrażenia inicjatora tablicy mogą być inicjatorami innych tablic. W ten sposób
można tworzyć zagnieżdżone tablice:

let matrix = [[1,2,3], [4,5,6], [7,8,9]];


Wyniki wyrażeń inicjujących elementy są wyliczane za każdym razem, gdy wyliczany jest wynik
samego inicjatora. Oznacza to, że za każdym razem wynik ten może być inny.

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:

let sparseArray = [1,,,,5];

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.

let q = {}; // Obiekt bez właściwości.

q.x = 2.3; q.y = -1.2; // Obiekt q ma teraz te same właściwości co


obiekt p.

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 }

};

Z inicjatorami obiektów i tablic spotkasz się jeszcze w rozdziałach 6. i 7.

4.3. Wyrażenia definiujące funkcje


Wynikiem wyrażenia definiującego funkcję jest nowa funkcja. Tego rodzaju wyrażenie jest
literałem funkcyjnym, podobnie jak inicjator obiektu jest literałem obiektowym. Wyrażenie
definiujące funkcję zazwyczaj składa się ze słowa kluczowego function, następujących po nim
nawiasów zwykłych, wewnątrz których można umieścić listę oddzielonych przecinkami
identyfikatorów (nazw parametrów), oraz bloku kodu (ciała funkcji) zamkniętego w nawiasach
klamrowych. Poniżej przedstawiony jest przykład:
// Funkcja zwracająca kwadrat argumentu.

let square = function(x) { return x * x; };

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.

4.4. Wyrażenia dostępu do właściwości


Wynikiem wyrażenia dostępu do właściwości jest wartość właściwości obiektu lub elementu
tablicy. Język JavaScript definiuje dwie składnie dostępu do właściwości:
wyrażenie . identyfikator

wyrażenie [ wyrażenie]

Pierwsza składnia to wyrażenie z następującą po nim kropką i identyfikatorem. Wyrażenie


określa obiekt, a identyfikator nazwę żądanej właściwości. Druga składnia to wyrażenie (obiekt
lub tablica) z następującym po nim drugim wyrażeniem umieszczonym wewnątrz nawiasów
kwadratowych. Wyrażenie to określa nazwę żądanej właściwości lub indeks żądanego elementu
tablicy. Poniżej przedstawionych jest kilka przykładów:
let o = {x: 1, y: {z: 3}}; // Przykładowy obiekt.
let a = [o, 4, [5, 6]]; // Przykładowa tablica zawierająca obiekt.

o.x // => 1: właściwość x wyrażenia o.

o.y.z // => 3: właściwość z wyrażenia o.y.

o["x"] // => 1: właściwość x obiektu o.

a[1] // => 4: element o indeksie 1 wyrażenia a.


a[2]["1"] // => 6: element o indeksie 1 wyrażenia a[2].

a[0].x // => 1: właściwość x wyrażenia a[0].

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.

4.4.1. Warunkowy dostęp do właściwości


W wersji ES2020 zostały wprowadzone dwa nowe rodzaje wyrażeń dostępu do właściwości:

wyrażenie ?. identyfikator

wyrażenie ?.[ wyrażenie ]

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).

Tego rodzaju wyrażenie dostępu do właściwości jest niekiedy nazywane „opcjonalnym


łańcuchowaniem”, ponieważ może składać się z większej liczby połączonych wyrażeń, na
przykład:

let a = { b: null };

a.b?.c.d // => undefined


W powyższym przykładzie a jest obiektem, więc a.b jest poprawnym wyrażeniem dostępu do
właściwości. Jednak właściwość a.b ma wartość null, więc użycie wyrażenia a.b.c skutkuje
zgłoszeniem wyjątku TypeError. Jeżeli zamiast kropki użyje się zapisu ?., wyjątek nie zostanie
zgłoszony, a wynikiem wyrażenia a.b?.c będzie undefined. Oznacza to, że wyrażenie
(a.b?.c).d zgłosi wyjątek TypeError, ponieważ odwołuje się do właściwości wartości
undefined. Natomiast wynikiem wyrażenia a.b?.c.d (bez nawiasów) jest wartość undefined i
błąd nie jest zgłaszany. Ujawnia się tu bardzo ważna cecha opcjonalnego łańcuchowania, zwana
„krótkim zwarciem”: jeżeli wynikiem podwyrażenia znajdującego się po lewej stronie sekwencji
?. jest wartość null lub undefined, wówczas jako wynik całego wyrażenia jest przyjmowana
wartość undefined bez uprzedniego odwoływania się do właściwości umieszczonej po prawej
stronie.

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: {} };

a.b?.c?.d // => undefined

W wyrażeniu warunkowego dostępu do właściwości można również stosować zapis ?.[]


zamiast []. Przeanalizujmy wyrażenie a?.[b][c]. Jeżeli zmienna a ma wartość null lub
undefined, wówczas jako wynik całego wyrażenia jest natychmiast przyjmowana wartość
undefined bez uprzedniego sprawdzania podwyrażeń b i c. Jeżeli wyrażenia te wywołują jakieś
skutki uboczne, nie będą wtedy miały miejsca:

let a; // Ups, zapomnieliśmy zainicjować tę zmienną!

let index = 0;

try {

a[index++]; // Zgłoszenie wyjątku TypeError.


} catch(e) {

index // => 1: zmienna jest powiększana przed zgłoszeniem wyjątku


TypeError.

a?.[index++] // => undefined: ponieważ zmienna a ma wartość undefined.


index // => 1: zmienna nie jest powiększana, ponieważ zapis ?.[]
powoduje krótkie zwarcie.

a[index++] // TypeError: nie można indeksować niezdefiniowanej zmiennej.

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.

4.5. Wyrażenia wywołujące


Wyrażenie wywołujące jest frazą powodującą wywołanie (uruchomienie) funkcji lub metody.
Obejmuje ona wyrażenie identyfikujące funkcję, która ma być wywołana, z następującymi po
niej nawiasami zwykłymi, wewnątrz których może znajdować się lista rozdzielonych
przecinkami wyrażeń argumentów. Poniżej przedstawionych jest kilka przykładów:
f(0) // f jest wyrażeniem funkcyjnym, a 0 wyrażeniem argumentu.

Math.max(x,y,z) // Math.max jest funkcją, a x, y, i z są argumentami.

a.sort() // a.sort jest funkcją bez argumentów.

Podczas wyliczania wyrażenia wywołującego najpierw wyliczany jest wynik wyrażenia


funkcyjnego. Następnie wyliczane są wyniki wyrażeń argumentów i tworzona lista wartości.
Jeżeli wynikiem wyrażenia funkcyjnego nie jest funkcja, zgłaszany jest wyjątek TypeError. W
przeciwnym razie parametrom określonym w definicji funkcji są przypisywane wartości
argumentów i na koniec wykonywany jest kod funkcji. Jeżeli w kodzie użyta jest instrukcja
return, zwracana przez nią wartość staje się wynikiem całego wyrażenia wywołującego. W
rozdziale 8. będą opisane wszystkie szczegóły dotyczące wywoływania funkcji oraz operacje
wykonywane w sytuacji, gdy liczba wyrażeń argumentów różni się od liczby parametrów w
definicji funkcji.

Każde wyrażenie wywołujące zawiera parę nawiasów i podwyrażenie umieszczone przed


nawiasem otwierającym. Jeżeli jest to wyrażenie dostępu do właściwości, wówczas mamy do
czynienia z wywołaniem metody. W takim przypadku obiekt lub tablica, której dotyczy
odwołanie, po uruchomianiu kodu funkcji staje się wartością słowa kluczowego this. Jest to
paradygmat programowania obiektowego, w którym funkcje (nazywane „metodami”, jeżeli są
wywoływane w opisany sposób) działają na obiekcie, którego są częściami. Szczegółowe
informacje na ten temat znajdziesz w rozdziale 9.

4.5.1. Wywołania warunkowe


W wersji języka ES2020 można wywoływać funkcje, stosując notację ?.() zamiast (). Jeżeli
funkcja jest wywoływana w zwykły sposób, a wyrażenie znajdujące się przed nawiasami ma
wartość null, undefined lub nie jest funkcją, jest zgłaszany wyjątek TypeError. Jeżeli
natomiast użyje się nowej notacji ?.() i wyrażenie po lewej stronie znaku zapytania będzie
miało wartość null lub undefined, to wynikiem całego wyrażenia wywołującego będzie wartość
undefined, a wyjątek nie zostanie zgłoszony.

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:

function square(x, log) { // Drugim argumentem jest opcjonalna funkcja.

if (log) { // Jeżeli opcjonalny argument został określony,

log(x); // wywołaj go.

return x * x; // Zwrócenie kwadratu argumentu.

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ą:

function square(x, log) { // Drugim argumentem jest opcjonalna funkcja.

log?.(x); // Wywołanie funkcji, jeżeli została określona.

return x * x; // Zwrócenie kwadratu argumentu.

}
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.

W warunkowym wywołaniu funkcyjnym ?.(), podobnie jak w warunkowym wyrażeniu dostępu


do właściwości (patrz punkt 4.4.1), obowiązuje reguła krótkiego zwarcia. Jeżeli wartość
umieszczona po lewej stronie znaku zapytania jest równa null lub undefined, wówczas nie jest
wyliczane żadne z wyrażeń umieszczonych wewnątrz nawiasów:

let f = null, x = 0;

try {

f(x++); // Zgłoszenie wyjątku TypeError, jeżeli f ma wartość null.

} catch(e) {

X // => 1: zmienna x jest zwiększana przed zgłoszeniem wyjątku


TypeError.

}
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:

o.m() // Zwykłe odwołanie do właściwości i zwykłe wywołanie metody.


o?.m() // Warunkowe odwołanie do właściwości i zwykłe wywołanie metody.

o.m?.() // Zwykłe odwołanie do właściwości i warunkowe wywołanie metody.


W pierwszym wyrażeniu zmienna o musi być obiektem posiadającym właściwość m, którego
wartością musi być funkcja. W drugim przypadku, jeżeli o ma wartość null lub undefined,
wynikiem całego wyrażenia jest undefined. Jeżeli o ma inną wartość, musi posiadać właściwość
m, którego wartością musi być funkcja. W trzecim przypadku wartością o nie może być null ani
undefined. Jeżeli zmienna nie ma właściwości o nazwie m lub ma taką właściwość o wartości
null, wówczas wynikiem całego wyrażenia jest undefined.

Warunkowe wywołanie ?.() jest jedną z najnowszych funkcjonalności języka JavaScript. W


pierwszych miesiącach 2020 r. było obsługiwane we wstępnych wersjach większości
najpopularniejszych przeglądarek.

4.6. Wyrażenia tworzące obiekty


Wyrażenie tworzące obiekt wywołuje funkcję (tzw. konstruktor) inicjującą właściwości nowo
utworzonego obiektu. Tego rodzaju wyrażenia są podobne do wyrażeń wywołujących. Różnią się
jednak tym, że poprzedza się je słowem kluczowym new:
new Object()

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.

4.7. Przegląd operatorów


Operatory są stosowane w wyrażeniach arytmetycznych, porównujących, logicznych,
przypisujących i innych. Tabela 4.1 zawiera podsumowanie operatorów i stanowi podręczne
odniesienie.
Tabela 4.1 . Operatory w języku JavaScript

Operator Operacja W L Typy

++ Pre- i postinkrementacja P 1 l-wartość → liczba

-- Pre- i postdekrementacja P 1 l-wartość → liczba

- Zmiana znaku liczby P 1 liczba → liczba

+ Konwersja na liczbę P 1 dowolny → liczba

liczba całkowita → liczba


~ Odwrócenie bitów P 1
całkowita

wart. logiczna → wart.


! Negacja wartości logicznej P 1
logiczna

l-wartość → wart.
delete Usunięcie właściwości P 1
logiczna

typeof Określenie typu operandu P 1 dowolny → ciąg znaków

void Zwrócenie pustej wartości P 1 dowolny → undefined

** Potęgowanie P 2 liczba, liczba → liczba

*, /, % Mnożenie, dzielenie, reszta L 2 liczba, liczba → liczba

+, - Dodawanie, odejmowanie L 2 liczba, liczba → liczba

ciąg znaków, ciąg


+ Łączenie ciągów znaków L 2
znaków → ciąg znaków

liczba całkowita, liczba


<< Przesunięcie bitów w lewo L 2 całkowita → liczba
całkowita

>> Przesunięcie bitów w prawo z L 2 liczba całkowita, liczba


zachowaniem znaku całkowita → liczba
całkowita

liczba całkowita, liczba


Przesunięcie bitów w prawo z
>>> L 2 całkowita → liczba
uzupełnieniem zerami
całkowita

liczba, liczba → wart.


<, <=, >, >= Porównanie liczbowe L 2
logiczna

ciąg znaków, ciąg


<, <=, >, >= Porównanie alfabetyczne L 2
znaków → wart. logiczna

obiekt, funkcja → wart.


instanceof Określenie klasy obiektu L 2
logiczna

Sprawdzenie istnienia dowolny, obiekt → wart.


in L 2
właściwości 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

liczba całkowita, liczba


& Bitowa operacja ORAZ L 2 całkowita → liczba
całkowita

liczba całkowita, liczba


^ Bitowa różnica symetryczna L 2 całkowita → liczba
całkowita

liczba całkowita, liczba


| Bitowa operacja LUB L 2 całkowita → liczba
całkowita

dowolny, dowolny →
&& Logiczna operacja ORAZ L 2
dowolny

dowolny, dowolny →
|| Logiczna operacja LUB L 2
dowolny

Wybranie pierwszego dowolny, dowolny →


?? L 2
zdefiniowanego operandu dowolny

Wybranie drugiego lub wart. logiczna, dowolny,


?: P 3
trzeciego operandu dowolny → dowolny

Przypisanie wartości zmiennej l-wartość, dowolny →


= P 2
lub właściwości dowolny
**=, *=, /=, %=, +=, -=, &=, Wykonanie operacji P 2 l-wartość, dowolny →
^=, |=, <<=, >>=, >>>= z przypisaniem wartości dowolny

Pominięcie pierwszego
dowolny, dowolny →
, operandu i zwrócenie L 2
dowolny
drugiego

Zwróć uwagę, że większość operatorów zapisuje się za pomocą znaków specjalnych, na


przykład + lub =. Niektóre jednak są słowami kluczowymi, jak delete lub instanceof. Tego
rodzaju operatory są zwykłymi wyrażeniami, podobnymi do innych wyrażeń o mniej zwięzłej
składni i zawierających znaki specjalne.
Operatory w tabeli 4.1 są ułożone według ich priorytetów. Na początku wymienione są
operatory o najwyższych priorytetach. Poziome linie oddzielają grupy operatorów o różnych
priorytetach. Kolumna W określa wiązanie operatora, które może być lewostronne (L) lub
prawostronne (P). Kolumna L zawiera liczbę operandów. W kolumnie Typy wymienione są typy
operandów i typ wyniku operatora (po symbolu →). W kolejnych punktach wyjaśnione są
priorytety, wiązania, typy operatorów i poszczególne operatory.

4.7.1. Liczba operandów


Operatory można podzielić na kategorie według liczby operandów (arności). Większość
operatorów, na przykład mnożenia *, jest dwuargumentowych. Łączą one dwa wyrażenia w
jedno, bardziej złożone wyrażenie. W języku JavaScript dostępne są również operatory
jednoargumentowe, przekształcające pojedyncze wyrażenie w inne, bardziej złożone.
Operator – w wyrażeniu -x jest operatorem jednoargumentowym zmieniającym znak operandu
x. Jest jeszcze warunkowy operator trójargumentowy ?:, łączący trzy wyrażenia w jedno.

4.7.2. Typy operandów i wyników


Niektóre operatory działają na wartościach dowolnych typów, jednak większość wymaga, aby
ich operandy były określonych typów. Zwracany wynik też ma określony typ. W tabeli 4.1
w kolumnie Typy wymienione są typy operandów (przed strzałkami) i typy wyników operatorów
(za strzałkami).
Operatory zazwyczaj konwertują typy operandów odpowiednio do wymagań (patrz podrozdział
3.9). Na przykład operator mnożenia * wymaga, aby operandy były liczbami, ale wyrażenie "3"
* "5" jest poprawne, ponieważ użyte operandy mogą być przekształcone w liczby. W tym
przypadku wynikiem wyrażenia jest oczywiście liczba 15, a nie ciąg "15". Pamiętaj, że każda
wartość w języku JavaScript jest „prawdziwa” lub „fałszywa”, zatem operatory logiczne działają
na wartościach wszystkich typów.
Niektóre operatory działają różnie w zależności od typów operandów. Dotyczy to głównie
operatora +, który dodaje operandy liczbowe, jak również łączy operandy tekstowe. Podobnie
operatory porównania, na przykład <, stosują porządek liczbowy lub alfabetyczny w zależności
od typów operandów. W opisach poszczególnych operatorów można znaleźć wyjaśnienia
dotyczące ich działania w zależności od typów operandów oraz rodzaju przeprowadzanych
konwersji.

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.

4.7.4. Priorytety operatorów


Operatory wymienione w tabeli 4.1 są ułożone według priorytetów, od najwyższych do
najniższych. Poziome linie oddzielają grupy operatorów o takich samym priorytetach. Priorytety
określają kolejność stosowania operatorów. Operator o wyższym priorytecie (znajdującym się
bliżej początku tabeli) jest stosowany przed operatorem o priorytecie niższym (umieszczonym
bliżej końca tabeli). Przeanalizujmy poniższe wyrażenie:

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

// argumencie wartość y i sprawdzamy typ zwróconego wyniku.


typeof my.functions[x](y)

Operator typeof ma jeden z najwyższych priorytetów, ale operuje na wynikach wyrażenia


dostępu do właściwości, indeksie tablicy i wywołaniu funkcji, dlatego te operacje są
wykonywane w pierwszej kolejności.
W praktyce, gdy pojawiają się wątpliwości dotyczące priorytetów operatorów, najlepiej jest użyć
nawiasów jawnie określających kolejność operacji. Warto jednak znać następujące ważne
zasady: mnożenie i dzielenie jest wykonywane przed dodawaniem i odejmowaniem, a
przypisanie ma bardzo niski priorytet i niemal zawsze jest wykonywane na końcu.

Nowe operatory wprowadzone do języka JavaScript nie zawsze pasowały do opisanego


schematu priorytetów. Na przykład operator ?? (patrz punkt 4.13.2) ma według tabeli niższy
priorytet niż operatory || i &&, jednak w rzeczywistości względne priorytety tych operatorów
nie są określone. Dlatego począwszy od wersji ES2020 w wyrażeniach wykorzystujących
powyższe operatory trzeba jawnie określać kolejność ich stosowania za pomocą nawiasów.
Podobnie operator potęgowania ** nie ma ściśle określonego względnego priorytetu wobec
jednoargumentowego operatora zmiany znaku liczby. Dlatego w wyrażeniach wykorzystujących
oba operatory trzeba stosować nawiasy.

4.7.5. Wiązanie operatorów


W tabeli 4.1 w kolumnie W określone są wiązania operatorów. Litery L i P oznaczają,
odpowiednio, wiązanie lewostronne i prawostronne. Wiązanie określa kolejność wykonywania
operacji o takich samych priorytetach. Wiązanie lewostronne oznacza, że operacje są
wykonywane w kolejności od lewej do prawej. Takim operatorem jest na przykład odejmowanie.
Zatem wyrażenie:

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.

4.7.6. Kolejność przetwarzania


Priorytety i wiązania operatorów określają kolejność ich stosowania w złożonych wyrażeniach,
ale nie określają kolejności wyliczania wyników podwyrażeń. W języku JavaScript wyrażenia są
zawsze przetwarzane od strony lewej do prawej. Na przykład w wyrażeniu w = x + y * z
najpierw przetwarzane jest podwyrażenie w, a następnie x, y i z. W dalszej kolejności mnożone
są wyrażenia y i z, do uzyskanego iloczynu jest dodawana wartość wyrażenia x, a uzyskany
wynik jest przypisywany zmiennej lub właściwości opisanej za pomocą wyrażenia w.
Umieszczając nawiasy w powyższym wyrażeniu, można zmienić kolejność wykonywania
operacji mnożenia, dodawania i przypisania, ale nie zmienia to kolejności przetwarzania całego
wyrażenia od strony lewej do prawej.

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.

Większość operatorów arytmetycznych można z opisanymi niżej zastrzeżeniami stosować


zarówno z operandami typu BigInt (patrz punkt 3.2.5), jak i zwykłymi liczbami, o ile tylko
operandy nie są różnych typów.

Podstawowe operatory arytmetyczne to ** (potęgowanie), * (mnożenie), / (dzielenie), %


(modulo, czyli reszta z dzielenia), + (dodawanie) i – (odejmowanie). Jak wspomniałem
wcześniej, operator dodawania będzie opisany w osobnym punkcie. Pięć pozostałych
operatorów po prostu wylicza wartości operandów, przekształca je w liczby, jeżeli jest taka
potrzeba, i na koniec wylicza potęgę, iloczyn, iloraz, sumę lub różnicę. Operandy, których nie
można przekształcić w liczby, są konwertowane na wartości NaN. Jeżeli jeden z operandów ma
taką wartość (lub został w nią przekształcony), wynikiem całej operacji jest prawie zawsze NaN.

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:

Jeżeli jeden z operandów jest obiektem, jest przekształcany w wartość prymitywną


zgodnie z algorytmem opisanym w punkcie 3.9.3. Obiekt typu Date jest przekształcany w
ciąg za pomocą jego metody toString(), a obiekty wszystkich pozostałych typów za
pomocą metody valueOf(), jeżeli metoda ta zwraca wartość prymitywną. Większość
obiektów nie posiada jednak przydatnej metody valueOf(), dlatego stosowana jest
metoda toString().
Jeżeli po przekształceniu obiektów w wartości prymitywne jeden z operandów jest ciągiem
znaków, to drugi operand jest również przekształcany w ciąg, a oba ciągi są ze sobą
łączone.
W przeciwnym razie oba operandy są przekształcane w liczby (lub wartość NaN) i
wykonywane jest dodawanie.

Poniżej jest przedstawionych kilka przykładów:


1 + 2 // => 3: dodawanie.

"1" + "2" // => "12": łączenie ciągów.


"1" + 2 // => "12": łączenie ciągów po przekształceniu liczby w ciąg.
1 + {} // => "1[object Object]": łączenie ciągów po przekształceniu
obiektu w ciąg.
true + true // => 2: dodawanie po przekształceniu wartości logicznej w
liczbę.
2 + null // => 2: dodawanie po przekształceniu wartości null w 0.
2 + undefined // => NaN: dodawanie po przekształceniu wartości undefined w
NaN.
Pamiętaj, że w przypadku użycia liczby i ciągu znaków może nie obowiązywać wiązanie
operatora. W takiej sytuacji wynik zależy od kolejności wykonania operacji, na przykład:

1 + 2 + " dni" // => "3 dni"


1 + (2 + " dni") // => "12 dni"
W pierwszym wyrażeniu nie ma nawiasów, więc zostało zastosowane wiązanie od lewej do
prawej. Dlatego najpierw zostały dodane dwie liczby, a następnie ich suma połączona z ciągiem.
W drugim wyrażeniu nawiasy zmieniły kolejność operacji: najpierw liczba 2 została połączona z
ciągiem i został utworzony nowy ciąg. Potem do niego została dołączona liczba 1 i w ten sposób
został wyliczony ostateczny wynik.

4.8.2. Jednoargumentowe operatory arytmetyczne


Operator jednoargumentowy modyfikuje pojedynczy operand i tworzy nową wartość. W języku
JavaScript wszystkie operatory jednoargumentowe mają wysokie priorytety i prawostronne
wiązania. Wszystkie tego rodzaju operatory opisane w tym punkcie (+, -, ++ i --) przekształcają
w razie potrzeby operand w liczbę. Zwróć uwagę, że znaki + i – są zarówno operatorami
jednoargumentowymi, jak i dwuargumentowymi.
Dostępne są następujące jednoargumentowe operatory arytmetyczne:
Plus (+)
Jednoargumentowy operator + przekształca operand w liczbę (lub wartość NaN) i zwraca
uzyskaną wartość. Jeżeli operand jest już liczbą, operator nic nie robi. Nie można go
stosować z wartością typu BigInt, ponieważ nie można jej przekształcić w zwykłą liczbę.

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.

4.8.3. Operatory bitowe


Operatory bitowe wykonują niskopoziomowe operacje na bitach reprezentujących liczby. Choć
nie są to typowe operatory arytmetyczne, są kwalifikowane jako takie, ponieważ działają na
liczbach i zwracają liczby. Cztery operatory wykonują operacje logiczne na poszczególnych
bitach operandów, tj. traktują każdy bit jako osobną wartość logiczną (1 = true, 0 = false).
Trzy inne operatory przesuwają bity w lewo lub prawo. Są rzadko stosowane, więc jeżeli nie
znasz zasad binarnego wyrażania liczb całkowitych, w tym liczb ujemnych, możesz pominąć ten
punkt.
Operandami operatora bitowego są 32-bitowe liczby całkowite, a nie 64-bitowe liczby
zmiennoprzecinkowe. Operator przekształca w razie potrzeby operandy w liczby, a następnie
zaokrągla je do 32-bitowych liczb całkowitych poprzez odrzucenie części ułamkowych lub bitów
na pozycjach 32 i wyższych. Operatory przesunięcia wymagają, aby operand po prawej stronie
był liczbą z zakresu od 0 do 31. Po przekształceniu go w 32-bitową liczbę całkowitą odrzucane
są bity na pozycjach wyższych niż piąta, dzięki czemu uzyskiwana jest wartość należąca do
wymaganego zakresu. Co ciekawe, wartości NaN, Infinity i -Infinity są przekształcane w
liczbę zero.
Wszystkie operatory z wyjątkiem >>> można stosować ze zwykłymi liczbami lub operandami
typu BigInt (patrz punkt 3.2.5).
Bitowa operacja ORAZ (&)
Operator & wykonuje logiczną operację ORAZ na poszczególnych bitach operandów.
Ustawia bit wyniku na 1 tylko wtedy, gdy oba odpowiadające sobie bity operandów są
równe 1. Na przykład wynikiem wyrażenia 0x1234 & 0x00FF jest 0x0034.
Bitowa operacja LUB (|)

Operator | wykonuje logiczną operację LUB na poszczególnych bitach operandów. Ustawia


bit wyniku na 1 tylko wtedy, gdy przynajmniej jeden z odpowiadających sobie bitów
operandów jest równy 1. Na przykład wynikiem wyrażenia 0x1234 | 0x00FF jest 0x12FF.
Bitowa różnica symetryczna (^)
Operator ^ wylicza różnicę symetryczną poszczególnych bitów operandów. Różnica
symetryczna ma miejsce wtedy, gdy tylko jeden z operandów ma wartość true. Operator
ustawia bit wyniku na 1 tylko wtedy, gdy dokładnie jeden z odpowiadających sobie bitów
operandów jest równy 1. Na przykład wynikiem wyrażenia 0xFF00 ^ 0xF0F0 jest 0x0FF0.
Bitowa operacja NIE (~)
Operator ~ jest operatorem jednoargumentowym, umieszczanym przed operandem.
Odwraca wszystkie bity operandu. Ze względu na sposób wyrażania w języku JavaScript
znaku liczby całkowitej, użycie operatora ~ jest równoważne zmianie znaku operandu i
odjęciu od niego liczby 1. Na przykład wynikiem wyrażenia ~0x0F jest liczba 0xFFFFFFF0,
czyli −16.
Przesunięcie bitów w lewo (<<)
Operator << przesuwa wszystkie bity lewego operandu w lewo o zadaną liczbą pozycji,
określoną za pomocą prawego operandu, która powinna zawierać się w przedziale od 0 do
31. Na przykład wyrażenie a << 1 powoduje, że pierwszy bit (licząc od prawej) jest
przesuwany na drugą pozycję, drugi na trzecią itd. Na pierwszej pozycji umieszczane jest
zero, a bit z 32. pozycji jest odrzucany. Przesunięcie o jeden bit jest równoważne mnożeniu
przez 2, o dwa bity mnożeniu przez 4 itd. Na przykład wynikiem wyrażenia 7 << 2 jest
liczba 28.
Przesunięcie bitów w prawo z zachowaniem znaku (>>)
Operator >> przesuwa wszystkie bity lewego operandu w prawo o zadaną liczbę pozycji,
określoną za pomocą prawego operandu, która powinna zawierać się w przedziale od 0 do
31. Skrajne prawe bity są odrzucane, natomiast wartości bitów wstawianych z lewej strony
zależą od początkowego znaku operandu. Celem jest uzyskanie prawidłowego znaku
wyniku. Jeżeli lewy operand jest dodatni, wstawiane są zera, a w przeciwnym razie jedynki.
Przesunięcie dodatniej wartości w prawo o jeden bit jest równoważne podzieleniu jej przez
2 i odrzuceniu reszty, przesunięcie o dwa bity — równoważne podzieleniu przez 4 itd.
Wynikiem wyrażenia 7 >> 1 jest liczba 3. Zwróć jednak uwagę, że wynikiem wyrażenia −7
>> 1 jest liczba −4.

Przesunięcie bitów w prawo z uzupełnieniem zerami (>>>)


Operator >>> działa podobnie jak >> z tą różnicą, że bity po lewej stronie uzupełnia zerami,
niezależnie od znaku operandu. Jest to ważne w sytuacji, gdy 32-bitowa liczba całkowita
powinna być traktowana jako liczba bez znaku. Na przykład wynikiem wyrażenia −1 >> 4
jest liczba –1, ale wynikiem −1 >>> 4 jest 0x0FFFFFFF. Jest to jedyny operator bitowy,
którego nie można użyć z operandem typu BigInt, ponieważ w tym typie liczba ujemna nie
ma ustawionego najbardziej znaczącego bitu na 1, jak w przypadku zwykłej 32-bitowej
liczby całkowitej. Stosowanie tego operatora ma sens jedynie wtedy, gdy liczba jest
kodowana w systemie uzupełnienia do dwóch.

4.9. Wyrażenia relacyjne


W tym podrozdziale opisane są operatory relacyjne wykorzystywane do sprawdzania zależności
pomiędzy dwiema wartościami (na przykład „równe”, „mniejsze niż”, „właściwość obiektu”).
Zwracanym wynikiem jest true lub false w zależności od relacji pomiędzy wartościami.
Wynikiem wyrażenia relacyjnego jest zawsze wartość logiczna. Tego rodzaju wyrażenia są
często wykorzystywane w instrukcjach if, while i for sterujących przepływem programu
(patrz rozdział 5.). Wkolejnych punktach są opisane operatory równości, nierówności,
porównania oraz in i instanceof.

4.9.1. Operatory równości i nierówności


Operatory == i === sprawdzają, czy dwie wartości są sobie równe, ale każdy stosuje inną
definicję równości. Operandy mogą być dowolnych typów, a oba operatory zwracają wartość
true, jeżeli operandy są sobie równe, lub false w przeciwnym razie. Operator === jest
nazywany operatorem ścisłej równości (lub tożsamości), ponieważ sprawdza identyczność
operandów, wykorzystując ścisłą definicję równości. Operator == jest nazywany po prostu
operatorem równości. Sprawdza, czy dwa operandy są sobie równe, wykorzystując mniej
restrykcyjną definicję równości, dopuszczającą konwersję typów.
Operatory != i !== funkcjonują odwrotnie do operatorów == i ===. Operator nierówności !=
zwraca wartość false, gdy oba operandy są według operatora == sobie równe. W przeciwnym
razie zwraca wartość true. Operator !== zwraca wartość false, gdy oba operandy są ściśle
sobie równe. W przeciwnym razie zwraca true. Jak się przekonasz w podrozdziale 4.10,
operator ! wykonuje logiczną operację NIE. Łatwo więc zapamiętać, że operatory != i !==
oznaczają, odpowiednio, „nie jest równe” i „nie jest ściśle równe”.

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 wartości są różnych typów, są uznawane za różne.


Jeżeli obie wartości są równe null lub obie są równe undefined, są uznawane za równe
sobie.
Jeżeli dwie logiczne wartości są równe true lub obie są równe false, są uznawane za
równe sobie.
Jeżeli przynajmniej jedna wartość jest równa NaN, obie są uznawane za różne. (Może się to
wydawać dziwne, ale wartość NaN nie jest równa żadnej innej wartości, również NaN. Aby
sprawdzić, czy zmienna x ma wartość NaN, należy użyć wyrażenia x !== x lub globalnej
funkcji isNaN()).
Jeżeli obie wartości są takimi samymi liczbami, są uznawane za równe sobie. Jeżeli jedna z
nich jest równa 0, a druga –0, również są uznawane za równe sobie.
Jeżeli obie wartości są ciągami zawierającymi dokładnie takie same 16-bitowe wartości
(patrz ramka w podrozdziale 3.3) na tych samych pozycjach, są uznawane za równe sobie.
Jednak dwa ciągi zawierające tę samą treść i wyglądające tak samo mogą być zakodowane
na różne sposoby. W języku JavaScript nie jest przeprowadzana normalizacja ciągów i
takie ciągi dla operatorów == i === są różne.
Jeżeli obie wartości odwołują się do tego samego obiektu, tablicy lub funkcji, są uznawane
za równe sobie. Jeżeli odwołują się do różnych obiektów posiadających identyczne
właściwości, są uznawane za różne.

Równość z konwersją typów


Operator == jest mniej ścisły od operatora ===. Jeżeli operandy są różnych typów, operator
dokonuje konwersji i ponownie porównuje wartości:

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.

Przeanalizujmy dla przykładu następujące porównanie:


"1" == true // => true
Wynikiem powyższego wyrażenia jest wartość true oznaczająca, że te dwie, tak odmiennie
wyglądające wartości są sobie równe. Najpierw wartość logiczna true jest przekształcana w
liczbę 1 i ponownie porównywana. Następnie ciąg znaków "1" jest przekształcany w liczbę 1.
Ponieważ obie uzyskane w ten sposób wartości są takie same, wynikiem całego wyrażenia jest
wartość true.

4.9.2. Operatory porównania


Operatory porównania sprawdzają kolejność (liczbową lub tekstową) dwóch operandów:
Mniejszy niż (<)
Operator < zwraca wartość true, jeżeli lewy operator jest mniejszy od prawego. W
przeciwnym razie zwraca wartość false.

Większy niż (>)


Operator > zwraca wartość true, jeżeli lewy operator jest większy od prawego. W
przeciwnym razie zwraca wartość false.
Mniejszy lub równy (<=)
Operator <= zwraca wartość true, jeżeli lewy operator jest mniejszy lub równy prawemu.
W przeciwnym razie zwraca wartość false.
Większy lub równy niż (>=)
Operator >= zwraca wartość true, jeżeli lewy operator jest większy lub równy prawemu.
W przeciwnym razie zwraca wartość false.
Powyższe operatory działają na operandach dowolnych typów. Porównywane mogą być tylko
liczby i ciągi znaków, więc wszystkie inne typy są przekształcane.

Porównywanie i przekształcanie operandów przebiega następująco:

Jeżeli któryś z operandów jest obiektem, jest przekształcany w wartość prymitywną w


sposób opisany na końcu punktu 3.9.3. Jeżeli jego metoda valueOf() zwraca wartość
prymitywną, jest ona wykorzystywana w porównaniu. W przeciwnym razie
wykorzystywana jest wartość zwrócona przez metodę toString().
Jeżeli po dokonaniu powyższego przekształcenia oba operandy są ciągami znaków, są ze
sobą porównywane. Stosowany jest przy tym porządek alfabetyczny określony za pomocą
16-bitowych wartości Unicode, z których składają się ciągi.
Jeżeli po dokonaniu powyższego przekształcenia oba operandy nie są ciągami znaków, są
przekształcane w liczby i porównywane ponownie. Wartości 0 i -0 są uznawane za sobie
równe. Wartości Infinity i -Infinity są uznawane, odpowiednio, za większą i mniejszą
niż dowolna liczba. Jeżeli przynajmniej jeden z operandów ma wartość NaN (lub został w
nią przekształcony), operator zwraca wartość false. W odróżnieniu od operatorów
arytmetycznych można mieszać operandy typu BigInt i zwykłe liczby.

Pamiętaj, że ciągi znaków w języku JavaScript są sekwencjami 16-bitowych wartości, więc


porównywanie ciągów polega w rzeczywistości na porównywaniu wartości liczbowych.
Kodowanie zdefiniowane w standardzie Unicode może różnić się od kodowania dla danego
języka lub ustawień regionalnych. Zwróć przede wszystkim uwagę, że przy porównywaniu
uwzględniana jest wielkość liter. Wszystkie wielkie litery w zestawie ASCII są „mniejsze” od
małych liter. Nieuwzględnienie tej zasady może skutkować uzyskaniem niespodziewanych
wyników. Na przykład dla operatora < ciąg "Zoo" jest mniejszy niż "aardvark".
Jeżeli potrzebny jest bardziej uniwersalny algorytm porównywania ciągów, należy użyć metody
String.localeCompare() uwzględniającej kolejność alfabetyczną właściwą dla danego języka.
Aby porównać ciągi bez względu na wielkości liter, należy je przekształcić w wielkie lub małe
litery, odpowiednio, za pomocą metod String.toLowerCase() lub String.toUpperCase().
Jeszcze lepszym narzędziem do porównywania ciągów charakterystycznych dla danego języka
jest klasa Intl.Collator, która będzie opisana w punkcie 11.7.3.
Operator + i operatory porównania funkcjonują odmiennie w przypadku operandów liczbowych
i tekstowych. Operator + faworyzuje ciągi znaków, tj. łączy je, jeżeli przynajmniej jeden z
operandów jest ciągiem. Operatory porównania faworyzują liczby i działają na ciągach tylko
wtedy, gdy oba operandy są ciągami:
1 + 2 // => 3: dodawanie.

"1" + "2" // => "12": łączenie ciągów.


"1" + 2 // => "12": liczba 2 jest przekształcana w ciąg "2".
11 < 3 // => false: porównywanie liczb.
"11" < "3" // => true: porównywanie ciągów.

"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".

"toString" in point // => true: obiekt dziedziczy metodę toString().


let data = [7,8,9]; // Tablica elementów o indeksach 0, 1 i 2.
"0" in data // => true: tablica zawiera element o indeksie
"0".
1 in data // => true: liczby są przekształcane w ciągi
znaków.
3 in data // => false: tablica nie zawiera elementu o
indeksie 3.
4.9.4. Operator instanceof
Operand umieszczony po lewej stronie operatora instanceof powinien być obiektem, a
operand po prawej — klasą. Operator zwraca wartość true, jeżeli operand po lewej stronie jest
instancją klasy po stronie prawej. W przeciwnym razie zwraca wartość false. W rozdziale 9.
wyjaśnię, jak definiuje się klasy i inicjuje je za pomocą konstruktorów. Jak się przekonasz,
operand umieszczony po prawej stronie powinien być funkcją. Poniżej znajduje się kilka
przykładów:
let d = new Date(); // Utworzenie nowego obiektu za pomocą konstruktora
Date().
d instanceof Date // => true: obiekt d został utworzony za pomocą
konstruktora Date().
d instanceof Object // => true: wszystkie obiekty są instancjami klasy
Object.
d instanceof Number // => false: obiekt d nie jest instancją klasy Number.
let a = [1, 2, 3]; // Utworzenie tablicy za pomocą literału.

a instanceof Array // => true: a jest tablicą.


a instanceof Object // => true: wszystkie tablice są obiektami.
a instanceof RegExp // => false: tablice nie są wyrażeniami regularnymi.
Zwróć uwagę, że wszystkie obiekty są instancjami klasy Object. Operator instanceof
uwzględnia klasy nadrzędne. Jeżeli operand po lewej stronie operatora nie jest obiektem,
operator zwraca wartość false. Jeżeli operand po prawej stronie nie jest klasą, zgłaszany jest
wyjątek TypeError.
Aby zrozumieć zasadę działania operatora instanceof, trzeba najpierw poznać pojęcie
łańcucha prototypów. Jest to mechanizm dziedziczenia klas, który będzie opisany w punkcie
6.3.2. Interpreter języka JavaScript, wyliczając wynik wyrażenia o instanceof f, przetwarza
wyrażenie f.prototype i sprawdza, czy uzyskany wynik znajduje się w łańcuchu prototypów
obiektu o. Jeżeli tak, operator uznaje, że obiekt o jest instancją klasy f (lub jej podklasy) i
zwraca wartość true. W przeciwnym razie zwraca wartość false.

4.10. Wyrażenia logiczne


Operatory &&, || i ! wykonują operacje logiczne i są często stosowane łącznie z operatorami
relacyjnymi. W ten sposób dwa wyrażenia relacyjne można połączyć w jedno złożone wyrażenie.
Operatory logiczne są opisane w kolejnych punktach. Jednak aby je w pełni zrozumieć,
przypomnij sobie wprowadzone w podrozdziale 3.4 pojęcia wartości „prawdziwych” i
„fałszywych”.

4.10.1. Operator logiczny ORAZ (&&)


Operator && można rozpatrywać w trzech różnych kontekstach. W najprostszym, gdy
operandami są wartości logiczne, operator ten wykonuje logiczną operację ORAZ i zwraca
wartość true tylko wtedy, gdy oba operandy reprezentują wartości true. Jeżeli przynajmniej
jeden z nich ma wartość false, operator również zwraca wartość false.
Operator && jest często stosowany do łączenia dwóch wyrażeń relacyjnych, na przykład:
x === 0 && y === 0 // true tylko wtedy, gdy zarówno x, jak i y są równe 0.
Wynikiem wyrażenia relacyjnego jest zawsze wartość true lub false. Dlatego w wyrażeniu
takim jak powyższe operator && zwraca jedną z tych wartości. Operator relacyjny ma wyższy
priorytet od operatora && (i ||), więc wyrażenia takie jak powyższe można bez obaw wpisywać
bez nawiasów.
Jednak operator && nie wymaga, aby oba operandy były wartościami logicznymi. Jak już wiesz,
wszystkie wartości w języku JavaScript są „prawdziwe” lub „fałszywe”. (Szczegółowe
informacje na ten temat były przedstawione w podrozdziale 3.4. Wartości „fałszywe” to false,
null, undefined, 0, -0, NaN i "". Wszystkie pozostałe, włącznie z obiektami, to wartości
„prawdziwe”). Drugi kontekst, w którym można interpretować operator &&, to wykonywanie
logicznej operacji ORAZ na wartościach prawdziwych i fałszywych. Jeżeli oba operandy są
prawdziwe, operator zwraca prawdziwy wynik. Jeżeli przynajmniej jeden z operandów jest
fałszywy, operator zwraca fałszywy wynik. Każde wyrażenie lub instrukcja operująca na
wartościach logicznych operuje również na wartościach prawdziwych i fałszywych. Zatem
operator &&, który nie zawsze zwraca wartość true lub false, w praktyce nie powoduje
problemów.
Zwróć uwagę, że w powyższym opisie zostały użyte zwroty „operator zwraca wartość
prawdziwą” lub „fałszywą” bez uszczegółowienia, co te pojęcia oznaczają. Dlatego operator &&
można rozpatrywać w jeszcze jednym, najbardziej ogólnym kontekście. Operator najpierw
wylicza wartość lewego operandu. Jeżeli jest fałszywa, oznacza to, że wartość całego wyrażenia
jest również fałszywa, więc operator zwraca po prostu wartość lewego operandu bez wyliczania
wartości prawego operandu.
Jeżeli natomiast wartość lewego operandu jest prawdziwa, wówczas wartość całego wyrażenia
zależy od wartości prawego operandu. Jeżeli jest prawdziwa, wartość całego wyrażenia jest
również prawdziwa. W przeciwnym razie wartość wyrażenia jest fałszywa. Zatem gdy wartość
po lewej stronie jest prawdziwa, operator && wylicza i zwraca wartość znajdującą się po jego
prawej stronie:

let o = {x: 1};


let p = null;
o && o.x // => 1: o ma wartość prawdziwą, więc operator zwraca wartość
o.x.
p && p.x // => null: p ma wartość fałszywą, więc operator nie wylicza
wartości p.x.

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.

Ogólnie jednak należy zachowywać ostrożność, tworząc wyrażenia z operatorem && i


operandem po prawej stronie wywołującym skutki uboczne (przypisanie, inkrementację,
dekrementację lub wywołanie funkcji), ponieważ od operandu po lewej stronie będzie zależało,
czy te skutki będą miały miejsce.
Operator &&, mimo dość złożonego mechanizmu działania, jest najczęściej wykorzystywany do
wykonywania prostych operacji logicznych na wartościach prawdziwych i fałszywych.

4.10.2. Operator logiczny LUB (||)


Operator || wykonuje operację logiczną LUB na dwóch operandach. Jeżeli przynajmniej jeden z
nich ma wartość prawdziwą, operator również zwraca wartość prawdziwą. Jeżeli oba operandy
mają wartości fałszywe, operator zwraca wartość fałszywą.

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 obiekcie preferences. Jeżeli nie jest prawdziwa, użyj wpisanej stałej.


let max = maxWidth || preferences.maxWidth || 500;
Zwróć uwagę, że jeżeli zero jest poprawną wartością zmiennej maxWidth, to kod nie będzie
działał właściwie, ponieważ 0 jest wartością fałszywą. Alternatywnym rozwiązaniem jest użycie
operatora ?? (patrz punkt 4.13.2).

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={}) { ... }.

4.10.3. Operator logiczny NIE (!)


Operator ! jest operatorem jednoargumentowym umieszczanym przed operandem. Jego
zadaniem jest negowanie wartości logicznej operandu. Jeżeli na przykład zmienna x ma wartość
prawdziwą, wyrażenie !x ma wartość false. Jeżeli natomiast x ma wartość fałszywą, wyrażenie
!x ma wartość true.
Operator !, w odróżnieniu od operatorów && i ||, przekształca operand w wartość logiczną
(zgodnie z regułami opisanymi w rozdziale 3.), a następnie ją neguje. Oznacza to, że operator
zwraca wartość true lub false, a operand można przekształcić w równoważną mu wartość
logiczną, stosując operator dwukrotnie: !xx (patrz punkt 3.9.2).
Operator ! ma wysoki priorytet i ściśle wiąże operand. Aby na przykład zanegować wartość
wyrażenia p && q, trzeba użyć nawiasów: !(p && q). Zauważ, że stosując składnię języka
JavaScript, można wyrazić dwa prawa algebry Boole’a:
// Prawa De Morgana.
!(p && q) === (!p || !q) // => true: dla wszystkich wartości p i q.
!(p || q) === (!p && !q) // => true: dla wszystkich wartości p i q.

4.11. Wyrażenia przypisujące


W języku JavaScript do przypisywania wartości zmiennej lub właściwości służy operator =, na
przykład:

i = 0; // Przypisanie zmiennej i wartości 0.


o.x = 1; // Przypisanie właściwości x obiektu o wartości 1.
Operator = wymaga, aby operand po jego lewej stronie był l-wartością, tzn. zmienną,
właściwością obiektu lub elementem tablicy. Wynikiem operatora przypisania jest wartość
operandu znajdującego się po jego prawej stronie. Efektem ubocznym wywoływanym przez
operator = jest przypisanie zmiennej lub właściwości umieszczonej po jego lewej stronie
wartości, która jest umieszczona po jego prawej stronie. Zatem przyszłe odwołania do zmiennej
lub właściwości będą zwracały nową wartość.
Choć wyrażenia przypisujące są zazwyczaj bardzo proste, czasami ich wartości są
wykorzystywane w bardziej złożonych wyrażeniach. Na przykład w jednym wierszu można
przypisać wartość zmiennej a i sprawdzić ją:
(a = b) === 0

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.

4.11.1. Przypisanie z działaniem


W języku JavaScript oprócz zwykłego operatora przypisania = dostępnych jest kilka
dodatkowych operatorów łączących przypisanie z innym działaniem. Na przykład operator +=
wykonuje dodawanie i przypisanie. Zatem poniższe wyrażenie:

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

Operator Przykład Odpowiednik

+= 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 większości przypadków wyrażenie:


a op= b
gdzie op oznacza operator, jest równoważne wyrażeniu:
a = a op 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;

4.12. Wyrażenia interpretujące


W języku JavaScript, podobnie jak w wielu innych językach interpretowanych, można w ciągach
znaków umieszczać kod źródłowy, uruchamiać go i odczytywać zwracane przez niego wyniki.
Służy do tego celu globalna funkcja eval():
eval("3+2") // => 5
Dynamiczne interpretowanie ciągów zawierających kod źródłowy to potężna funkcjonalność,
jednak w praktyce niemal zbędna. Zanim użyjesz funkcji eval(), dokładnie się zastanów, czy
rzeczywiście jest Ci ona potrzebna. Funkcja ta stanowi potencjalną lukę w bezpieczeństwie
kodu, dlatego nie można w jej argumencie umieszczać ciągów wprowadzanych przez
użytkownika. W przypadku języka tak skompilowanego jak JavaScript nie ma możliwości
oczyszczania uzyskiwanych w opisany sposób danych i bezpiecznego ich stosowania z funkcją
eval(). Ze względów bezpieczeństwa niektóre serwery WWW stosują nagłówek HTTP
„Content-Security-Policy” blokujący użycie funkcji eval() w całej witrynie.
W kolejnych punktach opisane są podstawy korzystania z funkcji eval() wraz z jej dwiema
ograniczonymi wersjami, które w mniejszym stopniu wpływają na optymalizator języka
JavaScript.

Czy funkcja eval() jest operatorem?


Funkcja eval() znalazła się w niniejszym rozdziale poświęconym wyrażeniom, ponieważ
tak naprawdę powinna być operatorem. Została wprowadzona w jednej z pierwszych
wersji JavaScriptu i od tamtej pory twórcy języka i interpretera nakładają na nią kolejne
ograniczenia sprawiające, że coraz bardziej przypomina ona operator. Nowoczesne
interpretery przeprowadzają bardzo zaawansowaną analizę i optymalizację kodu
źródłowego. Jednak interpreter nie może zoptymalizować funkcji wywołującej funkcję
eval(). Problem z nią polega na tym, że można jej nadać inną nazwę:
let f = eval;

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.

4.12.1. Funkcja eval()


Funkcja eval() ma jeden argument. Jeżeli umieści się w nim wartość inną niż ciąg znaków,
funkcja po prostu zwróci tę wartość. Jeżeli natomiast będzie to ciąg, funkcja podejmie próbę
zinterpretowania go jako kodu źródłowego JavaScript i w przypadku niepowodzenia zgłosi
wyjątek SyntaxError. W przeciwnym razie uruchomi kod i zwróci wynik ostatniej zawartej w
nim instrukcji lub wyrażenia albo wartość undefined, jeżeli takiego wyniku nie będzie. Jeżeli
wykonywany kod zgłosi wyjątek, zostanie on przejęty i zgłoszony przez funkcję eval().
Funkcja eval() ma tę cechę, że wykorzystuje zmienne kodu, który ją wywołuje. Oznacza to, że
może odczytywać wartości tych zmiennych, jak również definiować nowe zmienne i funkcje
w taki sam sposób, jak to się dzieje w lokalnym kodzie. Jeżeli w funkcji jest zdefiniowana
lokalna zmienna x, wynikiem zwróconym przez funkcję eval("x") będzie wartość tej zmiennej.
Jeżeli zostanie wywołana funkcja eval("x=1"), zmieni się wartość lokalnej zmiennej. Natomiast
wywołanie eval("var y = 3;") spowoduje zadeklarowanie nowej zmiennej lokalnej y. Z drugiej
strony, jeżeli w ciągu zostanie użyta instrukcja let lub const, zostanie zadeklarowana lokalna
w kontekście funkcji zmienna lub stała, która nie będzie dostępna w wywołującym ją kodzie.
Analogicznie funkcja eval() może definiować własne lokalne funkcje, jak niżej:

eval("function f() { return x+1; }");


Oczywiście funkcja eval() wywołana na najwyższy poziomie kodu operuje na globalnych
zmiennych i funkcjach.
Zwróć uwagę, że kod umieszczany w argumencie funkcji eval() musi stanowić syntaktyczną
całość. Nie można w nim umieszczać fragmentów kodu. Na przykład zapis eval("return;")
nie ma sensu, ponieważ instrukcję return stosuje się wyłącznie wewnątrz funkcji. Fakt, że kod
umieszczony w argumencie funkcji eval() wykorzystuje to samo środowisko co wywołująca ją
funkcja, nie oznacza, że kod w argumencie jest częścią tej funkcji. Jeżeli ciąg może być
samodzielnym skryptem, nawet najkrótszym, na przykład x=0, można go umieścić w
argumencie funkcji eval(). W przeciwnym razie zostanie zgłoszony wyjątek SyntaxError.
4.12.2. Globalne wywołanie funkcji eval()
To właśnie możliwość modyfikowania przez funkcję eval() lokalnych zmiennych jest tak
problematyczna dla optymalizatora kodu JavaScript. Dlatego funkcje wywołujące tę funkcję są
w mniejszym stopniu optymalizowane. Co może jednak zrobić interpreter, jeżeli w kodzie jest
zdefiniowany alias funkcji eval() i użyty w celu jej wywołania? Zgodnie ze specyfikacją języka
JavaScript funkcja eval() wywołana przy użyciu innej nazwy niż eval powinna traktować
umieszczony w jej argumencie ciąg, tak jakby był uruchamiany na najwyższym, globalnym
poziomie kodu. Zawarty w ciągu kod może definiować globalne zmienne i funkcje, przypisywać
wartości globalnym zmiennym, ale nie może modyfikować lokalnych zmiennych funkcji
wywołującej funkcję eval(). Zatem lokalna optymalizacja nie obejmuje takiego kodu.

Termin „bezpośrednia ewaluacja” oznacza wywołanie funkcji eval() w wyrażeniu


wykorzystującym niekwalifikowaną nazwę „eval” (która na pierwszy rzut oka wygląda jak
zarezerwowane słowo). Bezpośrednio wywołana funkcja eval() wykorzystuje zmienne
stosowane w wywołującym ją kodzie. We wszystkich innych wywołaniach, na przykład
pośrednich, wykorzystywane są globalne zmienne i nie można odczytywać, zapisywać ani
definiować lokalnych zmiennych funkcji. W wywołaniach bezpośrednich i pośrednich nowe
zmienne można definiować wyłącznie za pomocą instrukcji var. Instrukcje let i const użyte w
ciągu tworzą lokalne zmienne i stałe i nie wpływają na środowisko nadrzędnej funkcji ani
środowisko globalne.
Ilustruje to poniższy kod:
const geval = eval; // Inna nazwa, przeznaczona do globalnego
wywołania funkcji eval().
let x = "global", y = "global"; // Dwie zmienne globalne.

function f() { // Ta funkcja wywołuje funkcję eval()


lokalnie.
let x = "local"; // Definicja lokalnej zmiennej.
eval("x += 'changed';"); // Funkcja eval() przypisuje wartość
zmiennej lokalnej.
return x; // Zwrócenie zmodyfikowanej zmiennej
lokalnej.
}
function g() { // Ta funkcja wywołuje funkcję eval()
globalnie.

let y = "local"; // Lokalna zmienna.


geval("y += 'changed';"); // Pośrednie przypisanie wartości zmiennej
globalnej.
return y; // Zwrócenie niezmodyfikowanej zmiennej
lokalnej.
}
console.log(f(), x); // Zmodyfikowana zmienna lokalna. Wyświetlany wynik:
"localchanged global".
console.log(g(), y); // Zmodyfikowana zmienna globalna. Wyświetlany wynik
"local globalchanged".
Zwróć uwagę, że możliwość globalnego wywoływania funkcji eval() nie oznacza jedynie
przystosowania jej do wymagań optymalizatora. W rzeczywistości jest to niezwykle przydatna
funkcjonalność umożliwiająca uruchamianie fragmentów kodu tak, jakby były niezależnymi
skryptami operującymi na najwyższym poziomie kodu. Jak wspomniałem na początku
podrozdziału, rzadko pojawia się potrzeba uruchamiania kodu w postaci ciągu znaków. Jeżeli
jednak jest to konieczne, należy w tym celu wywoływać funkcję eval() globalnie, a nie lokalnie.

4.12.3. Ścisłe wywołanie funkcji eval()


Tryb ścisły (patrz punkt 5.6.3) nakłada kolejne ograniczenia na funkcję eval(), a nawet
identyfikator „eval”. Jeżeli funkcja zostanie wywołana w trybie ścisłym lub ciąg umieszczony w
jej argumencie będzie rozpoczynał się od dyrektywy "use strict", to zawarty w nim kod
zostanie wykonany w lokalnym, prywatnym środowisku. Oznacza to, że będzie mógł odczytywać
i przypisywać wartości lokalnym zmiennym, ale nie będzie mógł definiować lokalnych
zmiennych ani funkcji.
Co więcej, tryb ścisły sprawia, że funkcja eval() jeszcze bardziej przypomina operator, a „eval”
staje się niemal słowem zarezerwowanym. Nie można zdefiniować funkcji o nazwie eval() ani
zmiennej, parametru funkcji i parametru instrukcji catch o nazwie „eval”.

4.13. Inne operatory


W języku JavaScript dostępnych jest jeszcze kilka innych operatorów, opisanych w poniższych
punktach.

4.13.1. Operator warunkowy (?:)


Operator warunkowy jest jedynym operatorem trójargumentowym. Opisuje się go jako ?:,
ale nie używa w takiej postaci w kodzie. Ponieważ ma on trzy operandy, pierwszy umieszczany
jest przed znakiem ?, drugi pomiędzy znakami ? a :, a trzeci po znaku :, jak niżej:
x > 0 ? x : -x // Wartość bezwzględna zmiennej x.

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:

(a !== null && a !== undefined) ? a : b


Operator ?? stanowi użyteczną alternatywę dla operatora || (patrz punkt 4.10.2), gdy trzeba
wybrać pierwszy zdefiniowany, a nie pierwszy prawdziwy operand. Choć operator || jest z
założenia operatorem LUB, można go w wyrażeniu takim jak poniższe używać do wybierania
pierwszego niefałszywego operandu:
// Jeżeli zmienna maxWidth jest prawdziwa, użyj jej. W przeciwnym razie
poszukaj jej
// w obiekcie preferences. Jeżeli nie jest prawdziwa, użyj wpisanej stałej.
let max = maxWidth || preferences.maxWidth || 500;
Problem z takim idiomatycznym użyciem operatora polega na tym, że liczba zero, pusty ciąg
znaków i wartość logiczna false są traktowane jako wartości fałszywe, które w określonych
warunkach mogą być całkowicie poprawne. W powyższym przykładzie zmienna maxWidth o
wartości 0 zostanie pominięta. Jeżeli natomiast operator || zostanie zmieniony na ??,
powstanie wyrażenie, w którym liczba zero będzie poprawną wartością:
// Jeżeli zmienna maxWidth jest prawdziwa, użyj jej. W przeciwnym razie
poszukaj jej
// w obiekcie preferences. Jeżeli nie jest zdefiniowana, użyj wpisanej
stałej.
let max = maxWidth ?? preferences.maxWidth ?? 500;
Poniżej przedstawionych jest kilka dodatkowych przykładów pokazujących, jak działa operator
??, gdy pierwszy operand ma wartość fałszywą. Jeżeli taki operand jest zdefiniowany, operator
zwraca go. Drugi operand jest wyliczany i zwracany tylko wtedy, gdy pierwszy ma wartość null
lub undefined:

let options = { timeout: 0, title: "", verbose: false, n: null };


options.timeout ?? 1000 // => 0: wartość zdefiniowana w obiekcie.
options.title ?? "Untitled" // => "": wartość zdefiniowana w obiekcie.
options.verbose ?? true // => false: wartość zdefiniowana w obiekcie.
options.quiet ?? false // => false: właściwość nie jest zdefiniowana.
options.n ?? 10 // => 10: właściwość ma wartość null.
Zwróć uwagę, że gdyby zamiast operatora ?? był użyty operator ||, wyniki powyższych
wyrażeń wykorzystujących właściwości timeout, title i verbose byłyby inne.
Operator ?? jest podobny do operatorów && i || i jego priorytet nie jest ani wyższy, ani niższy.
Dlatego w wyrażeniach wykorzystujących te operatory należy za pomocą nawiasów określać,
które operacje mają być wykonane w pierwszej kolejności:
(a ?? b) || c // Najpierw ??, potem ||.
a ?? (b || c) // Najpierw ||, potem ??.
a ?? b || c // SyntaxError: wymagane użycie nawiasów.
Operator ?? został wprowadzony w wersji języka ES2020 i od początku 2020 r. jest obsługiwany
we wstępnych wersjach większości najpopularniejszych przeglądarek. Formalnie jest nazywany
operatorem „zerowołączącym”. Unikam jednak tego terminu, ponieważ operator wybiera jeden
z operandów, ale nie łączy ich w żaden widoczny sposób.

4.13.3. Operator typeof


Operator typeof jest jednoargumentowy i umieszcza się go przed operandem, który może być
dowolnego typu. Zwracanym wynikiem jest ciąg znaków opisujący typ operandu. Tabela 4.3
przedstawia wyniki operatora typeof użytego ze wszystkimi wartościami dostępnymi w języku
JavaScript.

Tabela 4.3 . Wyniki zwracane przez operator typeof

x typeof x

undefined "undefined"

null "object"

true lub false "boolean"

Dowolna liczba lub NaN "number"

Dowolna liczba BigInt "bigint"

Dowolny ciąg znaków "string"

Dowolny symbol "symbol"

Dowolna funkcja "function"

Dowolny obiekt inny niż funkcja "object"

Operator typeof można stosować w wyrażeniach takich jak poniższe:


// Jeżeli zmienna value jest typu string, umieść ją wewnątrz apostrofów.

// W przeciwnym razie przekształć w ciąg znaków.


(typeof value === "string") ? "'" + value + "'" : value.toString()
Zwróć uwagę, że jeżeli operand ma wartość null, operator typeof zwraca wartość "object".
Jeżeli trzeba odróżnić wartość null od obiektu, należy ją sprawdzić jawnie.
Funkcje w języku JavaScript są specjalnego rodzaju obiektami, ale operator typeof odróżnia je
od obiektów do tego stopnia, że rezerwuje dla nich osobną zwracaną wartość.
Ponieważ operator typeof użyty z obiektem lub tablicą (ale nie funkcją) zwraca wartość
"object", wystarczy w jakiś sposób odróżniać obiekt tylko od prymitywnych typów. Aby
odróżnić jedną klasę od innej, trzeba zastosować inną technikę, na przykład użyć operatora
instanceof (patrz punkt 4.9.4), atrybutu klasy (punkt 14.4.3) lub właściwości konstruktora
(punkt 9.2.2 i podrozdział 14.3).

4.13.4. Operator delete


Operator delete jest jednoargumentowy i służy do usuwania właściwości obiektu lub elementu
tablicy wskazanego za pomocą operandu. Podobnie jak operator przypisania, inkrementacji
i dekrementacji, obszar delete jest zazwyczaj stosowany w celu wywołania efektu ubocznego, a
nie uzyskania wartości. Poniżej jest przedstawionych kilka przykładów:
let o = { x: 1, y: 2}; // Początkowy obiekt.
delete o.x; // Usunięcie jednej z właściwości obiektu.
"x" in o // => false: dana właściwość już nie istnieje.
let a = [1,2,3]; // Początkowa tablica.
delete a[2]; // Usunięcie ostatniego elementu tablicy.
2 in a // => false: element o indeksie 2 już nie istnieje.
a.length // => 3: zwróć uwagę, że długość tablicy nie zmieniła
się.
Zwróć uwagę, że usuniętej w ten sposób właściwości lub elementowi tablicy nie jest jedynie
przypisywana wartość undefined. Usunięta właściwość przestaje istnieć. Próba odwołania się
do niej skutkuje uzyskaniem wartości undefined, ale za pomocą operatora in (patrz punkt
4.9.3) można sprawdzić, czy właściwość w ogóle istnieje. W miejscu usuniętego elementu
tablicy powstaje luka, ale długość tablicy nie zmienia się. Wynikowa tablica jest rozrzedzona
(patrz podrozdział 14.1).
Argumentem operatora delete powinna być l-wartość. Operator podejmuje próbę jej usunięcia
i w przypadku powodzenia zwraca true. Jeżeli typ jest inny, operator nie wykonuje żadnej
operacji i zwraca wartość true. Jednak niektórych właściwości, na przykład
niekonfigurowalnych (patrz podrozdział 14.1) nie da się usunąć.
Jeżeli w trybie ścisłym operand jest niekwalifikowanym identyfikatorem, na przykład zmienną,
funkcją lub parametrem funkcji, operator delete zgłasza wyjątek SyntaxError. Kod jest
poprawny tylko wtedy, gdy operand jest wyrażeniem dostępu do właściwości (patrz podrozdział
4.4). Ponadto w trybie ścisłym operator zgłasza wyjątek TypeError przy próbie usunięcia
niekonfigurowalnej (czyli nieusuwalnej) właściwości. Jeżeli tryb ścisły nie jest stosowany,
wyjątki nie są zgłaszane, a operator delete po prostu zwraca wartość false oznaczającą, że
operandu nie można usunąć.
Poniżej przedstawionych jest kilka przykładów użycia operatora delete:
let o = {x: 1, y: 2};
delete o.x; // Usunięcie jednej z właściwości obiektu. Wynik: true.
typeof o.x; // Właściwość nie istnieje. Wynik: true.
delete o.x; // Próba usunięcia nieistniejącej właściwości. Wynik: true.
delete 1; // To wyrażenie nie ma sensu, ale jego wynikiem jest wartość
true.
// Zmiennej nie można usunąć. Wynik: true lub wyjątek SyntaxError w trybie
ścisłym.
delete o;
// Nieusuwana właściwość. Wynik: false lub wyjątek TypeError w trybie
ścisłym.
delete Object.prototype;
Z operatorem delete spotkasz się ponownie w podrozdziale 6.4.
4.13.5. Operator await
Operator await został wprowadzony w wersji języka ES2017, aby programowanie
asynchroniczne w JavaScripcie było bardziej naturalne. Jego działanie poznasz w rozdziale 13.
W uproszczeniu jedynym operandem operatora await jest promesa, czyli obiekt reprezentujący
asynchroniczną operację. Operator sprawia, że kod czeka na zakończenie wykonania promesy,
jednak nie blokuje swojego działania ani nie wstrzymuje innych asynchronicznych operacji.
Wynikiem operatora jest wartość opisująca spełnienie promesy. Co istotne, można go stosować
tylko z funkcjami zadeklarowanymi jako asynchroniczne za pomocą słowa kluczowego async.
Szczegółowe informacje znajdziesz we wspomnianym wyżej rozdziale 13.

4.13.6. Operator void


Operator void jest jednoargumentowy, a jego operand może być dowolnego typu. Jest to
nietypowy i rzadko stosowany operator, który wylicza wartość operandu, po czym ją odrzuca i
zwraca wartość undefined. Z tego względu stosowanie go jest uzasadnione tylko wtedy, gdy
operand wywołuje efekty uboczne.
Operator void jest tak niezwykły, że trudno jest podać przykład jego praktycznego użycia.
Jednym z nich może być funkcja, która nie zwraca wyniku, zdefiniowana przy użyciu składni ze
strzałką (patrz punkt 8.1.3) i jednym wyrażeniem. Jeżeli takie wyrażenie ma być wyliczone
wyłącznie w celu wywołania efektu ubocznego, najprościej jest umieścić ciało funkcji wewnątrz
nawiasów klamrowych. Ewentualnie można użyć operatora void:
let counter = 0;
const increment = () => void counter++;

increment() // => undefined


counter // => 1

4.13.7. Operator przecinek (,)


Przecinek jest operatorem dwuargumentowym, a jego operandy mogą być dowolnych typów.
Operator wylicza wartości obu operandów i zwraca wartość prawego. Zatem wynikiem
poniższego kodu:
i=0, j=1, k=2;
jest liczba 2, a kod jest równoważny następującemu:
i = 0; j = 1; k = 2;
Wyrażenie pierwsze z lewej jest wyliczane zawsze, ale jego wynik jest odrzucany. Oznacza to, że
stosowanie przecinka jest uzasadnione jedynie wtedy, gdy wyrażenie to wywołuje efekty
uboczne. Jedyną konstrukcją, w której przecinek jest powszechnie stosowany, jest pętla for
(patrz punkt 5.4.3) wykorzystująca kilka zmiennych:
// Pierwszy przecinek jest częścią składni instrukcji let. Drugi przecinek
jest operatorem.
// Dzięki niemu można scalić dwa wyrażenia (i++ oraz j--) i umieścić w
instrukcji
// operującej na jednym wyrażeniu (pętli for).
for(let i=0,j=10; i < j; i++,j--) {
console.log(i+j);
}

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ć:

Wyrażenia to frazy programu.


Każde wyrażenie ma wartość.
Wyrażenia mogą oprócz zwracania wartości wywoływać efekty uboczne (na przykład
przypisywać zmiennej nową wartość).
Proste wyrażenia, takie jak literały czy odwołania do zmiennych i właściwości, można
łączyć ze sobą za pomocą operatorów i tworzyć w ten sposób bardziej złożone wyrażenia.
W języku JavaScript są dostępne m.in. operatory arytmetyczne, relacyjne, logiczne,
przypisujące i bitowe. Jest też trójargumentowy operator warunkowy.
Operator + wykorzystuje się do sumowania wartości, jak również do łączenia ze sobą
ciągów znaków.
Operatory logiczne && i || działają na zasadzie „krótkiego zwarcia”, tzn. mogą wyliczać
wartość tylko jednego z dwóch operandów. Do stosowania popularnych konstrukcji
składniowych języka JavaScript wymagana jest znajomość działania tych operatorów.
Rozdział 5.
Instrukcje
W rozdziale 4. wyrażenia stosowane w języku JavaScript nazwałem frazami. Kontynuując tę
analogię, instrukcje można nazwać zdaniami lub poleceniami. Na końcu instrukcji umieszcza
się średnik (patrz podrozdział 2.6), tak jak na końcu zdania w języku pisanym — kropkę.
Wyrażenia są wyliczane w celu uzyskania wyników, natomiast instrukcje są wykonywane, aby
coś się stało.

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.

5.1. Instrukcje wyrażeniowe


Najprostszymi instrukcjami są wyrażenia wywołujące efekty uboczne. Tego rodzaju wyrażenia
zostały opisane w rozdziale 4. Inną ważną kategorią instrukcji są instrukcje wyrażeniowe, na
przykład:

greeting = "Cześć, " + name;


i *= 3;
Operatory inkrementacji ++ i dekrementacji –– są powiązane z instrukcjami przypisania.
Wywołują efekty uboczne polegające na modyfikowaniu wartości zmiennych, podobne do
przypisania nowych wartości:

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

Można natomiast wyliczyć wartość i przypisać ją do zmiennej do wykorzystania w przyszłości:


cx = Math.cos(x);

Zwróć uwagę, że wszystkie wiersze w powyższych przykładach są zakończone średnikami.

5.2. Instrukcje złożone i puste


Podobnie jak przecinek (patrz punkt 4.13.7) łączy kilka wyrażeń w jedno, tak blok instrukcji
łączy kilka instrukcji w jedną złożoną instrukcję. Blok instrukcji jest po prostu sekwencją
instrukcji umieszczoną wewnątrz nawiasów klamrowych. Zatem poniższy kod można traktować
jako pojedynczą instrukcję i umieścić ją w dowolnym miejscu kodu, w którym oczekiwana jest
jedna instrukcja:

x = Math.PI;
cx = Math.cos(x);

console.log("cos(π) = " + cx);

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.

for(let i = 0; i < a.length; a[i++] = 0) ;

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.

Zwróć uwagę, że przypadkowe umieszczenie średnika po nawiasie zamykającym instrukcji for,


loop i if skutkuje trudnym do wykrycia błędem. Na przykład poniższy kod nie działa tak, jak by
sobie tego życzył jego autor:
if ((a === 0) || (b === 0)); // Ups, ten wiersz nic nie robi…

o = null; // A ten z kolei jest wykonywany zawsze.

Jeżeli użycie pustej instrukcji jest zamierzone, dobrą praktyką jest umieszczenie stosownego
komentarza, na przykład:

for(let i = 0; i < a.length; a[i++] = 0) /* pusta instrukcja */ ;

5.3. Instrukcje warunkowe


Instrukcje warunkowe powodują wykonanie lub pomięcie innych instrukcji w zależności od
wartości zadanego wyrażenia. Tego rodzaju instrukcje pełnią role punktów decyzyjnych w
kodzie. Czasami są też nazywane „odgałęzieniami”. Jeżeli wyobrazimy sobie, że kod jest
ścieżką, którą podąża interpreter języka JavaScript, to instrukcja warunkowa jest miejscem, w
którym kod rozdziela się na dwie lub więcej ścieżek i interpreter musi wybrać jedną z nich.
W kolejnych punktach jest opisana podstawowa instrukcja warunkowa if/else oraz bardziej
złożona, wielościeżkowa instrukcja switch.

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:

if (username == null) // Jeżeli zmienna username ma wartość null lub


undefined,
username = "Jan Nowak"; // zdefiniuj ją.
Inny przykład:

// Jeżeli zmienna username ma wartość null, undefined, false, 0, "" lub NaN,
nadaj jej nową wartość.

if (!username) username = "Jan Nowak";

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 = "";

message = "Podaj adres pocztowy.";


}

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)

console.log("Masz jedną nową wiadomość.");


else

console.log(`Masz ${n} nowych wiadomości.`);

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)

console.log("i jest równe k");

else

console.log("i nie jest równe j"); // BŁĄD!!!

W powyższym przykładzie wewnętrzna instrukcja if stanowi pojedynczą instrukcję


wykonywaną przez zewnętrzną instrukcję if. Jednak nie wiadomo dokładnie, której z nich
dotyczy klauzula else. Pewną podpowiedzią są wcięcia, ale one też są błędne, ponieważ dla
interpretera kod ten ma następującą postać:
if (i === j) {

if (j === k)

console.log("i jest równe 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) {

console.log("i jest równe k");

} else { // Nawiasy klamrowe robią wielką różnicę!

console.log("i nie jest równe j");

}
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.

5.3.2. Instrukcja else if


Instrukcja if/else powoduje wyliczenie wyrażenia i wykonanie w zależności od jego wyniku
jednego z dwóch fragmentów kodu. Co jednak robić, gdy trzeba wykonać jeden z większej
liczby fragmentów? Jedno z rozwiązań polega na użyciu instrukcji else if. W rzeczywistości nie
jest to instrukcja, ale prosty i często stosowany idiom pojawiający się w połączonych
instrukcjach if/else:

if (n === 1) {

// Wykonaj blok kodu nr 1.

} else if (n === 2) {
// Wykonaj blok kodu nr 2.

} else if (n === 3) {

// Wykonaj blok kodu nr 3.

} else {

// Jeżeli żaden warunek nie jest spełniony, wykonaj blok kodu nr 4.

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) {

// Wykonaj blok kodu nr 1.

else {
if (n === 2) {

// Wykonaj blok kodu nr 2.

else {

if (n === 3) {

// Wykonaj blok kodu nr 3.

else {

// Jeżeli żaden warunek nie jest spełniony, wykonaj blok kodu nr 4.


}

5.3.3. Instrukcja switch


Instrukcja if powoduje rozgałęzienie przepływu programu. Jeżeli takich rozgałęzień jest wiele,
można stosować idiom else if. Nie jest to jednak najlepsze rozwiązanie w przypadku, gdy
wybór każdego odgałęzienia zależy od wartości tego samego wyrażenia, ponieważ jest ono
wyliczane wielokrotnie w kolejnych instrukcjach if.

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) {

case 1: // Zacznij tutaj, jeżeli n === 1.


// Wykonaj blok kodu nr 1.

break; // Zakończ tutaj.

case 2: // Zacznij tutaj, jeżeli n === 2.

// Wykonaj blok kodu nr 2.

break; // Zakończ tutaj.


case 3: // Zacznij tutaj, jeżeli n === 3.

// Wykonaj blok kodu nr 3.

break; // Zakończ tutaj.

default: // Jeżeli żaden warunek nie jest spełniony…

// …wykonaj blok kodu nr 4.

break; // Zakończ tutaj.

}
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.

Poniżej jest przedstawiony bardziej praktyczny przykład użycia instrukcji switch do


przekształcenia zadanej wartości odpowiednio do jej typu:

function convert(x) {
switch(typeof x) {

case "number": // Przekształcenie liczby dziesiętnej w


szesnastkową.
return x.toString(16);

case "string": // Zwrócenie ciągu znaków ujętego w cudzysłowy.


return '"' + x + '"';

default: // Przekształcenie wartości innego typu w zwykły


sposób.
return String(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.

5.4.1. Instrukcja while


Podobnie jak if jest podstawową instrukcją warunkową, tak instrukcja while jest podstawową
pętlą. Jej składnia jest następująca:

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;

while(count < 10) {


console.log(count);

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.

5.4.3. Instrukcje do/while


Pętla do/while jest podobna to while. Różni się od niej jednak tym, że wartość wyrażenia jest
sprawdzana na początku, a nie na końcu pętli. Oznacza to, że ciało pętli jest wykonywane
przynajmniej jeden raz. Składnia pętli do/while jest następująca:
do

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

} while(++i < len);


}

}
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.

5.4.3. Pętla for


Instrukcja for jest często wygodniejsza w użyciu niż instrukcja while, ponieważ upraszcza
typową definicję pętli. W większości przypadków pętla zawiera zmienną licznikową, inicjowaną
przed rozpoczęciem wykonywania pętli. Przed wykonaniem kolejnej iteracji wartość tej
zmiennej jest sprawdzana, a na końcu ciała pętli, tuż przed kolejnym sprawdzeniem,
inkrementowana lub modyfikowana w inny sposób. W tego rodzaju pętli operacje inicjowania,
sprawdzania i modyfikowania wartości zmiennej mają krytyczne znaczenie. Te trzy wyrażenia
stanowią części składni instrukcji for:

for(inicjalizacja; sprawdzenie; inkrementacja)


instrukcja

Poszczególne wyrażenia oddzielone są średnikami. Umieszczone w jednym wierszu czytelnie


opisują działanie pętli. Dzięki temu unika się pomyłek, takich jak użycie niezainicjowanej
zmiennej.

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 --.

Poniższa pętla wyświetla liczby od 0 do 9. Porównaj ją z równoważną jej pętlą while z


poprzedniego punktu:

for(let count = 0; count < 10; count++) {


console.log(count);
}

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;

for(i = 0, j = 10 ; i < 10 ; i++, j--) {


sum += i * j;
}

We wszystkich zaprezentowanych przykładach użyta została liczbowa zmienna licznikowa. Jest


to najczęstszy przypadek, ale nie jedyny. Poniższy kod przedstawia funkcję przetwarzającą za
pomocą pętli połączoną listę obiektów i zwracającą ostatni z nich (tj. pierwszy obiekt, który nie
ma właściwości next):
function tail(o) { // Funkcja zwracająca koniec
połączonej listy obiektów o.
for(; o.next; o = o.next) /* puste ciało */ ; // Wykonywanie pętli, dopóki
wyrażenie o.next jest prawdziwe.

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).

5.4.4. Pętla for/of


W wersji ES6 języka wprowadzono nową pętlę: for/of. Jest w niej stosowane słowo kluczowe
for, ale tego rodzaju pętla całkowicie różni się od zwykłej pętli for, jak również starszej
odmiany for/in, która będzie opisana w punkcie 5.4.5.
Pętla for/of operuje na obiektach iterowalnych. Znaczenie tego terminu opiszę w rozdziale
12., natomiast teraz wystarczy wiedzieć, że takimi obiektami są tablice, ciągi znaków, zbiory i
mapy. Każdy z nich reprezentuje sekwencję lub zestaw elementów, które można iterować za
pomocą pętli for/of.

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;

for(let element of data) {


sum += element;

}
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.

Pętla for/of z obiektami


Obiekty domyślnie nie są iterowalne. Próba użycia pętli for/of ze zwykłym obiektem skutkuje
zgłoszeniem wyjątku TypeError:
let o = { x: 1, y: 2, z: 3 };
for(let element of o) { // Zgłoszenie wyjątku TypeError, ponieważ obiekt o
nie jest iterowalny.
console.log(element);

}
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 = "";

for(let [k, v] of Object.entries(o)) {


pairs += k + v;
}

pairs // => "x1y2z3"


Metoda Object.entries() zwraca tablicę dwuelementowych tablic reprezentujących parę
nazwa-wartość właściwości obiektu. Przypisanie destrukturyzujące rozpakowuje każdą
wewnętrzną tablicę na osobne zmienne.

Pętla for/of z ciągami znaków


Począwszy od wersji ES6 ciągi znaków można iterować znak po znaku:
let frequency = {};

for(let letter of "mississippi") {


if (frequency[letter]) {

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 " ".

Pętla for/of ze zbiorami i mapami


Klasy Set i Map są iterowalne. Kod zawarty w ciele pętli for/of jest wykonywany jeden raz dla
każdego elementu zbioru. Poniższy kod można wykorzystać do wyświetlenia unikatowych słów
w zadanym tekście:

let text = "Na na na na na na na na Batman!";


let wordSet = new Set(text.split(" "));
let unique = [];

for(let word of wordSet) {


unique.push(word);

}
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:

let m = new Map([[1, "jeden"]]);


for(let [key, value] of m) {
key // => 1

value // => "jeden"


}

Iterowanie asynchroniczne za pomocą pętli for/await


W wersji ES2018 został wprowadzony nowy rodzaj iteratora, tzw. iterator asynchroniczny,
oraz odmiana pętli for/of, nazywana pętlą for/await, która wykonuje iteracje asynchroniczne.
Działanie pętli for/await poznasz w rozdziałach 12. i 13., natomiast tutaj jest przedstawiony
przykładowy kod:

// Odczytywanie porcji danych z asynchronicznego, iterowalnego strumienia i


wyświetlanie ich na ekranie
async function printStream(stream) {
for await (let chunk of stream) {

console.log(chunk);
}
}

5.4.5. Pętla for/in


Pętla for/in jest bardzo podobna do for/of. Różni się jednak nie tylko słowem kluczowym in
użytym zamiast of. Pętla for/of wymaga użycia iterowanego obiektu, natomiast for/in działa
na dowolnym obiekcie. Ponadto pętla for/of została wprowadzona w wersji ES6 języka,
natomiast for/in była w nim od samego początku (z tego też powodu jej składnia wygląda
bardziej naturalnie).
Pętla for/in iteruje nazwy właściwości użytego obiektu. Jej składnia jest następująca:
for (zmienna in obiekt)

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:

for(let p in o) { // Przypisanie nazw właściwości obiektu o zmiennej p.


console.log(o[p]); // Wyświetlenie wartości każdej właściwości.
}
Interpreter najpierw wylicza wyrażenie obiekt. Jeżeli wynikiem jest null lub undefined,
interpreter pomija pętlę i przechodzi do następnej instrukcji. W przeciwnym razie wykonuje
kod ciała pętli dla każdej właściwości obiektu. Przed każdą iteracją wylicza wyrażenie zmienna i
przypisuje mu nazwę właściwości (ciąg znaków).
Zwróć uwagę, że zmienna może być dowolnym wyrażeniem, o ile tylko jego wynik można
umieścić po lewej stronie operatora przypisania. Wyrażenie jest wyliczane przy każdej iteracji,
co oznacza, że jego wynik może być za każdym razem inny. Na przykład poniższy kod kopiuje
nazwy wszystkich właściwości obiektu do tablicy:

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.

5.5.1. Instrukcje z etykietami


Każda instrukcja może być opatrzona etykietą, złożoną z identyfikatora i dwukropka:
identyfikator: instrukcja
Etykieta jest nazwą, do której można odwoływać się w dowolnym miejscu kodu. Można nią
opatrzyć każdą instrukcję, jednak etykieta przydatna jest tylko wtedy, gdy instrukcja ma ciało,
tzn. jest na przykład pętlą lub instrukcją warunkową. Instrukcja break lub continue użyta z
etykietą powoduje wyjście interpretera z pętli lub przejście na jej początek i rozpoczęcie
kolejnej iteracji. Są to jedyne instrukcje w języku JavaScript wykorzystujące etykiety. Obie
zostaną opisane w kolejnych punktach. Poniżej jest przedstawiony przykład pętli, w której
wykorzystana jest etykieta i odwołująca się do niej instrukcja continue:
mainloop: while(token !== null) {

// 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.

5.5.2. Instrukcja break


Instrukcja break użyta bez etykiety powoduje natychmiastowe wyjście interpretera z
najbardziej zagnieżdżonej pętli lub instrukcji switch. Jej składnia jest bardzo prosta:
break;
Instrukcji break w takiej postaci można używać tylko wewnątrz pętli lub instrukcji switch.
Poznałeś już przykłady użycia instrukcji break wewnątrz instrukcji switch. W pętli jest
zazwyczaj wykorzystywana do przerywania jej działania, gdy z jakiegoś powodu nie trzeba jej
wykonywać do końca. Jeżeli wyrażenie warunkowe użyte w pętli jest skomplikowane, często
łatwiej jest zaimplementować je za pomocą instrukcji break. Poniższy kod przeszukuje
elementy tablicy pod kątem określonej wartości. Pętla kończy działanie, gdy osiągnie koniec
tablicy. Jeżeli wartość zostanie znaleziona, pętla jest przerywana za pomocą instrukcji break.
for(let i = 0; i < a.length; i++) {
if (a[i] === target) break;

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

// Rozpocznij wykonywanie opatrzonej etykietą instrukcji, którą będzie można


przerwać, gdy wystąpi błąd.
computeSum: if (matrix) {
for(let x = 0; x < matrix.length; x++) {
let row = matrix[x];

if (!row) break computeSum;


for(let y = 0; y < row.length; y++) {
let cell = row[y];
if (isNaN(cell)) break computeSum;

sum += cell;
}
}
success = true;
}

// Instrukcja break powoduje przejście do tego miejsca. Jeżeli success ==


false,
// oznacza to, że coś poszło źle. W przeciwnym razie zmienna sum zawiera
// sumę wszystkich elementów tablicy.
Na koniec zauważ, że instrukcja break użyta bez etykiety nie powoduje wyjścia interpretera
poza granice funkcji. Nie można na przykład opatrzyć etykietą instrukcji definiującej funkcję, a
następnie użyć jej wewnątrz tej funkcji.

5.5.3. Instrukcja continue


Instrukcja continue jest podobna do break. Nie powoduje jednak wyjścia poza pętlę, lecz
rozpoczęcie kolejnej iteracji. Składnia instrukcji continue jest równie prosta jak instrukcji
break:
continue;

Można ją również stosować z etykietą:


continue identyfikator;
Instrukcja continue w obu formach może być stosowana jedynie w ciele pętli. Użycie jej w
innym miejscu jest błędem składniowym.

Gdy interpreter napotka instrukcję continue, przerywa iterację obejmującej ją pętli i


rozpoczyna kolejną. W zależności od pętli skutki mogą być różne:

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++) {

if (!data[i]) continue; // Nie można przetwarzać niezdefiniowanych danych.


total += data[i];
}
Instrukcję continue, podobnie jak break, można stosować z etykietą, gdy pętla, która ma
zostać wykonana od początku, nie jest pętlą bezpośrednio obejmującą instrukcję. Ponadto
pomiędzy instrukcją continue a etykietą nie można umieszczać podziału wiersza.

5.5.4. Instrukcja return


Jak wiadomo, wywołania funkcji są wyrażeniami, a wszystkie wyrażenia mają wartości.
Instrukcja return użyta w funkcji określa wartość jej wywołania. Składnia instrukcji wygląda
tak:
return wyrażenie;

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.

5.5.5. Instrukcja yield


Instrukcja yield jest podobna do return, ale stosuje się ją wyłącznie w funkcjach generatorów
(patrz podrozdział 12.3) wprowadzonych w wersji języka ES6. Powoduje ona pobranie
następnej wartości z generowanej sekwencji bez opuszczania ciała funkcji:
// Funkcja generatora tworząca serię liczb całkowitych.
function* range(from, to) {

for(let i = from; i <= to; i++) {


yield i;
}
}

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.

5.5.6. Instrukcja throw


Wyjątek jest sygnałem oznaczającym sytuację wyjątkową lub błąd. Zgłoszenie wyjątku to
wysłanie sygnału, że ma miejsce sytuacja wyjątkowa. Przechwycenie wyjątku to obsłużenie
go, czyli wykonanie operacji odpowiednich do zaistniałej sytuacji. Wyjątek jest zgłaszany wtedy,
gdy pojawi się błąd lub zostanie jawnie użyta instrukcja throw. Wyjątki przechwytuje się za
pomocą instrukcji try/catch/finally opisanych w kolejnym punkcie.
Składnia instrukcji throw jest następująca:
throw wyrażenie;
Użyte wyrażenie może mieć wartość dowolnego typu. Może to być kod błędu lub ciąg znaków
zawierający komunikat czytelny dla człowieka. Interpreter JavaScript wykorzystuje do
zgłaszania wyjątków klasę Error i jej podklasy, które można również stosować we własnym
kodzie. Klasa Error ma właściwość o nazwie name opisującą typ błędu, oraz message
zawierającą ciąg podany w argumencie funkcji. Poniżej przedstawiona jest przykładowa
funkcja, która w przypadku wywołania jej z niewłaściwym argumentem zgłasza wyjątek za
pomocą klasy Error:
function factorial(x) {

// Zgłoszenie wyjątku, gdy argument jest niewłaściwy.


if (x < 0) throw new Error("x nie może być liczbą ujemną");
// W przeciwnym razie następuje wyliczenie wartości i powrót.
let f;
for(f = 1; x > 1; f *= x, x--) /* puste ciało */ ;

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.

5.5.7. Instrukcje try/catch/finally


Instrukcje try/catch/finally stanowią mechanizm obsługi wyjątku. Klauzula try definiuje
blok kodu, który może zgłaszać wyjątki. Po nim następuje klauzula catch i blok instrukcji
wykonywanych po zgłoszeniu wyjątku w dowolnym miejscu bloku try. Na końcu znajduje się
instrukcja finally zawierająca blok instrukcji, które są wykonywane niezależnie od tego, co się
stało w bloku try. Zarówno blok catch, jak i finally jest opcjonalny, ale musi być użyty
przynajmniej jeden z nich. Bloki try, catch i finally rozpoczynają się i kończą nawiasami
klamrowymi, które stosuje się zawsze, nawet jeżeli blok składa się tylko z jednej instrukcji.
Poniższy kod ilustruje składnię i przeznaczenie instrukcji try/catch/finally:
try {
// W normalnych warunkach ten blok kodu jest wykonywany bez problemów,

// od pierwszej do ostatniej instrukcji. Może w nim jednak zostać zgłoszony


// wyjątek pośrednio, poprzez wywołanie metody powodującej błąd,
// lub bezpośrednio za pomocą instrukcji throw.
}
catch(e) {

// Instrukcje umieszczone w tym bloku są wykonywane tylko wtedy,


// gdy w bloku try zostanie zgłoszony wyjątek. Instrukcje mogą
wykorzystywać
// lokalną zmienną e zawierającą obiekt typu Error lub inną wartość
// reprezentującą wyjątek. Blok może obsługiwać wyjątek w określony

// 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,

// niezależnie od tego, co się stało w bloku try, to znaczy, gdy:


// 1) została wykonana ostatnia instrukcja bloku
// 2) została wykonana instrukcja break, continue lub return
// 3) wyjątek został obsłużony w bloku catch

// 4) wyjątek nie został obsłużony i trzeba go eskalować


}
Zwróć uwagę, że po słowie catch zazwyczaj jest umieszczany identyfikator w nawiasach, który
można porównać do parametru funkcji. Gdy zostanie zgłoszony wyjątek, przypisana mu
wartość, na przykład obiekt Error, jest umieszczana w tym parametrze. Jego zasięg ogranicza
się tylko do bloku catch.

Poniżej jest przedstawiony bardziej praktyczny przykład użycia instrukcji try/catch.


Wykorzystana jest w nim funkcja factorial() z poprzedniego punktu oraz metody klienckie
prompt() i alert() do wprowadzania i wyświetlania danych:
try {
// Prośba do użytkownika o podanie liczby.

let n = Number(prompt("Podaj dodatnią liczbę całkowitą", ""));


// Wyliczenie silni przy założeniu, że podana wartość jest poprawna.
let f = factorial(n);
// Wyświetlenie wyniku.
alert(n + "! = " + f);

}
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.

Klauzula catch bez parametru


Czasami klauzuli catch używa się wyłącznie do przechwytywania wyjątku i
powstrzymywania jego eskalacji, bez względu na skojarzoną z nim wartość i jej typ.
Począwszy od wersji języka ES2019 można stosować klauzulę catch bez nawiasów i
identyfikatora, jak niżej:

// Funkcja podobna do JSON.parse(), która jednak nie zgłasza wyjątku, tylko


zwraca wartość undefined.
function parseJSON(s) {
try {
return JSON.parse(s);

} catch {
// Coś poszło źle, ale nieważne co.
return undefined;
}

5.6. Inne instrukcje


W tym podrozdziale są opisane instrukcje with i debugger oraz dyrektywa "use strict".

5.6.1. Instrukcja with


Instrukcja with sprawia, że właściwości użytego z nią obiektu są w zdefiniowanym bloku
traktowane jak zmienne. Jej składnia jest następująca:

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.

5.6.2. Instrukcja debugger


Instrukcja debugger właściwie nie robi nic, ale pozwala użyć debugera do wykonywania
różnych operacji diagnostycznych. Instrukcja ta pełni rolę pułapki, w której wstrzymywane jest
wykonywanie kodu. Można wtedy za pomocą debugera wyświetlić wartości zmiennych, stos
wywołań itp. Załóżmy, że funkcja f() zgłasza wyjątek, ponieważ jest wywoływana z
argumentem undefined i trzeba sprawdzić, w którym miejscu następuje wywołanie. Aby
ułatwić diagnostykę, można zmienić kod funkcji w następujący sposób:
function f(o) {
if (o === undefined) debugger; // Tymczasowy wiersz utworzony na potrzeby
diagnostyki.

... // Pozostała część kodu funkcji.


}
Teraz, gdy funkcja f() zostanie wywołana bez argumentów, wykonywanie kodu zostanie
wstrzymane i za pomocą debugera będzie można przejrzeć stos wywołań i dowiedzieć się, gdzie
w kodzie ma miejsce niewłaściwe wywołanie funkcji.

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):

W trybie ścisłym nie można stosować instrukcji with.


W trybie ścisłym wszystkie zmienne muszą być zadeklarowane. Jeżeli identyfikatorowi,
który nie został zadeklarowany jako zmienna, funkcja, parametr funkcji, parametr klauzuli
catch lub właściwości obiektu globalnego, zostanie przypisana wartość (w trybie zwykłym
w ten sposób tworzona jest nowa właściwość obiektu globalnego i deklarowana globalna
zmienna), zostanie zgłoszony wyjątek ReferenceError.
W trybie ścisłym w funkcjach, które nie są wywoływane jako metody, zmienna this ma
wartość undefined (w trybie zwykłym jest to obiekt globalny). Ponadto gdy funkcja jest
wywoływana za pomocą funkcji call() lub apply() (patrz punkt 8.7.4), wartością
zmiennej this jest pierwszy argument tej funkcji. W trybie zwykłym wartości null i
undefined są zastępowane obiektem globalnym, a wartości nieobiektowe są
przekształcane w obiekty.
W trybie ścisłym próba przypisania wartości właściwości przeznaczonej tylko do odczytu i
próba utworzenia właściwości nieistniejącego obiektu skutkuje zgłoszeniem wyjątku
TypeError. W trybie zwykłym błąd nie jest zgłaszany.
W trybie ścisłym w kodzie umieszczonym w argumencie funkcji eval() zadeklarowane
zmienne i zdefiniowane funkcje nie działają w zakresie kodu wywołującego, jak to ma
miejsce w zwykłym trybie, tylko w nowym zakresie, utworzonym dla funkcji eval().
Zakres ten jest usuwany po zakończeniu wykonywania funkcji eval().
W trybie ścisłym obiekt Arguments (patrz punkt 8.3.3) zawiera statyczną kopię
argumentów funkcji. W trybie zwykłym obiekt ten zachowuje się „magicznie”, tzn.
elementy tablicy i nazwane argumenty funkcji odwołują się do tych samych wartości.
W trybie ścisłym próba użycia operatora delete z niekwalifikowanym identyfikatorem, na
przykład zmienną, funkcją lub parametrem funkcji, skutkuje zgłoszeniem wyjątku
SyntaxError. W trybie zwykłym nic się nie dzieje, a operator zwraca wartość false.
W trybie ścisłym próba usunięcia niekonfigurowalnej właściwości skutkuje zgłoszeniem
wyjątku TypeError. W trybie zwykłym próba kończy się niepowodzeniem, a operator
delete zwraca wartość false.
W trybie ścisłym próba zdefiniowania dwóch lub więcej właściwości o takich samych
nazwach w literale obiektowym jest błędem składniowym. W trybie zwykłym nie jest
zgłaszany błąd.
W trybie ścisłym próba zdefiniowania funkcji o dwóch lub więcej parametrach o takich
samych nazwach jest błędem składniowym. W trybie zwykłym nie jest zgłaszany błąd.
W trybie ścisłym nie można stosować ósemkowych literałów liczb całkowitych (tj.
zaczynających się od znaku 0, po którym nie następuje znak x). W trybie zwykłym w
niektórych implementacjach języka można stosować literały ósemkowe.
W trybie ścisłym identyfikatory eval i arguments są traktowane jako słowa kluczowe i nie
można im przypisywać wartości. Nie można też deklarować zmiennych, funkcji,
parametrów funkcji ani parametru klauzuli catch o takich nazwach.
W trybie ścisłym możliwości przeglądania stosu wywołań są ograniczone. Próba użycia
właściwości arguments.caller lub arguments.callee skutkuje zgłoszeniem wyjątku
TypeError. Podobny efekt ma próba odczytania właściwości caller i arguments funkcji
zawierających kod ścisły (w niektórych implementacjach powyższe niestandardowe
właściwości są zdefiniowane w funkcjach ze zwykłym kodem).

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.

5.7.1. Deklaracje const, let i var


Deklaracje const, let i var zostały opisane w podrozdziale 3.10. Począwszy od wersji języka
ES6 słowo const służy do deklarowania stałych, a let zmiennych. W starszych wersjach do
deklarowania zmiennych służyło wyłączne słowo var, a stałych nie można było deklarować
wcale. Zasięg zmiennych obejmował całą funkcję, a nie blok zawierający deklarację. Takie
podejście było źródłem błędów. W nowszych wersja języka JavaScript nie ma żadnego powodu,
aby używać słowa var zamiast let. Poniżej przedstawionych jest kilka przykładowych
deklaracji:
const TAU = 2*Math.PI;

let radius = 3;
var circumference = TAU * radius;

5.7.2. Deklaracja function


Deklaracja function służy do definiowana funkcji, które będą szczegółowo opisane w rozdziale
8. (Z tym słowem spotkałeś się już w podrozdziale 4.3, gdzie było częścią wyrażenia
funkcyjnego, a nie deklaracją). Przykładowa deklaracja funkcji wygląda tak:
function area(radius) {
return Math.PI * radius * 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.

5.7.3. Deklaracja class


W wersjach języka ES6 i nowszych deklaracja class tworzy nową klasę i nadaje jej określoną
nazwę. Klasy będą opisane w rozdziale 9. Prosta deklaracja klasy może mieć taką postać:
class Circle {
constructor(radius) { this.r = radius; }
area() { return Math.PI * this.r * this.r; }
circumference() { return 2 * Math.PI * this.r; }
}
Deklaracje klas, w odróżnieniu od funkcji, nie są windowane, a więc nie można użyć klasy w
kodzie poprzedzającym jej deklarację.

5.7.4. Deklaracje import i export


Deklaracje import i export są stosowane razem, aby wartość zdefiniowana w jednym module
była dostępna w innym. Moduł jest plikiem zawierającym kod JavaScript, który ma własną
przestrzeń nazw i jest całkowicie niezależny od innych modułów. Wartość zdefiniowaną w
jednym module można wykorzystywać w innym tylko wtedy, gdy zostanie wyeksportowana ze
źródłowego modułu za pomocą deklaracji export i zaimportowana do docelowego modułu za
pomocą deklaracji import. Modułom będzie poświęcony rozdział 10., a deklaracje import i
export będą szczegółowo opisane w podrozdziale 10.3.
Deklaracja import służy do importowania jednej lub kilku wartości z pliku zawierającego kod
JavaScript i nadawania im nazw w bieżącym module. Deklaracja może przybierać różne formy.
Poniżej przedstawionych jest kilka przykładów:
import Circle from './geometry/circle.js';

import { PI, TAU } from './geometry/constants.js';


import { magnitude as hypotenuse } from './vectors/utils.js';
Wartości zdefiniowane w module są prywatne i nie można ich importować do innych modułów,
chyba że zostaną jawnie wyeksportowane. Do eksportowania służy deklaracja export, które
określa, że jedna lub kilka zmiennych zdefiniowanych w bieżącym module może być
zaimportowanych do innych modułów. Deklaracja export ma więcej odmian niż import. Poniżej
pokazana jest jedna z nich:
// geometry/constants.js
const PI = Math.PI;

const TAU = 2 * PI;


export { PI, TAU };
Czasami słowo kluczowe export jest wykorzystywane jako modyfikator innej deklaracji. W ten
sposób można za jednym razem utworzyć i wyeksportować stałą, zmienną, funkcję lub klasę.
Jeżeli z modułu trzeba wyeksportować tylko jedną wartość, można to zrobić za pomocą
specjalnej deklaracji export default:
export const TAU = 2 * Math.PI;
export function magnitude(x,y) { return Math.sqrt(x*x + y*y); }
export default class Circle { /* Definicja klasy pominięta. */ }

5.8. Podsumowanie instrukcji


W tym rozdziale zostały przedstawione wszystkie instrukcje stosowane w języku JavaScript.
Tabela 5.1 zawiera ich podsumowanie.
Tabela 5.1 . Przeznaczenie instrukcji

Instrukcja Przeznaczenie

Wyjście z najbardziej wewnętrznej pętli, instrukcji switch lub wskazanej


break
instrukcji obejmującej.

case Etykieta w instrukcji switch.

class Deklaracja klasy.

const Deklaracja i inicjalizacja jednej stałej lub ich większej liczby.

Rozpoczęcie następnej iteracji najbardziej wewnętrznej pętli lub


continue
wskazanej za pomocą nazwy.

debugger Pułapka diagnostyczna.

default Domyślna etykieta w instrukcji switch.

do/while Alternatywa dla pętli while.

export Deklaracja wartości, którą można zaimportować w innym module.

for Prosta w użyciu pętla.

for/await Asynchroniczne iterowanie wartości.


for/in Iterowanie nazw właściwości obiektu.

for/of Iterowanie właściwości iterowalnego obiektu, na przykład tablicy.

function Deklaracja funkcji.

if/else Wykonanie jednej z dwóch instrukcji w zależności od wyniku wyrażenia.

import Deklaracja nazw wartości zdefiniowanych w innych modułach.

Przypisanie instrukcji nazwy wykorzystywanej w instrukcji break lub


label
continue.

Deklaracja i inicjalizacja jednej lub kilku zmiennych o zasięgu bloku


let
(nowa składnia).

return Zwrócenie wartości funkcji.

Wielościeżkowa instrukcja warunkowa wykorzystująca etykiety case i


switch
deault.

throw Zgłoszenie wyjątku.

try/catch/finally Obsługa wyjątku i porządkowanie danych.

"use strict" Zastosowanie trybu ścisłego w skrypcie lub funkcji.

var Deklaracja i inicjalizacja jednej lub kilku zmiennych (stara składnia).

while Podstawowa pętla.

Rozszerzenie łańcucha zasięgu (przestarzała instrukcja, niedozwolona


with
w trybie ścisłym).

Zwrócenie iterowanej wartości. Instrukcja stosowana tylko w funkcjach


yield
generatorów.

[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.

6.1. Wprowadzenie do obiektów


Obiekt jest strukturą złożoną z wartości prymitywnych i innych obiektów, do których można
odwoływać się za pomocą nazw. Jest to kolekcja właściwości ułożonych bez określonego
porządku, z których każda ma nazwę i wartość. Nazwy właściwości są zazwyczaj ciągami
znaków, aczkolwiek, jak się przekonasz w punkcie 6.10.3, mogą być również symbolami. Można
więc powiedzieć, że obiekt wiąże ciągi znaków z wartościami. Tego rodzaju powiązania określa
się różnymi terminami. Prawdopodobnie znasz jedną z fundamentalnych struktur danych o
nazwie „hash”, „tablica mieszająca”, „słownik” lub „tablica asocjacyjna”. Obiekt jednak bardziej
przypomina prostą mapę „ciąg znaków-wartość”. Oprócz tego, że ma własne właściwości, może
dziedziczyć właściwości po innym obiekcie, tzw. prototypie. Metody obiektu są zazwyczaj
odziedziczonymi właściwościami, a „dziedziczenie prototypowe” jest jedną z kluczowych
funkcjonalności języka JavaScript.
Obiekty w języku JavaScript są dynamiczne, tzn. ich właściwości można dodawać i usuwać.
Mogą też funkcjonować tak jak obiekty statyczne lub struktury w językach statycznie
typowanych, a także reprezentować zbiory ciągów znaków, jeżeli pominie się wartości w
powiązaniach ciągi znaków-wartości.
W języku JavaScript obiektem jest każda wartość, która nie jest ciągiem znaków, liczbą,
symbolem, wartością logiczną, null i undefined. Natomiast ciągi znaków, liczby i listingi
logiczne mogą funkcjonować jak niemutowalne obiekty.
Jak pamiętasz z podrozdziału 3.8, obiekty są mutowalne i operuje się na ich referencjach, a nie
wartościach. Załóżmy, że zmienna x odwołuje się do obiektu. Instrukcja let y = x; powoduje, że
zmiennej y jest przypisywana referencja do tego obiektu, a nie sam obiekt. W efekcie wszystkie
modyfikacje wprowadzone w zmiennej y są odzwierciedlane w zmiennej x.

Najczęściej wykonywane operacje na obiektach to tworzenie, odpytywanie, usuwanie,


sprawdzanie i wyliczanie ich właściwości. Te fundamentalne czynności są opisane na początku
tego rozdziału. W kolejnych podrozdziałach poruszone są bardziej zaawansowane tematy.

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.

Każda właściwość, oprócz nazwy i wartości, posiada trzy atrybuty:

atrybut „zapisywalna” określający, czy właściwości można przypisywać wartość;


atrybut „wyliczalna” określający, czy nazwa właściwości może być odczytywana za
pomocą pętli for/in;
atrybut „konfigurowalna” określający, czy właściwość można usunąć oraz czy można
zmieniać jej atrybuty.

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.

6.2. Tworzenie obiektów


Obiekt można utworzyć za pomocą literału obiektowego, słowa kluczowego new lub funkcji
Object.create(). W kolejnych punktach opisane są poszczególne techniki.

6.2.1. Literały obiektowe


Najprościej obiekt tworzy się za pomocą literału. Literał w najbardziej podstawowej postaci jest
listą oddzielonych przecinkami par nazwa:wartość umieszczoną w nawiasach klamrowych.
Nazwa właściwości jest identyfikatorem lub literałem tekstowym (dopuszczalny jest też pusty
ciąg znaków). Wartością właściwości jest dowolne wyrażenie, wartość prymitywna lub obiekt.
Poniżej przedstawionych jest kilka przykładów:

let empty = {}; // Obiekt bez właściwości.

let point = { x: 0, y: 0 }; // Dwie właściwości liczbowe.

let p2 = { x: point.x, y: point.y+1 }; // Bardziej złożone wartości.


let book = {

"main title": "JavaScript", // Nazwy właściwości zawierają spację

"sub-title": "Komletny przewodnik", // i myślnik, więc muszą być


literałami tekstowymi.

for: "dla każdego", // For jest zarezerwowanym słowem, nie


można go umieszczać

// 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.

6.2.2. Tworzenie obiektów za pomocą operatora new


Operator new tworzy obiekt i inicjuje go. Po operatorze umieszcza się nazwę funkcji zwaną
konstruktorem, która inicjuje nowo utworzony obiekt. Wbudowane obiekty mają następujące
konstruktory:

let o = new Object(); // Utworzenie pustego obiektu. To samo co {}.

let a = new Array(); // Utworzenie pustej tablicy. To samo co [].


let d = new Date(); // Utworzenie obiektu typu Date reprezentującego
aktualny czas.

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.

Dziedziczenie właściwości będzie opisane w punkcie 6.3.2. W rozdziale 9. poznasz dokładniej


zależności pomiędzy prototypami a konstruktorami. Dowiesz się, jak za pomocą konstruktora i
właściwości prototype definiuje się nowe klasy obiektów. Właściwość ta jest wykorzystywana
przez instancje klasy tworzone za pomocą takiego konstruktora. W podrozdziale 14.3 nauczysz
się odpytywać i zmieniać prototyp obiektu.

6.2.4. Funkcja Object.create()


Funkcja Object.create() tworzy nowy obiekt. Jej pierwszym argumentem jest prototyp
obiektu:

let o1 = Object.create({x: 1, y: 2}); // Obiekt o1 dziedziczy właściwości


x i y.
o1.x + o1.y // => 3

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 +:

let o2 = Object.create(null); // Obiekt o2 nie dziedziczy żadnych


właściwości ani metod.

Aby utworzyć zwykły, pusty obiekt, podobny do utworzonego za pomocą literału {} lub
instrukcji new Object(), należy użyć argumentu Object.prototype:

let o3 = Object.create(Object.prototype); // Obiekt o3 jest podobny do


utworzonego za pomocą {} lub Object().

Możliwość tworzenia nowych obiektów na podstawie dowolnych prototypów jest bardzo


przydatna. W tym rozdziale funkcja Object.create() będzie jeszcze wykorzystywana
wielokrotnie. Funkcja ta ma także drugi, opcjonalny argument opisujący właściwości
tworzonego obiektu. Jest to zaawansowana funkcjonalność, która będzie opisana w
podrozdziale 14.1.

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:

let o = { x: "Nie zmieniaj tej właściwości." };

library.function(Object.create(o)); // Zabezpieczenie przed przypadkowymi


modyfikacjami.
Aby zrozumieć działanie tego mechanizmu, musisz najpierw dowiedzieć się, jak są odpytywane
i ustawiane właściwości. Tym zagadnieniom są poświęcone kolejne podrozdziały.

6.3. Odpytywanie i ustawianie


właściwości
Aby odczytać wartość właściwości, należy użyć jednego z dwóch operatorów opisanych w
podrozdziale 4.4: kropki (.) lub nawiasów kwadratowych ([]). Po lewej stronie operatora musi
znajdować się wyrażenie, którego wartością jest obiekt. W przypadku użycia kropki po jej
prawej stronie musi znajdować się prosty identyfikator właściwości (nazwa). Jeżeli stosowane
są nawiasy, wewnątrz nich musi znajdować się wyrażenie, którego wartością jest ciąg znaków
reprezentujący nazwę właściwości. Poniżej pokazanych jest kilka przykładów:
let author = book.author; // Odczytanie wartości właściwości "author"
obiektu book.

let name = author.surname; // Odczytanie wartości właściwości "surname"


obiektu author.

let title = book["main title"]; // Odczytanie wartości właściwości "main


title" obiektu book.

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:

book.edition = 7; // Utworzenie właściwości "edition"


obiektu book.

book["main title"] = "ECMAScript"; // Zmiana wartości właściwości "main


title".

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.

6.3.1. Obiekty jako tablice asocjacyjne


Jak wyjaśniłem w poprzednim podrozdziale, dwa poniższe wyrażenia mają tę samą wartość:

obiekt.właściwość

obiekt["właściwość"]

Pierwsza składnia, z kropką i identyfikatorem, jest podobna do stosowanej w językach C i Java


do odwoływania się do statycznych pól struktur i obiektów. Natomiast druga, z nawiasami
kwadratowymi, jest podobna do wyrażenia odwołującego się do elementu tablicy. Równica
jednak polega na tym, że zamiast liczbowego indeksu stosuje się ciąg znaków. Jest to tzw.
tablica asocjacyjna (inne pojęcia to „mapa” lub „słownik”). W języku JavaScript obiekty są
tablicami asocjacyjnymi. W tym punkcie dowiesz się, dlaczego jest to ważne.
W językach silnie typowanych, np. C, C++ i Javie, obiekt ma stałą liczbę właściwości, których
nazwy muszą być zdefiniowane na etapie pisania kodu. JavaScript jest językiem luźno
typowanym, więc powyższe zasady w nim nie obowiązują. W kodzie można tworzyć w każdym
obiekcie dowolne właściwości. Aby odwołać się do właściwości za pomocą kropki, nazwa
właściwości musi być poprawnym identyfikatorem. Identyfikator musi być literałem. Nie jest to
typ danych, więc nie można go modyfikować w kodzie.

Natomiast w odwołaniu z nawiasami kwadratowymi nazwa właściwości jest ciągiem znaków.


Ciąg jest typem danych, więc można go tworzyć i modyfikować w kodzie. Na przykład
poprawny jest następujący kod:

let addr = "";

for(let i = 0; i < 4; i++) {

addr += customer[`address${i}`] + "\n";

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

let total = 0.0;


for(let stock in portfolio) { // Dla każdej akcji w portfelu:

let shares = portfolio[stock]; // odczytaj ich liczbę,

let price = getQuote(stock); // pobierz cenę,

total += shares * price; // dodaj wartość do sumy.

return total; // Zwróć sumę.

Obiekty są w języku JavaScript powszechnie stosowane w charakterze tablic asocjacyjnych, jak


w powyższym przykładzie. Dlatego ważne jest poznanie ich funkcjonowania. Jednak w wersjach
języka ES6 i nowszych zazwyczaj lepszą praktyką jest stosowania klasy Map, która będzie
opisana w punkcie 11.1.2.

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:

let o = {}; // Obiekt o dziedziczy metody po obiekcie


Object.prototype.
o.x = 1; // Od teraz ma również własną właściwość x.

let p = Object.create(o); // Obiekt p dziedziczy właściwości po obiektach o i


Object.prototype.

p.y = 2; // Od teraz ma również własną właściwość y.

let q = Object.create(p); // Obiekt q dziedziczy właściwości po obiektach p,


o…

q.z = 3; // … i Object.prototype, jak również ma własną


właściwość z.
let f = q.toString(); // Metoda toString() jest dziedziczona po obiekcie
Object.prototype.
q.x + q.y // => 3; właściwości x i y są dziedziczone po
obiektach o i p.

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:

let unitcircle = { r: 1 }; // Obiekt, po którym są dziedziczone


właściwości.
let c = Object.create(unitcircle); // Obiekt c dziedziczy właściwość r.

c.x = 1; c.y = 1; // W obiekcie c są definiowane dwie własne


właściwości.

c.r = 2; // W obiekcie c jest nadpisywana


odziedziczona właściwość.
unitcircle.r // => 1: prototyp nie jest modyfikowany.

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.

6.3.3. Błędy dostępu do właściwości


Niektóre wyrażenia odwołujące się do właściwości nie zwracają ani nie przypisują wartości. W
tym punkcie opisane są operacje odpytywania i ustawiania właściwości, które mogą zakończyć
się niepowodzeniem.
Próba odpytania nieistniejącej właściwości nie jest błędem. Jeżeli obiekt o nie ma własnej ani
odziedziczonej właściwości x, to odwołujące się do niej wyrażenie ma wartość undefined. Na
przykład obiekt book ma właściwość o nazwie sub-title, ale nie o nazwie subtitle:
book.subtitle // => undefined: właściwość nie istnieje.

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:

let len = book.subtitle.length; // !TypeError: wartość undefined nie ma


właściwości length.

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:

// Rozbudowana, jawna technika.


let surname = undefined;

if (book) {
if (book.author) {

surname = book.author.surname;
}
}

// Zwięzła, idiomatyczna technika uzyskania wartości właściwości surname,


null lub undefined.

surname = book && book.author && book.author.surname;


Aby zrozumieć, dlaczego to idiomatyczne wyrażenie zapobiega zgłoszeniu wyjątku TypeError,
przypomnij sobie opisaną w punkcie 4.10.1 zasadę krótkiego zwarcia, zgodnie z którą działa
operator &&.
Za pomocą wprowadzonego w wersji języka ES2020 operatora warunkowego dostępu do
właściwości ?. (patrz punkt 4.4.1) można powyższe wyrażenie napisać w następujący sposób:

let surname = book?.author?.surname;


Próba przypisania właściwości wartości null lub undefined również powoduje zgłoszenie
wyjątku TypeError. Ponadto przypisanie innych wartości nie zawsze kończy się pomyślnie. Na
przykład nie można ustawić właściwości przeznaczonej tylko do odczytu. Ponadto w niektórych
obiektach nie można tworzyć nowych właściwości. W trybie ścisłym (patrz punkt 5.6.3) każda
nieudana próba ustawienia właściwości powoduje zgłoszenie wyjątku TypeError. W zwykłym
trybie tego rodzaju błędy zazwyczaj nie są sygnalizowane.
Reguły udanego i nieudanego ustawienia właściwości są intuicyjne, ale dość trudno jest je
precyzyjnie opisać. Próba ustawienia właściwości p obiektu o kończy się niepowodzeniem w
następujących przypadkach:

Gdy obiekt o posiada własną właściwość p przeznaczoną tylko do odczytu. Takim


właściwościom nie można przypisywać wartości.
Gdy obiekt o posiada odziedziczoną właściwość p przeznaczoną tylko do odczytu.
Odziedziczonej właściwości przeznaczonej tylko do odczytu nie można przesłonić własną
właściwością o takiej samej nazwie.
Gdy obiekt o nie ma własnej właściwości o nazwie p, nie dziedziczy właściwości p z
setterem, a jego atrybut rozszerzalności ma wartość false (patrz podrozdział 14.2). W
takim przypadku ustawienie właściwości o nazwie p oznacza konieczność jej utworzenia.
Ponieważ jednak obiekt o jest nierozszerzalny, nie można w nim tworzyć nowych
właściwości.

6.4. Usuwanie właściwości


Operator delete (patrz punkt 4.13.4) usuwa właściwość z obiektu. Jego jedynym operandem
jest wyrażenie opisujące właściwość. Ważne jest, że operator działa na nazwie właściwości, a
nie na jej wartości:
delete book.author; // Obiekt book nie ma już właściwości o nazwie
author.
delete book["main title"]; // Nie ma też właściwości o nazwie "main title".

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:

let o = {x: 1}; // Obiekt o ma własną właściwość x i dziedziczy właściwość


toString.

delete o.x // => true: usunięcie właściwości x.


delete o.x // => true: nic się nie dzieje (właściwość x nie
istnieje), ale operator zwraca

// wartość true.
delete o.toString // => true: nic się nie dzieje (właściwość toString jest
odziedziczona).

delete 1 // => true: nielogiczna operacja, ale operator zwraca


wartość true.

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 Object.prototype // => false: właściwość jest niekonfigurowalna.


var x = 1; // Deklaracja zmiennej globalnej.

delete globalThis.x // => false: nie można usunąć tej właściwości.


function f() {} // Deklaracja funkcji globalnej.

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:

globalThis.x = 1; // Utworzenie konfigurowalnej właściwości obiektu


globalnego (bez użycia deklaracji let i var).
delete x // => true: tę właściwość można usunąć.

Natomiast jeżeli w trybie ścisłym operandem operatora delete jest niekwalifikowany


identyfikator, na przykład x, to jest zgłaszany wyjątek SyntaxError. Aby go uniknąć, należy
jawnie odwołać się do właściwości:

delete x; // Wyjątek SyntaxError w trybie ścisłym.


delete globalThis.x; // Poprawna instrukcja.

6.5. Sprawdzanie właściwości


Obiekty w języku JavaScript można traktować jako zestawy właściwości. Przydatną operacją
jest sprawdzanie, czy zestaw zawiera określony element, tj. czy obiekt zawiera właściwość o
zadanej nazwie. W tym celu można użyć operatora in, metody hasOwnProperty() lub
propertyIsEnumerable() albo po prostu odpytać właściwość. W poniższych przykładach
wszystkie nazwy są ciągami znaków, ale mogą być również symbolami (patrz punkt 6.10.3).

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

"x" in o // => true: obiekt o ma własną właściwość o nazwie "x".


"y" in o // => false: obiekt o nie ma właściwości o nazwie "y".

"toString" in o // => true: obiekt o dziedziczy właściwość toString.


Metoda hasOwnProperty()sprawdza, czy obiekt, do którego należy, ma własną właściwość o
podanej nazwie. Jeżeli jest to właściwość odziedziczona, metoda zwraca wynik false:

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ą.

Metoda propertyIsEnumerable() sprawdza właściwości bardziej dokładnie niż


hasOwnProperty(). Zwraca wynik true, jeżeli dana właściwość jest własna, a jej atrybut
„wyliczalna” ma wartość true. Niektóre wbudowane właściwości nie są wyliczalne. Wyliczalne
są właściwości utworzone za pomocą zwykłego kodu, o ile nie zastosuje się opisanych w
podrozdziale 14.1 technik tworzenia niewyliczalnych właściwości.
let o = { x: 1 };

o.propertyIsEnumerable("x") // => true: obiekt o ma własną wyliczalną


właściwość o nazwie "x".

o.propertyIsEnumerable("toString") // => false: to nie jest własna


właściwość obiektu.
Object.prototype.propertyIsEnumerable("toString") // => false: właściwość nie
jest wyliczalna.
Często w celu sprawdzenia, czy właściwość nie jest zdefiniowana, można zamiast operatora in
użyć !==:

let o = { x: 1 };
o.x !== undefined // => true: obiekt o ma właściwość o nazwie "x".

o.y !== undefined // => false: obiekt o nie ma właściwości o nazwie


"y".
o.toString !== undefined // => true: obiekt o dziedziczy właściwość toString.

Operator in ma tę przewagę nad opisanymi prostymi technikami dostępu do właściwości, że


pozwala sprawdzić, czy dana właściwość nie istnieje, czy też została jej przypisana wartość
undefined. Przeanalizujmy poniższy kod:

let o = { x: undefined }; // Właściwości jest jawnie przypisana wartość


undefined.
o.x !== undefined // => false: właściwość istnieje, ale ma wartość
undefined.
o.y !== undefined // => false: właściwość nie istnieje.

"x" in o // => true: właściwość istnieje.


"y" in o // => false: właściwość nie istnieje.
delete o.x; // Usunięcie właściwości x.

"x" in o // => false: właściwość już nie istnieje.

6.6. Wyliczanie właściwości


Czasami trzeba nie tylko sprawdzać, czy istnieje właściwość o zadanej nazwie, ale również
tworzyć listę wszystkich właściwości obiektu. Można to osiągnąć na kilka sposobów.
W punkcie 5.4.5 opisałem pętlę for/in. Za jej pomocą można uruchamiać fragment kodu jeden
raz dla każdej właściwości obiektu (własnej i odziedziczonej). Nazwa właściwości jest
przypisywana zmiennej. Wbudowanych, odziedziczonych metod nie można wyliczać, ale
właściwości dodane za pomocą zwykłego kodu są domyślnie wyliczalne, na przykład:
let o = {x: 1, y: 2, z: 3}; // Trzy własne, wyliczalne właściwości.

o.propertyIsEnumerable("toString") // => false: metoda niewyliczalna.


for(let p in o) { // Iterowanie właściwości.

console.log(p); // Wyświetlenie właściwości x, y i z,


ale nie metody toString().
}

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) {

if (!o.hasOwnProperty(p)) continue; // Pominięcie odziedziczonych


właściwości.
}

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:

Object.keys() zwracającej tablicę własnych, wyliczalnych właściwości. Tablica nie


zawiera właściwości niewyliczalnych, odziedziczonych oraz tych, których nazwami są
symbole (patrz punkt 6.10.3).
Object.getOwnPropertyNames() działającej podobnie do Object.keys(). Różnica polega
na tym, że zwracana przez nią tablica zawiera również nazwy niewyliczalnych własnych
właściwości, o ile nie są one symbolami.
Object.getOwnPropertySymbols() zwracającej nazwy własnych właściwości,
wyliczalnych i niewyliczalnych, które są symbolami.
Reflect.ownKeys() zwracającej nazwy wszystkich właściwości, wyliczalnych i
niewyliczalnych, które są ciągami znaków i symbolami (patrz podrozdział 14.6).

W podrozdziale 6.7 będą opisane przykłady użycia funkcji Object.keys() z pętlą for/of.

6.6.1. Kolejność wyliczania właściwości


W wersji języka ES6 została formalnie zdefiniowana kolejność wyliczania własnych właściwości
obiektu. Funkcje Object.keys(), Object.getOwnPropertyNames(),
Object.getOwnPropertySymbols(), Reflect.ownKeys(), jak również metody takie jak
JSON.stringify() zwracają właściwości w opisanej niżej kolejności z podziałem na
właściwości wyliczalne i niewyliczalne oraz nazwy będące ciągami znaków i symbolami:

Najpierw są zwracane właściwości, których nazwy są ciągami znaków reprezentującymi


nieujemne liczby całkowite, w kolejności od najmniejszych do największych. Oznacza to,
że właściwości tablic i obiektów podobnych do tablic są wyliczane według ich indeksów.
Po właściwościach przypominających indeksy wyliczane są pozostałe właściwości, których
nazwy są ciągami znaków, również tych, które reprezentują liczby ujemne i
zmiennoprzecinkowe. Właściwości te są wyliczane w kolejności ich utworzenia.
Właściwości utworzone za pomocą literału obiektowego są wyliczane w takiej kolejności,
w jakiej zostały umieszczone w literale.
Na końcu wyliczane są właściwości, których nazwy są symbolami, w kolejności ich
utworzenia.

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.

6.7. Rozszerzanie obiektów


Często pojawia się potrzeba kopiowania właściwości jednego obiektu do innego. Można to łatwo
osiągnąć za pomocą następującego kodu:
let target = {x: 1}, source = {y: 2, z: 3};

for(let key of Object.keys(source)) {


target[key] = source[key];

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

for(let source of sources) {


for(let key of Object.keys(source)) {
if (!(key in target)) { // Tu jest różnica w porównaniu z funkcją
Object.assign().
target[key] = source[key];

}
}
}
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.

6.8. Serializacja obiektów


Serializacja obiektu to proces przekształcania go w ciąg znaków, który później można z
powrotem przekształcić w obiekt. Służą do tego celu, odpowiednio, funkcje: JSON.stringify()
i JSON.parse() wykorzystujące format JSON (ang. JavaScript Object Notation, zapis obiektów
JavaScript), bardzo podobny do formatu literałów obiektowych i tablicowych:
let o = {x: 1, y: {z: [false, null, ""]}}; // Definicja przykładowego
obiektu.

let s = JSON.stringify(o); // s == '{"x":1,"y":{"z":[false,null,""]}}'


let p = JSON.parse(s); // p == {x: 1, y: {z: [false, null, ""]}}
Format JSON jest częścią składni języka JavaScript. Nie można jednak za jego pomocą
reprezentować wszystkich zmiennych. Można serializować i odtwarzać obiekty, tablice, ciągi
znaków, liczby, wartości true, false i null. Wartości NaN, Infinity i -Infinity są zamieniane
na null. Daty są serializowane do postaci ciągów znaków w formacie ISO (funkcja
Date.toJSON()), jednak funkcja JSON.parse() pozostawia takie ciągi w oryginalnej formie i nie
odtwarza obiektu Date. Nie można serializować funkcji, obiektów RegExp i Error oraz wartości
undefined. Funkcja JSON.stringify() serializuje tylko własne, wyliczalne właściwości
obiektów. Właściwości, których wartości nie można serializować, są pomijane. Zarówno funkcja
JSON.stringify(), jak i JSON.parse() posiada opcjonalny drugi argument umożliwiający
dostosowywanie procesu serializacji i odtwarzania obiektów. W tym argumencie można na
przykład umieszczać listę właściwości, które mają być serializowane, lub wartości, które mają
być przekształcane w procesie serializacji i odtwarzania. Pełny opis tych funkcji będzie podany
w podrozdziale 11.6.

6.9. Metody obiektów


Jak wspomniałem wcześniej, wszystkie obiekty, z wyjątkiem utworzonych jawnie bez prototypu,
dziedziczą właściwości po obiekcie Object.prototype. Dziedziczonymi właściwościami są
przede wszystkim metody, które ze względu na swoją uniwersalną dostępność są dla
programistów szczególnie atrakcyjne. Poznałeś już na przykład metody hasOwnProperty() i
propertyIsEnumerable(), jak również kilka statycznych funkcji, m.in. Object.create() i
Object.keys(), zdefiniowanych w konstruktorze obiektu Object. W tym podrozdziale
opisanych jest kilka uniwersalnych metod zdefiniowanych w obiekcie Object.prototype, które
zastępuje się innymi, bardziej wyspecjalizowanymi implementacjami. W kolejnych punktach
pokazane są przykłady definiowania takich metod. W rozdziale 9. dowiesz się, jak definiować
własne metody w bardziej ogólny sposób dla całej klasy obiektów.

6.9.1. Metoda toString()


Metoda toString() nie ma argumentów. Zwraca ciąg znaków, który w pewien sposób
reprezentuje wartość danego obiektu. Jest wywoływana wszędzie tam, gdzie obiekt musi być
zamieniony na ciąg znaków, na przykład w wyrażeniu łączącym ciągi za pomocą operatora + lub
w argumencie tekstowym innej metody.
Domyślny wynik zwracany przez metodę toString() zawiera niewiele informacji. Można na
jego podstawie jedynie określić klasę obiektu, o czym przekonasz się w punkcie 14.4.3. Na
przykład wynikiem wykonania poniższego kodu jest ciąg "[object Object]":
let s = { x: 1, y: 1 }.toString(); // s == "[object Object]"
Ponieważ domyślnie metoda toString() zwraca nieszczególnie przydatne informacje, wiele
klas definiuje swoje własne wersje tej metody. Na przykład tablica jest przekształcana w ciąg
zawierający listę jej elementów, a funkcja w ciąg zawierający jej kod źródłowy. Własną metodę
toString() definiuje się w następujący sposób:

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.

6.9.2. Metoda toLocaleString()


Wszystkie obiekty mają oprócz toString()metodę toLocaleString(), która zwraca
reprezentację obiektu zgodną z ustawieniami regionalnymi. Domyślna metoda
toLocaleString() obiektu Object nie uwzględnia żadnych informacji regionalnych, po prostu
wywołuje metodę toString() i zwraca jej wynik. Klasy Date i Number mają własne metody
toLocaleString(), które formatują daty, godziny i liczby zgodnie z ustawieniami regionalnymi.
Klasa Array definiuje metodę toLocaleString(), która działa podobnie jak toString(), tylko
do formatowania elementów tablicy wykorzystuje metodę toLocaleString(), a nie
toString(). W obiekcie point metodę tę można zdefiniować w następujący sposób:
let point = {
x: 1.1,
y: 2.2,
toString: function() { return `(${this.x}, ${this.y})`; },

toLocaleString: function() {
return `(${this.x.toLocaleString()}, ${this.y.toLocaleString()})`;
}
};

point.toString() // => "(1.1, 2.2)"


point.toLocaleString() // => "(1,1, 2,2)": zwróć uwagę na symbol dziesiętny.
Podczas implementowania metody toLocaleString() przydają się klasy internacjonalizacyjne,
które będą opisane w podrozdziale 11.7.

6.9.3. Metoda valueOf()


Metoda valueOf() jest bardzo podobna do toString(). Jest wywoływana wtedy, gdy obiekt
musi być przekształcony w wartość prymitywną inną niż ciąg znaków, zazwyczaj w liczbę.
Wywołanie następuje automatycznie, jeżeli obiekt jest użyty w miejscu, w którym wymagana
jest wartość prymitywna. Domyślna metoda nie robi niczego ciekawego. Niektóre wbudowane
klasy definiują własne wersje metody valueOf(). Na przykład w klasie Date metoda ta
przekształca datę w liczbę, dzięki czemu daty można porównywać chronologicznie za pomocą
operatorów < i >. Podobnie można rozbudować obiekt point, definiując w nim metodę
zwracającą odległość punktu od początku układu współrzędnych:

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

6.9.4. Metoda toJSON()


Obiekt Object.prototype nie ma metody toJSON(), ale ma JSON.stringify() (patrz
podrozdział 6.8), która szuka metody toJSON() obiektu, który ma być poddany serializacji.
Jeżeli ją znajdzie, wywołuje ją i serializuje zwrócony przez nią wynik. Na przykład metoda
toJSON() klasy Date (patrz podrozdział 11.4) zwraca reprezentujący datę ciąg znaków, który
można serializować. Podobną metodę można zaimplementować w obiekcie point w następujący
sposób:
let point = {
x: 1,

y: 2,
toString: function() { return `(${this.x}, ${this.y})`; },
toJSON: function() { return this.toString(); }
};
JSON.stringify([point]) // => '["(1, 2)"]'

6.10. Udoskonalona składnia literału


obiektowego
W najnowszych wersjach języka JavaScript składnia literału obiektowego została wzbogacona o
kilka użytecznych funkcjonalności, opisanych w poniższych przykładach.

6.10.1. Uproszczone definiowanie właściwości


Załóżmy, że mamy zmienne x i y zawierające pewne wartości i chcemy utworzyć obiekt z
właściwościami o nazwach i wartościach takich jak powyższe zmienne. Aby to zrobić, stosując
zwykłą składnię literału obiektowego, należałoby użyć każdego identyfikatora dwukrotnie:

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

6.10.2. Wyliczane nazwy właściwości


Czasami trzeba utworzyć obiekt zawierający określoną właściwość, której nazwa nie jest
zawczasu znana, ponieważ zawiera ją zmienna lub wynik zwracany przez funkcję. Stosując
zwykłą składnię literału obiektowego, nie da się tego celu osiągnąć. Trzeba najpierw utworzyć
obiekt, a w następnym kroku dodać do niego żądaną właściwość:
const PROPERTY_NAME = "p1";
function computePropertyName() { return "p" + 2; }
let o = {};
o[PROPERTY_NAME] = 1;

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";

function computePropertyName() { return "p" + 2; }


let p = {
[PROPERTY_NAME]: 1,
[computePropertyName()]: 2

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

6.10.3. Symbole jako nazwy właściwości


Wyliczane nazwy właściwości są bardzo ważne z jeszcze jednego powodu. W wersjach języka
ES6 i nowszych nazwy mogą być ciągami znaków i symbolami. Stosując wyliczane nazwy
właściwości, można definiować właściwości obiektu z wykorzystaniem stałych lub zmiennych
zawierających symbole:
const extension = Symbol("mój symbol");
let o = {

[extension]: { /* Dane rozszerzenia zapisane w obiekcie. */ }


};
o[extension].x = 0; // Symbol nie zakłóca funkcjonowania innych właściwości
obiektu o.
Jak wyjaśniłem w podrozdziale 3.6, symbole są nietypowymi wartościami. Można ich używać
tylko w charakterze nazw właściwości. Każdy symbol jest inny, co oznacza, że można je
stosować do tworzenia unikatowych nazw. W tym celu należy wywołać funkcję Symbol()
(symbol jest wartością prymitywną, a nie obiektem, więc Symbol() nie jest konstruktorem,
którego można używać z operatorem new). Zwrócony wynik nie jest równy żadnemu innemu
symbolowi ani żadnej innej wartości. W argumencie funkcji można umieścić ciąg znaków, który
zostanie wykorzystany przy przekształceniu symbolu w ciąg. Jednak tę funkcjonalność
wykorzystuje się wyłącznie do celów diagnostycznych. Dwa symbole utworzone na podstawie
tego samego ciągu różnią się od siebie.
Symbole stanowią mechanizm bezpiecznego rozszerzenia obiektów. Jeżeli na przykład do
zewnętrznego obiektu, nad którym nie ma się pełnej kontroli, trzeba dodać właściwość w taki
sposób, aby nie kolidowała z istniejącymi wartościami, można użyć symbolu. W ten sposób
uzyskuje się również pewność, że zewnętrzny kod nie zmieni utworzonej tak właściwości.
Oczywiście taki kod może wykorzystywać funkcję Object.getOwnPropertySymbols()
zwracającą symbole, a następnie modyfikować i usuwać zdefiniowane w ten sposób
właściwości. Dlatego symbole nie stanowią mechanizmu bezpieczeństwa.

6.10.4. Operator rozciągania


W wersjach języka ES2018 i nowszych można kopiować właściwości istniejącego obiektu do
nowego, umieszczając w literale operator rozciągania (...):
let position = { x: 0, y: 0 };
let dimensions = { width: 100, height: 75 };

let rect = { ...position, ...dimensions };


rect.x + rect.y + rect.width + rect.height // => 175
Powyższy kod „rozciąga” właściwości obiektów position i dimensions w literale definiującym
obiekt rect, tj. wpisuje je wewnątrz nawiasów klamrowych. Należy pamiętać, że nie jest to
operator w ścisłym znaczeniu tego słowa. Jest to szczególnego rodzaju składnia, którą można
stosować tylko w literałach obiektowych. W rzeczywistości wielokropek jest wykorzystywany w
innym kontekście i do innych celów. Opisana interpolacja jednego obiektu w innym jest możliwa
tylko w literale obiektowym.
Jeżeli obiekty rozciągany i docelowy mają właściwości o takich samych nazwach, przyjmowana
jest wartość tej drugiej:
let o = { x: 1 };

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.

6.10.5. Uproszczone definiowanie metod


Funkcja zdefiniowana jako właściwość obiektu nosi nazwę metody (więcej o metodach dowiesz
się w rozdziałach 8. i 9.). W wersjach języka starszych niż ES6 metody definiowało się w literale
obiektowym tak samo jak właściwości, wykorzystując wyrażenia funkcyjne:
let square = {
area: function() { return this.side * this.side; },
side: 10

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

const METHOD_NAME = "m";


const symbol = Symbol();
let weirdMethods = {
"metoda ze spacjami"(x) { return x + 1; },

[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.

6.10.6. Gettery i settery


Wszystkie omówione w tym rozdziale właściwości miały nazwy i zawierały wartości. W języku
JavaScript można definiować właściwości dostępowe, które nie mają wartości, tylko jedną lub
dwie metody — gettera i settera.
Podczas odpytywania właściwości dostępowej wywoływany jest getter bez argumentów.
Zwrócony przez niego wynik jest wartością wyrażenia dostępu do właściwości. Podczas
przypisywania wartości wywoływany jest setter, w którego argumencie jest umieszczana
wartość znajdująca się po prawej stronie operatora przypisania. Setter jest w pewnym sensie
odpowiedzialny za przypisywanie wartości właściwości. Zwracany przez niego wynik jest
pomijany.
Właściwość, która ma settera i gettera, jest typu „odczyt/zapis”. Jeżeli właściwość ma tylko
gettera, jest typu „tylko odczyt”, a jeżeli tylko settera — „tylko zapis” (tej cechy nie mają zwykłe
właściwości z danymi). Próba odczytania właściwości „tylko zapis” skutkuje uzyskaniem wyniku
undefined.

Właściwości dostępowe definiuje się za pomocą rozszerzonej składni literału obiektowego


(gettery i settery zostały wprowadzone w wersji języka ES5, tj. przed opisanymi wcześniej
rozszerzeniami w wersji ES6):
let o = {
// Zwykła właściwość z danymi.

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.

Zdefiniowane wyżej metody dostępowe jedynie odczytują i przypisują wartość innej


właściwości, a więc tak naprawdę nie ma powodów, aby je definiować zamiast zwykłej
właściwości z danymi. Poniżej przedstawiony jest ciekawszy przykład obiektu reprezentującego
punkt w dwuwymiarowym kartezjańskim układzie współrzędnych. Obiekt ten ma zwykłe
właściwości zawierające współrzędne x i y, jak również właściwości dostępowe reprezentujące
współrzędne biegunowe:

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) {

let oldvalue = Math.hypot(this.x, this.y);


let ratio = newvalue/oldvalue;
this.x *= ratio;
this.y *= ratio;
},

// Właściwość theta jest typu "tylko odczyt", ponieważ posiada wyłącznie


gettera.
get theta() { return Math.atan2(this.y, this.x); }
};
p.r // => Math.SQRT2

p.theta // => Math.PI / 4


Zwróć uwagę na użyte w powyższym przykładzie słowo kluczowe this. Obie metody są
wywoływane w odniesieniu do obiektu, w którym są zdefiniowane. Oznacza to, że this
odwołuje się do obiektu p. Zatem getter właściwości r może odwoływać się do właściwości x i y
za pomocą wyrażeń this.x i this.y. Metody i słowo kluczowe this będą dokładniej opisane w
punkcie 8.2.2.

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.

q.r // => 5: odziedziczone właściwości dostępowe


funkcjonują poprawnie.
q.theta // => Math.atan2(4, 3)
W powyższym kodzie właściwości dostępowe definiują interfejs API oferujący dwie
reprezentacje jednego zestawu danych (współrzędne kartezjańskie i biegunowe). Inne
zastosowanie właściwości dostępowych to sprawdzanie poprawności przypisywanych wartości
oraz zwracanie różnych wartości przy każdym odczycie:
// Ten obiekt generuje serię kolejnych liczb.
const serialnum = {
// Ta zwykła właściwość zawiera kolejną liczbę.
// Symbol podkreślenia w nazwie właściwości oznacza, że służy ona tylko do
użytku wewnętrznego.
_n: 0,
// Zwrócenie bieżącej wartości i powiększenie jej.
get next() { return this._n++; },
// Przypisanie właściwości n nowej wartości, pod warunkiem, że jest większa
niż bieżąca.
set next(n) {

if (n > this._n) this._n = n;


else throw new Error("przypisywana wartość musi być większa od
bieżącej");
}
};

serialnum.next = 10; // Ustawienie początkowej wartości.


serialnum.next // => 10
serialnum.next // => 11: za każdym razem odczytywana jest inna
wartość.
Na koniec jeszcze jeden przykład, w którym getter jest wykorzystany do zaimplementowania
„magicznej” właściwości:
// Ten obiekt ma właściwości dostępowe zwracające liczby losowe.
// Na przykład wyrażenie "random.octet" przy każdym użyciu
// ma inną losową wartość z zakresu od 0 do 255.
const random = {

get octet() { return Math.floor(Math.random()*256); },


get uint16() { return Math.floor(Math.random()*65536); },
get int16() { return Math.floor(Math.random()*65536)-32768; }
};

6.11. Podsumowanie
W tym rozdziale zostały szczegółowo opisane obiekty i związane z nimi następujące
zagadnienia:

podstawowa terminologia, w tym pojęcia takie jak właściwości wyliczalne i własne,


składnia literałów obiektowych, obejmująca funkcjonalności wprowadzone w wersjach
języka ES6 i nowszych,
odczytywanie, zapisywanie, usuwanie, wyliczanie i sprawdzanie dostępności właściwości,
dziedziczenie prototypów i tworzenie obiektów pochodnych za pomocą metody
Object.create(),
kopiowanie właściwości z jednego obiektu do innego za pomocą metody
Object.assign().

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.

7.1. Tworzenie tablic


Tablice można utworzyć na kilka sposobów. W kolejnych punktach opisane jest tworzenie tablic
za pomocą:

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:

let empty = []; // Tablica bez elementów.

let primes = [2, 3, 5, 7, 11]; // Tablica złożona z pięciu elementów


liczbowych.
let misc = [ 1.1, true, "a", ]; // Trzy elementy różnych typów i końcowy
przecinek.

Wartości użyte w literale nie muszą być stałymi. Mogą to być dowolne wyrażenia:
let base = 1024;

let table = [base, base+1, base+2, base+3];

Literały tablicowe mogą zawierać literały obiektowe i inne literały tablicowe:


let b = [[1, {x: 1, y: 2}], [2, {x: 3, y: 4}]];

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.

7.1.2. Operator rozciągania


W wersjach języka ES6 i nowszych jest dostępny operator rozciągania (...) służący do
umieszczania w literale tablicowym elementów innej tablicy:

let a = [1, 2, 3];


let b = [0, ...a, 4]; // b == [0, 1, 2, 3, 4]

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.

Za pomocą operatora rozciągania wygodnie tworzy się płaskie kopie tablic:

let original = [1,2,3];

let copy = [...original];

copy[0] = 0; // Modyfikacja kopii tablicy nie wpływa na oryginał.


original[0] // => 1

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:

let letters = [..."Witaj, świecie!"];

[...new Set(letters)] // => [ "W", "i", "t", "a", "j", ",", " ", "ś", "w",
"e", "c", "!" ]

7.1.3. Konstruktor Array()


Tablicę tworzy się również za pomocą konstruktora Array(), którego można wywoływać na trzy
sposoby:

Bez argumentów:

let a = new Array();


W ten sposób tworzy się pustą tablicę. Ten sam efekt uzyskuje się za pomocą literału
tablicowego [].

Z jednym argumentem liczbowym opisującym długość tablicy:

let a = new Array(10);


W ten sposób tworzy się tablicę o zadanej długości. Konstruktor w takiej formie
wykorzystuje się wtedy, gdy wiadomo z góry, ile elementów ma zawierać tablica. Pamiętaj,
że elementom nie są przypisywane wartości, jak również nie są definiowane indeksy „0”,
„1” itd.

Jawnie umieszczając w argumentach dwa lub więcej elementów lub jeden element
nieliczbowy:

let a = new Array(5, 4, 3, 2, 1, "testy, testy");

Argumenty konstruktora użytego w tej formie są przekształcane w elementy tworzonej


tablicy. Prościej jest jednak użyć literału tablicowego.

7.1.4. Metoda Array.of()


W przypadku wywołania konstruktora Array()z jednym argumentem zostanie utworzona
tablica o zadanej długości. Jeżeli argumentów liczbowych będzie więcej, zostaną potraktowane
jako elementy tablicy. Oznacza to, że za pomocą konstruktora nie można utworzyć tablicy z
jednym elementem liczbowym.

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]

7.1.5. Funkcja Array.from()


Inną metodą fabryczną wprowadzoną w wersji ES6 jest Array.from(). Jej pierwszym
argumentem jest iterowalny obiekt lub tablica, a zwracanym wynikiem nowa tablica złożona z
elementów argumentu. Jeżeli argumentem jest iterowalny obiekt, funkcja Array.from(obiekt)
działa tak jak operator rozciągania [...obiekt]. W ten sposób można łatwo utworzyć kopię
tablicy:

let copy = Array.from(original);

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 truearray = Array.from(arraylike);

Funkcja Array.from() ma jeszcze drugi, opcjonalny argument, w którym umieszcza się


funkcję. Każdy element obiektu źródłowego jest umieszczany w argumencie tej funkcji, a
zwrócony przez nią wynik jest dodawany do tablicy jako jej element. Metoda działa wtedy
bardzo podobnie jak opisana w dalszej części rozdziału metoda map(), tylko jest wydajniejsza,
ponieważ przekształca elementy w trakcie tworzenia tablicy. Dzięki temu nie trzeba tworzyć
dodatkowej tablicy zawierającej przekształcone elementy.

7.2. Odczytywanie i zapisywanie


elementów tablicy
Do odwoływania się do elementów tablicy służy operator []. Po lewej stronie nawiasu
otwierającego musi znajdować się odwołanie do tablicy, a wewnątrz nawiasów wyrażenie,
którego wartością jest liczba całkowita nieujemna. Taką składnię wykorzystuje się zarówno do
odczytywania, jak i zapisywania elementów. Zatem wszystkie poniższe wyrażenia są poprawne:

let a = ["świecie!"]; // Na początku tablica zawiera jeden element.

let value = a[0]; // Odczytanie elementu o indeksie 0.

a[1] = 3.14; // Zapisanie elementu o indeksie 1.

let i = 2;

a[i] = 3; // Zapisanie elementu o indeksie 2.

a[i + 1] = "Witaj,"; // Zapisanie elementu o indeksie 3.


a[a[i]] = a[0]; // Odczytanie elementów o indeksach 0 i 2, zapisanie
elementu o indeksie 3.
Tablice mają tę szczególną cechę, że automatycznie aktualizują właściwość length, jeżeli
stosowane nazwy właściwości są nieujemnymi liczbami całkowitymi mniejszymi niż 232–1. W
powyższym przykładzie została utworzona tablica z jednym elementem, a następnie zostały
elementom przypisane wartości o indeksach 1, 2 i 3. W efekcie została odpowiednio zmieniona
właściwość length:

a.length // => 4

Pamiętaj, że tablica jest specjalnego rodzaju obiektem. Nawiasy kwadratowe wykorzystywane


do odwoływania się do jej elementów funkcjonują tak samo jak w przypadku właściwości
obiektu. Liczbowy indeks jest przekształcany na ciąg znaków (na przykład 1 na "1"),
wykorzystywany następnie jako nazwa właściwości. W tym przekształceniu nie ma niczego
szczególnego, ponieważ taka sama zasada obowiązuje w zwykłych obiektach:

let o = {}; // Utworzenie zwykłego obiektu.

o[1] = "jeden"; // Indeksem jest liczba całkowita.

o["1"] // => "jeden"; liczbowa i tekstowa nazwa właściwości oznacza


to samo.

Należy jednoznacznie odróżniać indeksy tablicy od nazw właściwości obiektów. Wszystkie


indeksy są nazwami właściwości, natomiast indeksami są tylko te właściwości, których nazwy
są liczbami całkowitymi z przedziału od 0 do 232–2. Ponieważ tablica jest obiektem, jej
właściwości mogą mieć dowolne nazwy. Jednak właściwość length jest aktualizowana tylko
wtedy, jeżeli stosowane są właściwości będące indeksami.
Zwróć uwagę, że indeksami mogą być liczby ujemne, jak również inne niż całkowite. W takim
wypadku liczba jest przekształcana w ciąg znaków i traktowana jako zwykła właściwość, a nie
indeks, ponieważ nie jest liczbą całkowitą nieujemną. Jeżeli nazwą będzie ciąg znaków
reprezentujący liczbę całkowitą nieujemną, zostanie potraktowany jako indeks, a nie zwykła
właściwość. Ta sama zasada dotyczy liczb zmiennoprzecinkowych będących liczbami
całkowitymi:

a[-1.23] = true; // Utworzenie właściwości o nazwie "–1.23".

a["1000"] = 0; // Utworzenie 1001. elementu tablicy.

a[1.000] = 1; // Element o indeksie 1. Zapis równoważny: a[1] = 1;.

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:

let a = [true, false]; // Ta tablica ma elementy o indeksach 0 i 1.


a[2] // => undefined; nie ma elementu o tym indeksie.

a[-1] // => undefined; nie ma właściwości o tej nazwie.

7.3. Rozrzedzone tablice


Tablica jest rozrzedzona, jeżeli indeksy jej elementów nie tworzą ciągłej sekwencji liczb
zaczynających się od 0. Zazwyczaj właściwość length zawiera liczbę elementów tablicy. Jeżeli
tablica jest rozrzedzona, wartość tej właściwości jest większa od liczby elementów. Rozrzedzoną
tablicę można utworzyć, wywołując konstruktor Array() lub przypisując wartość elementowi o
indeksie większym niż właściwość length:
let a = new Array(5); // Tablica bez elementów, ale właściwość a.length ma
wartość 5.
a = []; // Utworzenie tablicy bez elementów, o długości równej
0.

a[1000] = 0; // Przypisanie wartości jednemu elementowi i ustawienie


długości na 1001.

W dalszej części rozdziału dowiesz się, że tablicę można rozrzedzić za pomocą operatora
delete.

Bardzo rozrzedzone tablice są zazwyczaj implementowane w mniej wydajny, ale za to


oszczędzający pamięć sposób w porównaniu z tablicami zagęszczonymi. Wyszukiwanie
elementów zajmuje wtedy tyle samo czasu co wyszukiwanie zwykłych właściwości obiektu.

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:

let a1 = [,]; // Ta tablica nie ma elementów, ale jej długość jest


równa 1.

let a2 = [undefined]; // Ta tablica zawiera jeden element o wartości


undefined.
0 in a1 // => false: tablica a1 nie ma elementu o indeksie 0.

0 in a2 // => true: wartość undefined ma element o indeksie


0.

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.

7.4. Długość tablicy


Każda tablica ma właściwość length, odróżniającą ją od zwykłego obiektu. W zagęszczonej
tablicy (nierozrzedzonej) właściwość ta zawiera liczbę elementów, tj. wartość o 1 większą niż
indeks ostatniego elementu:
[].length // => 0: ta tablica nie ma elementów.

["a","b","c"].length // => 3: największy indeks jest równy 2, długość jest


równa 3.

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:

a = [1,2,3,4,5]; // Początkowa tablica złożona z pięciu elementów.

a.length = 3; // Teraz tablica a ma postać [1,2,3].


a.length = 0; // Usunięcie wszystkich elementów. Tablica a ma postać
[].
a.length = 5; // Długość jest równa 5, ale tablica nie ma elementów,
tak jak utworzona za

// pomocą new Array(5).

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.

7.5. Dodawanie i usuwanie elementów


tablicy
Poznałeś już najprostszy sposób dodawania elementów do tablicy: wystarczy przypisać wartość,
używając nowego indeksu:
let a = []; // Początkowa, pusta tablica.

a[0] = "zero"; // Dodanie elementu.


a[1] = "jeden";

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.

2 in a // => false: element o indeksie 2 nie jest zdefiniowany.


a.length // => 3: usunięcie elementu nie wpływa na długość tablicy.
Usunięcie elementu tablicy można porównać do przypisania mu wartości undefined, choć
pomiędzy tymi operacjami jest subtelna różnica. Zwróć uwagę, że usunięcie elementu nie
zmienia wartości właściwości length ani nie powoduje przesunięcia elementów o wyższych
indeksach w celu zapełnienia powstałej luki. Usunięcie elementu powoduje rozrzedzenie
tablicy.
Jak już wiesz, elementy znajdujące się na końcu tablicy można usuwać, przypisując właściwości
length nową wartość.
Oprócz opisanych wyżej metod jest jeszcze uniwersalna metoda splice() umożliwiająca
wstawianie, usuwanie i zastępowanie elementów. Zmienia ona długość tablicy i odpowiednio
przesuwa elementy w kierunku wyższych lub niższych indeksów. Szczegółowe informacje na jej
temat znajdziesz w podrozdziale 7.8.

7.6. Iterowanie tablic


Począwszy od wersji języka ES6 najprostszym sposobem przetwarzania wszystkich elementów
tablicy (jak również każdego iterowalnego obiektu) jest użycie pętli for/of, opisanej w punkcie
5.4.4:

let letters = [..."Witaj, świecie!"]; // Tablica znaków.


let string = "";

for(let letter of letters) {


string += letter;

}
string // => "Witaj, świecie!"; odtworzony początkowy tekst.

Wbudowany iterator tablicy, wykorzystywany w pętli for/of, zwraca elementy tablicy w


kolejności rosnących indeksów. Tablice rozrzedzone nie są traktowane w specjalny sposób, po
prostu w przypadku nieistniejącego elementu iterator zwraca wartość undefined.

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 = "";

for(let [index, letter] of letters.entries()) {


if (index % 2 === 0) everyother += letter; // Litery o parzystych
indeksach.

}
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:

let uppercase = "";


letters.forEach(letter => { // Zwróć uwagę na strzałkową składnię.

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.

Metoda forEach() będzie szczegółowo opisana w punkcie 7.8.1. W tym punkcie


zaprezentowane są jeszcze metody map() i filter() wykonujące specjalnego rodzaju iteracje
na tablicy.

Elementy tablicy można też przetwarzać za pomocą starej dobrej pętli for:
let vowels = "";

for(let i = 0; i < letters.length; i++) { // Dla każdego elementu tablicy:


let letter = letters[i]; // uzyskaj jego indeks

if (/[aeiou]/.test(letter)) { // i sprawdź wartość za pomocą


wyrażenia regularnego.
vowels += letter; // Jeżeli jest to samogłoska,
zapamiętaj ją.
}
}

vowels // => "iaieie"


Jeżeli pętle są zagnieżdżone lub szybkość działania kodu ma krytyczne znaczenie, czasami tego
rodzaju podstawowe iteracje implementuje się w ten sposób, że długość tablicy odczytuje się
tylko raz, a nie przy każdej iteracji. Obie przedstawione niżej formy pętli są poprawne, choć
rzadko stosowane. Dodatkowo, jeżeli wykorzystywany jest nowoczesny interpreter, korzyści
wydajnościowe nie zawsze są wyraźnie widoczne:

// Zapisanie długości tablicy w zmiennej lokalnej.


for(let i = 0, len = letters.length; i < len; i++) {

// Ciało pętli pozostaje bez zmian.


}

// Iterowanie tablicy od jej końca do początku.


for(let i = letters.length-1; i >= 0; i--) {
// Ciało pętli pozostaje bez zmian.

}
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ć:

for(let i = 0; i < a.length; i++) {


if (a[i] === undefined) continue; // Pominięcie niezdefiniowanych i
nieistniejących elementów.

// 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.

let table = new Array(10); // Dziesięć wierszy tablicy.


for(let i = 0; i < table.length; i++) {

table[i] = new Array(10); // Każdy wiersz składa się z 10


kolumn.
}

// Zainicjowanie tablicy.
for(let row = 0; row < table.length; row++) {

for(let col = 0; col < table[row].length; col++) {


table[row][col] = row*col;

}
}
// Przykład użycia dwuwymiarowej tablicy do wyliczenia 5*7.

table[5][7] // => 35

7.8. Metody tablicowe


W poprzednich podrozdziałach skupiłem się na podstawowej składni wyrażeń z tablicami. W
rzeczywistości jednak najbardziej przydatne są metody klasy Array, opisane w kolejnych
punktach. Podczas lektury pamiętaj, że niektóre z nich modyfikują tablicę, do której należą, a
inne pozostawiają ją bez zmian. Oprócz tego wynikami zwracanymi przez niektóre metody są
tablice. Czasami są to nowe tablice, czasami zmodyfikowane oryginały.
Każdy z kolejnych punktów jest poświęcony grupie powiązanych ze sobą metod:

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:

let data = [1,2,3,4,5], sum = 0;


// Wyliczenie sumy elementów tablicy.

data.forEach(value => { sum += value; }); // sum == 15


// Zwiększenie wartości każdego elementu tablicy.
data.forEach(function(v, i, a) { a[i] = v + 1; }); // data == [2,3,4,5,6]

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.

Zwróć uwagę, że metoda filter() pomija nieistniejące elementy w rozrzedzonej tablicy, a


zwracana przez nią tablica jest zagęszczona. Aby usunąć luki w rozrzedzonej tablicy, można
użyć następującego kodu:

let dense = sparse.filter(() => true);


Natomiast luki oraz elementy zawierający wartości undefined i null można usunąć w poniższy
sposób:

a = a.filter(x => x !== undefined && x !== null);

Metody find() i findIndex()


Metody find() i findIndex() są podobne do filter(), ponieważ wyszukują elementy, dla
których wynik zwracany przez funkcję predykatu jest wartością prawdziwą. Różnica polega
jednak na tym, że metody te przerywają iterowanie przy pierwszym znalezionym elemencie.
Metoda find() zwraca wtedy wartość elementu, a findIndex() jego indeks. Jeżeli element nie
zostanie znaleziony, metoda find() zwraca wartość undefined, a findIndex() liczbę –1:

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.

a.find(x => x % 7 === 0) // => undefined: żaden element nie jest


wielokrotnością liczby 7.

Metody every() i some()


Metody every() i some() są predykatami tablicowymi, tj. wywołują zadaną funkcję predykatu
dla każdego elementu tablicy i zwracają wynik true lub false.

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.

a.some(isNaN) // => false; tablica a nie zawiera elementów innych niż


liczby.

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.

Metody reduce() i reduceRight()


Metody reduce() i reduceRight() przetwarzają elementy tablicy za pomocą zadanej funkcji i
zwracają pojedyncze wartości. Tego rodzaju operacje są typowe w programowaniu funkcyjnym i
noszą, odpowiednio, nazwy „wstrzykiwania” (ang. inject) i „zawijania” (ang. fold). Poniższe
przykłady ilustrują działanie tych metod:

let a = [1,2,3,4,5];
a.reduce((x,y) => x+y, 0) // => 15; suma wartości.

a.reduce((x,y) => x*y, 1) // => 120; iloczyn wartości.


a.reduce((x,y) => (x > y) ? x : y) // => 5; największa wartość.

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.

7.8.2. Spłaszczanie tablic za pomocą metod flat() i


flatMap()
Wprowadzona w wersji języka ES2019 metoda flat() tworzy i zwraca tablicę zawierającą te
same elementy co tablica, do której metoda należy. Elementy, które są tablicami, są
„spłaszczane”, tj. ich elementy są umieszczane w tablicy wynikowej, na przykład:
[1, [2, 3]].flat() // => [1, 2, 3]
[1, [2, [3]]].flat() // => [1, 2, [3]]
Metoda flat() wywołana bez argumentów obejmuje tylko jeden poziom zagnieżdżenia tablic.
Elementy oryginalnej tablicy, które same są tablicami, są spłaszczane, ale elementy
zagnieżdżonej tablicy, które są tablicami, już nie są spłaszczane. Jeżeli spłaszczanie ma
obejmować więcej poziomów zagnieżdżenia, należy metodę flat() wywołać z argumentem:
let a = [1, [2, [3, [4]]]];
a.flat(1) // => [1, 2, [3, [4]]]
a.flat(2) // => [1, 2, 3, [4]]

a.flat(3) // => [1, 2, 3, 4]


a.flat(4) // => [1, 2, 3, 4]
Metoda flatMap() działa podobnie jak map() (patrz podpunkt „Metoda map()” wcześniej) z tą
różnicą, że zwracaną tablicę automatycznie spłaszcza tak jak metoda flat(). Oznacza to, że
wyrażenie a.flatMap(f) jest równoważne wyrażeniu a.map(f).flat() (jednak jest od niego
bardziej wydajne):

let phrases = ["Witaj, świecie!", "Kompletny przewodnik"];


let words = phrases.flatMap(phrase => phrase.split(" "));
words // => [ "Witaj,", "świecie!", "Kompletny", "przewodnik" ]
Metodę flatMap() można traktować jako uogólnioną metodę map(), wiążącą elementy tablicy
wejściowej z elementami tablicy wyjściowej. W szczególności można wiązać elementy
wejściowe z pustą tablicą, która w spłaszczonej tablicy wynikowej nie jest uwzględniana:
// Powiązanie liczb nieujemnych z ich pierwiastkami kwadratowymi.
[-2, -1, 1, 2].flatMap(x => x < 0 ? [] : Math.sqrt(x)) // => [1, 2**0.5]

7.8.3. Łączenie tablic za pomocą metody concat()


Metoda concat() tworzy i zwraca tablicę składającą się z elementów tablicy, do której metoda
należy, oraz elementów podanych w argumencie. Jeżeli argumentem jest tablica, dodawane są
jej elementy, a nie sama tablica. Zwróć jednak uwagę, że metoda concat() nie spłaszcza
rekurencyjnie tablic złożonych z tablic, jak również nie modyfikuje tablicy, do której należy:
let a = [1,2,3];

a.concat(4, 5) // => [1,2,3,4,5]


a.concat([4,5],[6,7]) // => [1,2,3,4,5,6,7]; spłaszczone tablice.
a.concat(4, [5,[6,7]]) // => [1,2,3,4,5,[6,7]]; zagnieżdżone tablice nie są
spłaszczane.
a // => [1,2,3]; oryginalna tablica nie jest
modyfikowana.

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().

7.8.4. Stosy i kolejki, czyli metody push(), pop(),


shift() i unshift()
Dzięki metodom push() i pop() tablicę można wykorzystywać w charakterze stosu. Metoda
push() dołącza na końcu tablicy jeden lub więcej elementów i zwraca jej nową długość. Jednak
w odróżnieniu od metody concat() nie spłaszcza argumentów, które są tablicami. Metoda
pop() wykonuje odwrotną operację, tj. usuwa ostatni element tablicy, zmniejsza jej długość i
zwraca wartość usuniętego elementu. Zwróć uwagę, że obie metody modyfikują oryginalną
tablicę. Za pomocą obu metod można implementować stosy typu FIFO (ang. First-In, First-Out,
pierwszy wchodzi, pierwszy wychodzi), na przykład:
let stack = []; // stack == []
stack.push(1,2); // stack == [1,2];

stack.pop(); // stack == [1]; zwracany wynik: 2


stack.push(3); // stack == [1,3]
stack.pop(); // stack == [1]; zwracany wynik: 3
stack.push([4,5]); // stack == [1,[4,5]]

stack.pop() // stack == [1]; zwracany wynik: [4,5]


stack.pop(); // stack == []; zwracany wynik: 1
Metoda push() nie spłaszcza tablicy podanej w jej argumencie. Jeżeli więc trzeba dodać do
tablicy elementy innej tablicy, należy tę drugą jawnie spłaszczyć za pomocą operatora
rozciągania (patrz punkt 8.3.4):
a.push(...values);

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]

7.8.5. Podtablice, czyli metody slice(), splice(), fill() i


copyWithin()
Obiekt tablicy ma kilka metod operujących na jej obszarach, czyli podtablicach lub wycinkach.
W kolejnych punktach opisane są metody wyodrębniające, zastępujące, wypełniające i
kopiujące wycinki tablicy.

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].

a.slice(3); // Zwracany wynik: [4,5].


a.slice(1,-1); // Zwracany wynik: [2,3,4].
a.slice(-3,-2); // Zwracany wynik: [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].

a.splice(1,2) // => [2,3]; tablica a ma teraz postać [1,4].


a.splice(1,1) // => [4]; tablica a ma teraz postać [1].
Pierwsze dwa argumenty metody splice() określają, które elementy mają być usunięte. Za
nimi można wpisać dowolną liczbę innych argumentów zawierających elementy przeznaczone
do umieszczenia w tablicy począwszy od indeksu określonego w pierwszym argumencie, na
przykład:

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.

a.fill(9, 1) // => [0,9,9,9,9]; przypisanie wartości 9 począwszy


od elementu o indeksie 1.
a.fill(8, 2, -1) // => [0,9,8,8,9]; przypisanie wartości 8 elementom o
indeksach 2 i 3.
Pierwszym argumentem metody fill() jest wartość, która ma być przypisana elementom.
Drugi, opcjonalny argument określa początkowy indeks elementu. Jeżeli nie jest podany,
przyjmowany jest indeks 0. Trzeci argument, również opcjonalny, określa końcowy indeks
fragmentu tablicy. Elementowi o końcowym indeksie zadana wartość nie jest przypisywana.
Jeżeli argument ten nie jest określony, zadana wartość jest przypisywana elementom od
początkowego indeksu do końca tablicy. Argumenty mogą zawierać liczby ujemne, które
określają pozycje elementów względem końca tablicy.

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.

a.copyWithin(2, 3, 5) // => [1,1,3,4,4]: kopiowanie dwóch ostatnich elementów


do miejsca o indeksie 2.
a.copyWithin(0, -2) // => [4,4,3,4,4]: można również stosować ujemne
wartości.
Metoda copyWithin() jest bardzo wydajna, szczególnie w przypadku tablic o określonych
typach (patrz podrozdział 11.2). Jej pierwowzorem jest funkcja memmove() ze standardowej
biblioteki języka C. Zwróć uwagę, że elementy są kopiowane poprawnie nawet wtedy, gdy
wycinki źródłowy i docelowy zachodzą na siebie.

7.8.6. Metody przeszukujące i sortujące tablice


Obiekt tablicy zawiera metody indexOf(), lastIndexOf() i includes(), podobne do metod
tekstowych o takich samych nazwach. Dostępne są również metody sort() i reverse() służące
do zmieniania kolejności elementów w tablicy. Powyższe metody są opisane w kolejnych
punktach.

Metody indexOf() i lastIndexOf()


Każda z metod, indexOf() i lastIndexOf(), wyszukuje pierwszy element o zadanej wartości i
jeżeli go znajdzie, zwraca jego indeks. W przeciwnym razie zwraca wartość –1. Pierwsza
metoda przeszukuje tablicę od początku, a druga od końca:
let a = [0,1,2,1,0];
a.indexOf(1) // => 1: element a[1] zawiera wartość 1.
a.lastIndexOf(1) // => 3: element a[3] zawiera wartość 1.

a.indexOf(3) // => –1: żaden element nie zawiera wartości 3.


Metody indexOf() i lastIndexOf() wykorzystują operator === do porównywania elementu z
wartością argumentu. Jeżeli tablica nie zawiera wartości prymitywnych, tylko obiekty, każda z
metod sprawdza, czy obie referencje wskazują ten sam obiekt. Jeżeli trzeba przeszukiwać
elementy według zawartości, należy użyć metody find() z własną funkcją predykatu.
Obie powyższe metody mają opcjonalny drugi argument zawierający indeks, od którego ma
rozpoczynać się przeszukiwanie tablicy. Jeżeli argument ten nie jest określony, metoda
indexOf() przeszukuje tablicę od jej początku, a lastIndexOf() od końca. Ujemna wartość
oznacza pozycję względem końca tablicy, tak jak w metodzie slice(). Na przykład wartość –1
oznacza ostatni element tablicy.
Poniższa funkcja przeszukuje tablicę pod kątem zadanej wartości i zwraca tablicę indeksów
elementów, które tę wartość zawierają. Przykład ten pokazuje, jak można wykorzystać drugi
argument metody do wyszukiwania kolejnych elementów, a nie tylko pierwszego.

// Wyszukanie wszystkich wystąpień wartości x i zwrócenie


// indeksów elementów, które ją zawierają.
function findall(a, x) {
let results = [], // Zwracana tablica z indeksami.
len = a.length, // Długość przeszukiwanej tablicy.

pos = 0; // Indeks, od którego zaczyna się


przeszukiwanie.
while(pos < len) { // Dopóki zostały jeszcze elementy…
pos = a.indexOf(x, pos); // …przeszukaj je.
if (pos === -1) break; // Zakończ, jeżeli wartość nie została
znaleziona.
results.push(pos); // W przeciwnym razie zapisz indeks w tablicy…
pos = pos + 1; // …i kontynuuj przeszukiwanie od następnego
indeksu.
}

return results; // Zwrócenie tablicy indeksów.


}
Zwróć uwagę, że ciągi znaków też zawierają metody indexOf() i lastIndexOf(), które działają
podobnie jak opisane wyżej metody tablicowe. Różnią się od nich jedynie tym, że ujemna
wartość drugiego argumentu jest zamieniana na zero.

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

a.includes(NaN) // => true


a.indexOf(NaN) // => –1; metoda indexOf() nie wyszukuje wartości
NaN.

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):

let a = ["banany", "wiśnie", "jabłka"];


a.sort(); // a == [ "banany", "jabłka", "wiśnie" ]
Niezdefiniowane elementy są w wyniku sortowania umieszczane na końcu tablicy.
Aby posortować tablicę w porządkowaniu innym niż alfabetyczne, należy w argumencie metody
umieścić funkcję porównującą. Funkcja ta musi mieć dwa argumenty. Jeżeli pierwszy ma być
umieszczony w sortowanej tablicy przed drugim, funkcja musi zwracać wartość mniejszą od
zera. Jeżeli pierwszy argument ma być umieszczony za drugim, funkcja musi zwracać wartość
większą od zera. Jeżeli natomiast oba argumenty są sobie równe, tj. ich kolejność nie ma
znaczenia, zwracanym wynikiem musi być 0. Aby na przykład posortować elementy tablicy w
kolejności liczbowej, a nie alfabetycznej, można użyć następującego kodu:
let a = [33, 4, 1111, 222];
a.sort(); // a == [1111, 222, 33, 4]; kolejność alfabetyczna.

a.sort(function(a,b) { // Umieszczenie w argumencie funkcji porównującej.


return a-b; // W zależności od kolejności wynik jest mniejszy,
równy lub większy od zera.
}); // a == [4, 33, 222, 1111]; kolejność liczbowa.

a.sort((a,b) => b-a); // a == [1111, 222, 33, 4]; odwrócona kolejność


liczbowa.
Innym przykładem jest alfabetyczne sortowanie ciągów znaków, w którym nie jest
uwzględniana wielkość liter. W takim wypadku funkcja porównująca musi przed porównaniem
ciągów zamieniać ich wszystkie znaki na małe litery:
let a = ["aaa", "BBB", "ccc", "DDD"];

a.sort(); // a == [ "BBB", "DDD", "aaa", "ccc" ]; sortowanie


uwzględniające wielkości liter.
a.sort(function(s,t) {
let a = s.toLowerCase();
let b = t.toLowerCase();

if (a < b) return -1;


if (a > b) return 1;
return 0;
}); // a == [ "aaa", "BBB", "ccc", "DDD" ]; sortowanie nieuwzględniające
wielkości liter.

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]

7.8.7. Konwersja tablicy na ciąg znaków


Klasa Array ma trzy metody przekształcające tablicę w ciąg znaków, przydatne przy tworzeniu
dzienników i komunikatów o błędach. (Jednak aby zapisać tablicę w postaci tekstowej w celu jej
późniejszego wykorzystania, lepiej jest dokonać jej serializacji za pomocą metody
JSON.stringify() opisanej w podrozdziale 6.8).
Metoda join() zamienia wszystkie elementy tablicy w ciągi znaków, łączy ze sobą i zwraca
uzyskany wynik. W jej drugim argumencie można opcjonalnie podać ciąg, który będzie
rozdzielał elementy. Jeżeli argument nie jest określony, stosowany jest przecinek:
let a = [1, 2, 3];

a.join() // => "1,2,3"


a.join(" ") // => "1 2 3"
a.join("") // => "123"
let b = new Array(10); // Tablica o długości 10, bez elementów.

b.join("-") // => "---------": ciąg dziewięciu myślników.


Metoda join() jest przeciwieństwem metody String.split(), która tworzy tablicę złożoną
z fragmentów ciągu znaków.
Tablica, tak jak każdy obiekt w języku JavaScript, ma metodę toString(), która w tym
przypadku działa jak metoda join() bez argumentów:
[1, 2, 3].toString() // => "1,2,3"

["a", "b", "c"].toString() // => "a,b,c"


[1, [2, "c"]].toString() // => "1,2,c"
Zwróć uwagę, że wynik nie zawiera nawiasów kwadratowych ani innych symboli oznaczających
tablicę.

Metoda toLocaleString() jest odmianą metody toString() uwzględniającą ustawienia


regionalne. Przekształca każdy element tablicy w ciąg znaków, wywołując jego metodę
toLocaleString(), a następnie łączy uzyskane wyniki przy użyciu separatora właściwego dla
bieżących ustawień (i implementacji).

7.8.8. Statyczne funkcje tablicowe


Klasa Array, oprócz opisanych metod tablicowych, zawiera dodatkowo trzy statyczne funkcje,
wywoływane za pomocą konstruktora, a nie samego obiektu tablicy. Funkcje Array.of() i
Array.from() są metodami fabrycznymi tworzącymi nowe tablice, opisanymi w punktach 7.1.4
i 7.1.5.
Inną statyczną funkcją jest Array.isArray(). Określa ona, czy zadana tablica jest wartością
nieznaną:
Array.isArray([]) // => true
Array.isArray({}) // => false

7.9. Obiekty podobne do tablic


Jak się przekonałeś, tablice w języku JavaScript mają specjalne cechy, których nie posiadają
inne obiekty:

Właściwość length jest automatycznie aktualizowana podczas dodawania elementów.


Przypisanie właściwości length wartości mniejszej niż aktualna powoduje odrzucenie
części elementów.
Tablice dziedziczą metody po prototypie Array.prototype.
Funkcja Array.isArray() zwraca wartość true, jeżeli jej argumentem jest tablica.

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.

// Dodanie właściwości, aby upodobnić obiekt do tablicy.


let i = 0;
while(i < 10) {
a[i] = i * i;
i++;

}
a.length = i;
// Iterowanie obiektu tak, jakby był tablicą.
let total = 0;

for(let j = 0; j < a.length; j++) {


total += a[j];
}
W kodzie klienckim stosuje się kilka metod, na przykład document.querySelectorAll(), które
przetwarzają dokument HTML i zwracają obiekty podobne do tablic. Poniżej jest przedstawiona
funkcja sprawdzająca, czy zadany obiekt funkcjonuje jak tablica:
// Sprawdzenie, czy obiekt o jest podobny do tablicy.

// Ciągi znaków i funkcje mają właściwości length, jednak są wykluczone


// z testu typeof. W kodzie klienckim tekstowe węzły DOM mają właściwości
// length i można je również wykluczyć z testu poprzez użycie dodatkowego
// warunku o.nodeType !== 3.
function isArrayLike(o) {

if (o && // Jeżeli obiekt o nie jest wartością


null, undefined itp.,
typeof o === "object" && // zmienna o jest obiektem,
Number.isFinite(o.length) && // właściwość o.length jest zwykłą
liczbą,

o.length >= 0 && // jest liczbą nieujemną,


Number.isInteger(o.length) && // jest liczbą całkowitą,
o.length < 4294967295) { // jest mniejsza niż 2^32 – 1,
return true; // to obiekt o jest podobny do
tablicy.

} 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"]

Array.prototype.slice.call(a, 0) // => ["a","b","c"]: kopiowanie elementów


do prawdziwej tablicy.
Array.from(a) // => ["a","b","c"]: prostsze kopiowanie
elementów.
W przedostatnim wierszu powyższego kodu wywoływana jest metoda slice() obiektu w celu
skopiowania jego elementów do prawdziwej tablicy. Jest to idiomatyczna sztuczka, często
spotykana w starszych kodach. Obecnie o wiele łatwiej jest użyć w tym celu metody
Array.from().

7.10. Ciągi znaków jako tablice


Ciągi znaków funkcjonują jak tablice przeznaczone tylko do odczytu, złożone ze znaków
Unicode UTF-16. Zamiast odwoływać się do nich za pomocą metody charAt(), można użyć
nawiasów kwadratowych:
let s = "test";
s.charAt(0) // => "t"
s[1] // => "e"

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:

Array.prototype.join.call("JavaScript", " ") // => "J a v a S c r i p t"


Pamiętaj, że ciągi są wartościami niemutowalnymi, a więc tablicami przeznaczonymi tylko do
odczytu. Metody takie jak push(), sort(), reverse() i splice() modyfikują tablicę, do której
należą, natomiast użyte z ciągiem znaków nie wprowadzają żadnych zmian i nie sygnalizują
błędu.

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ć:

Literał tablicowy jest umieszczoną w nawiasach kwadratowych listą wartości


oddzielonych przecinkami.
Do poszczególnych elementów tablicy można odwoływać się za pomocą indeksu
umieszczonego w nawiasach kwadratowych.
Do iterowania tablicy szczególnie przydaje się pętla for/of i wprowadzony w wersji
języka ES6 operator rozciągania (...).
Klasa Array zawiera bogaty zestaw metod do przetwarzania tablic, dlatego warto
dokładnie poznać jej interfejs API.
Rozdział 8.
Funkcje
W tym rozdziale są opisane funkcje, czyli fundamentalne bloki, z których składa się program
napisany w JavaScripcie i niemal każdym innym języku. Zapewne spotkałeś się z innymi
pojęciami, równoważnymi funkcjom, na przykład podprogramami (ang. subroutine) lub
procedurami (ang. procedure).
Funkcja jest zdefiniowanym blokiem kodu, który można wykonywać, czyli wywoływać, dowolną
liczbę razy. W języku JavaScript funkcje są parametryczne, tzn. w ich definicjach można
umieszczać listy identyfikatorów — parametrów — pełniących w ciałach funkcji role lokalnych
zmiennych. Parametrom w wywołaniu funkcji przypisywane są wartości, czyli argumenty.
Wartości te są często wykorzystywane do wyliczania zwracanego wyniku, będącego wartością
wyrażenia funkcyjnego. W każdym wywołaniu określany jest jeszcze jego kontekst, którego
dane są przypisywane słowu kluczowemu this.
Funkcja przypisana właściwości obiektu nosi nazwę metody. Obiekt, którego funkcja jest
wywoływana, jest kontekstem i stanowi wartość słowa kluczowego this. Funkcja inicjująca
nowo utworzony obiekt nazywa się konstruktorem. Konstruktory zostały przedstawione w
podrozdziale 6.2 i będą dokładniej opisane w rozdziale 9.

W języku JavaScript funkcje są obiektami, na których można wykonywać różne operacje, na


przykład przypisywać je zmiennym, umieszczać w argumentach innych funkcji, przypisywać
wartości ich właściwościom, a nawet wywoływać ich metody.
Definicja funkcji może być zagnieżdżona w innej funkcji. Kod zagnieżdżonej funkcji ma dostęp
do wszystkich zmiennych zdefiniowanych w tym samym zasięgu co funkcja nadrzędna. Oznacza
to, że funkcje są domknięciami (ang. closure) — ważnymi i przydatnymi konstrukcjami
programistycznymi.

8.1. Definiowanie funkcji


Najprościej funkcję definiuje się za pomocą słowa kluczowego function, które można stosować
zarówno jako deklarację, jak i wyrażenie. Począwszy od wersji języka ES6 jest jeszcze jeden
ważny sposób definiowania funkcji, bez użycia słowa kluczowego function — są to tzw.
funkcje strzałkowe. Składnia definicji takiej funkcji jest bardzo zwięzła i szczególnie
przydatna wtedy, gdy w argumencie funkcji trzeba umieścić inną funkcję. W kolejnych
podrozdziałach opisane są wszystkie trzy rodzaje definicji. Zwróć uwagę, że opis niektórych
szczegółów definicji został odłożony do podrozdziału 8.3.
W literałach obiektowych i w klasach metody definiuje się, stosując skróconą, wygodną
składnię, która została opisana w punkcie 6.10.5. Są to wyrażenia funkcyjne przypisywane
właściwościom obiektu za pomocą literału nazwa:wartość. Oprócz tego w szczególnych
przypadkach stosuje się w literałach obiektowych słowa kluczowe get i set definiujące gettery i
settery. Składnia definicji tych funkcji została opisana w punkcie 6.10.6.
Funkcje można również definiować za pomocą konstruktora Function(), który będzie tematem
punktu 8.7.7. Oprócz tego można definiować funkcje specjalnego rodzaju. Za pomocą słowa
kluczowego function* definiuje się generatory (patrz rozdział 12.), a za pomocą async
function — funkcje asynchroniczne (patrz rozdział 13.).

8.1.1. Deklaracje funkcji


Deklaracja funkcji składa się ze słowa kluczowego function i umieszczonych za nim
następujących komponentów:

Identyfikatora stanowiącego nazwę funkcji. Jest to wymagana część deklaracji, ponieważ


identyfikator jest nazwą zmiennej, której jest przypisywany nowo utworzony obiekt
funkcyjny.
Pary zwykłych nawiasów, wewnątrz których można umieszczać listę identyfikatorów
oddzielonych przecinkami. Identyfikatory te stanowią nazwy parametrów i pełnią role
lokalnych zmiennych wewnątrz ciała funkcji.
Pary nawiasów klamrowych, wewnątrz których umieszcza się instrukcje tworzące ciało
funkcji. Instrukcje te są wykonywane po wywołaniu funkcji.

Poniżej przedstawionych jest kilka przykładowych deklaracji funkcji:

// Funkcja wyświetlająca nazwy i wartości wszystkich wartości obiektu o.


Zwraca wartość undefined.

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.

function distance(x1, y1, x2, y2) {

let dx = x2 - x1;
let dy = y2 - y1;

return Math.sqrt(dx*dx + dy*dy);

// Funkcja rekurencyjna (wywołująca samą siebie) wyliczająca silnię.


// Silnia x! to iloczyn wszystkich liczb naturalnych mniejszych lub równych
x.

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.

8.1.2. Wyrażenia funkcyjne


Wyrażenie funkcyjne wygląda podobnie jak deklaracja funkcji, ale stosuje się je w kontekście
większych wyrażeń i instrukcji. Nazwa funkcji w takim wyrażeniu nie jest obowiązkowa. Poniżej
przedstawionych jest kilka przykładów wyrażeń funkcyjnych:

// Wyrażenie funkcyjne wyliczające kwadrat argumentu.

// Zwróć uwagę, że jest przypisywane zmiennej.

const square = function(x) { return x*x; };


// Wyrażenie funkcyjne może zawierać nazwę przydatną w rekurencji.

const f = function fact(x) { if (x <= 1) return 1; else return x*fact(x-1);


};

// Wyrażenie funkcyjne można umieszczać w argumencie innej funkcji.

[3,2,1].sort(function(a,b) { return a-b; });


// Wyrażenie funkcyjne można wywoływać natychmiast po zdefiniowaniu.

let tensquared = (function(x) {return x*x;}(10));

Zwróć uwagę, że w wyrażeniu funkcyjnym nazwa jest opcjonalna i w większości powyższych


przykładów została pominięta. Deklaracja zwykłej funkcji w rzeczywistości deklaruje zmienną i
przypisuje jej obiekt funkcyjny. Natomiast wyrażenie funkcyjne nie deklaruje zmiennej. Od
programisty zależy, czy tak zdefiniowany obiekt będzie przypisany zmiennej lub stałej, aby
można go było wielokrotnie wykorzystywać później. Dobrą praktyką jest stosowanie w
wyrażeniu funkcyjnym słowa const, aby przypadkowo nie nadpisać funkcji inną wartością.

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.

8.1.3. Funkcje strzałkowe


Począwszy od wersji języka ES6 funkcje można definiować, wykorzystując szczególnie zwięzłą
składnię. Są to tzw. funkcje strzałkowe. Wykorzystuje się w tym celu notację matematyczną,
w której symbol =>, czyli strzałka, oddziela parametry funkcji od jej ciała. Nie jest stosowane
słowo kluczowe function, ponieważ funkcja strzałkowa jest wyrażeniem, a więc nie ma
potrzeby nadawania mu wartości. Funkcja strzałkowa składa się z umieszczonej w nawiasach
listy parametrów oddzielonych przecinkami, symbolu => i ciała umieszczonego wewnątrz
nawiasów klamrowych:

const sum = (x, y) => { return x + y; };

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ę:

const sum = (x, y) => x + y;

Co więcej, jeżeli funkcja strzałkowa ma tylko jeden parametr, można pominąć nawiasy, w
którym jest on umieszczony:

const polynomial = x => x*x + 2*x + 3;

Zwróć uwagę, że jeżeli funkcja strzałkowa nie ma parametrów, wymagane jest użycie pustej
pary nawiasów:

const constantFunc = () => 42;

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ł:

const f = x => { return { value: x }; }; // Dobrze: funkcja f() zwraca


obiekt.
const g = x => ({ value: x }); // Dobrze: funkcja g() zwraca
obiekt.

const h = x => { value: x }; // Źle: funkcja h() niczego nie


zwraca.

const i = x => { v: x, w: x }; // Źle: błąd składniowy.

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:

// Utworzenie kopii tablicy z usuniętymi elementami null.

let filtered = [1,null,2,3].filter(x => x !== null); // filtered == [1,2,3]

// Wyliczenie kwadratów liczb.


let squares = [1,2,3,4].map(x => x*x); // squares == [1,4,9,16]

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).

8.1.4. Zagnieżdżone funkcje


W języku JavaScript funkcje można zagnieżdżać w innych funkcjach, na przykład:

function hypotenuse(a, b) {

function square(x) { return x*x; }

return Math.sqrt(square(a) + square(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.

8.2. Wywoływanie funkcji


Kod tworzący ciało funkcji nie jest wykonywany w miejscu definicji funkcji, tylko jej wywołania.
Funkcje można wywoływać na pięć sposobów:

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.

8.2.1. Wywołanie funkcji


Funkcje można wywoływać jako funkcje lub metody za pomocą wyrażeń wywołujących (patrz
podrozdział 4.5). Wyrażenie wywołujące składa się z wyrażenia funkcyjnego (którego wartością
jest obiekt funkcyjny), nawiasu otwierającego, opcjonalnej listy argumentów oddzielonych
przecinkami i nawiasu zamykającego. Jeżeli funkcja jest właściwością obiektu lub elementu
tablicy, to wyrażenie funkcyjne jest wywołaniem metody. Ten przypadek będzie opisany w
następnym punkcie. W poniższym kodzie wykorzystanych jest kilka zwykłych wyrażeń
wywołujących funkcje:
printprops({x: 1});

let total = distance(0,0,2,1) + distance(2,1,3,5);

let probability = factorial(5)/factorial(13);

Podczas wywoływania funkcji wyliczane są wartości wszystkich wyrażeń umieszczonych w


argumentach. Uzyskane wartości są przypisywane nazwom parametrów określonych w definicji
funkcji. Wartością odwołania do parametru wewnątrz ciała funkcji jest wartość odpowiedniego
argumentu.

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:

(f !== null && f !== undefined) ? f(x) : undefined


Szczegółowe informacje na temat składni wywołania warunkowego znajdziesz w punkcie
4.5.1.

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:

// Definicja i wywołanie funkcji sprawdzającej, czy obwiązuje tryb ścisły:

const strict = (function() { return !this; }());

Wywołania rekurencyjne a stos


Funkcja jest rekurencyjna, jeżeli wywołuje samą siebie, tak jak funkcja factorial()
opisana na początku tego rozdziału. Za pomocą funkcji rekurencyjnych można
szczególnie elegancko implementować niektóre algorytmy, na przykład przetwarzające
drzewiaste struktury danych. Tworząc tego rodzaju funkcję, należy jednak pamiętać o
ograniczeniach związanych z pamięcią. Jeżeli funkcja A wywołuje funkcję B, która z kolei
wywołuje funkcję C, interpreter JavaScript rejestruje na stosie konteksty wywołań
wszystkich trzech funkcji. Gdy funkcja C zakończy działanie, interpreter musi wiedzieć,
od którego miejsca ma wznowić wykonywanie funkcji B. Z kolei gdy funkcja B zakończy
działanie, interpreter musi wiedzieć, od którego miejsca ma wznowić wykonywanie
funkcji A. Konteksty wywołań można sobie wyobrazić jako stos. Gdy funkcja wywołuje
inną funkcję, na stosie jest umieszczany nowy kontekst. Gdy funkcja kończy działanie,
kontekst jest zdejmowany ze stosu. Jeżeli funkcja wywołuje rekurencyjnie samą siebie
100 razy, na stosie jest umieszczanych, a potem z niego zdejmowanych 100 kontekstów.
Stos wywołań zajmuje pamięć. Na nowoczesnym komputerze można bez problemów
definiować funkcje rekurencyjne, które wywołują same siebie setki razy. Jeżeli jednak
liczba wywołań idzie w dziesiątki tysięcy, z dużym prawdopodobieństwem interpreter
zgłosi błąd „Osiągnięta maksymalna wielkość stosu”.
8.2.2. Wywołanie metody
Metoda nie jest niczym więcej jak tylko funkcją zapisaną we właściwości obiektu. Mając funkcję
f i obiekt o, można za pomocą następującego wiersza zdefiniować metodę m:

o.m = f;

Metodę m() po zdefiniowaniu wywołuje się tak:

o.m();

Jeżeli metoda ma argumenty, wywołuje się ją jak niżej:

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:

let calculator = { // Literał obiektowy.


operand1: 1,
operand2: 1,

add() { // Skrócona składnia definicji metody.


// Zwróć uwagę, że słowo kluczowe this odwołuje się do obiektu
zawierającego metodę.
this.result = this.operand1 + this.operand2;
}

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

o["m"](x,y); // Inna wersja zapisu o.m(x,y).


a[0](z) // To też jest wywołanie metody, przy założeniu, że a[0] jest
funkcją.
W wywołaniach metody można stosować bardziej zaawansowane wyrażenia dostępu do
właściwości:

customer.surname.toUpperCase(); // Wywołanie metody obiektu customer.surname.


f().m(); // Wywołanie metody m() obiektu zwróconego
przez funkcję f().

Metody i słowo kluczowe this stanowią istotę paradygmatu programowania obiektowego.


W argumencie każdej funkcji wywoływanej jako metoda jest niejawnie umieszczany obiekt, do
którego funkcja należy. Zazwyczaj metoda wykonuje określone operacje na obiekcie. Składnia
wywołania pozwala elegancko wyrazić to, że funkcja operuje na obiekcie. Porównajmy poniższe
dwa wiersze:

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.

m: function() { // Metoda m obiektu.


let self = this; // Zapisanie w zmiennej a wartości słowa this.
this === o // => true: "this" jest obiektem o.

f(); // Wywołanie funkcji pomocniczej f().


function f() { // Zagnieżdżona funkcja f.

this === o // => false: "this" jest obiektem globalnym lub ma


wartość undefined.
self === o // => true: zmienna self zawiera zewnętrzną wartość
this.
}
}

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

Począwszy od wersji języka ES6 można stosować inne rozwiązanie, polegające na


przekształceniu zagnieżdżonej funkcji f w funkcję strzałkową, która poprawnie dziedziczy
wartość słowa 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);

Więcej o metodzie bind() dowiesz się w punkcie 8.7.5.

8.2.3. Wywołanie konstruktora


Wywołanie funkcji lub metody poprzedzone słowem kluczowym new jest wywołaniem
konstruktora. Wywołania konstruktora zostały przedstawione w podrozdziale 4.6 i punkcie
6.2.2, a konstruktory będą bardziej szczegółowo opisane w rozdziale 9. Tego rodzaju wywołania
różnią się od zwykłych wywołań funkcji i metod sposobem przetwarzania argumentów,
kontekstu i zwracanego wyniku.

Lista parametrów umieszczona wewnątrz nawiasów jest w wywołaniu konstruktora


przetwarzana tak samo jak w zwykłym wywołaniu funkcji i metody. Jeżeli lista parametrów jest
pusta, można pominąć nawiasy, choć nie jest to często stosowana praktyka. Na przykład
poniższe dwa wiersze są sobie równoważne:

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.

8.2.4. Wywołanie pośrednie


Funkcje w języku JavaScript są obiektami, dlatego tak jak wszystkie obiekty mają metody. Dwie
z nich, call() i apply(), bezpośrednio wywołują funkcję. Umożliwiają również jawne
określanie wartości słowa kluczowego this. Oznacza to, że można wywoływać dowolne funkcje
dowolnych obiektów, tj. innych niż ten, do którego funkcje należą. Oprócz tego za pomocą obu
metod można określać argumenty wywołań. Metoda call() wykorzystuje własną listę
argumentów będących argumentami funkcji, a argumentem metody apply() jest tablica
wartości, które mają być umieszczone w argumentach funkcji. Obie metody będą opisane w
punkcie 8.7.4.

8.2.5. Niejawne wywołanie funkcji


Język JavaScript ma wiele funkcjonalności wywołujących funkcje, mimo że nie wyglądają jak
wywołania. Podczas pisania funkcji, które będą wywoływane niejawnie, należy zachować
szczególną ostrożność, ponieważ błędy, efekty uboczne i problemy wydajnościowe trudniej się
diagnozuje i poprawia niż w przypadku zwykłych funkcji. Przyczyna jest prosta: analizując taki
kod, nie zawsze wiadomo, kiedy funkcja jest wywoływana.

Poniżej są opisane przypadki niejawnych wywołań funkcji:

Jeżeli w obiekcie są zdefiniowane gettery i settery, wówczas odczytywanie i zapisywanie


wartości odpowiednich właściwości skutkuje wywołaniami powyższych metod. Więcej
informacji na ten temat jest zawartych w punkcie 6.10.6.
Jeżeli obiekt jest wykorzystywany w kontekście tekstowym, na przykład gdy jest łączony z
innym ciągiem, wywoływana jest jego metoda toString(). Analogicznie gdy obiekt jest
wykorzystywany w kontekście liczbowym, wywoływana jest jego metoda valueOf().
Szczegółowe informacje na ten temat są zawarte w punkcie 3.9.3.
W pętli przetwarzającej elementy obiektu iterowalnego wywoływanych jest kilka jego
metod. W rozdziale 12. opisane będzie działanie iteratorów na poziomie funkcji i
definiowanie metod umożliwiających kodowanie własnych, iterowalnych typów.
Oznakowany literał szablonowy jest ukrytym wywołaniem funkcji. W podrozdziale 14.5
opisane będzie tworzenie funkcji, które można stosować w połączeniu z tekstowymi
literałami szablonowymi.
Działanie obiektów pośredniczących, które będą opisane w podrozdziale 14.7, kontroluje
się wyłącznie za pomocą funkcji. Każda operacja wykonana na takim obiekcie powoduje
wywołanie funkcji.

8.3. Argumenty i parametry funkcji


W definicji funkcji nie określa się typów parametrów, jak również podczas jej wywoływania nie
są sprawdzane typy wartości umieszczonych w argumentach. W rzeczywistości nie jest nawet
sprawdzana liczba argumentów. W kolejnych punktach opisano, co się dzieje podczas
wywoływania funkcji, gdy liczba argumentów jest mniejsza lub większa od liczby
zadeklarowanych parametrów. Oprócz tego przedstawione są sposoby sprawdzania typów,
uniemożliwiające wywoływanie funkcji z niewłaściwymi argumentami.

8.3.1. Parametry opcjonalne i domyślne


Jeżeli funkcja jest wywoływana z mniejszą liczbą argumentów niż jest zadeklarowanych
parametrów, wówczas dodatkowym parametrom są przypisywane wartości domyślne, zazwyczaj
undefined. Bardzo przydatne są funkcje z opcjonalnymi argumentami. Poniżej przedstawiony
jest przykład:
// Funkcja dołączająca do tablicy a nazwy wyliczalnych właściwości obiektu o

// i zwracająca tę tablicę. Jeżeli tablica nie zostanie określona, funkcja


utworzy nową.
function getPropertyNames(o, a) {

if (a === undefined) a = []; // Utworzenie nowej tablicy, jeżeli została


określona.

for(let property in o) a.push(property);


return a;
}

// Funkcję getPropertyNames() można wywoływać z jednym lub dwoma argumentami:


let o = {x: 1}, p = {y: 2, z: 3}; // Dwa testowe obiekty.

let a = getPropertyNames(o); // a == ["x"]; umieszczenie właściwości obiektu


o w nowej tablicy.

getPropertyNames(p, a); // a == ["x","y","z"]; dołączenie właściwości


obiektu p do podanej tablicy.
Zamiast instrukcji if w pierwszym wierszu funkcji można użyć operatora || w poniższy,
idiomatyczny sposób:

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

// i zwracająca tę tablicę. Jeżeli tablica nie zostanie określona, funkcja


utworzy nową.
function getPropertyNames(o, a = []) {
for(let property in o) a.push(property);

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:

// Funkcja zwracająca obiekt reprezentujący wymiary prostokąta.


// Jeżeli określona jest tylko szerokość, to przyjmowana jest wysokość

// dwukrotnie większa od szerokości.


const rectangle = (width, height=width*2) => ({width, height});

rectangle(1) // => { width: 1, height: 2 }


Powyższy kod pokazuje funkcjonowanie parametrów, których domyślne wartości są określone
za pomocą funkcji strzałkowych. Zasada ta dotyczy wszystkich form definicji funkcji, w tym
skróconych metod.

8.3.2. Parametry resztowe i lista argumentów o


zmiennej długości
Dzięki domyślnym wartościom parametrów funkcję można wywoływać z mniejszą liczbą
argumentów, niż jest zdefiniowanych parametrów. Natomiast parametry resztowe pozwalają
na coś przeciwnego — definiowanie funkcji, które można wywoływać z dowolną liczbą
argumentów przekraczającą liczbę parametrów. Poniżej jest przedstawiony przykład funkcji,
którą można wywoływać z dowolną liczbą argumentów liczbowych. Funkcja zwraca największy
z nich:

function max(first=-Infinity, ...rest) {


let maxValue = first; // Początkowe założenie, że największy jest
pierwszy argument.

// Analiza pozostałych argumentów i wyszukiwanie większych wartości.


for(let n of rest) {

if (n > maxValue) {
maxValue = n;

}
}
// Zwrócenie największego argumentu.

return maxValue;
}

max(1, 10, 100, 2, 3, 1000, 4, 5, 6) // => 1000


Parametr resztowy poprzedza się trzema kropkami. Musi to być ostatni parametr w deklaracji
funkcji. W jej wywołaniu podane wartości są najpierw przypisywane parametrom nieresztowym,
a pozostałe („reszta”) są umieszczane w tablicy i przekazywane jako wartość parametru
resztowego. Należy pamiętać, że wartością parametru resztowego w ciele funkcji jest zawsze
tablica, która może być pusta, ale nigdy nie jest to wartość undefined. Dlatego definiowanie
domyślnej wartości parametru resztowego równej undefined nie jest ani przydatne, ani
dozwolone.
Funkcja taka jak powyższa, którą można wywoływać z dowolną liczbą argumentów, jest
nazywana funkcją wariadyczną, funkcją o zmiennej arności lub funkcją vararg. W tej
książce jest stosowane najpopularniejsze określenie, vararg, którego geneza sięga początków
języka C.
Nie należy mylić operatora ... definiującego parametr resztowy z tak samo wyglądającym
operatorem rozciągania, opisanym w punkcie 8.3.4, który to operator można stosować w
wywołaniach funkcji.

8.3.3. Obiekt Arguments


Parametry resztowe pojawiły się w wersji języka ES6. W starszych wersjach funkcję vararg
definiowało się, wykorzystując obiekt Arguments. Identyfikator arguments odwołuje się w ciele
funkcji do obiektu typu Arguments. Jest to obiekt podobny do tablicy (patrz podrozdział 7.9),
umożliwiający odwoływanie się do argumentów funkcji za pomocą indeksów, a nie nazw. Poniżej
jest przedstawiona odmiana opisanej wcześniej funkcji max(), w której wykorzystany jest obiekt
Arguments zamiast parametru resztowego:
function max(x) {

let maxValue = -Infinity;


// Analizowanie argumentów, wyszukiwanie i zapamiętywanie największego.

for(let i = 0; i < arguments.length; i++) {

if (arguments[i] > maxValue) maxValue = arguments[i];


}
// Zwrócenie największego argumentu.
return maxValue;
}

max(1, 10, 100, 2, 3, 1000, 4, 5, 6) // => 1000


Obiekt Arguments istnieje od najwcześniejszych wersji języka JavaScript i ciąży na nim kilka
dziwnych historycznych zaszłości utrudniających optymalizację kodu, szczególnie w zwykłym
trybie. Możesz mieć do czynienia z kodem wykorzystującym obiekt Arguments, ale unikaj
stosowania go w nowych programach, które będziesz pisał. Jeżeli podczas refaktoryzowania
starego kodu napotkasz funkcję wykorzystującą obiekt Arguments, z dużym
prawdopodobieństwem będziesz go mógł zastąpić parametrem resztowym. Niewygodną
spuścizną po tym obiekcie jest między innymi identyfikator arguments, będący słowem
kluczowym uniemożliwiającym deklarowanie parametru funkcji i lokalnej zmiennej o tej nazwie.
8.3.4. Operator rozciągania w wywołaniach funkcji
Operator rozciągania ... służy do rozpakowywania elementów tablicy (lub innego iterowalnego
obiektu, na przykład ciągu znaków) wszędzie tam, gdzie są wymagane osobne wartości. W
punkcie 7.1.2 poznałeś zastosowanie tego operatora w literałach tablicowych. W analogiczny
sposób można go wykorzystywać w wywołaniach funkcji:
let numbers = [5, 2, 10, -1, 9, 100, 1];
Math.min(...numbers) // => –1
Zwróć uwagę, że nie jest to operator w ścisłym znaczeniu tego słowa, ponieważ nie zwraca
wartości. W rzeczywistości jest to specjalny element składni, który można wykorzystywać w
literałach tablicowych i wywołaniach funkcji.
Symbol ... użyty w definicji funkcji, a nie w jej wywołaniu jest przeciwieństwem operatora
rozciągania. Jak się przekonałeś w punkcie 8.3.2, symbol ten użyty w definicji funkcji gromadzi
argumenty w tablicy. Często parametr resztowy stosuje się razem z operatorem rozciągania, jak
w poniższej funkcji, której argumentem jest inna funkcja, a zwracanym wynikiem jest wersja
zinstrumentalizowana na potrzeby testów:

// Argumentem tej funkcji jest inna funkcja, a zwracanym wynikiem jej


opakowana wersja.
function timed(f) {
return function(...args) { // Zebranie argumentów w parametr resztowy.
console.log(`Wywołanie funkcji ${f.name}`);

let startTime = Date.now();


try {
// Przekazanie wszystkich argumentów opakowanej funkcji.
return f(...args); // Ponowne rozciągnięcie argumentów.

}
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.

8.3.5. Destrukturyzacja argumentów funkcji do jej


parametrów
Wartości argumentów wywoływanej funkcji są przypisywane parametrom zadeklarowanym w jej
definicji. Początkowa faza wywołania bardzo więc przypomina przypisywanie wartości
zmiennej. Nie powinno więc dziwić, że z funkcjami można stosować przypisania
destrukturyzujące (patrz punkt 3.10.3).
Jeżeli nazwy parametrów funkcji umieści się w nawiasach kwadratowych, będzie to oznaczać,
że w wywołaniach tej funkcji w tych parametrach można umieszczać tablice. Argumenty
tablicowe są wtedy rozpakowywane i umieszczane w parametrach o własnych nazwach.
Załóżmy, że wektor w przestrzeni dwuwymiarowej jest reprezentowany jako tablica złożona z
dwóch liczb, z których pierwsza jest współrzędną X, a druga współrzędną Y. Wykorzystując tę
prostą strukturę danych, można napisać następującą funkcję dodającą do siebie dwa wektory:

function vectorAdd(v1, v2) {


return [v1[0] + v2[0], v1[1] + v2[1]];
}
vectorAdd([1,2], [3,4]) // => [4,6]

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

vectorAdd([1,2], [3,4]) // => [4,6]


Podobnie można rozpakowywać właściwości obiektowego argumentu funkcji. Wykorzystajmy
ponownie przykład z wektorami, ale tym razem przyjmijmy, że wektory są obiektami o
właściwościach x i y:
// Mnożenie wektora {x,y} przez wartość skalarną.

function vectorMultiply({x, y}, scalar) {


return { x: x*scalar, y: y*scalar };
}
vectorMultiply({x: 1, y: 2}, 2) // => {x: 2, y: 4}
Powyższy kod, w którym jeden argument obiektowy jest destrukturyzowany do dwóch
parametrów, jest bardzo czytelny, ponieważ nazwy parametrów odpowiadają nazwom
właściwości obiektu. Jeżeli jednak nazwy właściwości trzeba destrukturyzować do parametrów
o innych nazwach, wówczas składnia jest bardziej rozbudowana i zawiła. Poniżej przedstawiona
jest funkcja dodająca wektory reprezentowane za pomocą obiektów:
function vectorAdd(
{x: x1, y: y1}, // Rozpakowanie właściwości pierwszego obiektu do
parametrów o nazwach x1 i y1.
{x: x2, y: y2} // Rozpakowanie właściwości drugiego obiektu do
parametrów o nazwach x2 i y2.

)
{
return { x: x1 + x2, y: y1 + y2 };
}

vectorAdd({x: 1, y: 2}, {x: 3, y: 4}) // => {x: 4, y: 6}


W składni destukturyzującej {x:x1, y:y1} najtrudniej jest odróżnić nazwy właściwości od nazw
parametrów. W destrukturyzujących przypisaniach i wywołaniach funkcji należy przestrzegać
zasady, że deklarowane zmienne i parametry umieszcza się miejscach, w których wymagane są
wartości literału obiektowego. Zatem nazwę właściwości zawsze umieszcza się po lewej stronie
dwukropka, a parametr lub zmienną po prawej.
Można definiować domyślne wartości parametrów destrukturyzujących. Poniżej jest
przedstawiona funkcja mnożąca wektor w przestrzeni dwu- lub trójwymiarowej:
// Mnożenie wektora {x, y} lub {x, y, z} przez wartość skalarną.
function vectorMultiply({x, y, z=0}, scalar) {
return { x: x*scalar, y: y*scalar, z: z*scalar };

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

arraycopy({from: a, n: 3, to: b, toIndex: 4}) // => [9,8,7,6,1,2,3,5]


W kodzie destrukturyzującym tablicę można definiować parametr resztowy, w którym będą
umieszczane dodatkowe wartości rozpakowywanej tablicy. Taki parametr umieszczony
wewnątrz nawiasów kwadratowych jest całkowicie niezależny od parametru resztowego
funkcji:
// Argumentem tej funkcji jest tablica. Jej pierwsze dwa elementy są
rozpakowywane

// do parametrów x i y. Wszystkie pozostałe elementy są umieszczane w tablicy


coords.
// Argumenty drugi i kolejne są umieszczane w tablicy rest.
function f([x, y, ...coords], ...rest) {
return [x+y, ...rest, ...coords]; // Zwróć uwagę na użyty tutaj operator
rozciągania.

}
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.
}

Z mojego doświadczenia wynika, że kod, w którym destrukturyzowany jest bardziej złożony


obiekt niż powyższy, jest mniej czytelny. Czasami prościej jest jawnie odwoływać się do
właściwości obiektu lub elementów tablicy za pomocą indeksów.

8.3.6. Typy argumentów


W definicji funkcji nie deklaruje się typów jej parametrów, jak również nie są sprawdzane typy
umieszczanych w nich wartości. Można natomiast tworzyć samodokumentujący się kod,
stosując opisowe nazwy argumentów i opatrując je dokładnymi komentarzami. Ewentualnie
można wykorzystywać opisane w podrozdziale 17.8 rozszerzenie języka do warstwowego
sprawdzania typów.
Jak pisałem w podrozdziale 3.9, w języku JavaScript obowiązują liberalne zasady konwersji
typów. Jeżeli argumentem funkcji jest ciąg znaków, można w nim umieszczać wartości
dowolnych typów, które można przekształcić w ciągi. Warunek ten spełniają wszystkie
prymitywne wartości, natomiast każdy obiekt posiada metodę toString() (zazwyczaj niezbyt
przydatną). Zatem tego rodzaju funkcja nigdy nie zgłosi błędu.
Jednak nie zawsze tak jest. Przeanalizujmy ponownie opisaną wcześniej funkcję arraycopy().
Jej argumentami są dwie tablice. Jeżeli w tych argumentach umieści się wartości niewłaściwych
typów, pojawi się błąd. Jeżeli funkcja jest przeznaczona tylko do prywatnego użytku i jest
wywoływana tylko w pobliskim kodzie, można umieścić w niej instrukcje sprawdzające typy
argumentów, jak w poniższym przykładzie. Jednak lepszym rozwiązaniem jest wczesne i
jednoznaczne zgłaszanie błędu niż wykonywanie kodu, w którym błąd ujawni się później i
zostanie wyświetlony niezwiązany z nim komunikat. Poniżej przedstawiona jest przykładowa
funkcja, w której sprawdzane są typy jej argumentów:
// Funkcja zwracająca sumę właściwości iterowalnego obiektu a.
// Wszystkie właściwości muszą być liczbami.
function sum(a) {

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ą.

8.4. Funkcje jako wartości


Definicje i wywołania funkcji są składniowymi elementami większości języków programowania.
Jednak w języku JavaScript funkcje są również obiektami. Oznacza to, że można je przypisywać
zmiennym, właściwościom obiektów i elementom tablic, jak również umieszczać w
argumentach innych funkcji[3].
Aby przekonać, się jak w języku JavaScript funkcję można traktować jako dane i jako składnię,
przeanalizujmy poniższy przykład:
function square(x) { return x*x; }
Powyższa definicja tworzy nowy obiekt funkcyjny i przypisuje go zmiennej square. Nazwa
funkcji jest niematerialna, tzn. jest to po prostu nazwa zmiennej odwołującej się do obiektu
funkcyjnego. Powyższą funkcję można równie dobrze przypisać innej zmiennej:
let s = square; // Teraz zmienna s odwołuje się do tego samego obiektu co
zmienna square.
square(4) // => 16
s(4) // => 16

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:

let a = [x => x*x, 20]; // Literał tablicowy.


a[0](a[1]) // => 400
Składnia powyższego kodu wygląda dziwnie, ale jest poprawnym wyrażeniem wywołującym
funkcję!
Aby przekonać się, jak przydatna jest możliwość traktowania funkcji jako wartości,
przeanalizujmy metodę Array.sort() sortującą elementy tablicy. Ponieważ można je sortować
na wiele sposobów (jako liczby, ciągi znaków, daty, w kolejności rosnącej, malejącej itp.), w
opcjonalnym argumencie metody sort() umieszcza się funkcję określającą porządek
sortowania. Zadanie tej funkcji jest proste: musi zwracać wartość określającą, czy jej pierwszy
argument powinien być umieszczony w posortowanej tablicy przed drugim argumentem. Dzięki
tej funkcji metoda Array.sort() jest uniwersalna i elastyczna, może sortować dane dowolnych
typów w dowolnym porządku. Przykłady użycia zostały przedstawione w punkcie 7.8.6.
Poniższy kod demonstruje, co można osiągnąć, wykorzystując funkcje jako wartości. Przykład
jest dość skomplikowany, ale w komentarzach jest opisane jego działanie.
// Definicje kilku prostych funkcji.
function add(x,y) { return x + y; }
function subtract(x,y) { return x - y; }

function multiply(x,y) { return x * y; }


function divide(x,y) { return x / y; }
// Funkcja, której argumentem jest jedna z powyższych funkcji,
// wywoływana z dwoma operandami.
function operate(operator, operand1, operand2) {

return operator(operand1, operand2);


}
// Aby wyliczyć wartość wyrażenia (2+3) + (4*5) można tę funkcję wywołać w
następujący sposób:
let i = operate(add, operate(add, 2, 3), operate(multiply, 4, 5));

// Na potrzeby demonstracji ponownie implementowane są proste funkcje,


// tym razem w literale obiektowym:
const operators = {
add: (x,y) => x+y,
subtract: (x,y) => x-y,
multiply: (x,y) => x*y,

divide: (x,y) => x/y,


pow: Math.pow // Można również wykorzystywać predefiniowane
funkcje.
};
// Argumentem tej funkcji jest nazwa operatora. Funkcja szuka

// zadanego operatora w obiekcie i wywołuje go z zadanymi operandami.


// Zwróć uwagę na składnię wywołania funkcji operatora.
function operate2(operation, operand1, operand2) {
if (typeof operators[operation] === "function") {

return operators[operation](operand1, operand2);


}
else throw "nieznany operator";
}
operate2("add", "Witaj, ", operate2("add", " ", "świecie!")) // => "Witaj,
świecie"
operate2("pow", 10, 2) // => 100

8.4.1. Definiowanie własnych właściwości funkcji


W języku JavaScript funkcje nie są wartościami prymitywnymi, ale specjalnymi obiektami.
Oznacza to, że funkcje mają właściwości. Jeżeli w funkcji jest potrzebna statyczna zmienna,
która zachowuje wartość pomiędzy wywołaniami funkcji, wygodnym sposobem jest użycie
w tym celu właściwości funkcji. Załóżmy, że potrzebna jest funkcja zwracająca przy każdym
wywołaniu unikatową liczbę całkowitą. Funkcja ta nie może dwa razy zwrócić tej samej
wartości. Aby osiągnąć ten efekt, trzeba rejestrować zwracane wartości, a do tego informacje
te muszą być zachowywane pomiędzy kolejnymi wywołaniami funkcji. Można je zapisywać w
globalnej zmiennej, ale nie jest to najlepsze rozwiązanie, ponieważ dane te powinny być
dostępne tylko dla tej funkcji. Lepiej jest wykorzystać w tym celu właściwość obiektu Function.
Poniżej jest przedstawiona przykładowa funkcja, która po każdym wywołaniu zwraca inną liczbę
całkowitą:

// Zainicjowanie właściwości counter obiektu funkcyjnego.


// Deklaracje funkcji są windowane, więc przypisanie
// może znajdować się przed deklaracją funkcji.
uniqueInteger.counter = 0;

// Funkcja zwracająca po każdym wywołaniu inną liczbę całkowitą.


// Kolejną zwracaną wartość zapisuje w swojej właściwości.
function uniqueInteger() {
return uniqueInteger.counter++; // Zwrócenie i powiększenie właściwości
counter.

}
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.

if (!(n in factorial)) { // Jeżeli nie ma


zapamiętanych wyników…
factorial[n] = n * factorial(n-1); // …funkcja je wylicza i
zapamiętuje.
}
return factorial[n]; // Zwrócenie zapamiętanego
wyniku.
} else {
return NaN; // Zwracana wartość, jeżeli
dane wejściowe są błędne.
}

}
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.

8.5. Funkcje jako przestrzenie nazw


Zmienne zadeklarowane wewnątrz funkcji nie są widoczne poza nią. Dlatego czasami funkcje
wykorzystuje się jako tymczasowe przestrzenie nazw, w których można definiować zmienne, nie
zaśmiecając globalnej przestrzeni.
Załóżmy, że mamy fragment kodu, który chcemy wykorzystywać w kilku różnych programach.
Może to być na przykład skrypt kliencki, który będzie stosowany w kodach różnych stron WWW.
Jak w większości przypadków, w tym fragmencie są zdefiniowane zmienne wykorzystywane do
przechowywania wyników różnych operacji. Problem polega na tym, że nie wiadomo, czy te
zmienne nie będą kolidowały z innymi zmiennymi, zdefiniowanymi w programach, w których
będzie wykorzystywany ten fragment kodu. Rozwiązaniem może być umieszczenie fragmentu
kodu wewnątrz funkcji i wywołanie jej. Dzięki temu zmienne, które byłyby globalne, staną się
lokalnymi zmiennymi nowej funkcji:
function chunkNamespace() {
// Tu znajduje się fragment kodu.
// Wszystkie zdefiniowane w nim zmienne są lokalne,

// dzięki czemu nie zaśmiecają globalnej przestrzeni nazw.


}
chunkNamespace(); // Nie zapomnij o wywołaniu funkcji!
Powyższy kod definiuje tylko jedną globalną zmienną: nazwę funkcji chunkNamespace. Jeżeli
definicja nawet jednej właściwości nie jest dopuszczalna, można użyć funkcji anonimowej i
wywołać ją w jednym wyrażeniu:

(function() { // Funkcja chunkNamespace() zmieniona na anonimowe wyrażenie.


// Tu znajduje się fragment kodu.
}()); // Koniec literału funkcyjnego i wywołanie funkcji.
Powyższa technika definiowania i wywoływania funkcji za pomocą jednego wyrażenia jest tak
często stosowana, że stała się idiomatyczna i doczekała się własnej nazwy „natychmiastowo
wywoływane wyrażenie funkcyjne”. Zwróć uwagę na użycie nawiasów. Przed słowem function
wymagane jest umieszczenie nawiasu otwierającego. Jeżeli go nie będzie, interpreter
potraktuje słowo kluczowe function jako deklarację funkcji. Natomiast w przypadku użycia
nawiasów zinterpretuje je jako wyrażenie definiujące funkcję. Nawiasy znajdujące się na końcu
wyrażenia poprawiają czytelność kodu i oznaczają, że funkcja jest wywoływana natychmiast, a
nie definiowana w celu późniejszego wykorzystania.

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() {

let scope = "lokalny zasięg"; // Lokalna zmienna.


function f() { return scope; } // Zwrócenie wartości zmiennej scope.
return f();
}
checkscope() // => "lokalny zasięg"

Funkcja checkscope() deklaruje lokalną zmienną, a następnie definiuje i wywołuje inną


funkcję, która zwraca wartość tej zmiennej. Na pewno zrozumiałe jest, dlaczego funkcja ta
zwraca ciąg "lokalny zasięg". Teraz zmieńmy nieco kod. Czy potrafisz określić, co tym razem
zwróci funkcja?
let scope = "globalny zasięg"; // Globalna zmienna.

function checkscope() {
let scope = "lokalny zasięg"; // Lokalna zmienna.
function f() { return scope; } // Zwrócenie wartości zmiennej scope.
return f;
}

let s = checkscope()(); // Co zwraca ta funkcja?


W powyższym kodzie para nawiasów została przeniesiona z wnętrza funkcji checkscope() na
zewnątrz. Teraz funkcja ta nie wywołuje zagnieżdżonej funkcji i nie zwraca jej wyniku, tylko
obiekt zagnieżdżonej funkcji. Co się stanie, gdy taka zagnieżdżona funkcja zostanie wywołana
(za pomocą dodatkowej pary nawiasów, jak w ostatnim wierszu) poza funkcją, w której została
zdefiniowana?

Przypomnij sobie podstawową zasadę dotyczącą zasięgów leksykalnych: funkcja jest


wykonywana w tym zasięgu, w którym została zdefiniowana. Zagnieżdżona funkcja f() jest
zdefiniowana w zasięgu, w którym zmiennej scope jest przypisywany ciąg "lokalny zasięg".
Przypisanie to obowiązuje w chwili, gdy wywoływana jest funkcja f(), niezależnie od miejsca
wywołania. Dlatego ostatni wiersz powyższego kodu zwraca ciąg "lokalny zasięg", a nie
"globalny zasięg". Jest to niezwykła i bardzo przydatna cecha domknięcia: wykorzystuje
lokalne zmienne i parametry zewnętrznej funkcji, w której jest zdefiniowane.

W punkcie 8.4.1 została zdefiniowana funkcja uniqueInteger(), która wykorzystywała własną


właściwość do rejestrowania zwracanych wartości. Zastosowane rozwiązanie ma ten
mankament, że wadliwy lub złośliwy kod może zresetować właściwość lub przypisać jej wartość
inną niż liczba całkowita, przez co funkcja przestałaby działać zgodnie z założeniami. Natomiast
za pomocą domknięcia można do zapamiętania informacji o stanie funkcji wykorzystać lokalną
zmienną. Poniżej jest przedstawiona nowa wersja funkcji uniqueInteger(), która wykorzystuje
natychmiastowo wywoływane wyrażenie funkcyjne do zdefiniowania przestrzeni nazw, a
domknięcie do przechowywania swojego stanu w lokalnej zmiennej:
let uniqueInteger = (function() { // Zdefiniowanie i wywołanie funkcji
let counter = 0; // rejestrującej prywatny stan.
return function() { return counter++; };
}());
uniqueInteger() // => 0

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.

c.reset(); // Metody reset() i count() współdzielą


ten sam stan.
c.count() // => 0: reset licznika c.
d.count() // => 1: licznik d nie jest resetowany.

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.

Warto wspomnieć, że domknięcia można stosować razem z getterami i setterami. Poniższa


wersja funkcji counter() jest odmianą kodu przedstawionego w punkcie 6.10.6. Różni się od
niej tym, że do przetwarzania wewnętrznego stanu wykorzystuje domknięcia, a nie zwykłą
właściwość obiektu:
function counter(n) { // Argument n jest lokalną zmienną.
return {
// Getter zwraca wartość lokalnej zmiennej licznikowej i powiększa ją.

get count() { return n++; },


// Setter uniemożliwia pomniejszenie wartości zmiennej n.
set count(m) {
if (m > n) n = m;
else throw Error("przypisywana wartość musi być większa od bieżącej");

}
};
}
let c = counter(1000);

c.count // => 1000


c.count // => 1001
c.count = 2000;
c.count // => 2000
c.count = 2000; // !Error: przypisywana wartość musi być większa od
bieżącej.
Zwróć uwagę, że w tej wersji funkcji nie jest deklarowana lokalna zmienna. Do przechowywania
prywatnego stanu jest wykorzystywany po prostu parametr n, współdzielony przez metody
dostępowe. Dzięki nim w kodzie wywołującym funkcję counter() można określić początkową
wartość lokalnej zmiennej.
Poniższy listing przedstawia uniwersalny kod, w którym wykorzystana jest opisana wyżej
technika współdzielenia prywatnego stanu funkcji przez domknięcia. Zdefiniowana jest w nim
funkcja addPrivateProperty() z lokalną zmienną i dwiema zagnieżdżonymi funkcjami
służącymi do odczytywania i zapisywania wartości tej zmiennej. Obie funkcje są dodawane do
wskazanego obiektu.
// Poniższa funkcja dodaje do obiektu o metody odczytujące i zapisujące
// wartość właściwości o zadanej nazwie. Metody uzyskują nazwy get<name>

// i set<name>. Jeżeli podana jest funkcja predykatu, setter wykorzystuje ją


// do sprawdzania poprawności argumentu przed przypisaniem go właściwości.
// Jeżeli funkcja predykatu zwróci wartość false, setter zgłosi wyjątek.
//
// Funkcja ma tę wyjątkową cechę, że wartość przetwarzana za pomocą

// gettera i settera nie jest zapisywana w obiekcie o, tylko w lokalnej


zmiennej
// funkcji. Obie metody dostępowe są również zdefiniowane lokalnie, więc
// mają dostęp do tej zmiennej. Oznacza to, że jej wartość jest lokalna

// 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.

o.getName() // => "Jan"


o.setName(0); // !TypeError: setName: niepoprawna wartość – 0.
Poznałeś kilka przykładów funkcji, w których były zdefiniowane dwa domknięcia współdzielące
ten sam zasięg i dostęp do lokalnych zmiennych. Jest to ważna technika programowania, ale
równie ważne jest identyfikowanie przypadków, w których domknięcia nie powinny współdzielić
dostępu do lokalnych zmiennych. Przeanalizujmy poniższy kod:
// Funkcja zwracająca funkcję, która zawsze zwraca wartość argumentu v.
function constfunc(v) { return () => v; }
// Utworzenie tablicy funkcji zwracających stałe wartości.
let funcs = [];
for(var i = 0; i < 10; i++) funcs[i] = constfunc(i);

// Funkcja umieszczona w elemencie o indeksie 5 zwraca wartość 5.


funcs[5]() // => 5
W kodzie takim jak powyższy, w którym za pomocą pętli tworzonych jest wiele domknięć, często
popełnianym błędem jest umieszczanie pętli wewnątrz funkcji. Ilustruje to poniższy kod:
// Funkcja zwracająca tablicę funkcji, które zwracają wartości od 0 do 9.
function constfuncs() {
let funcs = [];
for(var i = 0; i < 10; i++) {
funcs[i] = () => i;
}
return funcs;

}
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.

8.7. Właściwości, metody i konstruktory


funkcji
Jak już dobrze wiesz, funkcje w języku JavaScript są obiektami. Operator typeof użyty z funkcją
zwraca ciąg "function". Funkcje, tak jak wszystkie obiekty, mogą mieć właściwości i metody.
Jest nawet konstruktor Function() tworzący obiekt funkcyjny. W kolejnych punktach opisane
są właściwości length, name i prototype, metody call(), apply(), bind() i toString() oraz
konstruktor Function().

8.7.1. Właściwość length


Właściwość length, przeznaczona tylko do odczytu, zawiera arność funkcji, czyli liczbę jej
parametrów, zazwyczaj równą liczbie argumentów, które można określać. Parametr resztowy
nie jest uwzględniany we właściwości length.
8.7.2. Właściwość name
Właściwość name, przeznaczona tylko do odczytu, może zawierać nazwę funkcji określoną w jej
definicji albo nazwę zmiennej lub właściwości, której zostało przypisane nienazwane wyrażenie
funkcyjne. Właściwość ta przydaje się podczas diagnozowania kodu i wyświetlania
komunikatów o błędach.

8.7.3. Właściwość prototype


Wszystkie funkcje, z wyjątkiem strzałkowych, mają właściwość prototype odwołującą się do
obiektu zwanego prototypem. Każda funkcja ma inny prototyp. Jeżeli funkcja jest
konstruktorem, tworzony obiekt dziedziczy właściwości po prototypie. Prototypy i właściwość
prototype zostały opisane w punkcie 6.2.3. Ponownie spotkasz się z nimi w rozdziale 9.

8.7.4. Metody call() i apply()


Za pomocą metod call() i apply() można pośrednio wywoływać funkcję tak, jakby była
metodą obiektu (patrz punkt 8.2.4). Pierwszym argumentem każdej z powyższych metod jest
obiekt, do którego funkcja należy. Argument ten stanowi kontekst funkcji i jest przypisywany
słowu kluczowemu this w ciele funkcji. Aby wywołać bez argumentów funkcję f() jako metodę
obiektu o, należy użyć metody call() lub apply():

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.

// Zastąpienie metody m obiektu o inną metodą, która wyświetla komunikat,


// zanim wywoła oryginalną metodę.
function trace(o, m) {
let original = o[m]; // Zapamiętanie oryginalnej metody w
domknięciu.
o[m] = function(...args) { // Zdefiniowanie nowej metody.
console.log(new Date(), "Wywołanie metody:", m); // Wyświetlenie
komunikatu.
let result = original.apply(this, args); // Wywołanie
oryginalnej metody.
console.log(new Date(), "Zakończenie metody:", m); // Wyświetlenie
komunikatu.
return result; // Zwrócenie wyniku.
};
}

8.7.5. Metoda bind()


Podstawowym przeznaczeniem metody bind() jest wiązanie funkcji z obiektem. Metoda ta,
należąca do funkcji f, wywołana z obiektem o w argumencie, zwraca nową funkcję. Wywołanie
tej funkcji skutkuje wywołaniem funkcji f jako metody obiektu o. Wszystkie argumenty nowej
funkcji są przekazywane oryginalnej funkcji. Ilustruje to poniższy przykład:
function f(y) { return this.x + y; } // Ta funkcja musi być powiązana z
obiektem.

let o = { x: 1 }; // Obiekt, z którym będzie powiązana


funkcja.
let g = f.bind(o); // Wywołanie funkcji g(x) powoduje
wywołanie metody f() obiektu o.
g(2) // => 3
let p = { x: 10, g }; // Wywołanie funkcji g() jako metody
obiektu this.
p.g(2) // => 3: Funkcja g jest wciąż powiązana
z obiektem o, a nie p.
Funkcja strzałkowa dziedziczy wartość this po funkcji, w której jest zdefiniowana, i nie można
jej nadpisać za pomocą metody bind(). Gdyby więc w powyższym przykładzie funkcja f() była
zdefiniowana jako strzałkowa, nie byłaby wiązana z obiektem. Metodę bind() najczęściej
wykorzystuje się po to, aby zwykłą funkcję można było stosować jako strzałkową. W praktyce
ograniczenia związane z wiązaniem funkcji nie są problemem.
Metoda bind() robi coś więcej niż wiązanie funkcji z obiektem. Przeprowadza również tzw.
częściowe stosowanie funkcji. Argumenty drugi i kolejne są wiązane z wartością this. Nie
dotyczy to jednak funkcji strzałkowych. Częściowe stosowanie, zwane również rozwijaniem
funkcji (ang. currying), jest popularną techniką programowania funkcyjnego. Poniżej jest
przedstawionych kilka przykładów częściowego stosowania funkcji za pomocą metody bind():
let sum = (x,y) => x + y; // Zwrócenie sumy dwóch argumentów.
let succ = sum.bind(null, 1); // Powiązanie pierwszego argumentu z liczbą 1.
succ(2) // => 3: argument x jest powiązany z liczbą 1. W argumencie y jest
umieszczana liczba 2.

function f(y,z) { return this.x + y + z; }


let g = f.bind({x: 1}, 2); // Powiązanie this i argumentu y.
g(3) // => 6: argument x jest powiązany z liczbą 1, y z liczbą 2, a z z
liczbą 3.
Właściwość name funkcji, zwrócona przez metodę bind(), ma taką samą wartość jak właściwość
name funkcji, do której należy metoda bind(), poprzedzona prefiksem bound.

8.7.6. Metoda toString()


Funkcja, tak jak każdy obiekt, ma metodę toString(). Zgodnie ze specyfikacją ECMAScript
metoda ta powinna zwracać ciąg zawierający deklarację funkcji. Jednak w praktyce w
większości przypadków zwraca pełny kod źródłowy funkcji. Funkcje wbudowane zazwyczaj
zwracają ciągi, które w miejscu ciała zawierają sekwencję [native code] lub podobną.

8.7.7. Konstruktor Function()


Ponieważ funkcje są obiektami, dostępny jest konstruktor Function(), za pomocą którego
można tworzyć nowe funkcje:
const f = new Function("x", "y", "return x*y;");
Ten kod tworzy nową funkcję, odpowiadającą mniej więcej poniższej, zdefiniowanej przy użyciu
dobrze znanej składni:
const f = function(x, y) { return x*y; };
W argumentach konstruktora Function() można umieszczać dowolną liczbę ciągów znaków.
Ostatnim argumentem jest ciało funkcji. Argument ten może zawierać dowolne instrukcje
JavaScriptu oddzielone średnikami. Wszystkie pozostałe argumenty konstruktora stanowią
nazwy parametrów funkcji. Aby zdefiniować funkcję bez argumentów, należy wywołać
konstruktor z jednym argumentem — ciałem funkcji.

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ć:

Dzięki niemu można dynamicznie tworzyć i kompilować funkcje w czasie działania


programu.
Konstruktor analizuje ciało funkcji i tworzy nową funkcję za każdym razem, gdy jest
wywoływany. Jeżeli ma to miejsce wewnątrz pętli lub innej, często wywoływanej funkcji,
cały proces może być niewydajny. Dla porównania zagnieżdżone funkcje i wyrażenia
funkcyjne umieszczone wewnątrz pętli nie są ponownie kompilowane w każdej iteracji.
Funkcje tworzone za pomocą konstruktora nie wykorzystują zasięgu leksykalnego. Są
zawsze kompilowane tak jak funkcje najwyższego poziomu. Ilustruje to poniższy kod:

let scope = "global";


function constructFunction() {
let scope = "local";
return new Function("return scope"); // Lokalny zasięg nie jest
przechwytywany!
}
// Poniższy wiersz zwraca wynik "global", ponieważ funkcja zwrócona przez
// konstruktor Function() nie wykorzystuje lokalnego zasięgu.

constructFunction()() // => "global"


Konstruktor Function() najlepiej jest traktować jako funkcję eval() o globalnym zasięgu
(patrz punkt 4.12.2), definiującą nowe zmienne i funkcje we własnym, prywatnym zasięgu.
Prawdopodobnie nigdy nie będziesz musiał stosować tego konstruktora w swoim kodzie.

8.8. Programowanie funkcyjne


JavaScript nie jest językiem funkcyjnym, jak Lisp czy Haskell. Ponieważ jednak funkcjami
można posługiwać się tak jak obiektami, można również stosować techniki programowania
funkcyjnego. Szczególnie dobrze nadają się do tego celu metody tablicowe, takie jak map() i
reduce(). W tym podrozdziale przedstawione są techniki programowania funkcyjnego,
dostępne w języku JavaScript. Należy je jednak traktować jako potwierdzenie potęgi funkcji, a
nie zasady dobrego programowania.

8.8.1. Przetwarzanie tablic za pomocą funkcji


Załóżmy, że dana jest tablica liczb i trzeba wyliczyć ich średnią oraz odchylenie standardowe.
Można to zrobić w niefunkcyjnym stylu, jak niżej:
let data = [1,1,3,5,5]; // Tablica liczb.
// Średnia jest sumą elementów podzieloną przez ich liczbę.
let total = 0;
for(let i = 0; i < data.length; i++) total += data[i];
let mean = total/data.length; // mean == 3; średnia zadanych wartości jest
równa 3.

// Aby wyliczyć odchylenie standardowe, najpierw należy zsumować


// kwadraty odchyleń wszystkich elementów od średniej.
total = 0;
for(let i = 0; i < data.length; i++) {
let deviation = data[i] - mean;
total += deviation * deviation;
}
let stddev = Math.sqrt(total/(data.length-1)); // stddev == 2
Te same obliczenia można wykonać w zwięzłym, funkcyjnym stylu przy użyciu metod
tablicowych map() i reduce() (opisanych w punkcie 7.8.1):
// Najpierw definiujemy dwie proste funkcje.
const sum = (x,y) => x+y;
const square = x => x*x;
// Następnie stosujemy funkcje z metodami tablicowymi
// w celu wyliczenia średniej i odchylenia standardowego.
let data = [1,1,3,5,5];
let mean = data.reduce(sum)/data.length; // mean == 3
let deviations = data.map(x => x-mean);
let stddev = Math.sqrt(deviations.map(square).reduce(sum)/(data.length-1));

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;

let data = [1,1,3,5,5];


let mean = reduce(data, sum)/data.length;
let deviations = map(data, x => x-mean);
let stddev = Math.sqrt(reduce(map(deviations, square), sum)/(data.length-1));
stddev // => 2

8.8.2. Funkcje wyższego rzędu


Funkcje wyższego rzędu operują na funkcjach. Ich argumentami i zwracanymi wynikami są
funkcje. Poniżej jest przedstawiony przykład:
// Ta funkcja wyższego rzędu zwraca nową funkcję, która umieszcza argumenty
// w funkcji f i zwraca logiczną negację zwracanego przez nią wyniku.

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) {

return a => map(a, f);


}
const increment = x => x+1;
const incrementAll = mapper(increment);
incrementAll([1,2,3]) // => [2,3,4]
Poniżej jest przedstawiony bardziej ogólny przykład funkcji, której argumentami są funkcje f i
g, a zwracanym wynikiem funkcja zwracająca wartość wyrażenia f(g()):
// Funkcja zwracająca funkcję wyliczającą wartość wyrażenia f(g(…)).
// Zwracana funkcja h umieszcza wszystkie swoje argumenty w funkcji g,

// następnie wynik zwrócony przez funkcję g umieszcza w argumencie funkcji f


// i na koniec zwraca wynik funkcji f. Funkcje f i g są wywoływane z tą samą
// wartością this jak w funkcji h.
function compose(f, g) {
return function(...args) {
// Wykorzystywana jest metoda call() funkcji f, ponieważ w jej argumencie
// jest umieszczana pojedyncza wartość, oraz metoda apply() funkcji g,
// ponieważ w jej argumencie jest umieszczana tablica.
return f.call(this, g.apply(this, args));
};
}

const sum = (x,y) => x+y;


const square = x => x*x;
compose(square, sum)(2,3) // => 25; kwadrat sumy.
W kolejnych punktach są zdefiniowane dwie ważne funkcje wyższego rzędu: partial() i
memoize().

8.8.3. Częściowe stosowanie funkcji


Metoda bind() funkcji f (patrz punkt 8.7.5) zwraca nową funkcję, która wywołuje funkcję f w
zadanym kontekście i z zadanymi argumentami. Wiąże więc funkcję z obiektem i częściowo
stosuje argumenty. Metoda bind() częściowo stosuje argumenty znajdujące się po jej lewej
stronie. Oznacza to, że argumenty metody bind() są umieszczane na początku listy
argumentów przekazywanych oryginalnej funkcji. Możliwe jest również częściowe stosowanie
argumentów znajdujących się po prawie stronie:

// Argumenty tej funkcji są umieszczane po lewej stronie.


function partialLeft(f, ...outerArgs) {
return function(...innerArgs) { // Zwracana funkcja.
let args = [...outerArgs, ...innerArgs]; // Utworzenie listy
argumentów
return f.apply(this, args); // i wywołanie funkcji f z
tą listą.
};
}
// Argumenty tej funkcji są umieszczane po prawej stronie.

function partialRight(f, ...outerArgs) {


return function(...innerArgs) { // Zwracana funkcja.
let args = [...innerArgs, ...outerArgs]; // Utworzenie listy
argumentów
return f.apply(this, args); // i wywołanie funkcji f z
tą listą.
};
}
// Argumenty tej funkcji stanowią szablon. Niezdefiniowane wartości

// w liście są wypełniane wartościami pochodzącymi z wewnętrznego zbioru.


function partial(f, ...outerArgs) {
return function(...innerArgs) {
let args = [...outerArgs]; // Lokalna kopia szablonu z zewnętrznymi
argumentami
let innerIndex=0; // i następującymi po nich argumentami
wewnętrznymi.
// Iterowanie argumentów i zastępowanie niezdefiniowanych wartościami
pochodzącymi
// z wewnętrznego zbioru.
for(let i = 0; i < args.length; i++) {

if (args[i] === undefined) args[i] = innerArgs[innerIndex++];


}
// Dołączenie pozostałych wewnętrznych argumentów.
args.push(...innerArgs.slice(innerIndex));
return f.apply(this, args);
};
}
// Funkcja z trzema argumentami.
const f = function(x,y,z) { return x * (y - z); };

// Zwróć uwagę na różnice pomiędzy tymi trzema częściowymi zastosowaniami.


partialLeft(f, 2)(3,4) // => –2: powiązanie pierwszego argumentu: 2 *
(3 – 4)
partialRight(f, 2)(3,4) // => 6: powiązanie ostatniego argumentu: 3 *
(4 – 2)
partial(f, undefined, 2)(3,4) // => –6: powiązanie środkowego argumentu: 3 *
(2 – 4)
Dzięki częściowemu stosowaniu funkcji można na podstawie wcześniej zdefiniowanych tworzyć
nowe, ciekawe funkcje. Poniżej jest przedstawionych kilka przykładów:
const increment = partialLeft(sum, 1);

const cuberoot = partialRight(Math.pow, 1/3);


cuberoot(increment(26)) // => 3
Częściowe stosowanie funkcji staje się jeszcze ciekawsze, jeżeli dodatkowo wykorzysta się
funkcje wyższego rzędu. Poniżej jest przedstawiony przykład definicji opisanej wcześniej funkcji
not(). Tym razem wykorzystana została kompozycja i częściowe stosowanie funkcji:
const not = partialLeft(compose, x => !x);
const even = x => x % 2 === 0;
const odd = not(even);
const isNumber = not(isNaN);
odd(3) && isNumber(2) // => true

Powyższą technikę można również zastosować do wyliczenia średniej i odchylenia


standardowego w ekstremalnie funkcyjnym stylu:
// Funkcje sum() i square() zostały zdefiniowane wcześniej. Poniżej jest
zdefiniowanych kilka dodatkowych:
const product = (x,y) => x*y;
const neg = partial(product, -1);
const sqrt = partial(Math.pow, undefined, .5);
const reciprocal = partial(Math.pow, undefined, neg(1));
// Wyliczenie średniej i odchylenia standardowego.
let data = [1,1,3,5,5]; // Dane wejściowe.
let mean = product(reduce(data, sum), reciprocal(data.length));

let stddev = sqrt(product(reduce(map(data,


compose(square,
partial(sum, neg(mean)))),
sum),
reciprocal(sum(data.length,neg(1)))));
[mean, stddev] // => [3, 2]
Zwróć uwagę, że powyższy kod w całości składa się z wywołań funkcji. Nie zostały w nim
wykorzystane żadne operatory, za to nawiasów jest tak dużo, że program przypomina kod
napisany w języku Lisp. Powtórzę jednak: nie jest to zalecany styl programowania, a raczej
ciekawe ćwiczenie pokazujące, jak bardzo funkcyjny może być kod JavaScript.

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).

function gcd(a,b) { // Typy argumentów a i b nie są sprawdzane.


if (a < b) { // Sprawdzenie, czy a >= b.
[a, b] = [b, a]; // Destrukturyzacja przypisania w celu zamienienia
wartości miejscami.
}
while(b !== 0) { // Algorytm Euklidesa wykorzystywany do wyliczenia
największego
// wspólnego dzielnika.
[a, b] = [b, a%b];
}

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

factorial(5) // => 120: zapamiętane są również wyniki dla liczb 4, 3, 2


i 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.

[1] Termin wymyślony przez Martina Fowlera (patrz


http://martinfowler.com/dslCatalog/methodChaining.html).
[2] Jeżeli znasz język Python, zauważysz w tym miejscu różnicę. W Pythonie w każdym
wywołaniu jest wykorzystywana ta sama domyślna wartość.
[3] Być może nie wygląda to na szczególnie ciekawą cechę, chyba że znasz statyczny język
programowania, w którym funkcje są częściami kodu i nie można na nich wykonywać operacji.
Rozdział 9.
Klasy
W rozdziale 6. zostały opisane obiekty. Każdy obiekt był tam traktowany jako unikatowy zestaw
właściwości, tj. różnił się od wszystkich innych obiektów. Często jednak wygodnej jest
definiować klasy obiektów o takich samych właściwościach. Członkowie, czyli instancje klasy,
mają własne właściwości przechowujące lub definiujące stany instancji oraz metody definiujące
ich funkcjonowanie. Metody definiuje się w klasie i są one dostępne we wszystkich instancjach.
Wyobraźmy sobie na przykład klasę o nazwie Complex reprezentującą liczby zespolone i służącą
do wykonywania na nich operacji arytmetycznych. Instancja tej klasy posiada właściwości
przechowujące części rzeczywistą i urojoną liczby (stan obiektu). Natomiast sama klasa
definiuje metody umożliwiające dodawanie i mnożenie liczb (funkcjonowanie obiektu).

W języku JavaScript obowiązuje dziedziczenie prototypowe. Jeżeli dwa obiekty dziedziczą


właściwości tego samego prototypu (zazwyczaj właściwości funkcyjne, czyli metody), oznacza
to, że są instancjami tej samej klasy. Tak w skrócie można opisać funkcjonowanie klas w języku
JavaScript. Prototypy i dziedziczenie zostały opisane w punktach 6.2.3 i 6.3.2, więc posiadasz
już wiedzę niezbędną do lektury niniejszego rozdziału. Prototypy będą dodatkowo opisane w
podrozdziale 9.1.
Jeżeli dwa obiekty pochodzą od tego samego prototypu, zazwyczaj oznacza to, że zostały
utworzone i zainicjowane za pomocą tego samego konstruktora lub funkcji fabrycznej.
Konstruktory zostały opisane w podrozdziale 4.6 oraz punktach 6.2.2 i 8.2.3. Więcej na ich
temat dowiesz się w podrozdziale 9.2.

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.

9.1. Klasy i prototypy


Klasa w języku JavaScript jest zbiorem obiektów dziedziczących właściwości po tym samym
prototypie. Zatem prototyp jest centralnym elementem klasy. W rozdziale 6. opisałem metodę
Object.create() zwracającą nowo utworzony obiekt, pochodny od prototypu. Definiowanie
klasy w JavaScripcie polega na definiowaniu prototypu. Następnie w celu utworzenia obiektu
pochodnego od tego prototypu wywołuje się metodę Object.create(). Zazwyczaj instancje
klasy wymagają dodatkowej inicjalizacji, dlatego definiuje się w tym celu funkcję, która tworzy i
inicjuje nowy obiekt. Ilustruje to listing 9.1. Zdefiniowany jest w nim prototyp reprezentujący
zakres wartości oraz funkcja fabryczna tworząca i inicjująca nową instancję klasy.
Listing 9.1. Prosta klasa w języku JavaScript

// Funkcja fabryczna zwracająca nowy obiekt reprezentujący zakres wartości.

function range(from, to) {


// Za pomocą metody Object.create() jest tworzony obiekt pochodny

// od zdefiniowanego niżej prototypu. Prototyp, zapisany jako właściwość


// funkcji, definiuje metody (działanie) wspólne dla wszystkich obiektów.

let r = Object.create(range.methods);
// Zapisanie początku i końca zakresu (stanu) nowego obiektu.

// Są to unikatowe właściwości obiektu, nieodziedziczone po prototypie.


r.from = from;

r.to = to;

// Na koniec zwracany jest nowy obiekt.


return r;

}
// 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.

// Metoda operuje na zakresach wartości tekstowych, liczbowych i typu Date.


includes(x) { return this.from <= x && x <= this.to; },

// Funkcja generatora, dzięki której instancje klasy można iterować.

// Zwróć uwagę, że dotyczy to tylko zakresów liczbowych.

*[Symbol.iterator]() {
for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;

},

// Metoda zwracająca ciąg znaków reprezentujący zakres.

toString() { return "(" + this.from + "..." + this.to + ")"; }


};

// Przykład użycia obiektu range.

let r = range(1,3); // Utworzenie obiektu.

r.includes(2) // => true: liczba 2 zawiera się w zakresie.

r.toString() // => "(1…3)"


[...r] // => [1, 2, 3]; przekształcenie obiektu w tablicę
za pomocą iteratora.
W powyższym listingu należy zwrócić uwagę na kilka szczegółów:

Zdefiniowana jest funkcja range() tworząca obiekty reprezentujące zakresy wartości.


Prototyp definiujący klasę jest wygodnie przechowywany we właściwości funkcji range().
Nie jest to żaden specjalny, idiomatyczny sposób przechowywania prototypu.
Funkcja range() definiuje w każdym obiekcie właściwości from i to. Nie są to jednak
wspólne, dziedziczone właściwości. Przechowywany jest w nich unikatowy stan każdego
obiektu.
W obiekcie range.methods wykorzystana jest stara, skrócona składnia definicji metody,
stosowana w języku ES6. Z tego powodu nie zostało użyte słowo kluczowe function
(skrócona składnia literału obiektowego została opisana w punkcie 6.10.5).
Jedna z metod prototypu ma wyliczoną nazwę Symbol.iterator (patrz punkt 6.10.2).
Oznacza to, że definiuje ona iterator obiektów range. Prefiks * oznacza, że nie jest to
zwykła funkcja, tylko generator. Iteratory i generatory będą szczegółowo opisane w
rozdziale 12. Tutaj ważne jest, że dzięki tej funkcji instancje klasy można stosować z pętlą
for/of oraz z operatorem rozciągania (...).
Wszystkie wspólne, dziedziczone, zdefiniowane w obiekcie range.methods metody
wykorzystują właściwości from i to zainicjowane za pomocą funkcji fabrycznej range(). W
odwołaniach do nich jest wykorzystywane słowo kluczowe this reprezentujące obiekt, do
którego te właściwości należą. Słowo to jest powszechnie stosowane w metodach
wszystkich klas.

9.2. Klasy i konstruktory


Listing 9.1 ilustruje prosty sposób definiowania klasy. Nie jest to jednak idiomatyczny przykład,
ponieważ nie jest w nim zdefiniowany konstruktor, czyli funkcja służąca do inicjowania nowo
utworzonego obiektu. Funkcję tę wywołuje się za pomocą słowa kluczowego new opisanego
w punkcie 8.2.3. Wywołanie konstruktora skutkuje utworzeniem nowego obiektu. Zatem
konstruktor musi jedynie inicjować stan tego obiektu. Należy pamiętać, że właściwość
prototype konstruktora jest również prototypem nowego obiektu. (Prototypy zostały opisane w
punkcie 6.2.3). Pamiętaj, że choć niemal wszystkie obiekty mają swoje prototypy, tylko niektóre
z nich mają właściwość prototype. Podkreślę jeszcze raz: właściwość tę ma obiekt funkcyjny.
Oznacza to, że wszystkie obiekty utworzone za pomocą tego samego konstruktora dziedziczą
cechy tego samego obiektu, a więc są członkami tej samej klasy. Listing 9.2 pokazuje, jak należy
zmienić klasę zdefiniowaną w listingu 9.1, aby można było użyć konstruktora, a nie funkcji
fabrycznej. Listing prezentuje idiomatyczny sposób tworzenia klasy, w którym nie jest
wykorzystywane słowo kluczowe class wprowadzone w wersji ES6 języka. Mimo że słowo to
jest dostępne, bardzo często definiuje się klasy w przedstawiony niżej sposób. Powinieneś go
znać, aby rozumieć starszy kod i wiedzieć, co się dzieje w tle, gdy jest stosowane słowo
kluczowe class.
Listing 9.2. Klasa Range wykorzystująca konstruktor

// Konstruktor inicjujący nowy obiekt typu Range.

// Zwróć uwagę, że nie tworzy on i nie zwraca obiektu, tylko go inicjuje.

function Range(from, to) {


// Zapisanie początku i końca zakresu (stanu) nowego obiektu.

// Są to unikatowe właściwości obiektu, nieodziedziczone po prototypie.

this.from = from;

this.to = to;
}
// Wszystkie obiekty będą dziedziczyły cechy obiektu Range.

// Zwróć uwagę, że właściwość musi mieć nazwę "prototype", aby to było


możliwe.

Range.prototype = {

// Metoda zwracająca wartość true, jeżeli x zawiera się w zakresie, lub


false w przeciwnym razie.

// Metoda operuje na zakresach wartości tekstowych, liczbowych i typu Date.

includes: function(x) { return this.from <= x && x <= this.to; },

// Funkcja generatora, dzięki której instancje klasy można iterować.

// Zwróć uwagę, że dotyczy to tylko zakresów liczbowych.

[Symbol.iterator]: function*() {

for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;

},

// Metoda zwracająca ciąg znaków reprezentujący zakres.


toString: function() { return "(" + this.from + "..." + this.to + ")"; }

};

// Przykład użycia nowej klasy Range,

let r = new Range(1,3); // Utworzenie obiektu Range. Zwróć uwagę na użycie


słowa new.

r.includes(2) // => true: liczba 2 zawiera się w zakresie.


r.toString() // => "(1…3)"

[...r] // => [1, 2, 3]; przekształcenie obiektu w tablicę


za pomocą iteratora.

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.

Konstruktory i wyrażenie new.target


Za pomocą specjalnego wyrażenia new.target można w ciele funkcji sprawdzać, czy
została ona wywołana jako konstruktor. Jeżeli wyrażenie to ma zdefiniowaną wartość,
oznacza to, że funkcja została wywołana jako konstruktor, tj. za pomocą słowa
kluczowego new. W podrozdziale 9.5, poświęconym podklasom, dowiesz się, że wyrażenie
new.target nie zawsze odnosi się do konstruktora, w którym zostało użyte. Może się ono
odnosić do konstruktora podklasy.
Wartość undefined wyrażenia new.target oznacza, że funkcja została wywołana w
zwykły sposób, bez użycia słowa new. Tak można wywoływać konstruktory różnych klas
reprezentujących błędy. Gdybyś chciał zasymulować tę funkcjonalność za pomocą
własnego konstruktora, możesz to zrobić w następujący sposób:

function C() {

if (!new.target) return new C();

// Kod inicjujący.

Tę technikę można stosować wyłączne w przypadku konstruktorów zdefiniowanych przy


użyciu starej składni. Konstruktora klasy zdefiniowanej za pomocą słowa kluczowego
class nie można wywołać bez użycia słowa new.

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.

Ważne jest również, że w obu listingach w definicjach konstruktorów i metod są


wykorzystywane funkcje strzałkowe. Jak pamiętasz z punktu 8.1.3, funkcja zdefiniowana w ten
sposób nie ma właściwości prototype, więc nie można jej użyć jako konstruktora. Ponadto
funkcja strzałkowa dziedziczy słowo kluczowe this zgodnie z kontekstem, w którym została
wywołana, a nie po obiekcie, do którego należy. Nie może być metodą, ponieważ metoda ma tę
kluczową cechę, że można w niej stosować słowo this oznaczające instancję klasy, do której
ona należy.

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.

9.2.1. Konstruktory, tożsamość klas i operator


instanceof
Jak już wiesz, prototyp odgrywa kluczową rolę w tożsamości klasy. Dwa obiekty są instancjami
tej samej klasy tylko wtedy, gdy dziedziczą cechy tego samego prototypu. Konstruktor inicjujący
stan nowego obiektu nie jest tak istotny. Dwa różne konstruktory mogą mieć właściwość
prototype zawierającą ten sam prototyp. Mogą więc tworzyć instancje tej samej klasy.
Choć konstruktor nie jest tak istotny jak prototyp, pełni rolę „publicznej twarzy” klasy. Przede
wszystkim jego nazwa jest taka sama jak nazwa klasy. Mówi się na przykład, że konstruktor
Range() tworzy obiekt Range. Co więcej, konstruktor umieszcza się po prawej stronie operatora
instanceof, aby sprawdzić, czy dany obiekt jest instancją danej klasy. Jeżeli na przykład trzeba
sprawdzić, czy obiekt r jest instancją klasy Range, należy użyć następującego kodu:

r instanceof Range // => true: obiekt r dziedziczy cechy po prototypie


Range.prototype.
Operator instanceof został opisany w punkcie 4.9.4. Operand umieszczony po lewej stronie
jest sprawdzanym obiektem, a operand po prawej — konstruktorem klasy. Wyrażenie o
instanceof C ma wartość true, jeżeli obiekt o pochodzi od prototypu C.prototype.
Dziedziczenie nie musi być bezpośrednie. Jeżeli obiekt o pochodzi od obiektu pochodzącego od
prototypu C.prototype, to powyższe wyrażenie ma również wartość true.
Z technicznego punktu widzenia w powyższym przykładzie operator instanceof nie sprawdza,
czy obiekt r został zainicjowany za pomocą konstruktora Range(). Sprawdza jedynie, czy obiekt
pochodzi od prototypu Range.prototype. Jeżeli zdefiniujemy funkcję Strange() i jej
właściwości prototype przypiszemy wartość właściwości Range.prototype, to dla operatora
instanceof obiekt utworzony za pomocą funkcji Strange() będzie traktowany jako Range (w
rzeczywistości nie będzie to obiekt Range, ponieważ nie zostaną w nim zainicjowane
właściwości from i to). Ilustruje to poniższy przykład:

function Strange() {}
Strange.prototype = Range.prototype;

new Strange() instanceof Range // => true

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:

range.methods.isPrototypeOf(r); // range.methods jest prototypem danego


obiektu.

9.2.2. Właściwość constructor


W listingu 9.2 właściwości Range.prototype został przypisany obiekt zawierający metody
naszej klasy. Choć jest to wygodny sposób definiowania metod jako właściwości pojedynczego
literału obiektowego, nie należy go stosować do tworzenia nowych obiektów. Konstruktorem
może być zwykła funkcja (również strzałkowa, asynchroniczna lub generator), a do wywołania
konstruktora potrzebna jest właściwość prototype. Dlatego każda zwykła funkcja[1] jest
automatycznie wyposażana w tę właściwość.

Wartością tej właściwości jest obiekt zawierający jedynie niewyliczalną właściwość


constructor, której wartością jest obiekt funkcyjny:
let F = function() {}; // Obiekt funkcyjny.

let p = F.prototype; // Prototyp skojarzony z obiektem F.

let c = p.constructor; // Funkcja skojarzona z prototypem.


c === F // => true: F.prototype.constructor === F dla każdego
F.
Istnienie predefiniowanego prototypu z właściwością constructor oznacza, że obiekty będą
dziedziczyć właściwość odwołującą się do konstruktora. Ponieważ konstruktor reprezentuje
publiczną tożsamość klasy, właściwość constructor zawiera klasę obiektu:
let o = new F(); // Utworzenie obiektu o jako instancji klasy F.

o.constructor === F // => true: właściwość constructor określa klasę.

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.

Rysunek 9.1. Konstruktor, jego prototyp i instancje klasy

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 */
};

Inną często stosowaną w starszych kodach techniką jest wykorzystywanie predefiniowanego


prototypu z właściwością constructor i dodawanie pojedynczych metod, jak niżej:

// Rozszerzenie predefiniowanego obiektu Range.prototype, dzięki czemu nie


jest
// nadpisywana automatycznie tworzona właściwość Range.prototype.constructor.

Range.prototype.includes = function(x) {
return this.from <= x && x <= this.to;

};
Range.prototype.toString = function() {

return "(" + this.from + "..." + this.to + ")";


};
9.3. Słowo kluczowe class
Klasy istnieją od samego początku języka JavaScript, jednak dopiero w wersji ES6, wraz z
wprowadzeniem słowa kluczowego class, doczekały się własnej składni. Listing 9.3
przedstawia klasę Range zdefiniowaną przy jej użyciu.
Listing 9.3. Definicja klasy Range wykorzystująca nowe słowo kluczowe class

class Range {
constructor(from, to) {

// Zapisanie początku i końca zakresu (stanu) nowego obiektu.


// Są to unikatowe właściwości obiektu, nieodziedziczone po prototypie.

this.from = from;
this.to = to;

}
// Metoda zwracająca wartość true, jeżeli x zawiera się w zakresie, lub
false w przeciwnym razie.

// Metoda operuje na zakresach wartości tekstowych, liczbowych i typu Date.


includes(x) { return this.from <= x && x <= this.to; }

// Funkcja generatora, dzięki której instancje klasy można iterować.


// Zwróć uwagę, że dotyczy to tylko zakresów liczbowych.
*[Symbol.iterator]() {

for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;


}

// Metoda zwracająca ciąg znaków reprezentujący zakres.


toString() { return `(${this.from}...${this.to})`; }

}
// Przykład użycia nowej klasy Range.

let r = new Range(1,3); // Utworzenie obiektu Range.


r.includes(2) // => true: liczba 2 zawiera się w zakresie.
r.toString() // => "(1…3)"

[...r] // => [1, 2, 3]; przekształcenie obiektu w tablicę


za pomocą iteratora.

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,

// tylko wartości początkowej i długości.


class Span extends Range {
constructor(start, length) {

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:

let Square = class { constructor(x) { this.area = x * x; } };


new Square(3).area // => 9

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.

9.3.1. Metody statyczne


Aby w ciele klasy zdefiniować metodę statyczną, należy jej deklarację poprzedzić słowem
kluczowym static. Metody statyczne definiuje się jako właściwości konstruktora, a nie
prototypu.

Załóżmy, że do listingu 9.3 dodaliśmy następujący kod:


static parse(s) {

let matches = s.match(/^\((\d+)\.\.\.(\d+)\)$/);


if (!matches) {

throw new TypeError(`Nie można zinterpretować ciągu "${s}" jako obiektu


Range.`)
}

return new Range(parseInt(matches[1]), parseInt(matches[2]));


}

W powyższym kodzie zdefiniowana jest metoda Range.parse(), a nie


Range.prototype.parse(), dlatego trzeba ją wywoływać za pomocą konstruktora, a nie
instancji:

let r = Range.parse('(1...10)'); // Zwrócenie nowego obiektu Range.


r.parse('(1...10)'); // TypeError: r.parse nie jest funkcją.
Metody statyczne są czasami nazywane metodami klasy, ponieważ wywołuje się je,
wykorzystując nazwę klasy, czyli konstruktora. Określenie to stosuje się w celu odróżnienia
metod klasy od zwykłych metod instancji, które wywołuje się za pomocą obiektów. Ponieważ
metody statyczne wywołuje się za pomocą konstruktora, niemal nigdy nie ma potrzeby
stosowania w nich słowa kluczowego this.
Przykłady metod statycznych będą pokazane w listingu 9.4.

9.3.2. Gettery, settery i inne rodzaje metod


W ciele klasy, podobnie jak w literałach obiektowych, można definiować gettery i settery (patrz
punkt 6.10.6). Jedyna różnica polega na tym, że w ciele klasy nie umieszcza się przecinka po
getterze i setterze. Przykład użycia gettera zawiera listing 9.4.

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;

9.3.3. Pola publiczne, prywatne i statyczne


W opisie klas definiowanych za pomocą słowa kluczowego class wspomniałem, że metody
można definiować jedynie wewnątrz ciała klasy. Standard języka ES6 pozwala tworzyć metody
zwykłe (w tym gettery, settery i generatory) oraz statyczne. Nie jest jednak określona składnia
definicji pól. Pola, będące obiektowymi odpowiednikami właściwości, definiuje się w instancji
klasy za pomocą konstruktorów i metod. Statyczne pole definiuje się poza ciałem klasy.
Przykłady obu rodzajów pól zawiera listing 9.4.
Trwa jednak standaryzacja rozszerzonej składni umożliwiającej definiowanie pól instancji i pól
statycznych, zarówno publicznych jak i prywatnych. Kod wykorzystany w tym punkcie nie jest
jeszcze zgodny ze standardem z początku 2020 r. Niemniej jednak ze standardem tym jest
zgodna przeglądarka Chrome i częściowo (w części dotyczącej publicznych pól instancji)
Firefox. Składnię publicznych pól instancji powszechnie stosują programiści JavaScriptu
korzystający z platformy React i transpilatora Babel.

Załóżmy, że tworzymy poniższą klasę, w której konstruktor inicjuje trzy pola:


class Buffer {

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;

buffer = new Uint8Array(this.capacity);


}
Kod inicjujący pole został przeniesiony poza konstruktor i umieszczony bezpośrednio w ciele
klasy. (Oczywiście kod ten musi być uruchomiony przez konstruktor. Jeżeli konstruktor nie
będzie jawnie zdefiniowany, pola zostaną zainicjowane za pomocą konstruktora utworzonego
niejawnie). Nie ma tu prefiksu this, umieszczonego wcześniej po lewej stronie operatora
przypisania. Prefiks ten jest jednak niezbędny w odwołaniach do pól, nawet użytych po prawej
stronie w przypisaniu inicjującym. Nowy sposób inicjowania pól ma tę zaletę, że pozwala (choć
nie wymaga) umieszczać instrukcje inicjujące pola na początku definicji klasy, dzięki czemu jest
ona bardziej czytelna i programista dokładnie wie, jakie pola służą do przechowywania stanu
instancji. Pole można zdefiniować bez inicjowania go, po prostu wpisując jego nazwę i średnik.
W takim przypadku pole otrzymuje początkową wartość undefined. Dobrą praktyką jest jednak
jawne określanie wartości wszystkich pól w klasie.

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) {

let matches = s.match(Range.integerRangePattern);


if (!matches) {

throw new TypeError(`Nie można zinterpretować ciągu "${s}" jako obiektu


Range`)

}
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 #.

9.3.4. Przykład: klasa reprezentująca liczby


zespolone
Listing 9.4 przedstawia definicję klasy reprezentującą liczby zespolone. Klasa ta jest dość
prosta, ale zawiera metody instancji (w tym gettery), metody statyczne, pola instancji i pola
statyczne. W komentarzach znajduje się kod demonstrujący użycie jeszcze nieustandaryzowanej
składni definicji pól instancji i pól statycznych w ciele klasy.
Listing 9.4. Complex.js: klasa reprezentująca liczby zespolone

/**
* Instancje klasy Complex reprezentują liczby zespolone. Liczba

* zespolona składa się z części rzeczywistej i urojonej "i" równej


* pierwiastkowi kwadratowemu z –1.

*/
class Complex {

// Gdy deklaracja pola klasy zostanie ustandaryzowana, będzie można


// w następujący sposób deklarować pola zawierające części
// rzeczywistą i urojoną liczby zespolonej:

// #r = 0;
// #i = 0;

// Konstruktor definiuje pola instancji r oraz i w każdej tworzonej


// instancji. Pola te zawierają części rzeczywistą i urojoną liczby

// zespolonej. Reprezentują one stan obiektu.


constructor(real, imaginary) {

this.r = real; // To pole zawiera część rzeczywistą liczby.


this.i = imaginary; // To pole zawiera część urojoną liczby.
}

// Poniższe metody instancji dodają i mnożą liczby zespolone.


// Mając instancje c i d tej klasy, można użyć zapisu c.plus(d)
// lub d.times(c).

plus(that) {
return new Complex(this.r + that.r, this.i + that.i);
}
times(that) {

return new Complex(this.r * that.r - this.i * that.i,


this.r * that.i + this.i * that.r);
}
// Poniżej znajdują się statyczne warianty metod wykonujących
// działania arytmetyczne na liczbach zespolonych. Wywołuje się

// je w następujący sposób: Complex.sum(c,d) lub Complex.product(c,d).


static sum(c, d) { return c.plus(d); }

static product(c, d) { return c.times(d); }


// Poniżej znajduje się kilka metod instancji zdefiniowanych
// jako gettery. Można więc używać ich tak jak pól. Gettery
// real() i imaginary() mogą być przydatne w przypadku użycia
// prywatnych pól this.#r oraz this.#i.

get real() { return this.r; }


get imaginary() { return this.i; }
get magnitude() { return Math.hypot(this.r, this.i); }
// Niemal każda klasa ma metodę toString().

toString() { return `{${this.r},${this.i}}`; }


// Zazwyczaj warto zdefiniować metodę sprawdzającą,
// czy dwie instancje danej klasy reprezentują tę samą wartość.
equals(that) {
return that instanceof Complex &&

this.r === that.r &&


this.i === that.i;
}
// Gdy w ciele klasy będzie można stosować statyczne pola,

// przydatną stałą Complex.ZERO będzie można zdefiniować


// w następujący sposób:
// static ZERO = new Complex(0,0);
}
// Poniżej zdefiniowanych jest kilka pól klasy zawierających

// przydatne, predefiniowane liczby zespolone.


Complex.ZERO = new Complex(0,0);
Complex.ONE = new Complex(1,0);
Complex.I = new Complex(0,1);

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.

c.plus(d).toString() // => "{5,5}"; użycie metod instancji.


c.magnitude // => Math.hypot(2,3); użycie gettera.
Complex.product(c, d) // => new Complex(0, 13); metoda statyczna.
Complex.ZERO.toString() // => "{0,0}"; właściwość statyczna.

9.4. Dodawanie metod do istniejących


klas
Zaimplementowany w języku JavaScript mechanizm dziedziczenia oparty na prototypach jest
dynamiczny. Obiekt dziedziczy właściwości po prototypie nawet wtedy, gdy jego właściwości
zmienią się po utworzeniu obiektu. Oznacza to, że można modyfikować klasy, dodając po prostu
do ich prototypów nowe metody.

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

Poniżej przedstawiony jest inny przykład:


// Wielokrotne wywołanie funkcji, za każdym razem z wartością iteratora w
argumencie.
// Przykład: trzykrotne wyświetlenie "cześć":
// let n = 3;

// n.times(i => { console.log(`cześć ${i}`); });


Number.prototype.times = function(f, context) {
let n = this.valueOf();
for(let i = 0; i < n; i++) f.call(context, i);
};

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.

9.5.1. Podklasy i prototypy


Załóżmy, że chcemy zdefiniować podklasę Span, pochodną od klasy Range z listingu 9.2. Nowa
podklasa będzie działała podobnie jak Range, z tą różnicą że nie będzie inicjowana za pomocą
początku i końca przedziału wartości, tylko jego początku i długości. Instancja klasy Span
będzie również instancją nadklasy Range. Podklasa będzie dziedziczyła po prototypie
Span.prototype dostosowaną metodę toString(). Jednak aby klasa ta była podklasą Range,
musi również dziedziczyć metody, na przykład includes(), po prototypie Range.prototype.
Ilustruje to listing 9.5.
Listing 9.5. Span.js: prosta podklasa klasy Range
// Konstruktor podklasy.
function Span(start, span) {

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;

// Własna metoda toString() klasy Span nadpisuje metodę toString() klasy


Range.
// Gdyby jej nie było, odziedziczona byłaby metoda klasy Range.
Span.prototype.toString = function() {
return `(${this.from}... +${this.to - this.from})`;

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

Obiekty tworzone z pomocą konstruktora Span() będą dziedziczyć cechy prototypu


Span.prototype. Jednak ten obiekt pochodzi od Range.prototype. Zatem instancje klasy Span
będą dziedziczyły cechy zarówno prototypu Span.prototype, jak i Range.prototype.
Zapewne zauważyłeś, że konstruktor Span(), podobnie jak Range(), przypisuje wartości
właściwościom from i to. Nie musi więc w celu zainicjowania nowego obiektu wywoływać
konstruktora Range(). Analogicznie metoda toString() całkowicie implementuje konwersję
obiektu na ciąg znaków i nie musi wywoływać metody o tej samej nazwie z klasy Range. Pod
tym względem klasa Span jest wyjątkowa. Można ją było zdefiniować w ten sposób tylko
dlatego, że znane są szczegóły implementacji nadklasy. Porządny mechanizm tworzenia podklas
powinien pozwalać wywoływać metody i konstruktory nadklas. Jednak w wersjach języka
starszych niż ES6 nie można było tego robić w prosty sposób.
Na szczęście w wersji ES6 problem został rozwiązany poprzez wprowadzenie do składni klasy
słowa kluczowego super, którego działanie opisuje następny punkt.

9.5.2. Tworzenie podklas za pomocą słów extends i


super
Począwszy od wersji języka ES6 podklasę można tworzyć, umieszczając po prostu w deklaracji
klasy klauzulę extends. Można tak robić nawet w przypadku wbudowanych klas. Ilustruje to
poniższy kod:
// Prosta podklasa klasy Array, rozbudowana o gettery zwracające pierwszy i
ostatni element.
class EZArray extends Array {

get first() { return this[0]; }


get last() { return this[this.length-1]; }
}
let a = new EZArray();
a instanceof EZArray // => true: a jest instancją podklasy.

a instanceof Array // => true: a jest również instancją nadklasy.


a.push(1,2,3,4); // a.length == 4; można stosować odziedziczone metody.
a.pop() // => 4: inna odziedziczona metoda.

a.first // => 1: pierwszy getter zdefiniowany w podklasie.


a.last // => 3: drugi getter zdefiniowany w podklasie.
a[1] // => 2: zwykła składnia odwołania do instancji
podklasy działa normalnie.
Array.isArray(a) // => true: instancja podklasy jest w rzeczywistości
tablicą.
EZArray.isArray(a) // => true: podklasa dziedziczy również metody
statyczne.
Podklasa EZArray definiuje dwa proste gettery. Instancje tej klasy funkcjonują tak jak zwykłe
tablice, więc można stosować odziedziczone metody i właściwości takie jak push(), pop() i
length. Oprócz tego można używać getterów first i last zdefiniowanych w podklasie.
Dziedziczone są nie tylko metody instancji takie jak pop(), ale również metody statyczne, na
przykład Array.isArray. Jest to nowa cecha składni wprowadzona w wersji języka ES6.
EZArray() jest funkcją pochodną od Array():
// EZArray dziedziczy metody instancji, ponieważ prototyp
// EZArray.prototype dziedziczy cechy Array.prototype.

Array.prototype.isPrototypeOf(EZArray.prototype) // => true


// Ponadto EZArray dziedziczy metody statyczne i właściwości,
// ponieważ pochodzi od Array. Jest to specjalna cecha słowa
// kluczowego extends, niedostępnego w wersjach języka starszych niż ES6.
Array.isPrototypeOf(EZArray) // => true

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

class TypedMap extends Map {


constructor(keyType, valueType, entries) {
// Sprawdzenie typu argumentu entries, jeżeli został podany.
if (entries) {
for(let [k, v] of entries) {

if (typeof k !== keyType || typeof v !== valueType) {


throw new TypeError(`Błędny typ danych [${k}, ${v}]`);
}
}

}
// 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.

if (this.keyType && typeof key !== this.keyType) {


throw new TypeError(`Klucz ${key} nie jest typu ${this.keyType}`);
}
if (this.valueType && typeof value !== this.valueType) {

throw new TypeError(`Wartość ${value} nie jest typu


${this.valueType}`);
}
// Jeżeli typy są poprawne, wywoływana jest metoda set() podklasy
// w celu dodania danych do mapy. Zwracanym wynikiem jest

// wartość zwrócona przez metodę nadklasy.


return super.set(key, value);
}
}

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().

Użycie słowa super reguluje kilka zasad, które należy znać:

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ć.

9.5.3. Delegowanie zamiast dziedziczenia


Słowo kluczowe extends ułatwia tworzenie podklas, co nie oznacza, że należy je zawsze
stosować. Jeżeli trzeba utworzyć klasę, która ma funkcjonować podobnie jak inna klasa, można
powielić działanie tej klasy, tworząc podklasę. Często jednak łatwiejszym i bardziej elastycznym
sposobem osiągnięcia tego celu jest utworzenie instancji innej klasy i oddelegowanie do niej
nowej klasy. Nie tworzy się wtedy podklasy, tylko opakowuje, czyli „komponuje” inną klasę.
Delegowanie jest często nazywane „komponowaniem”, a w programowaniu obiektowym
obowiązuje zalecenie, aby „przedkładać komponowanie nad dziedziczenie”[2].
Załóżmy, że mamy klasę Histogram, która działa podobnie jak wbudowana klasa Set z tą
różnicą, że nie dodaje wartości do zbioru, tylko zlicza je. Ponieważ interfejsy API obu klas są
podobne, można utworzyć klasę pochodną od Set i dodać do niej metodę count(). Z drugiej
strony, gdy zastanowimy się, jak zaimplementować tę metodę, okaże się, że klasa Histogram
jest bardziej podobna do Map niż do Set, ponieważ musi rejestrować, ile razy została dodana do
zbioru każda wartość. Dlatego zamiast tworzyć klasę pochodną od Set, można utworzyć klasę o
podobnym interfejsie API, ale delegującym metody do wewnętrznego obiektu typu Map. Ilustruje
to listing 9.7.

Listing 9.7. Histogram.js: klasa podobna do Set, zaimplementowana z wykorzystaniem


delegacji
/**
* Klasa podobna do Set, ale rejestrująca, ile razy została dodana wartość
* do zbioru. Posiada metody add() i remove() działające podobnie
* jak w klasie Set oraz metodę count(), której wynik określa, ile razy

* została dodana do zbioru zadana wartość. Domyślny iterator zwraca


* każdą dodaną wartość tylko raz. Metoda entries() służy do iterowania
* par [wartość, liczba].
*/

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

// lub wartość 0, jeżeli klucza nie ma.


count(key) { return this.map.get(key) || 0; }
// Metoda has(), tak jak w klasie Set, zwraca wartość true, jeżeli metoda
count() zwraca wynik różny od zera.
has(key) { return this.count(key) > 0; }

// Metoda size() zwraca liczbę elementów w obiekcie Map.


get size() { return this.map.size; }
// Aby dodać klucz, wystarczy zwiększyć liczbę jego wystąpień w obiekcie
Map.
add(key) { this.map.set(key, this.count(key) + 1); }

// Usunięcie klucza jest nieco trudniejsze, ponieważ trzeba go usunąć z


obiektu Map,
// jeżeli liczba jego wystąpień zmniejszy się do zera.
delete(key) {
let count = this.count(key);

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.

keys() { return this.map.keys(); }


values() { return this.map.values(); }
entries() { return this.map.entries(); }
}
Konstruktor Histogram() w powyższym listingu tworzy jedynie obiekt Map. Większość metod
składa się z pojedynczych wierszy delegujących działanie do odpowiednich metod powyższego
obiektu. Dzięki temu implementacja jest bardzo prosta. Ponieważ wykorzystana została
delegacja, a nie dziedziczenie, obiekt Histogram nie jest instancją klasy Set ani Map.
Implementuje natomiast kilka często używanych metod klasy Set. W nietypowanym języku
takim jak JavaScript jest to wygodne rozwiązanie. Formalne relacje dziedziczne są przydatne,
ale często opcjonalne.

9.5.4. Hierarchie klas i klasy abstrakcyjne


Listing 9.6 pokazuje, jak można utworzyć klasę pochodną od Map. Natomiast w listingu 9.7
metody są delegowane do obiektu Map bez tworzenia jakichkolwiek podklas. Opakowywanie
danych za pomocą klas i dzielenie kodu na moduły to bardzo przydatne techniki i na pewno
będziesz często stosował słowo kluczowe class. Może się okazać, że będziesz przedkładał
komponowanie klas nad ich dziedziczenie i rzadko będziesz stosował słowo kluczowe extends
(z wyjątkiem przypadków, w których będzie wymagane rozszerzanie podstawowych klas
biblioteki lub platformy).
Zdarzają się sytuacje, w których trzeba tworzyć wielopoziomowe podklasy. Ten rozdział kończy
się rozbudowanym przykładem ilustrującym hierarchię klas reprezentujących różnego rodzaju
zbiory. Klasy te są podobne do klas zbiorów zdefiniowanych w listingu 9.8, ale nie są w pełni
kompatybilne z wbudowaną klasą Set.
W listingu 9.8 zdefiniowanych jest wiele podklas, ale jego głównym celem jest pokazanie, jak
definiuje się klasy abstrakcyjne, czyli częściowo zaimplementowane klasy, pełniące role
nadklas dla grupy powiązanych ze sobą podklas. Abstrakcyjna nadklasa może zawierać
częściową implementację, dziedziczoną przez wszystkie podklasy. Implementowanie podklasy
polega na implementowaniu metod abstrakcyjnych zdefiniowanych (ale
niezaimplementowanych) w nadklasie. Zwróć uwagę, że nie ma formalnej definicji metody ani
klasy abstrakcyjnej. Wykorzystuje się po prostu nazwę niezaimplementowanej metody i
częściowo zaimplementowanej klasy.
Listing 9.8 zawiera szczegółowe komentarze i sam siebie dokumentuje. Stanowi zwieńczenie
niniejszego rozdziału i zachęcam Cię do zapoznania się z nim. W kodzie są bardzo często
wykorzystywane operatory &, | oraz ~ opisane w punkcie 4.8.3.

Listing 9.8. Sets.js: hierarchia klas abstrakcyjnych i klas zbiorów


/**
* Klasa AbstractSet definiuje pojedynczą metodę abstrakcyjną has().
*/
class AbstractSet {

// Zgłoszenie wyjątku, ponieważ podklasa musi definiować własną wersję tej


metody.
has(x) { throw new Error("Metoda abstrakcyjna"); }
}
/**
* NotSet jest klasą pochodną od AbstractSet. Reprezentuje zbiór elementów,
* które nie należą do innego zbioru. Ponieważ zbiór jest definiowany na
bazie
* innego zbioru, nie można go modyfikować. Nie można go również

* iterować, ponieważ liczba jego elementów nie jest określona. Można


* jedynie sprawdzać, czy należy do niego zadany element, i przekształcać
zbiór
* w ciąg znaków, stosując matematyczną składnię.
*/

class NotSet extends AbstractSet {


constructor(set) {
super();
this.set = set;
}

// Implementacja odziedziczonej metody abstrakcyjnej.


has(x) { return !this.set.has(x); }
// Tutaj nadpisywana jest metoda obiektu Object.
toString() { return `{ x| x ∉ ${this.set.toString()} }`; }

}
/**
* 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

* zmiennoprzecinkowe, zbiór nie jest wyliczalny i nie ma


* określonej wielkości.
*/
class RangeSet extends AbstractSet {

constructor(from, to) {
super();
this.from = from;
this.to = to;
}

has(x) { return x >= this.from && x <= this.to; }


toString() { return `{ x| ${this.from} ≤ x ≤ ${this.to} }`; }
}
/**
* AbstractEnumerableSet jest klasą pochodną od AbstractSet. Definiuje

* abstrakcyjny getter zwracający wielkość zbioru oraz abstrakcyjny


* iterator. Metody te są wykorzystywane w zaimplementowanych
* metodach isEmpty(), toString() i equals(). Podklasy implementujące
* iterator, getter i metodę has() dostają powyższe metody w prezencie.
*/

class AbstractEnumerableSet extends AbstractSet {


get size() { throw new Error("Metoda abstrakcyjna"); }
[Symbol.iterator]() { throw new Error("Metoda abstrakcyjna"); }
isEmpty() { return this.size === 0; }

toString() { return `{${Array.from(this).join(", ")}}`; }


equals(set) {
// Jeżeli inny zbiór nie jest typu Enumerable, to oznacza, że nie jest
zgodny z bieżącym zbiorem.
if (!(set instanceof AbstractEnumerableSet)) return false;

// Jeżeli zbiory mają różne wielkości, to oznacza, że nie są sobie równe.


if (this.size !== set.size) return false;
// Przetwarzanie elementów zbioru.
for(let element of this) {
// Jeżeli element nie należy do innego zbioru, to oznacza, że zbiory
nie są równe.
if (!set.has(element)) return false;
}
// Elementy są zgodne, więc zbiory są sobie równe.
return true;

}
}
/**
* SingletonSet jest klasą pochodną od AbstractEnumerableSet.

* Reprezentuje zbiór przeznaczony tylko do odczytywania


* i zawierający tylko jeden element.
*/
class SingletonSet extends AbstractEnumerableSet {
constructor(member) {
super();

this.member = member;
}
// Zaimplementowane są trzy metody i odziedziczone są
// wykorzystujące je implementacje metod isEmpty, equals()
// oraz toString().

has(x) { return x === this.member; }


get size() { return 1; }
*[Symbol.iterator]() { yield this.member; }
}

/**
* 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().

* Zwróć uwagę, że interfejs API różni się od interfejsu wbudowanej klasy


Set.
*/
class AbstractWritableSet extends AbstractEnumerableSet {
insert(x) { throw new Error("Metoda abstrakcyjna"); }

remove(x) { throw new Error("Metoda abstrakcyjna"); }


add(set) {
for(let element of set) {
this.insert(element);

}
}
subtract(set) {
for(let element of set) {
this.remove(element);

}
}
intersect(set) {
for(let element of this) {

if (!set.has(element)) {
this.remove(element);
}
}
}
}
/**

* BitSet jest klasą pochodną od AbstractWritableSet, zawierającą bardzo


* oszczędną implementację zbioru o stałej wielkości, którego elementami są
* liczby nieujemne mniejsze od zadanej wartości maksymalnej.
*/

class BitSet extends AbstractWritableSet {


constructor(max) {

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,

this.data[byte] |= BitSet.bits[bit]; // to jest ustawiany


this.n++; // i zwiększana jest wielość
zbioru.
}
} else {
throw new TypeError("Niepoprawny element zbioru: " + x );
}
}
// Usunięcie wartości x ze zbioru BitSet.

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]() {

for(let i = 0; i <= this.max; i++) {


if (this.has(i)) {
yield i;
}
}
}
}
// Kilka wstępnie wyliczonych wartości wykorzystywanych w metodach has(),
insert() i remove().
BitSet.bits = new Uint8Array([1, 2, 4, 8, 16, 32, 64, 128]);
BitSet.masks = new Uint8Array([~1, ~2, ~4, ~8, ~16, ~32, ~64, ~128]);

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, obiektów i domknięć,


tworzenie modułów w środowisku Node za pomocą funkcji require(),
tworzenie modułów w wersji języka ES6 za pomocą słów kluczowych export, import i
instrukcji import().

10.1. Tworzenie modułów za pomocą


klas, obiektów i domknięć
Może się to wydać oczywiste, ale wspomnę, że klasy mają między innymi tę ważną cechę, że są
modułami dla swoich metod. Przypomnij sobie listing 9.8, w którym było zdefiniowanych kilka
klas, każda zawierająca metodę has(). Można napisać program wykorzystujący klasy z tego
listingu bez obaw, że implementacja powyższej metody w klasie SingletonSet nadpisze na
przykład metodę o tej samej nazwie w klasie BitSet.
Metody znajdujące się w niepowiązanych ze sobą klasach są od siebie niezależne dlatego, że są
właściwościami niezależnych prototypów. Natomiast klasy są modułowe dlatego, że takie
właśnie są obiekty. Definiowanie właściwości obiektu w języku JavaScript jest bardzo podobne
do deklarowania zmiennej. Różnica polega jednak na tym, że dodanie właściwości do obiektu
nie wpływa na globalną przestrzeń nazw programu ani na właściwości innych obiektów. W
języku JavaScript sporo funkcji matematycznych jest zdefiniowanych jako stałe. Nie są to
jednak globalne funkcje, tylko właściwości jednego, globalnego obiektu Math. Ta sama technika
została wykorzystana w listingu 9.8, w którym nie są zdefiniowane globalne klasy o nazwach
SingletonSet i BitSet, tylko jeden globalny obiekt Sets z właściwościami odwołującymi się do
różnych klas. Do klas zawartych w bibliotece Sets można odwoływać się za pomocą nazw
Sets.Singleton i Sets.Bit.

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ę.

// W tym miejscu znajdują się szczegóły implementacyjne.


function isValid(set, n) { ... }

function has(set, byte, bit) { ... }

const BITS = new Uint8Array([1, 2, 4, 8, 16, 32, 64, 128]);


const MASKS = new Uint8Array([~1, ~2, ~4, ~8, ~16, ~32, ~64, ~128]);

// Publicznym interfejsem API tego modułu jest po prostu klasa BitSet,


zdefiniowana
// i zwracana w tym miejscu. Klasa ta może wykorzystywać zdefiniowane wyżej

// prywatne funkcje i stałe. Będą one jednak ukryte przed kodem odwołującym
się do tej klasy,

return class BitSet extends AbstractWritableSet {

// 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:

// W ten sposób można zdefiniować moduł stats.

const stats = (function() {


// Prywatne funkcje pomocnicze modułu.

const sum = (x, y) => x + y;

const square = x => x * x;

// Publiczna funkcja, która zostanie wyeksportowana.


function mean(data) {
return data.reduce(sum)/data.length;

// Inna publiczna funkcja, która zostanie wyeksportowana.

function stddev(data) {
let m = mean(data);

return Math.sqrt(

data.map(x => x - m).map(square).reduce(sum)/(data.length-1)

);

}
// Eksport publicznych funkcji jako właściwości obiektu.

return { mean, stddev };

}());

// Przykłady użycia modułu.


stats.mean([1, 3, 5, 7, 9]) // => 5

stats.stddev([1, 3, 5, 7, 9]) // => Math.sqrt(10)

10.1.1. Automatyzacja modułowości opartej na


domknięciach
Zwróć uwagę, że przekształcenie pliku z kodem w moduł, polegające na umieszczeniu kilku
wierszy na początku i końcu pliku, jest bardzo mechanicznym procesem. Trzeba jedynie,
stosując pewne konwencje, wskazać, które zmienne mają zostać wyeksportowane, a które nie.

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:

const modules = {};

function require(moduleName) { return modules[moduleName]; }


modules["sets.js"] = (function() {

const exports = {};

// Tu znajduje się zawartość pliku sets.js:

exports.BitSet = class BitSet { ... };

return exports;
}());

modules["stats.js"] = (function() {

const exports = {};


// Tu znajduje się zawartość pliku stats.js:
const sum = (x, y) => x + y;

const square = x = > x * x;

exports.mean = function(data) { ... };

exports.stddev = function(data) { ... };

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).

const stats = require("stats.js");

const BitSet = require("sets.js").BitSet;

// Kod wykorzystujący moduły.

let s = new BitSet(100);

s.insert(10);

s.insert(20);

s.insert(30);

let average = stats.mean([...s]); // Średnia jest równa 20.


Powyższy przykład pokazuje ogólnie, jak działają narzędzia takie jak webpack lub Parcel,
pakujące kod dla przeglądarek. Stanowi on również proste wprowadzenie do funkcji require(),
podobnej do stosowanej w środowisku Node.

10.2. Moduły w środowisku Node


W środowisku Node normalną praktyką jest dzielenie programów na tyle plików, ile wydaje się
to wskazane. Przyjęte jest założenie, że pliki z kodem JavaScript znajdują się w szybkim
systemie plików. W odróżnieniu od przeglądarek, które pobierają pliki JavaScript za pomocą
dość wolnych połączeń sieciowych, nie ma potrzeby scalania fragmentów kodu w jeden plik
programu ani nie wynikają z tego żadne korzyści.

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.

10.2.1. Eksport symboli w środowisku Node


W środowisku Node jest zdefiniowany globalny obiekt exports. Aby wyeksportować symbole z
modułu, należy je po prostu przypisać właściwościom powyższego obiektu:

const sum = (x, y) => x + y;

const square = x => x * x;

exports.mean = data => data.reduce(sum)/data.length;

exports.stddev = function(d) {

let m = exports.mean(d);

return Math.sqrt(d.map(x => x - m).map(square).reduce(sum)/(d.length-1));

};

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:

// Definicje wszystkich funkcji, publicznych i prywatnych.

const sum = (x, y) => x + y;

const square = x => x * x;

const mean = data => data.reduce(sum)/data.length;

const stddev = d => {

let m = mean(d);
return Math.sqrt(d.map(x => x - m).map(square).reduce(sum)/(d.length-1));

};

// Eksport wyłącznie publicznych funkcji.

module.exports = { mean, stddev };

10.2.2. Import symboli w środowisku Node


W środowisku Node moduł importuje się, wywołując funkcję require(). Jej argumentem jest
nazwa importowanego modułu, a zwracanym wynikiem eksportowany przez niego symbol
(zazwyczaj funkcja, klasa lub obiekt).

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ły wbudowane w środowisko Node.


const fs = require("fs"); // Wbudowany moduł obsługujący system
plików.
const http = require("http"); // Wbudowany moduł obsługujący protokół
HTTP.

// Zewnętrzny moduł zawierający platformę serwerową Express HTTP.

// Moduł ten nie jest częścią środowiska Node, tylko został zainstalowany
lokalnie.

const express = require("express");

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:

const stats = require('./stats.js');

const BitSet = require('./utils/bitset.js');

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:

// Import obiektu stats ze wszystkimi funkcjami.

const stats = require('./stats.js');


// Importowanych jest więcej funkcji, niż trzeba, ale są one uporządkowane w
przestrzeni nazw "stats".

let average = stats.mean(data);

// Ewentualnie można użyć przypisania destrukturyzującego

// i zaimportować bezpośrednio do lokalnej przestrzeni nazw tylko potrzebne


funkcje.

const { stddev } = require('./stats.js');

// Jest to dobry, zwięzły sposób, jednak tracony jest kontekst, gdy nazwy

// funkcji nie poprzedza prefiks 'stats' określający przestrzeń nazw.

let sd = stddev(data);

10.2.3. Moduły Node w przeglądarkach


Moduły zawierające obiekt exports i funkcję require() są wbudowane w platformę Node.
Jeżeli trzeba spakować własny kod za pomocą narzędzia takiego jak webpack, można
zastosować styl uruchamiania modułów w przeglądarkach. Do niedawna był to bardzo
popularny sposób i na pewno będziesz miał do czynienia z mnóstwem kodów, w których jest on
wykorzystywany.

Obecnie, po ustandaryzowaniu składni modułów, programiści używający narzędzi pakujących


zazwyczaj wykorzystują oficjalne słowa kluczowe import i export.
10.3. Moduły w języku ES6
W wersji języka ES6 pojawiły się słowa kluczowe import i export, a modułowość stała się
oficjalnie kluczową cechą języka. Koncepcja jest taka sama jak w środowisku Node: każdy plik
jest osobnym modułem. Wszystkie zdefiniowane w nim stałe, zmienne, funkcje i klasy są
prywatne, chyba że zostaną jawnie wyeksportowane. Język ES6 różni się od środowiska Node
składnią instrukcji do importowania i eksportowania modułów, jak również sposobem
korzystania z nich w przeglądarkach. Szczegóły opisane są w poniższych punktach.

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).

Moduły ES6 w przeglądarkach i środowisku Node

Moduły ES6 można stosować w przeglądarkach od lat dzięki narzędziom pakującym


takim jak webpack, które łączą osobne moduły JavaScript w jeden duży pakiet,
gotowy do umieszczenia w kodzie strony internetowej. W chwili pisania tej książki
wszystkie przeglądarki, oprócz Internet Explorera, natywnie obsługiwały moduły, tj.
można je było dołączać do kodu HTML za pomocą specjalnego, opisanego w dalszej
części rozdziału znacznika <script type="module">.

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.

10.3.1. Eksport symboli w języku ES6


Aby wyeksportować z modułu ES6 stałą, zmienną, funkcję lub klasę, wystarczy jedynie przed jej
deklaracją umieścić słowo kluczowe export:
export const PI = Math.PI;

export function degreesToRadians(d) { return d * PI / 180; }


export class Circle {
constructor(r) { this.r = r; }

area() { return PI * this.r * this.r; }


}
Aby nie rozrzucać słów export po całym module, można zdefiniować wszystkie stałe, zmienne,
funkcje i klasy w zwykły sposób, a następnie w jednym miejscu, najlepiej na końcu modułu,
umieścić instrukcję export wraz z eksportowanymi symbolami. Zatem zamiast trzech
eksportów, jak w powyższym przykładzie, można wpisać na końcu pliku jeden wiersz:

export { Circle, degreesToRadians, PI };


Powyższy zapis przypomina definicję literału obiektowego wykorzystującą uproszczoną
składnię, ale nie jest to literał. W nawiasach klamrowych umieszczona jest po prostu lista
oddzielonych przecinkami identyfikatorów.
Często z modułu eksportuje się tylko jeden symbol (funkcję lub klasę). W takim wypadku
zamiast instrukcji export stosuje się export default:

export default class BitSet {


// Implementacja pominięta.

}
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.

Dopuszczalnym, ale rzadko stosowanym rozwiązaniem jest stosowanie zarówno instrukcji


export, jak i export default. Tej drugiej można użyć tylko raz.
Ponadto należy zwrócić uwagę, że słowo export musi znajdować się na najwyższym poziomie
kodu. Nie można go umieszczać wewnątrz klasy, funkcji, pętli ani instrukcji warunkowej. Jest to
ważna cecha systemu modułów w języku ES6 umożliwiająca statyczną analizę kodu:
eksportowane symbole są zawsze takie same i można je określić bez uruchamiania kodu.

10.3.2. Import symboli w języku ES6


Symbole wyeksportowane z jednego modułu importuje się do innego za pomocą słowa
kluczowego import. Najprostszą formę przybiera ono w przypadku użycia instrukcji default
export:

import BitSet from './bitset.js';


Po słowie import umieszcza się identyfikator, za nim słowo kluczowe from i na końcu literał
znakowy będący nazwą importowanego modułu. Symbol domyślnie eksportowany ze
wskazanego modułu staje się wartością określonego identyfikatora w bieżącym module.
Identyfikator, któremu przypisywany jest eksportowany symbol, uzyskuje stałą wartość, tak
jakby został zdefiniowany za pomocą słowa kluczowego const. Słowo import, podobnie jak
export, można stosować wyłącznie na najwyższym poziomie modułu. Nie jest ono dozwolone
wewnątrz klas, funkcji, pętli i instrukcji warunkowych. Zgodnie z niemal uniwersalną
konwencją instrukcje importujące umieszcza się na początku modułu. Co ciekawe, nie jest to
bezwzględny wymóg, ponieważ instrukcje te, podobnie jak deklaracje funkcji, są „windowane”
na najwyższy poziom modułu. Wszystkie importowane symbole są dostępne w całym kodzie
modułu.

Nazwę modułu, z którego importowane są symbole, określa literał znakowy umieszczony


wewnątrz apostrofów lub cudzysłowów. Nazwą nie może być zmienna, wyrażenie testowe ani
ciąg umieszczony wewnątrz grawisów, ponieważ literały szablonów interpolują zmienne i nie
zawsze przyjmują stałe wartości. Przeglądarki interpretują ten ciąg jako względny adres URL w
odniesieniu do modułu importującego. Natomiast środowisko Node i narzędzia pakujące
interpretują go jako względną nazwę pliku w odniesieniu do bieżącego modułu, co w praktyce
ma pewne znaczenie. Ciąg znaków określający moduł musi być ścieżką bezwzględną
rozpoczynającą się od ukośnika, ścieżką względną rozpoczynającą się od prefiksu ./ lub ../
albo pełnym adresem URL zawierającym nazwę protokołu i hosta. Specyfikacja języka ES6 nie
dopuszcza stosowania niekwalifikowanych nazw, na przykład util.js, ponieważ mogą być one
niejednoznaczne i wskazywać zarówno moduł zapisany w bieżącym katalogu, jak i moduł
systemowy umieszczony w specjalnym miejscu. Wymóg ten nie obowiązuje w narzędziach
pakujących, takich jak webpack, które można łatwo skonfigurować tak, aby wyszukiwały
moduły po samej nazwie we wskazanym katalogu bibliotecznym. Być może w przyszłych
wersjach języka tego rodzaju „gołe specyfikatory” będą dopuszczalne, ale obecnie tak nie jest.
Aby zaimportować moduł zapisany w tym samym katalogu, w którym znajduje się bieżący
moduł, należy po prostu przed jego nazwą umieścić prefiks ./, na przykład "./util.js" (nie
można stosować zapisu "util.js").

Do tej pory rozważaliśmy importowanie tylko jednego symbolu z modułu wykorzystującego


instrukcję export default. Aby zaimportować kilka symboli, należy użyć nieco innej składni:
import { mean, stddev } from "./stats.js";

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 Histogram, { mean, stddev } from "./histogram-stats.js";


Poznałeś więc sposoby importowania symboli eksportowanych z modułów za pomocą instrukcji
export default, jak również importowania w zwykły sposób symboli nazwanych. Jest jeszcze
inna forma instrukcji import, wykorzystywana z modułami, które niczego nie eksportują. Aby
dołączyć taki moduł do programu, należy po prostu użyć słowa kluczowego import i
identyfikatora modułu, jak niżej:

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.

10.3.3. Importowanie i eksportowanie ze zmianą


nazw
Jeżeli dwa moduły eksportują dwa różne symbole o takich samych nazwach i oba są potrzebne,
można jeden lub oba przemianować podczas importu. Podobnie, jeżeli importowany symbol ma
taką samą nazwę jak inny symbol użyty w bieżącym module, można zmienić nazwę
importowanego symbolu w następujący sposób:
import { render as renderImage } from "./imageutils.js";

import { render as renderUI } from "./ui.js";


Powyższy kod importuje do bieżącego modułu dwie funkcje. Obie mają nazwy render() w
modułach, w których są zdefiniowane, ale po imporcie otrzymują jednoznaczne i bardziej
opisowe nazwy renderImage() i renderUI().
Jak już wiesz, domyślnie eksportowany symbol nie ma nazwy. Jest ona zawsze określana w
module importującym. Dlatego w takich przypadkach nie jest potrzebna specjalna składnia.
Dzięki możliwości zmieniania nazw symboli moduły eksportujące zarówno symbole domyślne,
jak i nazwane można importować w jeszcze jeden sposób. Wróćmy do przykładu modułu
"./histogram-stats.js" z poprzedniego punktu. Poniższy kod pokazuje, jak można
zaimportować z tego modułu symbol domyślny i symbole nazwane:
import { default as Histogram, mean, stddev } from "./histogram-stats.js";

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:

export { Math.sin as sin, Math.cos as cos }; // SyntaxError


10.3.4. Ponowny eksport
W tym punkcie wykorzystany jest hipotetyczny moduł "./stats.js" eksportujący funkcje
mean() i stddev(). Załóżmy, że większość użytkowników tego modułu będzie korzystać tylko z
jednej, wybranej funkcji. W takim wypadku można funkcję mean() zdefiniować w module
"./stats/mean.js", a funkcję stddev() w module "./stats/stddev.js". Dzięki temu do
programu będzie można zaimportować tylko wybraną funkcję i nie zaśmiecać kodu
niepotrzebnymi symbolami.

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.

Przy założeniu, że obie funkcje są zaimplementowane w osobnych plikach, moduł "./stats.js"


można zdefiniować bardzo łatwo w następujący sposób:
import { mean } from "./stats/mean.js";

import { stddev } from "./stats/stddev.js";


export { mean, stdev };

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:

export { mean } from "./stats/mean.js";


export { stddev } from "./stats/stddev.js";

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:

export * from "./stats/mean.js";


export * from "./stats/stddev.js";
Podczas ponownego eksportowania symboli można zmieniać ich nazwy tak jak za pomocą
zwykłych instrukcji import i export. Załóżmy, że chcemy ponownie wyeksportować funkcję
mean(), która powinna być również dostępna pod nazwą average(). W takim przypadku można
użyć następującego kodu:

export { mean, mean as average } from "./stats/mean.js";


export { stddev } from "./stats/stddev.js";

W powyższych przykładach przyjęte jest założenie, że moduły "./stats/mean.js" i


"./stats/stddev.js" eksportują swoje funkcje za pomocą instrukcji export, a nie export
default. W rzeczywistości uzasadnione byłoby użycie tej drugiej instrukcji, ponieważ każdy
moduł eksportuje tylko jeden symbol. W takim przypadku składnia ponownego eksportu staje
się nieco bardziej skomplikowana, ponieważ trzeba określić nazwę domyślnie eksportowanego
symbolu, jak niżej:
export { default as mean } from "./stats/mean.js";

export { default as stddev } from "./stats/stddev.js";


Aby zaimportowany nazwany symbol ponownie wyeksportować jako domyślny, można użyć
instrukcji import, a po niej export albo połączyć je ze sobą w następujący sposób:
// Zaimportowanie funkcji mean() z modułu ./stats.js
// i wyeksportowanie jej jako symbolu domyślnego.
export { mean as default } from "./stats.js"
Na koniec, aby ponownie wyeksportować domyślny symbol z modułu, z którego jest on również
eksportowany jako domyślny (choć raczej nie jest to uzasadnione, bo takiego modułu można
użyć bezpośrednio), należy wpisać następujący kod:
// Moduł average.js po prostu ponownie eksportuje domyślny symbol z modułu
stats/mean.js.

export { default } from "./stats/mean.js"

10.3.5. Moduły JavaScript w aplikacjach


internetowych
W poprzednich punktach zostały dość abstrakcyjnie opisane moduły stosowane w wersji języka
ES6 oraz słowa kluczowe import i export. W tym i następnym punkcie dowiesz się, jak te słowa
funkcjonują w przeglądarkach. Jeżeli nie masz doświadczenia w tworzeniu aplikacji
internetowych, łatwiej Ci będzie zrozumieć pozostałą część rozdziału po uprzedniej lekturze
rozdziału 15.

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:

<script type="module">import "./main.js";</script>


Kod umieszczony wewnątrz znacznika <script type="module"> jest modułem ES6, a więc
może zawierać instrukcję export. Jednak jej stosowanie niczego nie daje, ponieważ składnia
znacznika <script> nie pozwala definiować nazw modułów. Zatem nawet jeżeli dany moduł
eksportuje symbole, nie ma możliwości zaimportowania ich do innego modułu.

Skrypty posiadające atrybut type="module" są ładowane i wykonywane jako skrypty z


atrybutem defer. Ładowanie kodu rozpoczyna się z chwilą rozpoznania przez parser HTML
znacznika <script>. W przypadku modułów krok ten może być rekurencyjnym procesem
skutkującym załadowaniem wielu plików JavaScript. Jednak skrypty zawierające zarówno kod
zwykły, jak i modułowy są uruchamiane dopiero po przeanalizowaniu całego dokumentu HTML.
Moment uruchomienia kodu można zmieniać za pomocą atrybutu async, który działa tak samo
w modułach jak w zwykłych skryptach. Moduł asynchroniczny jest wykonywany zaraz po
załadowaniu, jeszcze zanim zakończy się analiza dokumentu HTML, do tego niezależnie od
kolejności, w jakiej są umieszczone skrypty.
Przeglądarka obsługująca znacznik <script type="module"> musi również obsługiwać <script
type="nomodule">. Jeżeli obsługuje moduły, nie uruchamia skryptu oznaczonego powyższym
znacznikiem. W przeciwnym razie nie rozpoznaje atrybutu nomodule i pomija skrypt. Jest to
przydatna technika rozwiązująca problemy z kompatybilnością przeglądarek. Przeglądarki
obsługujące wersję języka ES6 obsługują również inne nowoczesne funkcjonalności, takie jak
klasy, funkcje strzałkowe czy pętle for/of. Przeglądarka ładuje nowoczesny skrypt
umieszczony wewnątrz znacznika <script type="module"> tylko wtedy, gdy jest w stanie go
wykonać. W przypadku przeglądarki IE11 (jedynej, która w 2020 r. nie obsługiwała języka ES6)
można zastosować rozwiązanie awaryjne polegające na użyciu narzędzi Babel i webpack
przekształcających kod modułowy w jego niemodułowy i mniej wydajny, ale zgodny z językiem
ES5 wariant, który można załadować za pomocą znacznika <script nomodule>.

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.

10.3.6. Dynamiczny import za pomocą funkcji


import()
Dyrektywy import i export wprowadzone w wersji języka ES6 są całkowicie statyczne. Dzięki
nim interpreter JavaScriptu i różne narzędzia mogą określać relacje pomiędzy modułami w
drodze prostej analizy kodu bez konieczności uruchamiania go. Statyczny import daje
gwarancję, że wszystkie symbole będą gotowe do użycia, zanim zostanie uruchomiony kod
modułu.
Przeglądarka nie odczytuje kodu z systemu plików, tylko pobiera go przez sieć. Ponadto kod ten
jest nierzadko uruchamiany na urządzeniu przenośnym, wyposażonym w dość wolny procesor.
To nie są okoliczności, w których ma sens statyczne importowanie wszystkich modułów
niezbędnych do uruchomienia programu.
Aplikacje internetowe często ładują jedynie kod niezbędny do wyświetlenia strony startowej.
Dopiero po zaprezentowaniu użytkownikowi pewnej początkowej treści rozpoczynają ładowanie
znacznie większego kodu potrzebnego do działania całej aplikacji. Dynamiczne ładowanie kodu
jest proste — wystarczy za pomocą interfejsu DOM API umieścić w bieżącym dokumencie
HTML nowy znacznik <script>. Ten sposób jest stosowany w przeglądarkach od wielu lat.
Choć dynamiczny import jest możliwy od długiego czasu, nie była to funkcjonalność samego
języka. Sytuację zmieniło wprowadzenie instrukcji import() w wersji języka ES2020 (na
początku 2020 r. wszystkie przeglądarki obsługujące moduły ES6 umożliwiały również ich
dynamiczne importowanie). Jej argumentem jest nazwa modułu, a zwracanym wynikiem obiekt
Promise (pol. obietnica) reprezentujący asynchroniczny proces ładujący i uruchamiający ten
moduł. Jeżeli dynamiczny import jest możliwy, „obietnica jest spełniana”, tzn. jest tworzony
obiekt podobny do uzyskiwanego za pomocą instrukcji import * as (szczegółowe informacje na
temat programowania asynchronicznego znajdziesz w rozdziale 13.).

Zatem zamiast importować moduł "./stats.js" statycznie, jak niżej:


import * as stats from "./stats.js";
można to zrobić dynamicznie:
import("./stats.js").then(stats => {
let average = stats.mean(data);

})
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.3.7. Właściwość import.meta.url


Pozostała do omówienia jeszcze jedna funkcjonalność systemu modułów wprowadzonego w
wersji języka ES6. Jest nią specjalny obiekt import.meta (niedostępny w zwykłym modelu
ładowanym za pomocą znacznika <script> w przeglądarce ani funkcji require() w środowisku
Node), przechowujący metadane bieżącego modułu. Jego właściwość url zawiera adres URL, z
którego pobierany jest moduł (w środowisku Node zaczyna się on od prefiksu file://).
Właściwość tę wykorzystuje się głównie w odwołaniach do obrazów, plików danych i innych
zasobów zapisanych w tym samym lub innym katalogu co moduł. Za pomocą konstruktora
URL() można łatwo zamienić względny adres URL na bezwzględny, taki jak zapisany we
właściwości import.meta.url.
Załóżmy, że tworzymy moduł wykorzystujący ciągi znaków, które muszą uwzględniać ustawienia
regionalne. Niezbędne do tego celu pliki są zapisane w katalogu l10n/, tym samym co moduł.
Ciągi można ładować, wykorzystując adres URL utworzony za pomocą funkcji, na przykład
takiej jak poniższa:
function localStringsURL(locale) {
return new URL(`l10n/${locale}.json`, import.meta.url);
}

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:

W starszych wersjach języka modułowość uzyskiwało się, umiejętnie stosując natychmiast


wykonywane wyrażenia funkcyjne.
Środowisko Node zawiera własny system modułów oparty na języku JavaScript. Moduły
importuje się za pomocą funkcji require(), a eksportowane symbole za pomocą
właściwości obiektu exports lub właściwości module.exports.
W wersji języka ES6 pojawił się nowy system modułów wykorzystujący słowa kluczowe
import i export, a w wersji ES2020 została wprowadzona instrukcja import()
umożliwiająca dynamiczne importowanie modułów.

[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.

Niektóre podrozdziały, szczególnie poświęcone tablicom i wyrażeniom regularnym, są dość


obszerne, ponieważ zawierają wiele podstawowych informacji, które należy znać, aby móc się
skutecznie powyższymi typami posługiwać. Wiele innych podrozdziałów jest krótkich —
stanowią one po prostu wprowadzenie do nowego interfejsu API i zawierają kilka przykładów
jego użycia.

11.1. Zbiory i mapy


Typ Object jest uniwersalną strukturą danych, wykorzystywaną do wiązania ciągów znaków
(nazw właściwości obiektu) z dowolnymi wartościami. Jeżeli taką wartością jest stała, na
przykład true, wówczas ten obiekt jest w rzeczywistości zbiorem ciągów znaków.

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.

11.1.1. Klasa Set


Zbiór jest kolekcją wartości, podobnie jak tablica. Jednak w odróżnieniu od tablicy elementy
zbioru nie są ułożone w określonej kolejności, nie mają indeksów i nie mogą się powtarzać.
Dana wartość należy albo nie należy do zbioru, a pytanie, ile razy występuje ona w zbiorze, nie
ma sensu.

Zbiór tworzy się za pomocą konstruktora Set():


let s = new Set(); // Nowy, pusty zbiór.

let t = new Set([1, s]); // Nowy zbiór z dwoma elementami.


Argumentem konstruktora nie musi być tablica. Może to być dowolny obiekt iterowalny
(również inny zbiór):

let t = new Set(s); // Nowy zbiór zawierający kopie


elementów obiektu s.

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:

let s = new Set(); // Pusty początkowy zbiór.

s.size // => 0

s.add(1); // Dodanie liczby.


s.size // => 1; też zbiór zawiera jeden element.

s.add(1); // Powtórne dodanie tej samej liczby.

s.size // => 1; wielkość zbioru nie zmieniła się.

s.add(true); // Dodanie innej wartości. Zwróć uwagę, że można mieszać


typy.

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.size // => 2: wielkość zbioru zmniejszyła się do 2.

s.delete("test") // => false: zbiór nie zawiera ciągu "test", więc próba
jego usunięcia nie powiodła się.

s.delete(true) // => true: pomyślnie usunięty element.

s.delete([1,2,3]) // => false: tablica w zbiorze jest innym obiektem.


s.size // => 1: zbiór wciąż zawiera tablicę.

s.clear(); // Usunięcie ze zbioru wszystkich elementów.

s.size // => 0

Zwróć uwagę na kilka ważnych szczegółów w powyższym kodzie:

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.

Uwaga dla programistów używających języka Python: pomiędzy zbiorami


stosowanymi w językach JavaScript i Python jest istotna różnica. W Pythonie
sprawdzana jest równość, a nie tożsamość elementów. W efekcie można w zbiorze
umieszczać wyłącznie niemutowalne wartości, na przykład krotki. Nie mogą to być
listy ani słowniki.

W praktyce najczęściej wykonywanymi operacjami na zbiorach nie jest dodawanie i usuwanie


elementów, tylko sprawdzanie przynależności wartości do zbioru. Służy do tego celu metoda
has():
let oneDigitPrimes = new Set([2,3,5,7]);

oneDigitPrimes.has(2) // => true: 2 jest jednocyfrową liczbą pierwszą.

oneDigitPrimes.has(3) // => true: 3 też jest taką liczbą.

oneDigitPrimes.has(4) // => false: 4 nie jest liczbą pierwszą.

oneDigitPrimes.has("5") // => false: "5" nie jest nawet liczbą.


Zbiory mają tę ważną cechę, że są zoptymalizowane pod kątem sprawdzania przynależności
wartości. Niezależnie od liczby elementów w zbiorze metoda has() działa bardzo szybko. W
przypadku tablicy metoda includes() również sprawdza przynależność elementu, ale czas jej
wykonania jest proporcjonalny do wielkości tablicy. Jeżeli tablica jest wykorzystywana w
charakterze zbioru, to operacje na niej wykonywane są znacznie wolniejsze niż w przypadku
obiektu typu Set.

Klasa Set jest iterowalna, co oznacza, że elementy zbioru można wyliczać za pomocą pętli
for/of:

let sum = 0;

for (let p of oneDigitPrimes) { // Pętla iterująca jednocyfrowe liczby


pierwsze.

sum += p; // Sumowanie elementów.

sum // => 17: 2 + 3 + 5 + 7

Ponieważ obiekt typu Set jest iterowalny, można za pomocą operatora rozciągania (...)
zamieniać go na tablicę lub listę argumentów:

[...oneDigitPrimes] // => [2,3,5,7]: zbiór przekształcony w tablicę.

Math.max(...oneDigitPrimes) // => 7: elementy zbioru umieszczone w


argumentach funkcji.

Zbiory są często, choć nieściśle, nazywane „nieuporządkowanymi kolekcjami”. Elementy nie


mają indeksów. Z tego powodu nie jest możliwe sprawdzenie, jaki jest na przykład pierwszy lub
trzeci element zbioru, tak jak to można robić w przypadku tablicy. Jednak klasa Set zapamiętuje
kolejność dodawanych elementów i przestrzega jej podczas iterowania zbioru. Pierwszy dodany
element jest odczytywany jako pierwszy (o ile nie został wcześniej usunięty), a ostatnio dodany
jest odczytywany jako ostatni[2].

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;

oneDigitPrimes.forEach(n => { product *= n; });


product // => 210: 2 * 3 * 5 * 7

W przypadku tablicy drugim argumentem funkcji umieszczonej w argumencie metody


forEach() jest tablica indeksów. Elementy w zbiorze nie mają indeksów, dlatego w powyższej
metodzie pierwszym i drugim argumentem funkcji jest wartość elementu.

11.1.2. Klasa Map


Obiekt Map reprezentuje zbiór wartości, tzw. kluczy. Z każdym kluczem jest skojarzona pewna
wartość. Zatem mapa jest podobna do tablicy, ale różni się tym, że jej „indeksami” mogą być
dowolne wartości, nie tylko kolejne liczby całkowite. Operacje wykonywane na mapach,
podobnie jak na tablicach, są bardzo szybkie. Wyszukanie wartości skojarzonej z zadanym
kluczem trwa krótko (choć dłużej niż w przypadku tablicy), niezależnie od wielkości mapy.

Mapę tworzy się za pomocą konstruktora Map():


let m = new Map(); // Utworzenie nowej, pustej mapy.
let n = new Map([ // Nowa mapa zainicjowana za pomocą ciągów znaków
powiązanych z liczbami.

["jeden", 1],

["dwa", 2]

]);

Opcjonalnym argumentem konstruktora Map() jest iterowalny obiekt zawierający


dwuelementowe tablice [klucz, wartość]. W praktyce oznacza to, że tworzoną mapę inicjuje
się za pomocą tablicy, której każdy element jest tablicą złożoną z klucza i skojarzonej z nim
wartości. Oprócz tego za pomocą konstruktora Map() można kopiować inne mapy, jak również
właściwości obiektu i ich wartości:

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.

let p = new Map(Object.entries(o)); // Instrukcja równoważna new map([["x",


1], ["y", 2]]).

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.

let m = new Map(); // Początkowa, pusta mapa.

m.size // => 0: pusta mapa nie ma kluczy.

m.set("jeden", 1); // Przypisanie kluczowi "jeden" wartości 1.

m.set("dwa", 2); // Dodanie klucza "dwa" z wartością 2.


m.size // => 2: teraz mapa zawiera dwa kucze.

m.get("dwa") // => 2: zwrócenie wartości przypisanej kluczowi "dwa".

m.get("trzy") // => undefined: tego klucza nie ma w mapie.

m.set("one", true); // Zmiana wartości przypisanej istniejącemu kluczowi.

m.size // => 2: wielkość mapy nie zmieniła się.

m.has("jeden") // => true: mapa zawiera klucz "jeden".

m.has(true) // => false: mapa nie zawiera klucza true.

m.delete("jeden") // => true: zadany klucz był w mapie i został usunięty.

m.size // => 1
m.delete("trzy") // => false: nieudana próba usunięcia nieistniejącego
klucza.

m.clear(); // Usunięcie z mapy wszystkich kluczy i wartości.


Metoda add() klasy Map, podobnie jak w przypadku klasy Set, może tworzyć łańcuch. Dzięki
temu można inicjować mapę bez użycia tablicy tablic:
let m = new Map().set("jeden", 1).set("dwa", 2).set("trzy", 3);

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:

let m = new Map(); // Początkowa, pusta mapa.


m.set({}, 1); // Powiązanie pustego obiektu z liczbą 1.

m.set({}, 2); // Powiązanie innego pustego obiektu z liczbą 2.

m.size // => 2: mapa zawiera dwa klucze.

m.get({}) // => undefined: ten pusty obiekt nie jest kluczem.

m.set(m, undefined); // Powiązanie samej metody z wartością undefined.

m.has(m) // => true: m jest kluczem w mapie.

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:

let m = new Map([["x", 1], ["y", 2]]);

[...m] // => [["x", 1], ["y", 2]]

for(let [key, value] of m) {


// W pierwszej iteracji kluczem jest ciąg "x", a wartością liczba 1.

// W drugiej iteracji kluczem jest ciąg "y", a wartością liczba 2.

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.

[...m.values()] // => [1, 2]: tylko wartości.

[...m.entries()] // => [["x", 1], ["y", 2]]: to samo, co […m].


Mapy można również iterować przy użyciu metody forEach(), pierwotnie zaimplementowanej
w klasie Array:
m.forEach((value, key) => { // Uwaga na kolejność: "wartość, klucz", a nie
"klucz, wartość".

// W pierwszej iteracji wartością jest liczba 1, a kluczem ciąg "x".

// W drugiej iteracji wartością jest liczba 2, a kluczem ciąg "y".

});

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.

11.1.3. Klasy WeakMap i WeakSet


Klasa WeakMap jest odmianą (ale nie podklasą) klasy Map. Jej klucze nie są usuwane podczas
porządkowania sterty (ang. garbage collection), czyli podczas odzyskiwania przez interpreter
JavaScriptu pamięci zajmowanej przez niedostępne już obiekty, do których nie można
odwoływać się w programie. Zwykła mapa rejestruje „silne” odwołania do swoich kluczy i
przechowuje je nawet wtedy, gdy wszystkie inne odwołania przestaną istnieć. Natomiast klasa
WeakMap przechowuje „słabe” odwołania do kluczy, które nie powstrzymują procesu
odzyskiwania zajmowanej przez nie pamięci.

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:

elementami zbioru WeakSet nie mogą być wartości prymitywne,


klasa WeakSet implementuje wyłącznie metody add(), has() i delete(), jak również nie
jest iterowalna,
klasa WeakSet nie ma właściwości size.
Klasę WeakSet stosuje się raczej rzadko, zazwyczaj w takich samych sytuacjach jak klasę
WeakMap. Jeżeli na przykład trzeba „oznaczyć”, że obiekt ma specjalną właściwość lub jest
określonego typu, można umieścić go w zbiorze WeakSet. Następnie, aby sprawdzić daną
właściwość lub typ, należy zweryfikować obecność obiektu w zbiorze. W przypadku
wykorzystania do tego celu zwykłego zbioru oznaczone w ten sposób obiekty nie byłyby
usuwane z pamięci podczas jej porządkowania. Problem natomiast znika, jeżeli użyje się klasy
WeakSet.

11.2. Typowane tablice i dane binarne


Zwykłe tablice mogą zawierać elementy dowolnych typów, jak również mogą dynamiczne się
powiększać i zmniejszać. Język JavaScript implementuje wiele mechanizmów
optymalizacyjnych, dzięki którym typowe operacje na tablicach są wykonywane bardzo szybko.
Jednak mimo tego tablice te istotnie różnią się od stosowanych w językach niższego poziomu,
na przykład C i Java. Bardziej do nich zbliżone są tablice typowane, wprowadzone w wersji
języka ES6[3]. Z technicznego punktu widzenia nie są to tablice (metoda Array.isArray()
zwraca wynik false), ale implementują one wszystkie metody tablicowe opisane w
podrozdziale 7.8 i dodatkowo kilka własnych. Od zwykłych tablic różnią się pod kilkoma
istotnymi względami:

Elementami tablicy typowanej mogą być wyłącznie liczby. Jednak w odróżnieniu od


zwykłych tablic można określić typ liczby (całkowita ze znakiem lub bez niego lub liczba
zmiennoprzecinkowa zgodna ze standardem IEEE-754) oraz wielkość (8-bitowa lub 64-
bitowa).
Tworząc tablicę typowaną, należy określić jej wielkość, której potem nie można zmieniać.
Elementy tablicy typowanej podczas jej tworzenia są inicjowane zerami.

11.2.1. Typy elementów tablicy typowanej


W języku JavaScript nie ma jednej klasy reprezentującej tablicę typowaną. Jest za to 11
rodzajów tablic typowanych, z których każda definiuje własny konstruktor i typy elementów:

Konstruktor Typ liczby

Int8Array() Bajt ze znakiem

Uint8Array() Bajt bez znaku

Uint8ClampedArray() Bajt bez znaku i bez zawinięcia

Int16Array() 16-bitowa liczba całkowita ze znakiem

Uint16Array() 16-bitowa liczba całkowita bez znaku

Int32Array() 32-bitowa liczba całkowita ze znakiem

Uint32Array() 32-bitowa liczba całkowita bez znaku

BigInt64Array() 64-bitowa liczba całkowita ze znakiem (ES2020)

BigUint64Array() 64-bitowa liczba całkowita bez znaku (ES2020)


Float32Array() 32-bitowa liczba zmiennoprzecinkowa

64-bitowa liczba zmiennoprzecinkowa (zwykła liczba w języku


Float64Array()
JavaScript)

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.

11.2.2. Tworzenie tablic typowanych


Tablicę typowaną najprościej tworzy się, wywołując odpowiedni konstruktor z argumentem
określającym liczbę elementów:
let bytes = new Uint8Array(1024); // 1024 bajty.

let matrix = new Float64Array(9); // Tablica 3x3.


let point = new Int16Array(3); // Punkt w przestrzeni trójwymiarowej.
let rgba = new Uint8ClampedArray(4); // 4-bajtowy kolor RGBA piksela.

let sudoku = new Int8Array(81); // Plansza sudoku 9x9.


W tak utworzonej tablicy wszystkie elementy uzyskują wartości równe 0, 0n lub 0.0. Jeżeli
wartości mają być inne, można je określić podczas tworzenia tablicy. Każdy konstruktor posiada
statyczne metody from() i of(), działające podobnie jak Array.from() i Array.of():
let white = Uint8ClampedArray.of(255, 255, 255, 0); // Nieprzezroczysty
biały kolor RGBA.
Jak pamiętasz, pierwszym argumentem metody fabrycznej Array.from() jest obiekt iterowalny,
podobny do tablicy. Podobnie jest w przypadku tablic typowanych. Różnica polega jedynie na
tym, że obiekt musi zawierać elementy liczbowe. Na przykład ciągi znaków są iterowalne, ale
nie można ich umieszczać w argumencie metody from() należącej do tablicy typowanej.
Wywołując metodę from()tylko z jednym argumentem, można pominąć fragment .from i
umieścić iterowalny obiekt bezpośrednio w argumencie konstruktora, co da dokładnie taki sam
efekt. Zwróć uwagę, że zarówno za pomocą konstruktora, jak i metody from() można kopiować
istniejące tablice typowane, jednocześnie zmieniając typ elementów:
let ints = Uint32Array.from(white); // Takie same 4-bajtowe liczby, ale
całkowite.
Podczas tworzenia nowej tablicy typowanej na podstawie istniejącej tablicy, obiektu
iterowalnego lub obiektu podobnego do tablicy wartości mogą być przycinane, aby spełniały
wymagania typu. Nie są przy tym zgłaszane ostrzeżenia ani błędy:

// Liczby zmiennoprzecinkowe przycięte do całkowitych oraz dłuższe liczby


całkowite przycięte do 8 bitów.
Uint8Array.of(1.23, 2.99, 45000) // => new Uint8Array([1, 2, 200])

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:

let buffer = new ArrayBuffer(1024*1024);


buffer.byteLength // => 1024*1024; jeden megabajt.

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 asbytes = new Uint8Array(buffer); // Obszar jako ciąg bajtów.


let asints = new Int32Array(buffer); // Obszar jako ciąg 32-
bitowych liczb całkowitych ze znakiem.

let lastK = new Uint8Array(buffer, 1023*1024); // Ostatni kilobajt obszaru


jako ciąg bajtó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.

11.2.3. Korzystanie z tablic typowanych


Po utworzeniu tablicy typowanej można odczytywać i zapisywać jej elementy, stosując składnię
z nawiasami kwadratowymi, tak jak w przypadku zwykłego obiektu tablicowego:
// Funkcja wyszukująca za pomocą sita Eratostenesa największą liczbę pierwszą
mniejszą od n.
function sieve(n) {

let a = new Uint8Array(n+1); // Element a[x] ma wartość 1, jeżeli


x jest liczbą złożoną.
let max = Math.floor(Math.sqrt(n)); // Liczba większa niż ta nie będzie
przetwarzana.
let p = 2; // 2 jest najmniejszą liczbą
pierwszą.

while(p <= max) { // Dla liczby pierwszej p mniejszej


niż max…
for(let i = 2*p; i <= n; i += p) // …wielokrotność p jest oznaczana
jako liczba złożona.
a[i] = 1;

while(a[++p]) /* empty */; // Kolejny nieoznaczony indeks jest


liczbą pierwszą.
}

while(a[n]) n--; // Odwrócona pętla wyszukująca


największą liczbę pierwszą.
return n; // Zwrócenie znalezionej liczby.

}
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().

Tablice typowane nie są prawdziwymi tablicami, ale implementują większość


charakterystycznych dla nich metod. Korzysta się więc z nich tak samo jak ze zwykłych tablic:
let ints = new Int16Array(10); // 10 krótkich liczb całkowitych.

ints.fill(3).map(x=>x*x).join("") // => "9999999999"


Pamiętaj, że tablica typowana ma stałą wielkość, więc jej właściwość length można wyłącznie
odczytywać. Ponadto nie są zaimplementowane metody zmieniające wielkość tablicy, takie jak
push(), pop(), unshift(), shift() i splice(). Zaimplementowane są jedynie metody
zmieniające zawartość, m.in. sort(), reverse() i fill(). Metody map() i slice() zwracają
nowe tablice tych samych typów co tablice, do których należą.

11.2.4. Metody i właściwości tablicy typowanej


Typowane tablice, oprócz standardowych metod, implementują również kilka własnych. Metoda
set() nadaje wartość kilku elementom jednocześnie, kopiując do nich elementy zwykłej tablicy:

let bytes = new Uint8Array(1024); // Bufor o wielkości 1 kB.


let pattern = new Uint8Array([0,1,2,3]); // Tablica zawierająca 4 bajty.
bytes.set(pattern); // Skopiowanie tablicy na początek innej tablicy
bajtowej.
bytes.set(pattern, 4); // Ponownie skopiowanie tablicy w inne miejsce.
bytes.set([0,1,2,3], 8); // Skopiowanie wartości bezpośrednio ze zwykłej
tablicy.
bytes.slice(0, 12) // => new Uint8Array([0,1,2,3,0,1,2,3,0,1,2,3])

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:

let ints = new Int16Array([0,1,2,3,4,5,6,7,8,9]); // 10 krótkich liczb


całkowitych.

let last3 = ints.subarray(ints.length-3, ints.length); // Ostatnie 3


elementy.
last3[0] // => 7: to samo co ints[7].

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:

ints[9] = -1; // Zmiana wartości elementu oryginalnej tablicy…


last3[2] // => –1: … przekłada się na zmianę elementu podtablicy.

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:

last3.buffer // Obiekt ArrayBuffer wykorzystywany przez


typowaną tablicę.
last3.buffer === ints.buffer // => true: oba obiekty są widokami tego samego
bufora.
last3.byteOffset // => 14: ten widok rozpoczyna się od 14. bajtu
bufora.

last3.byteLength // => 6: ten widok ma wielkość 6 bajtów (3


liczby całkowite 16-bitowe)…,

last3.buffer.byteLength // => 20: …ale bufor ma wielkość 20 bajtów.


Właściwość buffer jest wykorzystywanym przez tablicę obiektem ArrayBuffer, właściwość
byteOffset zawiera początkową pozycję danych tablicy w buforze, natomiast właściwość
byteLength zawiera wielkość tablicy wyrażoną w bajtach. Dla danej tablicy typowanej a
poniższe wyrażenie powinno zawsze mieć wartość true:
a.length * a.BYTES_PER_ELEMENT === a.byteLength // => true

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.buffer[1] = 255; // Błędna próba ustawienia bajtu bufora.


bytes.buffer[1] // => 255: wartość jest przypisywana zwykłej
właściwości obiektu.

bytes[1] // => 0: powyższy wiersz nie ustawia bajtu.


Dowiedziałeś się wcześniej, jak tworzy się bufor za pomocą konstruktora ArrayBuffer(), a
następnie tablicę wykorzystującą ten bufor. Inne sposób polega na utworzeniu początkowej
tablicy typowanej, a następnie wykorzystaniu bufora tej tablicy do utworzenia innych widoków:
let bytes = new Uint8Array(1024); // 1024 bajty…

let ints = new Uint32Array(bytes.buffer); // …lub 256 liczb całkowitych…


let floats = new Float64Array(bytes.buffer); // …lub 128 liczb
zmiennoprzecinkowych.

11.2.5. Klasa DataView i kolejność bajtów


Wykorzystując tablicę typowaną, można ten sam ciąg bajtów traktować jako serię bloków 8-,
16-, 32- lub 64-bitowych. W tym miejscu pojawia się kwestia kolejności bajtów (ang. endianess)
tworzących dłuższe bloki. Ze względów wydajnościowych w tablicach typowanych stosowana
jest natywna kolejność wykorzystywana przez procesor. W systemach typu little-endian bajty
tworzące liczbę są ułożone w kolejności od najmniej do najbardziej znaczącego, natomiast w
systemach typu big-endian od najbardziej do najmniej znaczącego. Kolejność można sprawdzić
za pomocą następującego kodu:
// Jeżeli liczba całkowita 0x00000001 jest zapisana w pamięci jako ciąg 01 00
00 00, oznacza to, że system

// jest typu little-endian.


// W systemie typu big-endian liczba ta jest zapisana jako ciąg 00 00 00 01.

let littleEndian = new Int8Array(new Int32Array([1]).buffer)[0] === 1;

Większość dzisiejszych procesorów jest typu little-endian, jednak w wielu protokołach


sieciowych i kilku binarnych formatach plików stosowana jest kolejność big-endian. Jeżeli w
tablicy typowanej umieszczane są dane pochodzące z sieci lub plików, nie można zakładać, że
kolejność bajtów jest właściwa dla wykorzystywanej platformy. Zazwyczaj do przetwarzania
zewnętrznych danych jako tablic bajtów można stosować klasy Int8Array i Uint8Array, ale nie
można używać tablic typowanych, których elementy składają się z większej liczby bajtów.
Zamiast nich należy używać klasy DataView, która zawiera metody umożliwiające odczytywanie
i zapisywanie danych w buforze ArrayBuffer zgodnie z zadaną kolejnością bajtów:

// Załóżmy, że mamy tablicę typowaną zawierającą dane binarne.


// Najpierw tworzymy obiekt DataView, aby można było wygodnie odczytywać i
zapisywać bajty tworzące wartości.
let view = new DataView(bytes.buffer,
bytes.byteOffset,

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

// little-endian i nie ma znaku.


view.setUint32(8, int, false); // Zapisanie liczby w kolejności big-endian.
Klasa DataView definiuje dziesięć metod, po jednej dla każdej z dziesięciu klas tablic
typowanych (z wyjątkiem Uint8ClampedArray). Są to m.in. metody getInt16(), getUint32(),
getBigInt64() i getFloat64(). Pierwszym argumentem każdej z nich jest przesunięcie (w
bajtach) wartości zapisanej w buforze ArrayBuffer. Wszystkie metody, oprócz getInt8() i
getUint8(), mają opcjonalny, drugi argument logiczny. Jeżeli ma on wartość false lub nie jest
określony, metoda stosuje kolejność big-endian. Jeżeli argument ma wartość true, stosowana
jest kolejność little-endian.
Klasa DataView definiuje również dziesięć metod zapisujących dane w buforze ArrayBuffer.
Pierwszym argumentem każdej z nich jest przesunięcie (w bajtach), od którego jest zapisywana
wartość podana w drugim argumencie. Wszystkie metody, oprócz setInt8() i setUint8(),
mają trzeci, opcjonalny argument. Jeżeli ma on wartość false lub jest pominięty, metoda
zapisuje wartość w kolejności big-endian, tj. najpierw najbardziej znaczący bajt. Jeżeli
argument ma wartość true, metoda zapisuje wartość w kolejności little-endian, tj. najpierw
najmniej znaczący bajt.
Tablice typowane i klasa DataView to narzędzia niezbędne do przetwarzania danych binarnych,
umożliwiające tworzenie programów wykonujących takie operacje jak rozpakowywanie plików
ZIP i wyodrębnianie metadanych z plików JPEG.

11.3. Wyszukiwanie wzorców i wyrażenia


regularne
Wyrażenie regularne jest obiektem opisującym wzorzec testowy. Klasa RegExp reprezentuje
wyrażenie regularne, natomiast klasy String i RegExp definiują metody wykorzystujące te
wyrażenia do zaawansowanego wyszukiwania i zastępowania wzorców tekstu. Jednak aby
efektywnie korzystać z interfejsu API klasy RegExp, trzeba znać składnię wyrażeń regularnych,
która jest minijęzykiem programowania samym w sobie. Na szczęście składnia stosowana w
języku JavaScript jest bardzo podobna do spotykanej w innych językach, więc niewykluczone,
że już ją znasz. (Jeżeli nie, wysiłek włożony w poznanie wyrażeń regularnych stosowanych w
JavaScripcie przyda Ci się również w innych językach).
W kolejnych punktach opisana jest składnia wyrażeń regularnych, sposoby ich tworzenia i
użycia w metodach klas String i RegExp.

11.3.1. Definiowanie wyrażeń regularnych


W języku JavaScript wyrażenia regularne reprezentuje klasa RegExp. Oczywiście wyrażenia
można tworzyć za pomocą konstruktora tej klasy, ale częściej wykorzystuje się w tym celu
specjalną składnię. Podobnie jak literał znakowy jest ciągiem znaków umieszczonym wewnątrz
apostrofów lub cudzysłowów, tak wyrażenie regularne jest ciągiem znaków umieszczonym
pomiędzy ukośnikami (/). Zatem można użyć następującego kodu:
let pattern = /s$/;
Wyrażenie regularne składa się z serii znaków. Większość z nich, włącznie ze wszystkimi
znakami alfanumerycznymi, jest literalnie porównywana z zadanym tekstem. Zatem wyrażenie
regularne /java/ odpowiada każdemu ciągowi znaków zawierającemu podciąg „java”. Inne
znaki nie są porównywane wiernie, ponieważ mają specjalne znaczenie. Na przykład wyrażenie
regularne /s$/ składa się z dwóch znaków. Pierwszy, „s”, jest porównywany wiernie z tekstem.
Natomiast drugi, „$”, jest metaznakiem odpowiadającym końcowi ciągu. Zatem powyższe
wyrażenie regularne odpowiada każdemu ciągowi znaków kończącemu się na literę „s”.
Jak się przekonasz, wyrażenie regularne może również zawierać jedną lub kilka flag
modyfikujących jego działanie. Flagi określa się, umieszczając odpowiednie znaki za drugim
ukośnikiem lub za pomocą drugiego argumentu konstruktora RegExp(). Aby na przykład
wyszukiwać ciągi kończące się na literę „s” lub „S”, należy w wyrażeniu regularnym umieścić
flagę i oznaczającą, że wielkość liter nie ma znaczenia:
let pattern = /s$/i;
W kolejnych podpunktach opisane są różne znaki i metaznaki stosowane w wyrażeniach
regularnych w języku JavaScript.

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

alfanumeryczny Porównywany literalnie.

\0 Znak NUL (\u0000).

\t Tabulator (\u0009).

\n Nowy wiersz (\u000A).

\v Tabulator pionowy (\u000B).

\f Wysunięcie papieru (\u000C).

\r Powrót karetki (\u000D).

Znak Latin określony za pomocą liczby szesnastkowej nn. Na przykład


\xnn
sekwencja \x0A jest równoważna \n.

Znak Unicode określony za pomocą liczby szesnastkowej nnn. Na przykład


\uxxxx
sekwencja \x0009 jest równoważna \t.

Znak Unicode określony za pomocą kodu n, składającego się z serii od


jednej do sześciu cyfr szesnastkowych, tj. z zakresu od 0 do 10FFFF. Należy
\u{n}
pamiętać, że ta składnia jest dopuszczalna tylko w wyrażeniach regularnych
zawierających flagę u.
\cX Znak kontrolny ^X. Na przykład sekwencja \cJ jest równoważna \n.

W wyrażeniach regularnych można również stosować następujące znaki interpunkcyjne o


specjalnym znaczeniu:
^ $ . * + ? = ! : | \ / ( ) [ ] { }.
W kolejnych podpunktach opisane jest znaczenie poszczególnych znaków. Niektóre z nich mają
specjalne znaczenie tylko w określonym kontekście, a innym są traktowane literalnie. W tym
drugim wypadku, zgodnie z ogólną zasadą, należy je poprzedzać odwróconym ukośnikiem. Inne
znaki interpunkcyjne, na przykład cudzysłów i @, nie mają specjalnego znaczenia i są
traktowane literalnie.
Jeżeli nie ma pewności, czy przed danym znakiem interpunkcyjnym należy umieścić odwrócony
ukośnik, można na wszelki wypadek go użyć. Należy jednak pamiętać, że niektóre cyfry i litery
poprzedzone tym ukośnikiem mają specjalne znaczenie. Dlatego, aby były traktowane literalnie,
nie należy ich wpisywać w ten sposób. Aby odwrócony ukośnik był traktowany literalnie, należy
go również poprzedzić ukośnikiem. Na przykład wyrażenie /\\/ odpowiada każdemu ciągowi
zawierającemu odwrócony ukośnik. W przypadku użycia konstruktora RegExp() należy
pamiętać, że każdy odwrócony ukośnik należy wpisywać podwójnie, ponieważ również w
ciągach jest on znakiem specjalnym (tzw. znakiem ucieczki, ang. escape character).

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.

Ponieważ niektóre klasy znaków są często stosowane, w języku JavaScript są zdefiniowanie


reprezentujące je specjalne znaki i sekwencje. Na przykład sekwencja \s oznacza tzw. biały
znak Unicode (m.in. spację i tabulator), a \S oznacza znak inny niż biały. Tabela 11.2 zawiera
listę tych sekwencji i opis składni odpowiadających im klas. Zwróć uwagę, że niektóre z nich
odpowiadają tylko znakom ASCII, tj. nie można ich stosować z ciągami Unicode. Można jednak
definiować własne klasy. Na przykład wyrażenie /[\u0400-\u04FF]/ odpowiada dowolnemu
znakowi cyrylicy.

Tabela 11.2 . Klasy znaków w wyrażeniach regularnych

Sekwencja Opis

[...] Dowolny znak umieszczony wewnątrz nawiasów.

[^...] Dowolny znak z wyjątkiem umieszczonych wewnątrz nawiasów.

Dowolny znak z wyjątkiem nowego wiersza i innego znaku zakończenia wiersza


. Unicode. Kropka użyta w konstruktorze RegExp() z flagą s oznacza dowolny
znak, również końca wiersza.

\w Znak ASCII. Sekwencja równoważna wyrażeniu [a-zA-Z0-9_].

\W Znak inny niż ASCII. Sekwencja równoważna wyrażeniu [^a-zA-Z0-9_].


\s Dowolny biały znak Unicode.

\S Dowolny znak inny niż biały Unicode.

\d Dowolna cyfra. Sekwencja równoważna wyrażeniu [0-9].

\D Dowolny znak inny niż cyfra. Sekwencja równoważna wyrażeniu [^0-9].

[\b] Usunięcie znaku (przypadek szczególny).

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]/.

Klasy znaków Unicode


Począwszy od wersji języka ES2018 wyrażenie regularne zawierające flagę u obsługuje
klasę \p{...} reprezentującą znaki Unicode i klasę \P{...} wykluczającą te znaki. Od
początku 2020 r. klasy te są obsługiwane przez środowisko Node oraz przeglądarki
Chrome, Edge i Safari, ale nie Firefox. Klasy te są oparte na standardzie Unicode i
reprezentowane przez nie znaki mogą się zmieniać wraz z tym standardem.
Sekwencja \d odpowiada tylko cyfrom ASCII. Wyrażenie odpowiadające jednej cyfrze
dowolnego systemu liczbowego ma postać /\p{Decimal_Number}/u, natomiast
odpowiadające znakowi innemu niż cyfra ma postać \P{Decimal_Number}. Z kolei
wyrażenie \p{Number} odpowiada każdej liczbie, w tym ułamkowi i liczbie rzymskiej.
Zwróć uwagę, że oznaczenia Decimal_Number i Number nie są charakterystyczne dla
języka JavaScript ani składni wyrażeń regularnych. Są to nazwy kategorii znaków
zdefiniowane w standardzie Unicode.
Sekwencja \w dotyczy tylko tekstu ASCII, natomiast wykorzystując sekwencję \p, można
zdefiniować klasę reprezentującą międzynarodowe znaki:
/[\p{Alphabetic}\p{Decimal_Number}\p{Mark}]/u
Aby powyższe wyrażenie w pełni uwzględniało języki używane na całym świecie, należy
je rozbudować o kategorie Connector_Punctuation i Join_Control.
Ponadto za pomocą sekwencji \p można definiować wyrażenia odpowiadające znakom z
określonego alfabetu lub pisma:
let greekLetter = /\p{Script=Greek}/u;
let cyrillicLetter = /\p{Script=Cyrillic}/u;

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.

{n,} Powtórzenie poprzedniego wzorca przynajmniej n razy.

{n} Powtórzenie poprzedniego wzorca dokładnie n razy.

Brak wystąpienia lub jedno wystąpienie poprzedniego wzorca. Oznacza to, że


?
wzorzec ten jest opcjonalny. Symbol odpowiada wyrażeniu {0,1}.

Jedno lub więcej powtórzeń poprzedniego wzorca. Symbol odpowiada wyrażeniu


+
{1,}.

Zero lub więcej powtórzeń poprzedniego wzorca. Symbol odpowiada wyrażeniu


*
{0,}.

Poniżej przedstawionych jest kilka przykładów:


let r = /\d{2,4}/; // Liczba złożona z dwóch, trzech lub czterech cyfr.

r = /\w{3}\d?/; // Dokładnie trzy litery i opcjonalna cyfra.


r = /\s+java\s+/; // Ciąg „java” z opcjonalnymi spacjami na początku i
końcu.
r = /[^(]*/; // Zero lub więcej znaków innych niż nawias otwierający.
Zwróć uwagę, że we wszystkich powyższych przykładach powtórzenia dotyczą pojedynczego
znaku lub klasy. Aby powtarzać bardziej skomplikowane wyrażenia, należy za pomocą nawiasów
zdefiniować grupę opisaną w następnym punkcie.
Symbole * i ? należy stosować ostrożnie. Ponieważ oznaczają one zero lub więcej wystąpień
poprzedzających je wzorców, mogą również nie oznaczać niczego. Na przykład wyrażenie /a*/
odpowiada ciągowi "bbbb", ponieważ zawiera on zero wystąpień litery a!

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ę.

Alternatywy, grupy i odwołania


Składnia wyrażeń regularnych obejmuje znaki specjalne określające podwyrażenia
alternatywne, grupujące i odwołania do poprzednich podwyrażeń. Symbol | oddziela wyrażenia
alternatywne. Na przykład wyrażenie /ab|cd|ef/ odpowiada ciągowi "ab" lub ciągowi "cd",
lub ciągowi "ef". Natomiast wyrażenie /\d{3}|[a-z]{4}/ odpowiada trzem cyfrom lub
czterem małym literom.
Zwróć uwagę, że podwyrażenia alternatywne są przetwarzane w kolejności od lewej do prawej
aż do momentu znalezienia dopasowania. Jeżeli podwyrażenie znajdujące się po lewej stronie
znaku | jest dopasowane, to podwyrażenie umieszczone po prawej jest pomijane, nawet jeżeli
jest „lepiej” dopasowane. Zatem wyrażenie /a|ab/ użyte z ciągiem "ab" odpowiada tylko jego
pierwszej literze.

Nawiasy mają w wyrażeniach regularnych specjalne znaczenie. Przede wszystkim grupują


osobne elementy w podwyrażenie, które dzięki temu jest traktowane jak jeden element przez
znaki |, *, +, ? i inne. Na przykład wyrażenie /java(script)?/ odpowiada ciągowi "java" i
następującemu po nim ciągowi "script". Natomiast wyrażenie /(ab|cd)+|ef/ odpowiada
ciągowi "ef" lub jednemu lub kilku wystąpieniom ciągów "ab" lub "cd".
Oprócz tego nawiasy wykorzystuje się do definiowania podwyrażeń wewnątrz kompletnego
wzorca. Jeżeli wyrażenie zostanie dopasowane do zadanego ciągu znaków, można wyodrębniać
z niego fragmenty dopasowane do poszczególnych podwyrażeń umieszczonych w nawiasach.
(W następnym punkcie dowiesz się, jak tworzy się takie podwyrażenia.). Załóżmy, że trzeba
wyszukać jedną lub kilka małych liter z następującą po nich jedną lub kilkoma cyframi. Można
w tym celu użyć wyrażenia /[a-z]+\d+/. Załóżmy dodatkowo, że ważne są jedynie cyfry na
końcu dopasowanego ciągu. Jeżeli powyższe wyrażenie umieści się w nawiasach (/[a-z]+
(\d+)/), będzie można z każdego dopasowania wyodrębnić cyfry w opisany dalej sposób.
Do podwyrażeń umieszczonych w nawiasach można się odwoływać w dalszej części tego
samego wyrażenia. Wykorzystuje się w tym celu odwrócony ukośnik i jedną lub kilka cyfr, które
określają położenie podwyrażenia w całym wyrażeniu. Na przykład sekwencja \1 odwołuje się
do pierwszego podwyrażenia, a \3 do trzeciego. Należy również zwrócić uwagę, że
podwyrażenia można zagnieżdżać. Wtedy cyfra oznacza pozycję lewego nawiasu. Na przykład w
poniższym wyrażeniu sekwencja \s odwołuje się do podwyrażenia ([Ss]cript):
/([Jj]ava([Ss]cript)?)\sis\s(fun\w*)/

Odwołanie do poprzedniego podwyrażenia nie dotyczy jego treści, tylko dopasowanego do


niego tekstu. Zatem odwołania można wykorzystywać do wzmacniania warunku, aby osobne
części ciągu zawierały dokładnie te same znaki. Na przykład poniższe wyrażenie odpowiada
zeru lub dowolnej liczbie znaków umieszczonych wewnątrz apostrofów lub cudzysłowów. Nie
muszą to być jednak dwa apostrofy lub dwa cudzysłowy:
/['"][^'"]*['"]/

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*).

Tabela 11.4 zawiera podsumowanie składni alternatyw, grup i odwołań.


Tabela 11.4 . Skłania alternatyw, grup i odwołań w wyrażeniach regularnych

Sekwencja Opis

Alternatywa: dopasowanie do podwyrażenia znajdującego się po lewej lub


|
prawej stronie znaku.

Grupowanie: grupa elementów, którą można stosować ze znakami *, +, ?, | itp.


(...) Możliwość odwoływania się w dalszej części wyrażenia do tekstu dopasowanego
do grupy.

Tylko grupowanie: grupa elementów, jednak bez możliwości odwoływania się do


(?:...)
dopasowanego do niej tekstu.

Odwołanie do grupy. Grupa to podwyrażenie umieszczone wewnątrz nawiasów.


Podwyrażenia można zagnieżdżać. Numery grup dotyczą nawiasów
\n
otwierających, w kolejności od lewej do prawej. Grupy zaczynające się od
sekwencji (?: nie mają numerów.

Nazwane grupy przechwytujące


W wersji języka ES2018 została ustandaryzowana nowa funkcjonalność, dzięki której
wyrażenia regularne dokumentują same siebie i są bardziej czytelne. Są to tzw. nazwane
grupy przechwytujące, którym można przypisywać nazwy i wykorzystywać je, zamiast
numerów, w odwołaniach do dopasowanego tekstu. Równie ważny jest fakt, że dzięki
nazwom łatwiej jest innemu programiście zrozumieć przeznaczenie danego fragmentu
wyrażenia regularnego. Na początku 2020 r. funkcjonalność ta była obsługiwana przez
środowisko Node, przeglądarki Chrome, Edge i Safari, ale nie przez Firefox.
Aby utworzyć nazwaną grupę, należy zamiast nawiasu otwierającego użyć sekwencji (?
<...>, a pomiędzy znakami nierówności umieścić nazwę. Poniżej przedstawione jest
przykładowe wyrażenie sprawdzające format ostatniego wiersza amerykańskiego adresu
pocztowego:
/(?<city>\w+) (?<state>[A-Z]{2}) (?<zipcode>\d{5})(?<zip9>-\d{4})?/

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.

Określanie pozycji dopasowania


Jak wspomniałem wcześniej, wiele sekwencji wyrażenia regularnego odpowiada pojedynczemu
znakowi ciągu. Na przykład \s odpowiada pojedynczemu białemu znakowi. Inne sekwencje
odpowiadają pozycjom pomiędzy znakami, a nie samym znakom. Na przykład sekwencja \b
odpowiada granicy słowa złożonego ze znaków ASCII, tj. ciągowi pomiędzy \w (znak ASCII
tworzący słowo) a \W (znak ASCII inny niż tworzący słowo), jak również granicy pomiędzy
znakiem ASCII tworzącym słowo a końcem ciągu[4]. Sekwencje takie jak \b nie określają
dopasowywanych znaków ciągu. Określają za to pozycje dopasowań. Czasami sekwencje te są
nazywane zakotwiczeniami wyrażeń regularnych, ponieważ przywiązują wzorzec do
określonej pozycji znaku w dopasowywanym ciągu. Najczęściej stosowanymi zakotwiczeniami
są symbole ^ i $ oznaczające, odpowiednio, początek i koniec ciągu.
Na przykład aby dopasować słowo „JavaScript” do całego wiersza, należy użyć wyrażenia
/^JavaScript$/. Aby dopasować tylko słowo „Java” (nie jako prefiks, jak na przykład w słowie
„JavaScript”), należy użyć wyrażenia /\sJava\s/ wymagającego umieszczenia spacji przed tym
słowem i po nim. Wyrażenie to ma jednak dwa mankamenty. Po pierwsze, nie pasuje do słowa
„Java” znajdującego się na początku wiersza. Po drugie, dopasowany ciąg zawiera wiodącą i
końcową spację, co nie jest pożądanym efektem. Dlatego zamiast dopasowywać spacje za
pomocą sekwencji \s, należy określić granicę słowa za pomocą sekwencji \b. W efekcie
powstanie wyrażenie /\bJava\b/. Zakotwiczenie \B oznacza pozycję niebędącą granicą słowa.
Zatem wyrażenie /\B[Ss]cript/ odpowiada ciągom „JavaScript” i „postscript”, ale nie „script”
ani „Scripting”.
Ponadto jako warunki zakotwiczeń można stosować dowolne wyrażenia. Wyrażenie
umieszczone pomiędzy sekwencjami (?= i ) jest tzw. asercją wyprzedzającą, która określa, że
zawarte w niej znaki muszą być dopasowane, choć w rzeczywistości nie dopasowuje ich. Na
przykład wyrażenie /[Jj]ava([Ss]cript)?(?=\:)/ odpowiada nazwie popularnego języka
programowania, ale tylko wtedy, gdy umieszczony jest po niej dwukropek. Odpowiada więc
słowu „JavaScript” w ciągu „JavaScript: kompletny przewodnik”, ale nie odpowiada słowu
„Java” w ciągu „Java w pigułce”, ponieważ po słowie tym nie ma dwukropka.
Sekwencja (?! oznacza wykluczającą asercję wyprzedzającą określającą brak dopasowania. Na
przykład wyrażenie /Java(?!Script)([A-Z]\w*)/ odpowiada słowu „Java”, po którym
następuje wielka litera i dowolna liczba znaków ASCII tworzących słowa, przy czym znaki te nie
mogą tworzyć ciągu „Script”. Odpowiada zatem słowom „JavaBeans” i „JavaScrip”, ale nie
„Javanese”, „JavaScript” ani „JavaScripter”. Tabela 11.5 zawiera podsumowanie zakotwiczeń
stosowanych w wyrażeniach regularnych.
Tabela 11.5 . Zakotwiczenia stosowane w wyrażeniach regularnych

Sekwencja Opis

^ Początek ciągu, a w przypadku użycia flagi m początek wiersza.

$ Koniec ciągu, a w przypadku użycia flagi m początek wiersza.

Granica słowa, tj. pozycja pomiędzy znakami \w a \W lub pomiędzy znakiem \w


\b
a końcem ciągu. Zwróć uwagę, że sekwencja [\b] odpowiada usunięciu znaku.
\B Pozycja niebędąca granicą słowa.

Zwykła asercja wyprzedzająca, wymagająca dopasowania znaków do wzorca p,


(?=p)
ale nie dopasowująca ich w rzeczywistości.

Wykluczająca asercja wyprzedzająca, wymagająca braku dopasowania znaków


(?!p)
do wzorca p, ale niedopasowująca ich w rzeczywistości.

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

Flaga oznaczająca, że wyrażenie regularne jest globalne, tzn. ma wyszukiwać wszystkie


dopasowania, a nie tylko jedno. Flaga ta nie zmienia zbioru dopasowywania znaków, ale jak
się przekonasz później, istotnie zmienia działanie metod String.match() i RegExp.exec().

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 określająca, że w dopasowaniach nie jest uwzględniana wielkość liter.


m
Flaga określająca, że dopasowanie odbywa się w trybie wielowierszowym, tj.
dopasowywany ciąg składa się z kilku wierszy, a zakotwiczenia ^ i $ odpowiadają,
odpowiednio, początkowi i końcowi ciągu, jak również początkom i końcom poszczególnych
wierszy w ciągu.
s
Flaga ta, podobnie jak m, jest stosowana wtedy, gdy dopasowywany ciąg zawiera znaki
końców wierszy. Zazwyczaj kropka w wyrażeniu regularnym oznacza dowolny znak oprócz
końca wiersza. Jednak w przypadku użycia powyższej flagi kropka odpowiada każdemu
znakowi, również końcowi wiersza. Flaga ta pojawiła się w wersji języka ES2018 i na
początku 2020 r. była obsługiwana przez środowisko Node oraz przeglądarki Chrome, Edge
i Safari, ale nie Firefox.
u
Flaga ta oznacza standard Unicode i powoduje, że wyrażenie regularne przetwarza pełne
kody Unicode, a nie 16-bitowe wartości. Flaga ta została wprowadzona w wersji języka ES6
i należy ją domyślnie stosować we wszystkich wyrażeniach, chyba że są ważne powody, aby
tego nie robić. Jeżeli nie zostanie użyta, wyrażenie nie będzie poprawnie przetwarzać
tekstu zawierającego na przykład znaki emoji, których kody składają się z więcej niż 16
bitów. Poza tym kropka oznacza wtedy pojedynczy znak Unicode o 16-bitowym kodzie. W
przypadku użycia flagi kropka oznacza jeden znak Unicode, również o kodzie dłuższym niż
16-bitowy. Dzięki tej fladze można również stosować nową sekwencję \u{...} oznaczającą
znaki Unicode, jak również sekwencję \p{...} oznaczającą klasę tych znaków.
y

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ń.

11.3.2. Metody dopasowujące klasy String


Do tej pory zajmowaliśmy się składnią wyrażeń regularnych, ale nie sposobami wykorzystania
ich w kodzie JavaScript. Teraz skupimy się na interfejsie API klasy RegExp. Ten punkt zaczyna
się od opisu metod wyszukujących dopasowania wyrażeń regularnych oraz zastępujących
fragmenty ciągów. W kolejnych punktach przedstawione są metody i właściwości klasy RegExp.

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:

// Zmienna zawiera cudzysłów, następujące po nim znaki inne niż cudzysłowy


(przechwytywane przez wyrażenie)
// oraz drugi cudzysłów.
let quote = /"([^"]*)"/g;
// Zastąpienie cudzysłowów prostych ostrokątnymi.

// Tekst umieszczony wewnątrz nawiasów (zapisany w odwołaniu $1) nie jest


zmieniany.
'Powiedział: "stój!"'.replace(quote, '«$1»') // => ' Powiedział: «stój!»'
Jeżeli wyrażenie zawiera nazwane grupy przechwytujące, można się do ich dopasowań
odwoływać za pomocą nazw, a nie liczb:
let quote = /"(?<quotedText>[^"]*)"/g;

'Powiedział: "stój!"'.replace(quote, '«$<quotedText>»') // => 'Powiedział:


«stój»'
W drugim argumencie metody zamiast zastępującego tekstu można umieścić funkcję, która
będzie wywoływana w celu wyliczenia wartości zastępujących dopasowania. Funkcja ta ma
kilka argumentów. Pierwszym jest dopasowany tekst. Jeżeli wyrażenie zawiera nazwane grupy
przechwytujące, wówczas argumentem jest fragment tekstu dopasowany do grupy. Drugim
argumentem jest pozycja dopasowania w przeszukiwanym ciągu. Trzecim jest cały
przeszukiwany tekst. Jeżeli wyrażenie zawiera nazwane grupy przechwytujące, to czwartym
argumentem funkcji jest obiekt, którego właściwości mają nazwy takie jak grupy, a ich
wartościami są dopasowania do tych grup. Na przykład w poniższym kodzie jest wykorzystana
funkcja przekształcająca liczbę dziesiętną w szesnastkową:
let s = "15 razy 15 to 225";

s.replace(/\d+/gu, n => parseInt(n).toString(16)) // => "f razy f to e1"

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.

Przeanalizujmy przykład poniższego kodu analizującego adres URL[5]:


// Bardzo proste wyrażenie regularne analizujące adres URL.
let url = /(\w+):\/\/([\w.]+)\/(\S*)/;

let text = "Odwiedź mój blog na stronie http://www.example.com/~david";


let match = text.match(url);
let fullurl, protocol, host, path;
if (match !== null) {
fullurl = match[0]; // fullurl == "http://www.example.com/~david"

protocol = match[1]; // protocol == "http"


host = match[2]; // host == "www.example.com"
path = match[3]; // path == "~david"
}

W trybie nieglobalnym zwracana przez metodę tablica ma oprócz ponumerowanych elementów


również kilka właściwości. Właściwość input zawiera przeszukiwany ciąg. Właściwość indexs
zawiera pozycję znaku w ciągu, od którego zaczyna się dopasowanie. Jeżeli wyrażenie zawiera
nazwane grupy przechwytywania, to tablica posiada również właściwość groups, której
wartością jest obiekt. Jego właściwości mają nazwy takie jak grupy, a ich wartościami są
dopasowania tych grup. Kod z poprzedniego przykładu można zmienić w następujący sposób:
let url = /(?<protocol>\w+):\/\/(?<host>[\w.]+)\/(?<path>\S*)/;

let text = " Odwiedź mój blog na stronie http://www.example.com/~david";


let match = text.match(url);
match[0] // => "http://www.example.com/~david"
match.input // => text

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.

"test".match(vowel) // => null: słowo "test" nie zaczyna się od


samogłoski.
vowel.lastIndex = 1; // Określenie innej pozycji wyszukiwania.
"test".match(vowel)[0] // => "e": samogłoska została znaleziona na pozycji
1.

vowel.lastIndex // => 2: właściwość lastIndex jest automatycznie


aktualizowana.
"test".match(vowel) // => null: na pozycji 2 nie ma samogłoski.

vowel.lastIndex // => 0: właściwość lastIndex jest resetowana po


nieudanym dopasowaniu.
Warto zaznaczyć, że wywołanie metody match() z nieglobalnym wyrażeniem w argumencie ma
taki sam efekt jak wywołanie metody exec() obiektu reprezentującego wyrażenie regularne, tj.
w obu przypadkach tablice mają takie same elementy i właściwości.

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:

// Jeden lub kilka znaków Unicode umieszczonych pomiędzy granicami słowa.


const words = /\b\p{Alphabetic}+\b/gu; // Sekwencja \p nie jest jeszcze
obsługiwana przez
// przeglądarkę Firefox.
const text = "To jest prymitywny test metody matchAll().";
for(let word of text.matchAll(words)) {
console.log(`Znalezione słowo '${word[0]}' na pozycji ${word.index}.`);
}
Aby wskazać metodzie matchAll() pozycję, od której ma wyszukiwać dopasowania, należy
przypisać odpowiednią wartość właściwości lastIndex obiektu reprezentującego wyrażenie.
Jednak metoda ta, w odróżnieniu od innych metod wykorzystujących wyrażenia regularne, nie
modyfikuje wartości tej właściwości, dzięki czemu znacznie rzadziej jest przyczyną błędów w
kodzie.

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:

"1, 2, 3,\n4, 5".split(/\s*,\s*/) // => ["1", "2", "3", "4", "5"]


Co ciekawe, jeżeli wyrażenie umieszczone w argumencie metody split() zawiera grupy
przechwytujące, wówczas zwracana tablica zawiera tekst dopasowany do tych grup, na
przykład:
const htmlTag = /<([^>]+)>/; // Znak <, po nim dowolna liczba innych znaków
i na końcu znak >.
"Testing<br/>1,2,3".split(htmlTag) // => ["Testing", "br/", "1,2,3"]

11.3.3. Klasa RegExp


W tym punkcie opisany jest konstruktor klasy RegExp, właściwości jej instancji i dwie ważne
metody.
Aby utworzyć obiekt typu RegExp, należy wywołać konstruktor z jednym lub dwoma
argumentami. Pierwszym jest ciąg znaków zawierający treść wyrażenia regularnego, czyli tekst
umieszczony pomiędzy ukośnikami w literale wyrażenia. Zwróć uwagę, że odwrócone ukośniki
(\) są stosowane zarówno w literałach wyrażeń, jak i w ciągach znaków. Dlatego w literale
umieszczonym w argumencie konstruktora RegExp() każdy pojedynczy ukośnik należy zastąpić
podwójnym (\\). Drugi argument konstruktora jest opcjonalny i zawiera flagi wyrażenia. Musi
to być litera g, i, m, s, u, y lub ich dowolna kombinacja.
Poniżej przedstawiony jest przykład:
// Wyszukanie wszystkich pięciocyfrowych liczb. Zwróć uwagę na podwójny
ukośnik.
let zipcode = new RegExp("\\d{5}", "g");
Konstruktor RegExp() przydaje się w sytuacjach, w których wyrażenie regularne jest tworzone
dynamicznie i nie można go zdefiniować za pomocą literału. Na przykład aby użyć wyrażenia
wpisanego przez użytkownika, trzeba je utworzyć w trakcie działania programu za pomocą
konstruktora RegExp().
W pierwszym argumencie konstruktora można zamiast ciągu znaków umieścić obiekt typu
RegExp. W ten sposób można kopiować wyrażenia i zmieniać ich flagi:
let exactMatch = /JavaScript/;
let caseInsensitive = new RegExp(exactMatch, "i");

Właściwości obiektu RegExp


Obiekt RegExp ma następujące właściwości:
source
Właściwość przeznaczona tylko do odczytu, zawierająca treść wyrażenia regularnego, czyli
tekst umieszczony pomiędzy ukośnikami w literale wyrażenia.
flags
Właściwość przeznaczona tylko do odczytu, zawierająca litery oznaczające flagi wyrażenia.
global
Logiczna właściwość przeznaczona tylko do odczytu, przyjmująca wartość true, jeżeli
użyta jest flaga g.
ignoreCase
Logiczna właściwość przeznaczona tylko do odczytu, przyjmująca wartość true, jeżeli
użyta jest flaga i.
multiline
Logiczna właściwość przeznaczona tylko do odczytu, przyjmująca wartość true, jeżeli
użyta jest flaga m.
dotAll
Logiczna właściwość przeznaczona tylko do odczytu, przyjmująca wartość true, jeżeli
użyta jest flaga s.
unicode
Logiczna właściwość przeznaczona tylko do odczytu, przyjmująca wartość true, jeżeli
użyta jest flaga u.
sticky
Logiczna właściwość przeznaczona tylko do odczytu, przyjmująca wartość true, jeżeli
użyta jest flaga y.

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

Właściwość lastIndex i powtórne użycie wyrażenia regularnego


Jak już się przekonałeś, interfejs API klasy reprezentującej wyrażenie regularne jest dość
skomplikowany. Szczególnie zawiłe są flagi g i y. W przypadku ich użycia należy ostrożnie
wywoływać metody match(), exec() i test(). Działanie tych metod zależy od wartości
właściwości lastIndex, która z kolei zależy od wyniku poprzedniej operacji, co może być
przyczyną trudnych do wykrycia błędów w programie.
Załóżmy, że chcemy znaleźć w dokumencie HTML indeksy wszystkich znaczników <p>.
W tym celu możemy użyć następującego kodu:
let match, positions = [];
while((match = /<p>/g.exec(html)) !== null) { // Potencjalnie nieskończona
pętla.
positions.push(match.index);

}
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 = [];

let doubleLetter = /(\w)\1/g;

for(let word of dictionary) {


if (doubleLetter.test(word)) {
doubleLetterWords.push(word);
}
}
doubleLetterWords // => ["apple", "coffee"]: nie ma słowa "book"!
Ponieważ wyrażenie zawiera flagę g, właściwość lastIndex jest modyfikowana za
każdym razem, gdy zostanie znalezione dopasowanie. Metoda test() (wykorzystująca
metodę exec()) rozpoczyna wyszukiwanie od pozycji określonej w powyższej
właściwości. Po znalezieniu liter „pp” w słowie „apple” nadaje właściwości lastIndex
wartość 3, więc kolejne wyszukiwanie w słowie „book” zaczyna od pozycji 3 i nie znajduje
liter „oo”.
Problem można rozwiązać, usuwając z wyrażenia flagę g, która w rzeczywistości w tym
przykładzie nie jest potrzebna, lub przenosząc literał wyrażenia do ciała pętli, aby obiekt
przy każdej iteracji był tworzony na nowo. Oprócz tego można przed każdym wywołaniem
metody test() przypisywać właściwości lastIndex wartość 0.
Wniosek z powyższych rozważań jest taki, że program wykorzystujący interfejs API klasy
RegExp jest podatny na błędy. Dlatego zamiast metody exec() lepiej jest używać
wprowadzonej w wersji języka ES2020 metody matchAll() klasy String, ponieważ
metoda ta nie modyfikuje właściwości lastIndex.

11.4. Daty i czas


Klasa Date służy do przetwarzania dat i czasu. Konstruktor tej klasy wywołany bez argumentów
zwraca obiekt reprezentujący bieżącą datę i czas:
let now = new Date(); // Bieżąca data i czas.
Pojedynczy argument konstruktora musi być liczbą milisekund, jakie upłynęły od początku 1970
r.:
let epoch = new Date(0); // Północ, 1 stycznia 1970 r., czas GMT.

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.

d.setFullYear(d.getFullYear() + 1); // Zwiększenie roku.


Aby odczytać lub ustawić inne pola obiektu Date, należy prefiks FullYear w nazwie metody
zmienić, odpowiednio, na Month, Date, Hours, Minutes, Seconds lub Milliseconds. Za pomocą
niektórych metod można ustawiać kilka pól jednocześnie. Metody setFullYear() i
setUTCFullYear() pozwalają również opcjonalnie ustawiać miesiąc i dzień miesiąca, a metody
setHours() i setUTCHours() umożliwiają ustawienie nie tylko godziny, ale również minut,
sekund i milisekund.
Zwróć uwagę, że metody odczytujące dzień miesiąca mają nazwy getDate() i getUTCDate().
Metody o bardziej naturalnych nazwach getDay() i getUTCDay() zwracają numer dnia
tygodnia, gdzie 0 oznacza niedzielę, a 6 sobotę. Pole oznaczające dzień tygodnia jest
przeznaczone tylko do odczytu, dlatego nie ma metody setDay().

11.4.1. Znaczniki czasu


W języku JavaScript data jest wewnętrznie zapisywana w postaci liczby całkowitej. Jest to liczba
milisekund, które upłynęły od północy 1 stycznia 1970 r. Największą dopuszczalną liczbą jest
8 640 000 000 000 000, więc milisekund nie zabraknie przez najbliższe 270 tysięcy lat.
Metoda getTime() zwraca taką wewnętrzną wartość reprezentującą datę i czas, a setTime()
ustawia ją. Zatem, aby do bieżącego czasu dodać 30 sekund, należy użyć następującego kodu:
d.setTime(d.getTime() + 30000);
Liczba milisekund jest również nazywana znacznikiem czasu. Niekiedy wygodniej jest
posługiwać się znacznikami niż obiektami Date. Statyczna metoda Date.now() zwraca znacznik
reprezentujący bieżący czas. Można ją wykorzystywać na przykład do sprawdzania, jak długo
działa fragment kodu:
let startTime = Date.now();
reticulateSplines(); // Czasochłonne operacje.

let endTime = Date.now();


console.log(`Wyliczenie krzywej trwało ${endTime - startTime} ms.`);

Znaczniki o wysokiej rozdzielczości


Znacznik zwracany przez metodę Date.now() jest liczbą milisekund. Jedna milisekunda
to dla komputera dość długi interwał i niekiedy pojawia się potrzeba mierzenia czasu z
większą dokładnością. Służy do tego celu metoda performance.now(), której wynikiem
również jest liczba milisekund, ale jest to liczba zmiennoprzecinkowa, a więc zawierająca
ułamek milisekundy. Liczba ta nie jest jednak bezwzględnym znacznikiem czasu, jak w
przypadku metody Date.now(). Opisuje ona, ile czasu upłynęło od załadowania strony
lub uruchomienia środowiska Node.
Obiekt performance jest częścią większej klasy Performance, której nie definiuje
standard ECMAScript, ale implementują ją przeglądarki i środowisko Node. Aby użyć
obiektu performance w środowisku Node, należy go najpierw zaimportować:
const { performance } = require("perf_hooks");
Niektóre witryny internetowe mogą wykorzystywać pomiary czasu z wysoką dokładnością
do gromadzenia w złych intencjach poufnych informacji o użytkownikach. Dlatego
niektóre przeglądarki, na przykład Firefox, domyślnie zmniejszają dokładność działania
metody performance.now(). Przywrócenie wysokiej precyzji pomiaru jest zadaniem
programisty, który może w tym celu na przykład w przeglądarce Firefox ustawić opcję
privacy.reduceTimerPrecision na false.

11.4.2. Operacje na datach


Obiekty Date można ze sobą porównywać za pomocą standardowych operatorów <, <=, > i >=.
Oprócz tego obiekty można od siebie odejmować i w ten sposób wyliczać liczbę milisekund
pomiędzy dwiema datami. Jest to możliwe dlatego, że klasa Date zawiera metodę valueOf()
zwracającą liczbę milisekund.
Aby dodać do obiektu Date lub odjąć od niego określoną liczbę sekund, minut lub godzin,
najlepiej jest zmodyfikować znacznik czasu, jak w opisanym wcześniej przykładzie, w którym do
bieżącej godziny zostało dodanych 30 sekund. Technika ta okazuje się jednak dość uciążliwa,
gdy trzeba modyfikować liczbę dni, a w ogóle się nie sprawdza w przypadku operacji na
miesiącach i latach, ponieważ interwały te obejmują różne liczby dni. Do wykonywania operacji
na dniach, miesiącach i latach służą metody setDate(), setMonth() i setYear(). Poniższy
przykład pokazuje, jak do bieżącej daty dodać trzy dni i dwa tygodnie:
let d = new Date();
d.setMonth(d.getMonth() + 3, d.getDate() + 14);
Metody ustawiające datę działają poprawnie, nawet gdy ma miejsce przepełnienie. Na przykład
po dodaniu trzech miesięcy do bieżącej daty można uzyskać liczbę większą od 11, oznaczającą
grudzień. W takim przypadku metoda setMonth() odpowiednio zwiększa oznaczenie roku.
Analogicznie, jeżeli ustawi się liczbę dni większą niż liczba dni w miesiącu, zostanie
odpowiednio zmienione oznaczenie miesiąca.
11.4.3. Formatowanie dat i analizowanie ciągów
znaków
Jeżeli klasa Date jest wykorzystywana do rejestrowania daty i czasu, a nie do mierzenia
interwałów, często pojawia się potrzeba prezentowania tych informacji użytkownikom. Klasa
Date definiuje kilka metod przekształcających znaczniki czasu w ciągi znaków. Poniżej jest
przedstawionych kilka przykładów:
let d = new Date(2020, 0, 1, 17, 10, 30); // Nowy Rok, godz. 17:10:30.
d.toString() // => "Wed Jan 01 2020 17:10:30 GMT+0100 (czas
środkowoeuropejski standardowy)"
d.toUTCString() // => "Wed, 01 Jan 2020 16:10:30 GMT"
d.toLocaleDateString() // => "1.01.2020": polskie ustawienia regionalne.
d.toLocaleTimeString() // => "17:10:30": polskie ustawienia regionalne.

d.toISOString() // => "2020-01-01T16:10:30.000Z"


Poniżej znajduje się pełna lista metod formatujących, zdefiniowanych w klasie Date:
toString()
Metoda zwracająca czas w lokalnej strefie bez formatowania go zgodnie z ustawieniami
regionalnymi.
toUTCString()
Metoda zwracająca czas UTC bez formatowania go zgodnie z ustawieniami regionalnymi.
toISOString()
Metoda zwracająca datę i czas w standardowym formacie ISO-8601 rok-miesiąc-dzień
godzina:minuty:sekundy.milisekundy. Litera „T” oddziela część opisującą datę od części
czasu. Stosowana jest strefa UTC, którą oznacza litera „Z” na końcu ciągu.

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().

11.5. Klasy błędów


Za pomocą instrukcji throw i catch można zgłaszać i przechwytywać dowolne wartości,
również prymitywne. W języku JavaScript nie ma specjalnego typu wyjątku przeznaczonego do
zgłaszania problemów. Zdefiniowana jest natomiast klasa Error, której instancje i podklasy
wykorzystuje się za pomocą instrukcji throw do sygnalizowania błędów. Wynika to stąd, że w
obiekcie Error po jego utworzeniu zapisywany jest stos wywołań. Jeżeli wyjątek nie zostanie
przechwycony, stos ten jest wyświetlany razem z komunikatem o błędzie, co ułatwia
diagnostykę problemu. Zwróć jednak uwagę, że stos wywołań dotyczy miejsca w kodzie, w
którym obiekt został utworzony, a nie w którym znajduje się instrukcja throw. Dlatego, aby nie
uzyskiwać mylnych informacji, należy obiekt Error tworzyć tuż przed zgłoszeniem błędu.
Obiekt Error ma właściwości message i name oraz metodę toString(). Wartością pierwszej
właściwości jest argument konstruktora Error() przekształcony w ciąg znaków, jeżeli jest taka
potrzeba. W obiekcie utworzonym za pomocą konstruktora właściwość name ma zawsze wartość
"Error". Metoda toString() po prostu zwraca ciąg złożony z wartości właściwości name,
dwukropka, spacji i wartości właściwości message.
Ponadto środowisko Node i wszystkie nowoczesne przeglądarki definiują w obiekcie Error
właściwość stack, której nie specyfikuje standard ECMAScript. Jej wartością jest
wielowierszowy ciąg znaków zawierający ślad stosu wywołań w chwili utworzenia obiektu
Error. Jest to przydatna informacja w przypadku pojawienia się nieprzewidzianego błędu.
Oprócz klasy Error jest jeszcze kilka podklas wykorzystywanych do sygnalizowania
określonych typów błędów zdefiniowanych w standardzie ECMAScript. Są to podklasy
EvalError, RangeError, ReferenceError, SyntaxError, TypeError i URIError, które stosuje
się w kodzie, jeżeli oddają istotę błędu. Każda z nich, tak jak klasa Error, ma konstruktor,
którego argumentem jest komunikat o błędzie. Instancja każdej z podklas posiada właściwość
name, której wartością jest nazwa konstruktora.
Oprócz tego można definiować własne podklasy klasy Error, które wiernie oddają warunki
pojawienia się błędu. Należy zwrócić uwagę, że taka podklasa nie musi posiadać wyłącznie
właściwości name i message. W podklasie można definiować dowolne właściwości zawierające
szczegółowe informacje o błędzie. Na przykład w programie analizującym ciągi znaków może
być przydatna podklasa ParseError zawierająca właściwości line i column dokładnie
określające miejsce, w którym analiza się nie powiodła. Innym przykładem jest program
przetwarzający zapytania HTTP, w którym można zdefiniować podklasę HTTPError z
właściwością status przechowującą status (na przykład 400 lub 500) niepomyślnie
obsłużonego zapytania. Ilustruje to poniższy kod:
class HTTPError extends Error {
constructor(status, statusText, url) {
super(`${status} ${statusText}: ${url}`);
this.status = status;
this.statusText = statusText;
this.url = url;
}
get name() { return "HTTPError"; }
}

let error = new HTTPError(404, "Not Found", "http://example.com/");


error.status // => 404
error.message // => "404 Not Found: http://example.com/"
error.name // => "HTTPError"

11.6. Format JSON, serializacja i analiza


składni
Jeżeli dane utworzone w programie trzeba zapisać na dysku lub przesłać przez sieć do innego
programu, trzeba przekształcić strukturę zapisaną w pamięci w ciąg bajtów lub znaków, który
można zapisać lub przesłać, a później przeanalizować i odtworzyć oryginalną strukturę. Proces
przekształcania struktury danych w ciąg bajtów lub znaków nazywa się serializacją.
Najprościej dane serializuje się przy użyciu formatu JSON (ang. JavaScript Object Notation —
zapis obiektów JavaScript). Jak sugeruje nazwa, do przekształcania obiektów i tablic w ciągi
znaków wykorzystywana jest składnia obiektu JavaScript i literału tablicy. Format JSON
obejmuje liczby, ciągi znaków, wartości true, false, null oraz obiekty i tablice zawierające
wartości prymitywne. Nie obejmuje typów takich jak Map, Set, RegExp, Date i typowane tablice.
Okazał się jednak niezwykle elastycznym formatem i jest powszechnie używany nawet z
programami tworzonymi w innych językach niż JavaScript.
W języku JavaScript dostępne są metody JSON.stringify() i JSON.parse(), służące do
serializowania i deserializowania danych, krótko opisane w podrozdziale 6.8. Obiekty i tablice,
dowolnie głęboko zagnieżdżone, niezawierające danych, których nie można serializować, na
przykład wyrażeń regularnych ani typowanych tablic, łatwo serializuje się, umieszczając je w
argumencie metody JSON.stringify(). Jak sugeruje nazwa metody, zwracaną przez nią
wartością jest ciąg znaków, który można umieścić w argumencie metody JSON.parse() i
odtworzyć w ten sposób oryginalną strukturę. Ilustruje to poniższy kod:
let o = {s: "", n: 0, a: [true, false, null]};
let s = JSON.stringify(o); // s == '{"s":"","n":0,"a":[true,false,null]}'
let copy = JSON.parse(s); // copy == {s: "", n: 0, a: [true, false, null]}

Format JSON jest częścią języka JavaScript

W wyniku przekształcenia danych do formatu JSON uzyskuje się poprawny kod


źródłowy JavaScript wyrażenia, którego wartością jest kopia oryginalnej struktury.
Jeżeli ciąg JSON poprzedzi się frazą var data = i uzyskany wynik umieści
w argumencie metody eval(), uzyska się kopię oryginalnych danych. Nie należy
jednak tak robić, ponieważ naraża się w ten sposób program na poważne
niebezpieczeństwo. Jeżeli haker umieści w danych JSON własny kod, może on
zostać uruchomiony przez program. Szybszym i bezpieczniejszym sposobem
deserializacji danych JSON jest użycie metody JSON.parse().
W formacie JSON są czasami zapisywane pliki konfiguracyjne, aby były czytelne dla
człowieka. Edytując ręcznie plik zapisany w tym formacie, należy pamiętać, że JSON
jest bardzo podobny do składni języka JavaScript. Nie można w nim jednak
stosować komentarzy, a nazwy właściwości warto umieszczać w cudzysłowach,
mimo że nie jest to wymagane.

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.

11.6.1. Dostosowywanie formatu JSON


Metoda JSON.stringify(), rozpoczynając serializowanie obiektu, który nie jest natywnie
obsługiwany przez format JSON, szuka w nim metody toJSON(). Jeżeli ją znajdzie, wywołuje ją i
serializuje zwrócony przez nią wynik. Klasa Date implementuje metodę toJSON(), która zwraca
taki sam ciąg znaków jak metoda toISOString(). Oznacza to, że podczas serializowania
obiektu zawierającego dane typu Date zostaną one automatycznie przekształcone w ciąg
znaków. Struktura uzyskana w wyniku deserializacji tak uzyskanego ciągu znaków nie jest
dokładnie taka sama jak oryginalna. W miejscu, gdzie powinien znajdować się obiekt typu Date,
zostanie umieszczony ciąg znaków.
Jeżeli trzeba odtworzyć obiekt typu Date lub w jakikolwiek sposób zmodyfikować
przeanalizowany obiekt, należy w drugim argumencie metody JSON.parse() umieścić funkcję
„odtwarzającą”. Funkcja ta jest wywoływana przy analizowaniu każdej wartości prymitywnej
(ale nie obiektu ani tablicy zawierającej takie wartości) zapisanej w wejściowym ciągu. Ma dwa
argumenty. W pierwszym jest umieszczana nazwa właściwości obiektu lub indeks tablicy
przekształcony w ciąg znaków. W drugim argumencie jest umieszczana prymitywna wartość tej
właściwości lub elementu tablicy. Co więcej, funkcja ta jest wywoływana tak jak metoda obiektu
lub tablicy zawierającej prymitywne wartości, zatem można się do obiektu odwoływać za
pomocą słowa kluczowego this.
Wynik zwracany przez funkcję odtwarzającą jest przypisywany wskazanej właściwości. Jeżeli
wynik jest taki sam jak drugi argument, właściwość nie jest zmieniana. Jeżeli jest nim wartość
undefined, wskazana właściwość jest usuwana z obiektu lub tablicy, zanim metoda
JSON.parse() zwróci własny wynik.

Poniżej jest pokazany przykładowy kod, w którym metoda JSON.parse()wykorzystuje funkcję


odtwarzającą do filtrowania wybranych właściwości i ponownego tworzenia obiektu Date:
let data = JSON.parse(text, function(key, value) {
// Usunięcie wszystkich wartości, których nazwy właściwości rozpoczynają
się od znaku podkreślenia.
if (key[0] === "_") return undefined;
// Jeżeli wartość jest ciągiem znaków zawierającym datę zapisaną w formacie
ISO 8601, jest
// przekształcana w obiekt Date.
if (typeof value === "string" &&

/^\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.

let text = JSON.stringify(address, ["city", "state", "country"]);


// Funkcja zamiennika pomijająca właściwości będące wyrażeniami regularnymi.
c(o, (k, v) => v instanceof RegExp ? undefined : v);
W powyższym przykładzie metoda JSON.stringify() wykorzystuje drugi argument w zwykły
sposób i zwraca wynik, który może być z powrotem zamieniony na dane bez użycia specjalnej
funkcji odtwarzającej. Ogólnie jednak, aby wynik uzyskany za pomocą niestandardowej metody
toJSON(), która wykorzystuje funkcję zamiennika konwertującą wartości nieserializowalne w
serializowalne, przekształcić z powrotem w strukturę danych, trzeba użyć metody
JSON.parse() i funkcji odtwarzającej. Ważna jest w takim wypadku świadomość, że definiuje
się w ten sposób niestandardowy format danych i rezygnuje z ich przenośności oraz
kompatybilności z wieloma narzędziami i językami wykorzystującymi format JSON.

11.7. Internacjonalizacja aplikacji


Interfejs internacjonalizacyjny API języka JavaScript składa się z klas Intl.NumberFormat,
Intl.DateTimeFormat i Intl.Collator, służących do formatowania liczb (w tym wartości
walutowych i procentowych), dat i czasu oraz do porównywania ciągów znaków zgodnie z
ustawieniami regionalnymi. Klasy te nie są objęte standardem ECMAScript, tylko ECMA402
(https://tc39.es/ecma402) i są obsługiwane przez wszystkie przeglądarki. Obsługuje je również
środowisko Node, jednak w chwili pisania tej książki pliki binarne środowiska nie zawierały
danych niezbędnych do korzystania z innych ustawień regionalnych niż amerykańskie. Dlatego,
aby móc używać tych klas w środowisku Node, należy pobrać osobny pakiet danych lub użyć
niestandardowej wersji środowiska.
Jednym z najważniejszych aspektów internacjonalizacji aplikacji jest wyświetlanie tekstów w
ojczystym języku użytkownika. Można to osiągnąć na kilka sposobów. W żadnym z nich jednak
nie jest wykorzystywany interfejs nienacjonalizacyjny API.

11.7.1. Formatowanie liczb


Użytkownicy aplikacji w różnych regionach świata wymagają formatowania liczb na różne
sposoby. Symbolem dziesiętnym może być przecinek lub kropka, a separatorem tysięcy
przecinek lub spacja, które nie zawsze są umieszczane co trzy cyfry. Kwoty w niektórych
walutach wyraża się z dokładnością do setnych części jednostki, inne do tysięcznych, a jeszcze
inne w ogóle nie wyraża się w częściach ułamkowych. Ponadto cyfry arabskie nie są stosowane
we wszystkich językach i użytkownicy w niektórych krajach chcieliby widzieć własne cyfry.
Klasa Intl.NumberFormat definiuje metodę format(), która uwzględnia wszystkie powyższe
wymagania. Jej konstruktor ma dwa argumenty. Pierwszy określa ustawienia regionalne,
zgodnie z którymi ma być sformatowana liczba, a drugi jest obiektem zawierającym
dokładniejsze informacje o formacie. Jeżeli pierwszy argument nie zostanie określony lub
będzie miał wartość undefined, zostaną użyte lokalne ustawienia regionalne (przyjęte jest
założenie, że są to ustawienia preferowane przez użytkownika). Jeżeli argumentem tym jest
ciąg znaków, musi on określać żądane ustawienia, na przykład "pl" (polskie), "en-US"
(amerykańskie) lub "fr" (francuskie). Argumentem tym może być również tablica ciągów
oznaczających ustawienia regionalne. W takim wypadku klasa Intl.NumberFormat wybiera
najdokładniej określone i najlepiej obsługiwane ustawienia.
Drugi argument konstruktora Intl.NumberFormat(), jeżeli zostanie użyty, musi być obiektem
zawierającym jedną lub kilka poniższych właściwości:
style
Właściwość określająca rodzaj formatowania, domyślnie "decimal". Można jej nadać
wartości "percent" lub "currency" oznaczające, odpowiednio, formaty procentowy i
walutowy.
currency
Jeżeli właściwość style ma wartość "currency", to właściwość currency musi zawierać
trzyliterowy kod waluty, określony w normie ISO (na przykład "PLN” dla polskiego złotego,
"USD" dla amerykańskiego dolara lub "GBP" dla brytyjskiego funta).
currencyDisplay
Jeżeli właściwość style ma wartość "currency", to właściwość currencyDisplay musi
określać sposób formatowania waluty. Domyślna wartość "symbol" powoduje użycie
symbolu waluty, jeżeli taki jest, wartość "code" użycie trzyliterowego skrótu ISO, a "name"
użycie nazwy waluty.
useGrouping
Aby liczba nie zawierała separatorów tysięcy (lub innych znaków rzędów wielkości
charakterystycznych dla użytych ustawień regionalnych), należy tej właściwości przypisać
wartość false.
minimumIntegerDigits

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

Te dwie właściwości określają liczbę cyfr znaczących i są wykorzystywane na przykład


w zapisach naukowych. Jeżeli ich wartości są określone, mają priorytet przed
właściwościami opisanymi wyżej. Obie mogą przyjmować właściwości z przedziału od 1 do
21.
Po utworzeniu obiektu Intl.NumberFormat z żądanymi ustawieniami lokalnymi i opcjami można
wywołać jego metodę format() z liczbą w argumencie. Zwróconym wynikiem będzie
odpowiednio sformatowany ciąg znaków, na przykład:
let euros = Intl.NumberFormat("pl", {style: "currency", currency: "PLN"});
euros.format(10) // => "10,00 zł": dziesięć złotych, format polski.
let pounds = Intl.NumberFormat("en", {style: "currency", currency: "GBP"});
pounds.format(1000) // => "£1,000.00": tysiąc funtów, format brytyjski.

Każda z opisywanych klas internacjonalizacyjnych ma tę przydatną cechę, że metoda format()


odnosi się do obiektu, do którego należy. Dlatego nie trzeba definiować zmiennej odwołującej
się do obiektu i wywoływać jego metody format(). Można przypisać tę metodę zmiennej i
używać jej jak niezależnej funkcji, na przykład:
let data = [0.05, .75, 1];
let formatData = Intl.NumberFormat(undefined, {
style: "percent",
minimumFractionDigits: 1,
maximumFractionDigits: 1
}).format;
data.map(formatData) // => ["5.0%", "75.0%", "100.0%"]: amerykańskie
ustawienia regionalne.
W niektórych językach, na przykład arabskim, są stosowane własne cyfry:

let arabic = Intl.NumberFormat("ar", {useGrouping: false}).format;


arabic(1234567890) // => "١٢٣٤٥٦٧٨٩٠"
Język hindi ma własny zestaw cyfr, ale zazwyczaj stosowane są cyfry ASCII 0 – 9. Aby zmienić
domyślny zestaw znaków cyfrowych, należy po kodzie ustawień regionalnych wpisać ciąg -u-
nu-, a po nim oznaczenie pisma. Na przykład poniższy kod formatuje liczby zgodnie z
grupowaniem hinduskim i stosuje cyfry języka devanagari:
let hindi = Intl.NumberFormat("hi-IN-u-nu-deva").format;

hindi(1234567890) // => "१,२३,४५,६७,८९०"

Sekwencja -u- w oznaczeniu ustawień regionalnych sygnalizuje, że ma być użyte rozszerzenie


standardu Unicode. Sekwencja nu oznacza system liczbowy (ang. numbering system), a deva
jest skrótem od devanagari. W międzynarodowym interfejsie API są zdefiniowane nazwy kilku
innych systemów liczbowych, głównie używanych w językach indyjskich.

11.7.2. Formatowanie daty i czasu


Klasa Intl.DateTimeFormat ma bardzo wiele wspólnego z klasą Intl.NumberFormat.
Konstruktory obu klas mają takie same argumenty: ciąg znaków lub tablica ciągów opisujących
ustawienia regionalne oraz obiekt zawierające opcje formatu. Analogicznie, aby przekształcić
obiekt Date() w ciąg znaków, należy wywołać metodę format() instancji klasy
Intl.DateTimeFormat.
Jak wspomniałem w podrozdziale 11.4, klasa Date definiuje metody toLocaleDateString()
i toLocaleTimeString() zwracające wyniki sformatowane zgodnie z ustawieniami
regionalnymi. Jednak metody te nie dają żadnej kontroli nad wyświetlanymi polami daty i czasu.
Co można zrobić, aby na przykład usunąć rok z formatu, dodać nazwę dnia tygodnia albo użyć
nazwy miesiąca zamiast jego numeru? Klasa Intl.DateTimeFormat daje ścisłą kontrolę nad
wynikiem. Wykorzystuje w tym celu właściwości obiektu umieszczonego w drugim argumencie
konstruktora. Zwróć jednak uwagę, że klasa ta nie zawsze zwraca żądany wynik. Jeżeli
określisz opcje formatu godzin i sekund, a pominiesz format minut, okaże się, że minuty
zostaną również sformatowane. Obiekt zawierający opcje formatu określa, jakie pola daty i
czasu mają być prezentowane użytkownikowi i jak mają być sformatowane (na przykład jako
nazwy lub liczby). Natomiast klasa na tej podstawie określa format ustawień regionalnych,
który jest najbliższy zadanym opcjom.

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

Ta właściwość określa, czy sformatowana data powinna zawierać oznaczenie ery, na


przykład „p.n.e.” lub „n.e.”. Właściwość ta przydaje się w przypadku, gdy daty są bardzo
odległe lub stosowany jest kalendarz japoński. Dopasowane wartości tej właściwości to
"long", "short" i "narrow".
hour, minute, second
Te właściwości określają sposób formatowania czasu. Wartość "numeric" powoduje użycie
jedno- lub dwucyfrowej liczby, a "2-digit" zawsze liczby dwucyfrowej, dopełnionej w razie
potrzeby zerem z lewej strony.
timeZone
Ta właściwość określa strefę czasową, zgodnie z którą ma być sformatowany czas. Jeżeli
nie jest określona, przyjmowana jest lokalna strefa. Wszystkie implementacje języka
JavaScript uwzględniają oznaczenie "UTC" oraz nazwy zdefiniowane przez organizację
IANA (ang. Internet Assigned Numbers Authority — urząd nadawania adresów
internetowych), na przykład "Europe/Warsaw".
timeZoneName
Właściwość określająca sposób prezentowania strefy czasowej w sformatowanym ciągu
znaków. Wartość "long" oznacza pełną nazwę, a "short" nazwę skróconą lub numer strefy.
hour12
Właściwość przyjmująca wartość logiczną określającą, czy ma być stosowany zegar 12-
godzinny. Domyślnie przyjmowany jest format zgodny z lokalnymi ustawieniami
regionalnymi.
hourCycle
Właściwość ta określa, czy północ ma być oznaczona jako godzina 0, 12 czy 24. Domyślnie
stosowany jest format zgodny z lokalnymi ustawieniami regionalnymi. Wartość "h11"
powoduje, że północ jest oznaczana jako godzina 0, a godzina przed północą jako „11 PM”.
Wartość "h12" powoduje, że północ jest oznaczana liczbą 12, wartość "h23" liczbą 0, a
godzina przed północą liczbą 23. Wartość "h12" powoduje, że północ jest oznaczana liczbą
24.
Poniżej znajduje się kilka przykładów użycia opisywanej klasy:
let d = new Date("2020-01-02T13:14:15Z"); // 2 stycznia 2020 r., 13:14:15
UTC
// Klasa użyta bez opcji zwraca datę w podstawowym formacie liczbowym.
Intl.DateTimeFormat("pl-PL").format(d) // => "2.01.2020"
Intl.DateTimeFormat("fr-FR").format(d) // => "02/01/2020"

// Nazwy dni tygodnia i miesięcy.


let opts = { weekday: "long", month: "long", year: "numeric", day: "numeric"
};
Intl.DateTimeFormat("pl-PL", opts).format(d) // => "czwartek, 2 stycznia
2020"
Intl.DateTimeFormat("es-ES", opts).format(d) // => "jueves, 2 de enero de
2020"
// Czas nowojorski dla Kanadyjczyka francuskojęzycznego
opts = { hour: "numeric", minute: "2-digit", timeZone: "America/New_York" };
Intl.DateTimeFormat("fr-CA", opts).format(d) // => "8 h 14"
Za pomocą klasy Intl.DateTimeFormat można formatować daty według innych kalendarzy niż
domyślny gregoriański. Choć niektóre ustawienia regionalne wykorzystują inne kalendarze,
można go zawsze wskazać jawnie za pomocą ciągu składającego się z kodu języka, sekwencji -
u-ca- i jednej z nazw kalendarza: "buddhist", "chinese", "coptic", "ethiopic", "gregory",
"hebrew", "indian", "islamic", "iso8601", "japanese" lub "persian". Kontynuując
poprzedni przykład — poniższy kod wyświetla bieżący rok w różnych kalendarzach:
let opts = { year: "numeric", era: "short" };
Intl.DateTimeFormat("en", opts).format(d) // => "2020 AD"
Intl.DateTimeFormat("en-u-ca-iso8601", opts).format(d) // => "2020 AD"
Intl.DateTimeFormat("en-u-ca-hebrew", opts).format(d) // => "5780 AM"
Intl.DateTimeFormat("en-u-ca-buddhist", opts).format(d) // => "2563 BE"
Intl.DateTimeFormat("en-u-ca-islamic", opts).format(d) // => "1441 AH"
Intl.DateTimeFormat("en-u-ca-persian", opts).format(d) // => "1398 AP"

Intl.DateTimeFormat("en-u-ca-indian", opts).format(d) // => "1941 Saka"


Intl.DateTimeFormat("en-u-ca-chinese", opts).format(d) // => "36 78"
Intl.DateTimeFormat("en-u-ca-japanese", opts).format(d) // => "2 Reiwa"

11.7.3. Porównywanie ciągów znaków


Problem sortowania ciągów znaków w kolejności alfabetycznej (lub w „kolejności sortowania” w
językach niealfabetycznych) jest trudniejszy, niż się wydaje większości osób anglojęzycznych.
Alfabet angielski jest dość krótki, nie zawiera znaków diakrytycznych, a kody ASCII (włączone
do standardu Unicode) idealnie odzwierciedlają standardowy porządek sortowania. Rzecz się
jednak komplikuje w przypadku innych języków. W języku polskim litera „Ń” znajduje się
pomiędzy „N” a „O”. W języku litewskim „Y” znajduje się przed „J”, a w walijskim dwuznaki
„CH” i „DD” są traktowane jak pojedyncze znaki, przy czym „CH” znajduje się po literze „C”, a
„DD” po literze „D”.
Aby zaprezentować użytkownikowi ciągi znaków posortowane w naturalnej dla niego
kolejności, nie wystarczy użyć metody sort() obiektu tablicy. Trzeba utworzyć obiekt
Intl.Collator, umieścić jego metodę compare()w argumencie metody sort() i w ten sposób
posortować ciągi zgodnie z lokalnymi ustawieniami regionalnymi. Obiekt Intl.Collator można
skonfigurować tak, aby metoda compare() porównywała ciągi bez uwzględniania wielkości
znaków, a nawet ogonków i innych elementów. Konstruktor Intl.Collator(), podobnie jak
Intl.NumberFormat() i Intl.DateTimeFormat(), ma dwa argumenty. Pierwszy określa
ustawienia regionalne lub ich tablicę, a drugi (opcjonalny) jest obiektem, którego właściwości
dokładnie określają sposób porównywania ciągów. Obsługiwane są następujące właściwości:
usage
Właściwość określająca sposób użycia obiektu sortującego. Domyślnie ma wartość "sort",
ale można jej przypisać wartość "search". Celem jest, aby metoda rozróżniała jak
najwięcej sortowanych ciągów. Jednak w przypadku niektórych ustawień regionalnych
reguły sortowania są mniej restrykcyjne i dopuszczają na przykład pomijanie akcentów.
sensitivity

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.

// Należy go stosować, aby sortować ciągi w kolejności naturalnej dla


człowieka:
const collator = new Intl.Collator().compare;
["a", "z", "A", "Z"].sort(collator) // => ["a", "A", "z", "Z"]
// Nazwy plików często zawierają liczby, więc należy je sortować w specjalny
sposób.
const filenameOrder = new Intl.Collator(undefined, { numeric: true
}).compare;
["strona10", "strona9"].sort(filenameOrder) // => ["strona9", " strona10"]
// Wyszukanie wszystkich ciągów luźno dopasowanych do zadanego.
const fuzzyMatcher = new Intl.Collator(undefined, {

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"]

11.8. Interfejs API konsoli


Wielokrotnie w tej książce widziałeś funkcję console.log(). W przeglądarkach jest ona
wykorzystywana w panelu dla programistów w zakładce Konsola do diagnozowania problemów.
W środowisku Node jest to funkcja ogólnego przeznaczenia wysyłająca wartości argumentów
do strumienia stdout, zazwyczaj w celu wyświetlenia informacji w oknie terminala.
Interfejs API konsoli definiuje oprócz console.log() kilka innych przydatnych funkcji. Nie jest
objęty standardem ECMAScript, ale obsługują go przeglądarki i środowisko Node. Na stronie
https://console.spec.whatwg.org znajduje się jego formalny, znormalizowany opis.
Interfejs API konsoli definiuje następujące funkcje:
console.log()
Najpopularniejsza funkcja, przekształcająca wartości podane w argumentach na ciągi
znaków i wyświetlająca je w konsoli. Pomiędzy poszczególnymi argumentami wstawia
spacje, a na końcu umieszcza podział wiersza.
console.debug(), console.info(), console.warn(), console.error()
Funkcje niemal identyczne jak console.log(). W środowisku Node funkcja
console.error() wysyła dane do strumienia stderr, a nie stdout. Pozostałe funkcje są
aliasami console.log(). W przeglądarkach wartości wyświetlane przez poszczególne
funkcje mogą być poprzedzane ikonami oznaczającymi poziomy lub stopnie ważności
danych. Dzięki temu programista może filtrować komunikaty według ich ważności.
console.assert()
Jeżeli pierwszy argument tej funkcji jest prawdziwy (tj. spełnia warunek asercji), wówczas
funkcja nic nie robi. W przeciwnym razie wyświetla pozostałe argumenty, tak samo
console.error(), ale poprzedzone prefiksem "Assertion failed". Zwróć uwagę, że
funkcja ta, w odróżnieniu od innych jej podobnych, nie zgłasza wyjątku, jeżeli warunek
asercji nie jest spełniony.
console.clear()
Funkcja usuwająca zawartość konsoli przeglądarki lub terminala środowiska Node. Jeżeli
strumień wyjściowy w środowisku Node jest przekierowany do pliku lub potoku, wywołanie
tej funkcji nie daje żadnego efektu.
console.table()
Jest to mało znana funkcja wyświetlająca dane w formacie tabelarycznym. Szczególnie
przydaje się w środowisku Node do wyświetlania danych tabelarycznych. Jeżeli nie może
użyć formatu tabelarycznego, stosuje zwykłe formatowanie, tak jak funkcja console.log().
Najlepiej sprawdza się w sytuacji, gdy argumentem jest niewielka tablica obiektów o takich
samych, relatywnie niewielkich właściwościach. Każdy obiekt jest wtedy wyświetlany w
osobnym wierszu tabeli, a każda właściwość w osobnej kolumnie. W opcjonalnym drugim
argumencie funkcji można umieścić tablicę nazw właściwości określających żądane
kolumny. Jeżeli w argumencie zostanie umieszczony obiekt zamiast tablicy obiektów,
wynikiem będzie tabela z kolumnami zawierającymi nazwy właściwości i ich wartości.
Jeżeli wartościami właściwości są obiekty, ich nazwy staną się kolumnami tabeli.
console.trace()
Funkcja wyświetlająca argumenty (podobnie jak console.log()) oraz dodatkowo ślad
stosu wywołań. W środowisku Node dane są wysyłane do strumienia stderr, a nie stdout.
console.count()
Funkcja wyświetlająca wartość argumentu oraz liczbę wywołań z tym argumentem.
Przydaje się na przykład przy diagnozowaniu kodu obsługi zdarzeń i zliczaniu, ile razy
został wykonany.
console.countReset()

Funkcja resetująca licznik wywołań o nazwie podanej w argumencie.


console.group()
Funkcja wyświetlająca wartości argumentów, podobnie jak console.log(), i przełączająca
konsolę w wewnętrzny tryb, w którym kolejne komunikaty są przesuwane względem
bieżącego (do chwili wywołania funkcji console.groupEnd()). W ten sposób można
wizualnie za pomocą wcięć grupować powiązane ze sobą komunikaty. Tak uzyskane grupy
komunikatów można w konsolach przeglądarek zwijać i rozwijać. W argumentach funkcji
zazwyczaj umieszcza się opis zawartości grupy.
console.groupCollapsed()
Funkcja działająca podobnie jak console.group() z tą różnicą, że w konsoli przeglądarki
powoduje zwinięcie grupy komunikatów. Aby je wyświetlać, użytkownik musi kliknąć grupę
i ją rozwinąć. W środowisku Node funkcja ta działa identycznie jak console.group().

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().

11.8.1. Formatowanie danych w konsoli


Funkcje takie jak console.log(), wyświetlające w konsoli wartości argumentów, mają pewną
mało znaną cechę. Jeżeli w pierwszym argumencie umieści się ciąg zawierający sekwencje %s,
%i, %d, %f, %o, %O lub %c, wówczas jest on traktowany jako opis formatu[6], tj. sekwencje te są
zastępowane wartościami kolejnych argumentów. Ich znaczenie jest następujące:
%s
Argument jest przekształcany w ciąg znaków.

%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ń.

11.9. Interfejs API klasy URL


Ponieważ język JavaScript jest powszechnie stosowany w przeglądarkach i serwerach WWW,
wykorzystuje się go do wykonywania operacji na adresach URL. Za pomocą klasy URL można
analizować adresy URL i modyfikować je, na przykład dodawać parametry wyszukiwania lub
zmieniać ścieżki. Dzięki tej klasie można również poprawnie przetwarzać skomplikowane
sekwencje znaków ucieczki w różnych komponentach adresu.
Klasa URL nie jest częścią standardu ECMAScript, ale jest obsługiwana przez środowisko Node i
wszystkie przeglądarki (oprócz Internet Explorera). Na stronie https://url.spec.whatwg.org
znajduje się jej znormalizowany opis.
Obiekt URL tworzy się, wywołując konstruktor klasy z adresem URL w argumencie. Można
również w pierwszym argumencie umieścić względny adres URL, a w drugim jego bezwzględną
część. Po utworzeniu obiektu można za pomocą jego różnych właściwości przetwarzać
poszczególne części adresu URL (bez znaków ucieczki):

let url = new URL("https://example.com:8000/path/name?q=term#fragment");


url.href // => "https://example.com:8000/path/name?q=term#fragment"
url.origin // => "https://example.com:8000"
url.protocol // => "https:"
url.host // => "example.com:8000"
url.hostname // => "example.com"
url.port // => "8000"
url.pathname // => "/path/name"
url.search // => "?q=term"

url.hash // => "#fragment"


Zazwyczaj się tego nie praktykuje, ale w adresie URL można umieszczać login oraz hasło i
wyodrębniać je za pomocą klasy URL:
let url = new URL("ftp://admin:1337!@ftp.example.com/");
url.href // => "ftp://admin:1337!@ftp.example.com/"
url.origin // => "ftp://ftp.example.com"
url.username // => "admin"
url.password // => "1337!"
Właściwość origin zawiera kombinację nazwy protokołu, oznaczenia hosta i numeru portu
(jeżeli jest określony). Właściwość ta jest przeznaczona tylko do odczytu, natomiast wszystkie
pozostałe można zmieniać. Nadając im wartości, ustawia się poszczególne części adresu URL:
let url = new URL("https://example.com"); // Początkowy adres URL serwera.

url.pathname = "api/search"; // Dodanie ścieżki.


url.search = "q=test"; // Dodanie parametru.
url.toString() // =>
"https://example.com/api/search?q=test"
Klasa URL ma tę ważną cechę, że poprawnie przetwarza znaki interpunkcyjne i inne znaki
specjalne, jeżeli są stosowane w adresie URL:
let url = new URL("https://example.com");
url.pathname = "adres ze spacjami";
url.search = "q=foo#bar";

url.pathname // => "/adres%20ze%20spacjami"


url.search // => "?q=foo%23bar"
Właściwość href w powyższych przykładach jest wyjątkowa. Odczytanie jej wartości jest
równoważne wywołaniu metody toString(), tj. powoduje zebranie wszystkich części adresu do
postaci kanonicznej. Natomiast przypisanie tej właściwości nowego ciągu skutkuje
uruchomieniem analizatora adresu, tak jak przy wywołaniu konstruktora URL().
W przedstawionych wyżej przykładach właściwość search zawiera fragment adresu URL
począwszy od znaku zapytania do końca wiersza lub pierwszego znaku kratki. Czasami tę część
można traktować jako pojedynczą właściwość. Często jednak wartości pól formularza lub
parametry zapytań API są kodowane w tej części adresu w formacie application/x-www-form-
urlencoded. Zgodnie z nim po znaku zapytania umieszcza się jedną lub kilka par nazwa-
wartość oddzielonych znakami &. Ta sama nazwa może być użyta wielokrotnie, więc parametr
może mieć kilka wartości.

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"

url.searchParams.set("q", "x"); // Zmiana wartości parametru.


url.search // => "?q=x"
url.searchParams.get("q") // => "x": odczytanie wartości
parametru.
url.searchParams.has("q") // => true: adres zawiera parametr q.
url.searchParams.has("p") // => false: adres nie zawiera
parametru p.
url.searchParams.append("opts", "1"); // Dodanie kolejnego parametru.
url.search // => "?q=x&opts=1"
url.searchParams.append("opts", "&"); // Dodanie kolejnego parametru o takiej
samej nazwie.
url.search // => "?q=x&opts=1&opts=%26": zwróć
uwagę na znak ucieczki.
url.searchParams.get("opts") // => "1": pierwsza wartość.
url.searchParams.getAll("opts") // => ["1", "&"]: wszystkie wartości.
url.searchParams.sort(); // Sortowanie parametrów w kolejności
alfabetycznej.
url.search // => "?opts=1&opts=%26&q=x"
url.searchParams.set("opts", "y"); // Zmiana parametru opts.
url.search // => "?opts=y&q=x"
// Obiekt searchParams jest iterowalny.
[...url.searchParams] // => [["opts", "y"], ["q", "x"]]

url.searchParams.delete("opts"); // Usunięcie parametru opts.


url.search // => "?q=x"
url.href // => "https://example.com/search?q=x"
Wartością właściwości searchParams jest obiekt URLSearchParams. Aby zakodować parametry
adresu URL w postaci ciągu znaków, należy utworzyć obiekt URLSearchParams, dodać
parametry, przekształcić obiekt w ciąg znaków i przypisać go właściwości search obiektu URL:
let url = new URL("http://example.com");
let params = new URLSearchParams();
params.append("q", "term");
params.append("opts", "exact");

params.toString() // => "q=term&opts=exact"


url.search = params;
url.href // => "http://example.com/?q=term&opts=exact"

11.9.1. Starsze funkcje URL


Zanim pojawił się opisany wyżej interfejs API, zostało podjętych kilka prób zaimplementowania
w samym języku JavaScript funkcji do przetwarzania adresów URL. Pierwszą z nich było
utworzenie globalnych funkcji escape() i unescape(). Funkcje te są wciąż szeroko
wykorzystywane, mimo że nie są zalecane.
Gdy powyższe funkcje zostały uznane za przestarzałe, w standardzie ECMAScript zostały
wprowadzone dwie pary alternatywnych funkcji:
encodeURI() i decodeURI()
Argumentem funkcji encodeURI() jest ciąg znaków, a zwracanym wynikiem nowy ciąg,
w którym znaki inne niż ASCII, jak również niektóre znaki z tego zestawu, na przykład
spacja, są zakodowane za pomocą sekwencji ucieczki. Funkcja decodeURI() wykonuje
odwrotną operację. Znaki wymagające przekształcenia są najpierw kodowane zgodnie ze
standardem UTF-8, a następnie każdy bajt jest zamieniany na sekwencję %xx, gdzie xx
oznacza dwucyfrową liczbę szesnastkową. Ponieważ funkcja encodeURI() jest
przeznaczona do kodowania całych adresów URL, nie przekształca separatorów /, ? i #.
Oznacza to jednak, że funkcja ta nie przekształca poprawnie adresów, w których znaki te są
stosowane w parametrach.
encodeURIComponent() i decodeURIComponent()
Powyższe funkcje działają podobnie jak encodeURI() i decodeURI() z tą różnicą, że
przekształcają poszczególne komponenty adresu URL, w tym znaki /, ? i #. Funkcje te,
mimo że ich stosowanie nie jest zalecane, są bardzo przydatne. Należy jednak pamiętać, że
funkcja encodeURIComponent() przekształca również użyte w ścieżce ukośniki, które nie
powinny być przekształcane. Ponadto spacje użyte w parametrach przekształca na
sekwencje %20, choć powinna na znaki +.
Największy problem z powyższymi funkcjami polega na tym, że do kodowania wszystkich części
adresu URL stosują ten sam schemat. W rzeczywistości poszczególne części adresu są
kodowane z wykorzystaniem różnych schematów. Aby poprawnie formatować i kodować adresy
URL, należy we wszystkich operacjach wykorzystywać klasę URL.

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:

ważne struktury danych, takie jak zbiory, mapy i tablice typowane,


klasy Date i URL służące do wykonywania operacji na datach i adresach URL,
wyrażenie regularne i klasa RegExp do wyszukiwania wzorców tekstu,
międzynarodowe funkcje do formatowania daty i czasu oraz do sortowania ciągów
znaków,
obiekt JSON do serializowania i deserializowania prostych struktur danych,
obiekt console do wyświetlania komunikatów w konsoli.

[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:

let purpleHaze = Uint8Array.of(255, 0, 255, 128);


let [r, g, b, a] = purpleHaze; // a == 128
Iterator obiektu Map zwraca pary wartości [klucz, wartość], z których wygodnie korzysta się
w przypisaniu destrukturyzującym w pętli for/of:
let m = new Map([["jeden", 1], ["dwa", 2]]);

for(let [k,v] of m) console.log(k, v); // Wyświetlenie 'jeden 1' i 'dwa 2'.


Aby iterować wyłącznie klucze lub wartości, a nie ich pary, należy użyć, odpowiednio, metody
keys() lub values():

[...m] // => [["jeden", 1], ["dwa", 2]]: domyślna iteracja.


[...m.entries()] // => [["jeden", 1], ["dwa", 2]]: ten sam efekt daje metoda
entries().

[...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:

new Set("abc") // => new Set(["a", "b", "c"])


Na początku tego rozdziału jest opisane działanie iteratorów i tworzenie własnych iterowalnych
struktur danych. W dalszej części przedstawione są generatory stanowiące bardzo przydatną
funkcjonalność języka ES6. Za ich pomocą iteratory tworzy się szczególnie łatwo.

12.1. Jak działają iteratory?


Pętla for/of i operator rozciągania doskonale współpracują z iterowalnymi obiektami. Warto
jednak wiedzieć, jak właściwie działają iteratory. Są trzy zagadnienia, które należy poznać, aby
zrozumieć ideę iteracji. Pierwsze to iterowalny typ danych, na przykład Array, Set i Map, drugie
to sam obiekt iteratora realizujący iterację, a trzecie to wynikowe obiekty zawierające wyniki
każdego etapu iteracji.

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];

let iterator = iterable[Symbol.iterator]();


for(let result = iterator.next(); !result.done; result = iterator.next()) {

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];

let iter = list[Symbol.iterator]();

let head = iter.next().value; // head == 1

let tail = [...iter]; // tail == [2,3,4,5]

12.2. Implementowanie obiektów


iterowalnych
Wprowadzone w wersji języka ES6 obiekty iterowalne okazały się tak użyteczne, że obecnie
zawsze, gdy trzeba przetwarzać serię danych, warto tworzyć własne typy iterowalne.
Przykładem jest iterowalna klasa Range, zaprezentowana w rozdziale 9. w listingach 9.2 i 9.3,
posiadająca metodę generatora. Generatory będą opisane w dalszej części rozdziału, natomiast
teraz zajmijmy się ponownie implementacją iterowalnej klasy Range bez użycia generatora.
Aby klasa była iterowalna, musi mieć zaimplementowaną metodę o nazwie Symbol.iterator.
Metoda ta musi zwracać obiekt iteratora posiadający metodę next(). Natomiast metoda
next() musi zwracać wynik iteracji, czyli obiekt zawierający właściwości logiczne value lub
done (albo obie właściwości). Listing 12.1 przedstawia implementację iterowalnej klasy Range i
pokazuje, jak tworzy się obiekty iterowalne, iteratory i wyniki iteracji.
Listing 12.1. Iterowalna klasa Range

/*

* Obiekt Range reprezentuje zakres liczb {x: from <= x <= to}.

* Definiuje również metodę has() sprawdzającą, czy dana liczba zawiera się w
zakresie.

* Klasa Range jest iterowalna i służy do iterowania wszystkich liczb z


zadanego zakresu.
*/

class Range {

constructor (from, to) {

this.from = from;

this.to = to;
}

// Utworzenie klasy Range funkcjonującej jak zbiór liczb.

has(x) { return typeof x === "number" && this.from <= x && x <= this.to; }

// Metoda zwracająca tekstową reprezentację zakresu w notacji właściwej dla


zbioru.

toString() { return `{ x | ${this.from} ≤ x ≤ ${this.to} }`; }


// Aby klasa Range była iterowalna, musi zwracać obiekt iteratora.

// Zauważ, że nazwa tej metody jest specjalnym symbolem.

[Symbol.iterator]() {

// Każda instancja iteratora musi funkcjonować niezależnie od innych.


// Dlatego potrzebna jest zmienna, w której będzie zapisywana pozycja
iteracji.

// Początkową wartością jest pierwsza liczba większa lub równa


właściwości from.

let next = Math.ceil(this.from); // Następna zwracana wartość.

let last = this.to; // Wartość większa niż this.to nie


jest zwracana.

return { // Obiekt iteratora.


// Metoda next() sprawia, że this jest obiektem iteratora
// i zwraca wynik iteracji.
next() {

return (next <= last) // Jeżeli ostatnia wartość nie


została jeszcze zwrócona…

? { value: next++ } // …zwracamy następną i zwiększamy


pozycję.
: { done: true }; // W przeciwnym razie informujemy, że
to koniec iteracji.

},

// Dla wygody można utworzyć iterator, który jest iterowalny.

[Symbol.iterator]() { return this; }

};
}

for(let x of new Range(1,10)) console.log(x); // Wyświetlenie liczb od 1 do


10.

[...new Range(-2,2)] // => [–2, –1, 0, 1, 2]

Tworząc iterowalną klasę, warto zdefiniować funkcje zwracające iterowalne wartości.


Przeanalizujmy poniższe funkcje map() i filter(), alternatywne dla metod tablicowych,
opierające swoje działanie na iteracji:
// Funkcja zwracająca obiekt iterujący wyniki i wywołujący

// dla każdej wartości źródłowej funkcję f().

function map(iterable, f) {

let iterator = iterable[Symbol.iterator]();

return { // Ten obiekt jest iterowalnym iteratorem.

[Symbol.iterator]() { return this; },

next() {

let v = iterator.next();

if (v.done) {
return v;

} else {

return { value: f(v.value) };

};

}
// Utworzenie mapy liczb całkowitych i ich kwadratów, a następnie
przekształcenie jej w tablicę.

[...map(new Range(1,4), x => x*x)] // => [1, 4, 9, 16]

// Funkcja zwracająca obiekt iterowalny filtrujący zadaną wartość.

// Iteruje tylko te elementy, dla których predykat przyjmuje wartość true.

function filter(iterable, predicate) {

let iterator = iterable[Symbol.iterator]();

return { // Ten obiekt jest iterowalnym iteratorem.

[Symbol.iterator]() { return this; },

next() {
for(;;) {

let v = iterator.next();

if (v.done || predicate(v.value)) {

return v;

};

}
// Przefiltrowanie zakresu i pozostawienie w nim tylko liczb parzystych.

[...filter(new Range(1,10), x => x % 2 === 0)] // => [2,4,6,8,10]

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) {

var r = /\s+|$/g; // Wyrażenie regularne odpowiadające jednej spacji, kilku


spacjom lub końcowi wiersza.

r.lastIndex = s.match(/[^ ]/).index; // Wyszukanie pierwszego znaku


innego niż spacja.

return { // Zwrócenie iterowalnego


iteratora.
[Symbol.iterator]() { // Ta metoda sprawia, że obiekt
jest iterowalny.

return this;
},

next() { // Ta metoda sprawia, że obiekt


jest iteratorem.

let start = r.lastIndex; // Wznowienie operacji od końca


ostatniego dopasowania.

if (start < s.length) { // Jeżeli to jeszcze nie koniec…

let match = r.exec(s); // …szukamy kolejnej granicy słowa.


if (match) { // Jeżeli zostanie znaleziona,
zwracamy słowo.

return { value: s.substring(start, match.index) };

return { done: true }; // W przeciwnym razie informujemy,


że to już koniec iteracji.

};

[...words(" abc def ghi! ")] // => ["abc", "def", "ghi!"]

12.2.1. „Zamknięcie” iteratora: metoda return()


Wyobraźmy sobie odmianę funkcji words(), działającą po stronie serwera, której argumentem
nie jest ciąg znaków, tylko nazwa pliku. Funkcja ta otwiera plik, odczytuje jego zawartość i
iteruje poszczególne słowa. W większości systemów operacyjnych program otwierający plik i
odczytujący z niego dane musi „pamiętać”, aby ten plik zamknąć. Zatem nasz hipotetyczny
iterator musiałby również zamykać plik, gdy metoda next() zwróci odczytane z niego ostatnie
słowo.
Jednak iteratory nie zawsze przetwarzają wszystkie dostępne dane. Pętlę for/of można
przerwać za pomocą instrukcji break lub return, jak również zgłaszając wyjątek. Oprócz tego,
jeżeli iterator jest stosowany z przypisaniem destrukturyzującym, metoda next() jest
wywoływana minimalną liczbę razy, niezbędną do przypisania wartości wybranym zmiennym. W
takich sytuacjach iterator jest gotowy zwrócić więcej wartości, ale nie zostanie o nie
„poproszony”.

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ą.

Pętla for/of i operator rozciągania to naprawdę bardzo przydatne funkcjonalności. Zatem


tworząc własny interfejs API, warto je stosować wszędzie tam, gdzie jest to możliwe. Jednak
konieczność stosowania obiektu iterowalnego, obiektu iteratora i wyników iteracji komplikuje
cały proces. Na szczęście dzięki generatorom można go radykalnie uprościć, o czym przekonasz
się w dalszej części rozdziału.

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:

// Funkcja generatora zwracająca zbiór jednocyfrowych liczb pierwszych.


function* oneDigitPrimes() { // Wywołanie tej funkcji nie powoduje wykonania
jej kodu,

yield 2; // tylko zwrócenie obiektu generatora. Wywołanie


metody

yield 3; // next()generatora powoduje wykonanie kodu do


yield 5; // momentu, aż instrukcja yield zwróci wynikowy
obiekt,

yield 7; // który ma zwrócić metoda next().


}

// Wywołując funkcję generatora, uzyskujemy obiekt generatora.


let primes = oneDigitPrimes();
// Generator jest obiektem iterującym dostarczane wartości.

primes.next().value // => 2
primes.next().value // => 3

primes.next().value // => 5
primes.next().value // => 7

primes.next().done // => true


// Generator zawiera metodę Symbol.iterator, dzięki której jest iterowalny.

primes[Symbol.iterator]() // => primes


// Z generatora można korzystać tak samo jak z każdego iterowalnego obiektu.
[...oneDigitPrimes()] // => [2,3,5,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) {

for(let i = from; i <= to; i++) yield i;


};

[...seq(3,5)] // => [3, 4, 5]


W definicji klasy i literale obiektowym można użyć skróconej składni i całkowicie zrezygnować
ze słowa kluczowego function. Wystarczy po prostu umieścić symbol * przed nazwą metody,
czyli tam, gdzie mogłoby znajdować się słowo function:
let o = {

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"]

Zwróć uwagę, że generatora nie można zdefiniować za pomocą funkcji strzałkowej.


Przy użyciu generatorów szczególnie łatwo definiuje się iterowalne klasy. W tym celu metodę
[Symbol.iterator]() pokazaną w listingu 12.1 trzeba zastąpić znacznie krótszą funkcją *
[Symbol.iterator](), jak niżej:
*[Symbol.iterator]() {
for(let x = Math.ceil(this.from); x <= this.to; x++) yield x;

}
Listing 9.3 w rozdziale 9. zawiera przykład użycia zdefiniowanej w ten sposób funkcji
generatora.

12.3.1. Przykłady generatorów


Generatory są znacznie ciekawsze, gdy wyliczają zwracane wartości. Poniżej jest przedstawiony
generator zwracający wyrazy ciągu Fibonacciego:

function* fibonacciSequence() {
let x = 0, y = 1;

for(;;) {
yield y;

[x, y] = [y, x+y]; // Uwaga: przypisanie destrukturyzujące.


}
}

Zwróć uwagę, że powyższa funkcja fibonacciSequence() zawiera nieskończoną pętlę, zwraca


wartości i nigdy nie kończy działania. Użyta z operatorem rozciągania działałaby do momentu
zapełnienia pamięci komputera i awarii programu. Jednak umiejętnie zastosowana w pętli
for/of jest bezpieczna:
// Funkcja zwracająca wyraz ciągu Fibonacciego.

function fibonacci(n) {
for(let f of fibonacciSequence()) {
if (n-- <= 0) return f;

}
}

fibonacci(20) // => 10946


Tego rodzaju działające w nieskończoność generatory są bardziej przydatne, jeżeli mają
następującą postać:

// Funkcja zwracająca n elementów zawartych w zadanym iterowalnym obiekcie.


function* take(n, iterable) {

let it = iterable[Symbol.iterator](); // Utworzenie generatora dla


iterowalnego obiektu.

while(n-- > 0) { // Pętla wykonująca n obiegów.


let next = it.next(); // Pobranie następnego obiektu z
iteratora.

if (next.done) return; // Wcześniejszy powrót, jeżeli nie


ma więcej wartości.
else yield next.value; // W przeciwnym razie zwrócenie
wartości.
}

}
// Tablica zawierająca pięć wyrazów ciągu Fibonacciego.
[...take(5, fibonacciSequence())] // => [1, 1, 2, 3, 5]

Poniżej przedstawiony jest przydatny generator przeplatający elementy zawarte w kilku


iterowalnych obiektach:

// Funkcja zwracająca ułożone naprzemiennie elementy iterowalnych obiektów


zawartych w zadanej tablicy.
function* zip(...iterables) {

// Utworzenie iteratora dla każdego iterowalnego obiektu.


let iterators = iterables.map(i => i[Symbol.iterator]());
let index = 0;

while(iterators.length > 0) { // Pętla wykonywana, dopóki są


jakieś iteratory.
if (index >= iterators.length) { // Jeżeli został osiągnięty
ostatni iterator…

index = 0; // …następuje powrót do


pierwszego.

}
let item = iterators[index].next(); // Odczytanie następnego elementu
z następnego iteratora.

if (item.done) { // Jeżeli iterator skończył


działanie…

iterators.splice(index, 1); // …usuwamy go z tablicy.


}
else { // W przeciwnym razie…

yield item.value; // …zwracamy odczytaną wartość…


index++; // … i przechodzimy do następnego
iteratora.
}
}

}
// Ułożenie na przemian elementów zawartych w trzech obiektach.

[...zip(oneDigitPrimes(),"ab",[0])] // => [2,"a",0,3,"b",5,7]

12.3.2. Instrukcja yield* i generatory rekurencyjne


Oprócz opisanego w poprzednim przykładzie generatora zip() przydatny jest taki, który
zwraca elementy kilku obiektów iterowalnych ułożone sekwencyjnie, a nie naprzemiennie. Tego
rodzaju generator może mieć następującą postać:
function* sequence(...iterables) {

for(let iterable of iterables) {


for(let item of iterable) {

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

[...sequence("abc",oneDigitPrimes())] // => ["a","b","c",2,3,5,7]


Za pomocą metody forEach() można elegancko iterować elementy tablicy. Pojawia się więc
pokusa, aby zakodować funkcję w następujący sposób:
function* sequence(...iterables) {
iterables.forEach(iterable => yield* iterable ); // Błąd.

}
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*.

Słowo kluczowe yield* można stosować z każdym obiektem iterowalnym, również


zaimplementowanym za pomocą generatora. Oznacza to, że można w ten sposób definiować
generatory rekurencyjne i wykorzystywać je na przykład do iterowania drzewiastej struktury
danych.

12.4. Zaawansowane funkcjonalności


generatorów
Generatory najczęściej stosuje się do tworzenia iteratorów, ale ich fundamentalną cechą jest
możliwość wstrzymywania obliczeń, zwracanie pośrednich wyników i wznawianie obliczeń.
Oznacza to, że generatory, w porównaniu z iteratorami, mają znacznie bardziej zaawansowane
funkcjonalności, którymi zajmiemy się w kolejnych punktach.

12.4.1. Wartość zwracana przez funkcję generatora


Żadna z funkcji generatora, które poznałeś do tej pory, nie zawiera instrukcji return. Gdyby ją
miała, kończyłaby przedwcześnie swoje działanie i nie zwracałaby żadnej wartości. Funkcja
generatora może jednak, tak jak każda funkcja, zwracać wartość. Aby zrozumieć, co się w takim
przypadku dzieje, przypomnij sobie, na czym polega iteracja. Wartością zwracaną przez metodę
next() jest obiekt posiadający właściwość value lub done (lub obie właściwości). W przypadku
typowych iteratorów i generatorów, jeżeli właściwość value jest zdefiniowana, to właściwość
done jest niezdefiniowana lub ma wartość false. Jeżeli natomiast właściwość ma wartość true,
to właściwość value jest niezdefiniowana. Jednak w przypadku generatora zwracającego
wartość metoda next() wywołana ostatni raz zwraca obiekt, w którym obie powyższe
właściwości są zdefiniowane. Właściwość value zawiera wartość zwracaną przez funkcję
generatora, a done ma wartość true oznaczającą, że nie ma więcej wartości do iterowania.
Ostatnia wartość jest pomijana przez pętlę for/of i operator rozciągania, ale jest dostępna w
kodzie, w którym wartości są iterowane za pomocą osobnych wywołań metody next():

function *oneAndDone() {
yield 1;

return "done";
}

// Zwracana wartość nie pojawia się podczas normalnej iteracji.


[...oneAndDone()] // => [1]

// Jest jednak dostępna, jeżeli metoda next() jest wywoływana jawnie.


let generator = oneAndDone();
generator.next() // => { value: 1, done: false}

generator.next() // => { value: "done", done: true }


// Jeżeli generator zakończył działanie, wartość nie jest zwracana ponownie.

generator.next() // => { value: undefined, done: true }

12.4.2. Wartość wyrażenia yield


W poprzednim punkcie instrukcja yield była użyta z wartością, ale sama nie zwracała żadnej
wartości. W rzeczywistości yield jest wyrażeniem, które może mieć wartość.
Gdy wywoływana jest metoda next(), wykonywany jest kod funkcji generatora do miejsca, w
którym znajduje się instrukcja yield. Wyliczana jest wartość umieszczonego po niej wyrażenia,
która jest zwracana przy następnym wywołaniu metody next(). W tym momencie
wstrzymywane jest wykonywanie kodu funkcji generatora. Przy kolejnym wywołaniu metody
next() jej argument staje się wartością wstrzymanego wyrażenia yield. Zatem generator
zwraca za pomocą instrukcji yield wartości wywołującemu go kodowi, a kod wywołujący
umieszcza wartości w argumencie metody next(). Generator i kod go wywołujący są dwoma
osobnymi blokami kodu, które nawzajem przekazują sobie wartości. Ilustruje to poniższy
przykład:

function* smallNumbers() {
console.log("Metoda next() wywołana pierwszy raz. Argument pominięty.");

let y1 = yield 1; // y1 == "b"


console.log("Metoda next() wywołana drugi raz z argumentem", y1);
let y2 = yield 2; // y2 == "c"

console.log("Metoda next() wywołana trzeci raz z argumentem", y2);


let y3 = yield 3; // y3 == "d"

console.log("Metoda next() wywołana czwarty raz z argumentem", y3);


return 4;

}
let g = smallNumbers();

console.log("Utworzony generator, kod jeszcze niewykonany.");


let n1 = g.next("a"); // n1.value == 1

console.log("Generator zwrócił wartość", n1.value);


let n2 = g.next("b"); // n2.value == 2
console.log("Generator zwrócił wartość", n2.value);

let n3 = g.next("c"); // n3.value == 3


console.log("Generator zwrócił wartość", n3.value);

let n4 = g.next("d"); // n4 == { value: 4, done: true }


console.log("Generator zwrócił wartość", n4.value);

Powyższy kod wyświetla następujące informacje potwierdzające przesyłanie danych pomiędzy


dwoma blokami kodu:
Utworzony generator, kod jeszcze niewykonany.

Metoda next() wywołana pierwszy raz. Argument pominięty.


Generator zwrócił wartość 1

Metoda next() wywołana drugi raz z argumentem b


Generator zwrócił wartość 2

Metoda next() wywołana trzeci raz z argumentem c


Generator zwrócił wartość 3
Metoda next() wywołana czwarty raz z argumentem d

Generator zwrócił wartość 4


Zwróć uwagę na asymetrię kodu. Pierwsze wywołanie metody next() uruchamia generator, ale
umieszczona w jej argumencie wartość nie jest dla generatora dostępna.

12.4.3. Metody return() i throw() generatora


Wiesz już, że można odbierać wartości zwracane przez funkcję generatora oraz przekazywać
mu wartości w argumencie metody next().
Generatorowi można nie tylko przekazywać wartości za pomocą metody next(), ale można
również sterować jego działaniem, wywołując metody return() i throw(). Jak sugerują ich
nazwy, pierwsza powoduje zwrócenie wartości, a druga zgłoszenie wyjątku, tak jakby zrobiły to
instrukcje return i throw użyte w kodzie generatora.
Jak pamiętasz z wcześniejszej części rozdziału, jeżeli iterator definiuje metodę return() i
wcześniej zakończy iteracje, to interpreter automatycznie wywołuje powyższą metodę, aby
iterator mógł zamknąć pliki lub wykonać inne operacje porządkujące. W generatorze nie można
zdefiniować własnej funkcji return(), ale można użyć instrukcji try/finally, która w bloku
finally wykona niezbędne operacje, gdy generator zakończy działanie. Gdy generator
przestanie być potrzebny i wymusi się zakończenie jego działania, wbudowana metoda
return() wywoła kod porządkujący.
Za pomocą metody next() można działającemu generatorowi przekazywać dowolne wartości,
natomiast dzięki metodzie throw() można w postaci wyjątków wysyłać do niego dowolne
sygnały. Wywołanie metody throw() zawsze skutkuje zgłoszeniem wyjątku wewnątrz
generatora. Jeżeli funkcja generatora zawiera odpowiedni kod obsługujący wyjątki, wówczas
nie powoduje awarii, tylko modyfikuje jego działanie. Wyobraźmy sobie generator zwracający
kolejne liczby całkowite. Można go napisać tak, że zgłoszenie wyjątku za pomocą metody throw
resetuje licznik do zera.
Jeżeli generator wykorzystuje instrukcję yield* do zwracania wartości zawartych w innym
obiekcie iterowalnym, wówczas wywołanie metody next() generatora powoduje wywołanie
metody next() tego obiektu. Ta sama zasada dotyczy metod return() i throw(). Jeżeli
generator wykorzystuje instrukcję yield* z iterowalnym obiektem, w którym są zdefiniowane
powyższe trzy metody, wówczas wywołanie metody return() lub throw() generatora skutkuje
wywołaniem, odpowiednio, metody return() lub throw() iteratora. Każdy iterator musi mieć
metodę next(). Iterator wykonujący operacje porządkowe w przypadku przerwania iteracji
musi posiadać metodę return(). Ponadto iterator może definiować metodę throw(),
aczkolwiek trudno jest sobie wyobrazić jej praktyczne zastosowanie.

12.4.4. Końcowe uwagi dotyczące generatorów


Generator jest bardzo użyteczną strukturą sterującą. Za pomocą instrukcji yield można
wstrzymywać obliczenia i wznawiać je w dowolnym momencie, wykorzystując dowolną wartość
wejściową. Generatory można wykorzystywać do tworzenia współpracujących ze sobą bloków
kodu w jednowątkowym programie JavaScript. Za ich pomocą można również maskować
asynchroniczne części kodu, dzięki czemu program wydaje się sekwencyjny i synchroniczny,
gdy tymczasem niektóre funkcje są wywoływane asynchronicznie, na przykład w wyniku
wystąpienia zdarzeń sieciowych.

Jednak implementowanie powyższych funkcjonalności za pomocą generatorów powoduje, że


kod staje się wyjątkowo zagmatwany. Tak się jednak kiedyś robiło, ale jedyne praktyczne
zastosowanie dotyczyło zarządzania kodem asynchronicznym. Obecnie stosuje się w tym celu
słowa kluczowe async i await (patrz rozdział 13.), więc nie ma powodów, aby używać zamiast
nich generatorów.

12.5. Podsumowanie
W tym rozdziale dowiedziałeś się, że:

Pętlę for/of i operator rozciągania można stosować z iterowalnymi obiektami.


Obiekt iterowalny musi zawierać metodę o symbolicznej nazwie [Symbol.iterator].
Metoda ta musi zwracać obiekt iteratora.
Obiekt iteratora musi posiadać metodę next() zwracającą wynik iteracji.
Wynik iteracji jest obiektem posiadającym właściwość value zawierającą następną
iterowaną wartość, o ile taka istnieje. Po zakończeniu iteracji wynikowy obiekt musi
posiadać właściwość done zawierającą wartość true.
Własny obiekt iterowalny można zaimplementować, definiując metodę
[Symbol.iterator](). Metoda ta musi zwracać obiekt zawierający metodę next(), która
z kolei musi zwracać wynik iteracji. Można też zaimplementować funkcję, której
argumentem jest iterator, a zwracanym wynikiem wartość iteratora.
Inny sposób definiowania iteratora polega na użyciu funkcji generatora zdefiniowanej za
pomocą słowa kluczowego function*, a nie function.
W chwili wywołania funkcji generatora nie jest wykonywany jej kod. Zamiast tego jest
zwracany iterowalny iterator. Za każdym razem, gdy wywoływana jest metoda next(),
wykonywany jest inny fragment kodu funkcji generatora.
W funkcji generatora można stosować wyrażenie yield określające wartość zwracaną
przez iterator. Każde wywołanie metody next() skutkuje wznowieniem działania kodu do
momentu napotkania kolejnego wyrażenia yield, którego wartość staje się wynikiem
zwracanym przez iterator. Po wyliczeniu ostatniego wyrażenia yield funkcja generatora
kończy działanie, co oznacza koniec iteracji.
Rozdział 13.
Asynchroniczność w języku
JavaScript
Niektóre programy komputerowe, na przykład symulatory naukowe lub modele uczenia
maszynowego, są typowo obliczeniowe, tj. działają nieprzerwanie, dopóki nie wyliczą wyników.
Jednak wiele praktycznych programów jest asynchronicznych, czyli wstrzymuje działanie w
oczekiwaniu na dane lub wystąpienie jakiegoś zdarzenia. Działające w przeglądarkach
programy napisane w języku JavaScript są sterowane zdarzeniami, tj. zanim wykonają jakąś
operację, czekają, aż użytkownik coś kliknie lub czegoś dotknie na ekranie. Z kolei programy
serwerowe oczekują na zapytania użytkowników wysyłane przez sieć.
Programy asynchroniczne są wszechobecne. W tym rozdziale są opisane trzy ważne aspekty
języka JavaScript ułatwiające pisanie asynchronicznego kodu. Pierwszy aspekt to promesy (ang.
promise) wprowadzone w wersji języka ES6. Są to obiekty reprezentujące jeszcze niedostępne
wyniki operacji asynchronicznych. Drugim aspektem są słowa kluczowe async i await, które
pojawiły się w wersji ES2017. Dzięki nim składnia asynchronicznego kodu jest prostsza, a kod
oparty na promesach ma strukturę niemal synchroniczną. Trzecim aspektem są iteratory
asynchroniczne i pętla for/of wprowadzona w wersji języka ES2018. Za pomocą prostych pętli
podobnych do synchronicznych można przetwarzać strumienie zdarzeń asynchronicznych.
Choć język JavaScript oferuje przydatne funkcjonalności do tworzenia kodu asynchronicznego,
żadna z nich nie jest rdzennie asynchroniczna. Aby poznać promesy, słowa kluczowe async i
await oraz pętlę for/await, trzeba wcześniej zrozumieć działanie kodu klienckiego
uruchomionego w przeglądarce oraz serwerowego uruchomionego w środowisku Node (więcej
na temat powyższych rodzajów kodów dowiesz się w rozdziałach 15. i 16.).

13.1. Programowanie asynchroniczne i


funkcje zwrotne
Programowanie asynchroniczne w języku JavaScript w najbardziej podstawowym zakresie
polega na stosowaniu funkcji zwrotnych. Są to funkcje umieszczane w argumentach innych
funkcji i wywoływane w chwili spełnienia określonego warunku lub wystąpienia określonego,
asynchronicznego zdarzenia. Wywołanie funkcji zwrotnej jest powiadomieniem, że zostały
spełnione wymagane warunki. Czasami taka funkcja jest wywoływana z argumentami
zawierającymi dodatkowe informacje. Te zawiłości lepiej wyjaśnią konkretne przykłady. W
kolejnych punktach opisane są różne sposoby programowania asynchronicznego,
wykorzystujące funkcje zwrotne zarówno w przeglądarce, jak i w środowisku Node.

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:

// Wywoływanie funkcji checkForUpdates() co minutę.

let updateIntervalId = setInterval(checkForUpdates, 60000);


// Funkcja setInterval() zwraca wartość, którą można wykorzystać do
przerwania ciągu wywołań funkcji zwrotnej.

// W tym celu należy wywołać funkcję clearInterval(). Podobną wartość zwraca


funkcja setTimeout().
function stopCheckingForUpdates() {

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.

let okay = document.querySelector('#confirmUpdateDialog button.okay');

// Zarejestrowanie funkcji zwrotnej, która będzie wywoływana, gdy użytkownik


kliknie przycisk.

okay.addEventListener('click', applyUpdate);

W powyższym przykładzie applyUpdate() jest hipotetyczną funkcją zwrotną


zaimplementowaną w innym miejscu kodu. Metoda document.querySelector() zwraca obiekt
reprezentujący żądany element strony WWW. Metoda addEventListener() tego obiektu jest
wywoływana w celu zarejestrowania funkcji zwrotnej. Pierwszym argumentem metody jest ciąg
znaków określający zdarzenie — w tym przypadku jest to kliknięcie przycisku myszą lub
dotknięcie go. Gdy użytkownik kliknie wskazany element strony lub dotknie go, przeglądarka
wywoła funkcję zwrotną applyUpdate() z argumentem zawierającym szczegółowe informacje o
zdarzeniu, na przykład bieżący czas oraz współrzędne kursora na ekranie.
13.1.3. Zdarzenia sieciowe
Innym ważnym źródłem asynchroniczności są zapytania sieciowe. Program JavaScript
uruchomiony w przeglądarce może odbierać dane wysyłane przez serwer w następujący
sposób:

function getCurrentVersionNumber(versionCallback) { // Zwróć uwagę na


argument będący
// funkcją zwrotną.

// Wysłanie do interfejsu API zapytania HTTP o numer wersji.

let request = new XMLHttpRequest();

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() {

if (request.status === 200) {

// Jeżeli status HTTP zapytania jest poprawny, odczytujemy nr wersji i


wywołujemy funkcję zwrotną.

let currentVersion = parseFloat(request.responseText);


versionCallback(null, currentVersion);

} else {

// W przeciwnym razie zgłaszamy problem za pomocą funkcji zwrotnej.

versionCallback(response.statusText, null);
}

};

// Zarejestrowanie innej funkcji zwrotnej wywoływanej po wystąpieniu błędu


sieciowego.

request.onerror = request.ontimeout = function(e) {

versionCallback(e.type, null);
};

Program kliencki wysyła zapytania HTTP za pomocą klasy XMLHttpRequest, a odbierane


odpowiedzi serwera przetwarza za pomocą funkcji zwrotnej[1]. Funkcja
getCurrentVersionNumber() (możesz sobie wyobrazić, że wykorzystuje ją hipotetyczna funkcja
checkForUpdates() z punktu 13.1.1) wysyła zapytanie HTTP i definiuje kod obsługi zdarzenia
zgłaszanego po odebraniu odpowiedzi serwera lub wystąpieniu błędu, na przykład po
przekroczeniu maksymalnego czasu oczekiwania na odpowiedź.
Zwróć uwagę, że w powyższym kodzie nie jest wywoływana metoda addEventListener(), jak
w poprzednim przykładzie. W większości interfejsów API, również w użytym tutaj, procedurę
obsługi definiuje się, wywołując metodę addEventListener() obiektu generującego zdarzenie,
umieszczając w jej argumentach nazwę zdarzenia i funkcję zwrotną. Pojedynczą procedurę
obsługi zazwyczaj jednak definiuje się bezpośrednio, przypisując ją odpowiedniej właściwości
obiektu. Takie rozwiązanie jest zastosowane w powyższym przykładzie, w którym funkcje
zwrotne zostały przypisane właściwościom onload, onerror i ontimeout. Zgodnie z przyjętą
konwencją nazwy właściwości obiektu obsługującego zdarzenia mają prefiksy on. Użycie
metody addEventListener() jest bardziej elastyczną techniką, umożliwiającą definiowanie
wielu procedur obsługi. Jednak jeżeli wiadomo na pewno, że dla danego obiektu i typu
zdarzenia wystarczy zarejestrować tylko jedną procedurę, prościej jest przypisać funkcję
zwrotną odpowiedniej właściwości.
Ponadto zwróć uwagę, że funkcja getCurrentVersionNumber() wysyła zapytania
asynchronicznie, a więc nie może synchronicznie zwracać wyników (aktualnej wersji
oprogramowania), której żąda wywołujący ją kod. Dlatego zdefiniowana jest funkcja zwrotna,
wywoływana w chwili odebrania odpowiedzi lub wystąpienia błędu. W tym przykładzie funkcja
ta ma dwa argumenty. Jeżeli klasa XMLHttpRequest poprawnie obsłuży odpowiedź na zapytanie,
metoda getCurrentVersionNumber() wywoła funkcję zwrotną, umieszczając w jej pierwszym
argumencie wartość null, a w drugim numer wersji. Jeżeli natomiast pojawi się błąd, w
pierwszym argumencie zostanie umieszczony szczegółowy opis błędu, a w drugim wartość
null.

13.1.4. Funkcje zwrotne i zdarzenia w środowisku


Node
Środowisko serwerowe Node jest z założenia asynchroniczne i definiuje wiele interfejsów API
wykorzystujących funkcje zwrotne i zdarzenia. Na przykład domyślny interfejs przeznaczony do
odczytywania zawartości plików jest asynchroniczny i wywołuje funkcję zwrotną, gdy plik
zostanie odczytany. Ilustruje to poniższy przykład:

const fs = require("fs"); // Moduł fs zawiera interfejsy API obsługujące


system plików.

let options = { // Obiekt zawierający opcje programu.

// Tu zdefiniowane są domyślnie opcje.

};

// Odczytanie zawartości pliku konfiguracyjnego, a następnie wywołanie


funkcji zwrotnej.

fs.readFile("config.json", "utf-8", (err, text) => {

if (err) {

// Jeżeli wystąpił błąd, funkcja wyświetla komunikat i kontynuuje


działanie.

console.warn("Błąd odczytu pliku konfiguracyjnego:", err);

} else {

// W przeciwnym razie analizuje zawartość pliku i przekazuje ją obiektowi


options.

Object.assign(options, JSON.parse(text));
}

// Niezależnie od przypadku uruchamiany jest program.

startProgram(options);
});

W ostatnim argumencie metody fs.readFile() umieszcza się dwuargumentową funkcję


zwrotną. Metoda odczytuje asynchronicznie zawartość pliku, a następnie wywołuje tę funkcję.
Jeżeli odczyt zakończy się pomyślnie, zawartość pliku jest umieszczana w drugim argumencie
funkcji zwrotnej. Jeżeli wystąpi błąd, w pierwszym argumencie funkcji zwrotnej jest
umieszczany komunikat. W powyższym przykładzie funkcja zwrotna jest zdefiniowana przy
użyciu zwięzłej, naturalnej w tego rodzaju zastosowaniach, składni funkcji strzałkowej.

Ś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().

const https = require("https");

// Funkcja odczytująca zawartość strony o zadanym adresie URL i przekazująca


ją funkcji zwrotnej.

function getText(url, callback) {

// Wysłanie zapytania HTTP GET na zadany adres URL.

request = https.get(url);

// Zarejestrowanie funkcji obsługującej zdarzenie odpowiedzi.

request.on("response", response => {

// Zgłoszenie zdarzenia oznacza, że został odebrany nagłówek odpowiedzi.


let httpStatus = response.statusCode;

// Treść odpowiedzi jeszcze nie została odebrana,

// dlatego trzeba zarejestrować dodatkowe funkcje zwrotne,

// które zostaną wywołane, gdy nadejdzie odpowiedź.

response.setEncoding("utf-8"); // Spodziewany jest tekst zakodowany


w standardzie Unicode.
let body = ""; // Zostanie on umieszony w tej
zmiennej.

// Ta funkcja będzie wywoływana, gdy kolejny fragment odpowiedzi będzie


gotowy do odczytu.

response.on("data", chunk => { body += chunk; });

// Ta funkcja zostanie wywołana po odebraniu całej odpowiedzi.

response.on("end", () => {

if (httpStatus === 200) { // Jeżeli zapytanie HTTP zostało


obsłużone poprawnie,

callback(null, body); // treść odpowiedzi jest umieszczana


w argumencie

// funkcji zwrotnej.

} else { // W przeciwnym razie w argumencie jest


umieszczany
// komunikat o błędzie.

callback(httpStatus, null);

});

});
// Rejestrowana jest również procedura obsługi niskopoziomowych błędów
transmisji sieciowej.

request.on("error", (err) => {

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.

Pamiętaj, że promesa reprezentuje przyszłe wyniki pojedynczej asynchronicznej operacji, a nie


ich serii. Na przykład w dalszej części rozdziału poznasz opartą na promesie alternatywę dla
funkcji setTimeout(). Nie można jednak promesą zastąpić funkcji setInterval(), ponieważ
wywołuje ona funkcję zwrotną wielokrotnie, czyli wykonuje operację, do której promesa nie jest
przeznaczona. Analogicznie promesy można użyć zamiast obiektu XMLHttpRequest do obsługi
zdarzenia załadowania strony, ponieważ tego rodzaju procedura jest wywoływana tylko raz.
Promesy raczej nie używa się do obsługiwania kliknięć przycisków na stronie HTML, ponieważ
użytkownik powinien mieć możliwość ich wielokrotnego klikania.
W kolejnych punktach są opisane następujące tematy:

terminologia i podstawowe zastosowania promes,


łączenie promes w łańcuch,
tworzenie własnego interfejsu API opartego na promesach.

Na pierwszy rzut oka promesy wydają się proste i rzeczywiście w podstawowych


zastosowaniach takie są. Jednak poza najprostszymi przypadkami okazują się
niezwykle skomplikowane. Promesy stanowią potężną funkcjonalność
programowania asynchronicznego, ale aby właściwie spełniały swoje zadania,
trzeba je dokładnie poznać i świadomie stosować. Uważam, że warto poświęcić czas
na zapoznanie się z nimi, i zachęcam Cię do uważnego przeczytania tego rozdziału.

13.2.1. Korzystanie z promes


Wraz z wprowadzeniem promes do rdzenia języka JavaScript w przeglądarkach zaczęły być
implementowane oparte na promesach interfejsy API. W poprzednim podrozdziale opisana
została funkcja getText(), która asynchronicznie wysyłała zapytania HTTP i umieszczała
odpowiedzi w postaci ciągu znaków w argumencie funkcji zwrotnej. Wyobraźmy sobie odmianę
tej funkcji, getJSON(), która analizuje treść odpowiedzi HTTP zapisanej w formacie JSON, ale
nie wywołuje podanej w argumencie funkcji zwrotnej, tylko zwraca promesę. Funkcję
getJSON() zaimplementujemy w dalszej części rozdziału, natomiast teraz dowiedz się, jak
można użyć metody zwróconej promesy:

getJSON(url).then(jsonData => {
// Tutaj umieszczona jest funkcja zwrotna, wywoływana asynchronicznie,

// gdy przeanalizowany dokument JSON jest już dostępny.


});

Funkcja getJSON()asynchronicznie wysyła zapytanie HTTP na wskazany adres URL, po czym,


nie czekając na odpowiedź, zwraca obiekt promesy. Obiekt ten definiuje metodę instancji
then(). Funkcję zwrotną umieszcza się w argumencie tej metody, a nie bezpośrednio w
argumencie funkcji getJSON(). Gdy nadejdzie odpowiedź, jej zawartość, zapisana w formacie
JSON, jest analizowana, a wynikowa wartość umieszczana w argumencie funkcji umieszczonej
w argumencie metody then().
Metodę then() można traktować jako metodę rejestrującą funkcję zwrotną, tak jak to robi
metoda addEventListener() w kodzie klienckim. Jeżeli metoda then() zostanie wywołana
wielokrotnie z różnymi funkcjami, każda z nich zostanie wywołana, gdy tylko będzie dostępna
odpowiedź.

Jednak promesa, w odróżnieniu od wielu innych procedur obsługi zdarzeń, reprezentuje


pojedynczą operację, więc każda z funkcji zarejestrowanych za pomocą metody then() będzie
wywołana tylko raz. Warto pamiętać, że funkcja umieszczona w argumencie tej metody jest
wywoływana asynchronicznie, nawet gdy w momencie wywołania metody asynchroniczna
operacja jest już zakończona.
W najprostszej formie metoda then() jest integralną częścią promesy i typowe jest
bezpośrednie dopisywanie do niej kolejnego wywołania metody .then() zwracającej promesę.
Nie trzeba kodować dodatkowego kroku polegającego na przypisaniu obiektu do zmiennej.
Charakterystyczne jest również nadawanie funkcjom, których argumentami lub zwracanymi
wynikami są promesy, nazw będących czasownikami. Dzięki temu kod staje się bardzo czytelny:
// Załóżmy, że mamy następującą funkcję wyświetlającą profil użytkownika:

function displayUserProfile(profile) { /* Implementacja pominięta. */ }


// Funkcję tę można wykorzystać z promesą w poniższy sposób.

// Zwróć uwagę, że wiersz ten jest niemal poprawnie sformułowanym zdaniem w


języku angielskim.
getJSON("/api/user/profile").then(displayUserProfile);

Obsługa błędów za pomocą promes


Operacje asynchroniczne, szczególnie sieciowe, mogą kończyć się niepowodzeniem z różnych
powodów. Dobrze napisany kod powinien poprawnie obsługiwać tego rodzaju nieuniknione
błędy. Za pomocą promes cel ten można osiągnąć, umieszczając funkcję w drugim argumencie
metody then():

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

W tym przypadku funkcja getJSON() umieszcza pomyślnie uzyskany wynik w argumencie


funkcji displayUserProfile(), natomiast obiekt reprezentujący błąd, jak również wyjątek
zgłoszony przez funkcję displayUserProfile() umieszcza w argumencie funkcji
handleProfileError(). Metoda catch() stanowi po prostu skróconą wersję metody then() z
pierwszym argumentem zawierającym wartość null, a drugim — funkcję obsługi błędu.
Więcej na temat metody catch() i opisanego sposobu obsługi błędów dowiesz się w następnym
punkcie, poświęconym łączeniu promes w łańcuch.

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.

13.2.2. Łańcuch promes


Promesy mają tę ważną cechę, że za ich pomocą można w naturalny sposób kodować sekwencje
asynchronicznych operacji. W tym celu wystarczy utworzyć łańcuch metod then() bez
konieczności zagnieżdżania każdej kolejnej operacji w funkcji zwrotnej z poprzedniej operacji.
Poniżej jest przedstawiony przykładowy łańcuch promes:
fetch(documentURL) // Wysłanie zapytania HTTP.

.then(response => response.json()) // Pytanie o treść odpowiedzi


zapisanej w formacie JSON.
.then(document => { // Po odebraniu przeanalizowanej
treści JSON
return render(document); // następuje wyświetlenie jej w
interfejsie użytkownika.

})
.then(rendered => { // Po uzyskaniu gotowego dokumentu

cacheInDatabase(rendered); // następuje umieszczenie go w


lokalnej bazie danych.
})
.catch(error => handle(error)); // Obsługa błędów, które mogą się
pojawić.

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 jest zdeterminowana, dostępny jest status i nagłówki


odpowiedzi.
if (response.ok &&

response.headers.get("Content-Type") === "application/json") {


// Co tutaj można zrobić? Treść odpowiedzi nie jest jeszcze dostępna.

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

// Po odebraniu odpowiedzi jej treść zapisana w formacie JSON jest


// automatycznie analizowana i przekazywana poniższej funkcji.

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.

.then(callback1) // Zadanie nr 2, zwrócenie promesy nr 2.


.then(callback2); // Zadanie nr 3, zwrócenie promesy nr 3.

Przeanalizujmy szczegółowo poszczególne elementy tego kodu:

1. W pierwszym wierszu wywoływana jest metoda fetch() z adresem URL w argumencie.


Wysyła ona zapytanie HTTP GET pod zadany adres i zwraca promesę. Wysłanie zapytania
jest opisane jako zadanie nr 1, a zwrócony wynik jako promesa nr 1.
2. W drugim wierszu wywoływana jest metoda then() promesy nr 1. W jej argumencie jest
umieszczona funkcja zwrotna callback1(), która zostanie wywołana, gdy zostanie
spełniona promesa nr 1. Metoda then() zapamiętuje tę funkcję i zwraca wynik będący
promesą nr 2. Zadanie nr 2 rozpoczyna się w chwili wywołania funkcji callback1().
3. W trzecim wierszu wywoływana jest metoda then() promesy nr 2. W jej argumencie jest
umieszczona funkcja zwrotna callback2(), która zostanie wywołana, gdy zostanie
spełniona promesa nr 2. Metoda then() zapamiętuje tę funkcję i zwraca wynik będący
promesą nr 3. W chwili wywołania funkcji callback2() rozpoczyna się zadanie nr 3. Jego
nazwa nie jest jednak ważna, ponieważ nigdzie nie jest wykorzystywana.
4. Powyższe trzy kroki są wykonywane synchronicznie przy pierwszym wyliczeniu wyrażenia.
Później po wysłaniu zapytania HTTP w sieć następuje asynchroniczna pauza.
5. Po pewnym czasie rozpoczyna się odbieranie odpowiedzi. Asynchroniczna część metody
fetch() umieszcza status HTTP i nagłówki odpowiedzi w obiekcie Response. W ten
sposób spełnia promesę nr 1. Uzyskanym wynikiem jest obiekt Response.
6. Po spełnieniu promesy nr 1 uzyskany wynik (obiekt Response) jest umieszczany w
argumencie funkcji zwrotnej callback1() i zaczyna się wykonywanie zadania nr 2. Jego
celem jest uzyskanie na podstawie zadanego obiektu Response treści odpowiedzi
zapisanej w formacie JSON.
7. Załóżmy, że zadanie nr 2 zostało wykonane pomyślnie, a więc można przeanalizować
odpowiedź HTTP i utworzyć obiekt JSON, co oznacza spełnienie promesy nr 2.
8. Wartość uzyskana w wyniku spełnienia promesy nr 2 jest umieszczana w argumencie
funkcji zwrotnej callback2() i staje się wartością wejściową dla zadania nr 3. Zadanie to
polega na zaprezentowaniu danych użytkownikowi w określony sposób. Z chwilą
pomyślnego wykonania tego zadania spełniana jest promesa nr 3. Ponieważ uzyskany
wynik nie jest nigdzie wykorzystywany, na tym kończy się łańcuch asynchronicznych
operacji.

13.2.3. Determinowanie promes


W opisie promes przetwarzających zapytanie HTTP wyszczególnione są promesy nr 1, 2 i 3.
W rzeczywistości jest jeszcze czwarta promesa, która posłuży do wyjaśnienia ważnego pojęcia
zdeterminowanej promesy.

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

let p1 = fetch("/api/user/profile"); // Promesa nr 1, zadanie nr 1.


let p2 = p1.then(c1); // Promesa nr 2, zadanie nr 2.
let p3 = p2.then(c2); // Promesa nr 4, zadanie nr 3.

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

13.2.4. Więcej o promesach i błędach


Wcześniej w tym rozdziale dowiedziałeś się, że w argumencie metody then() można umieścić
drugą funkcję zwrotną, która jest wywoływana w przypadku odrzucenia promesy. Argumentem
tej funkcji jest wartość opisująca przyczynę odrzucenia, zazwyczaj obiekt Error. Dowiedziałeś
się również, że rzadko wywołuje się metodę then() z dwoma argumentami, ponieważ jest to
nieidiomatyczny sposób. Obsługę błędów, jakie mogą pojawiać się w promesach, zazwyczaj
koduje się, dodając do łańcucha metodę catch(). Teraz, gdy wiesz już, jak funkcjonuje łańcuch
promes, możemy wrócić do obsługi błędów i poznać ją dokładniej. Na wstępie chciałbym
zaznaczyć, że staranna obsługa błędów jest w programowaniu asynchronicznym naprawdę
ważna. W kodzie synchronicznym brak obsługi powoduje w najgorszym wypadku wyświetlenie
wyjątku i stosu wywołań, na podstawie których można dowiedzieć się, co poszło źle. Natomiast
w kodzie asynchronicznym nieobsłużone wyjątki zazwyczaj nie są sygnalizowane w ogóle i
błędy przechodzą bez echa, przez co znacznie trudniej jest je diagnozować. Dobra wiadomość
jest taka, że dzięki promesom i metodzie catch() obsługa błędów jest prosta.

Metody catch() i finally()


Metoda catch() obiektu promesy jest skróconą formą metody then(). Jej pierwszy argument
ma wartość null, a drugi jest funkcją zwrotną obsługującą błędy. Dla obiektu promesy p i
funkcji zwrotnej c() poniższe dwa wiersze są równoważne:
p.then(null, c);
p.catch(c);
Zalecane jest stosowanie metody catch(), ponieważ jest prostsza, a jej nazwa odpowiada
instrukcji catch w konstrukcji try/catch. Jak wiadomo, w kodzie asynchronicznym nie można
obsługiwać wyjątków w zwykły sposób. Alternatywnym rozwiązaniem, które można w takich
sytuacjach stosować, jest użycie metody catch() promesy. Jeżeli w kodzie synchronicznym coś
pójdzie źle, wyjątek jest przesyłany w górę stosu wywołań („bąbelkowany”), aż trafi do bloku
catch. W łańcuchu asynchronicznych promes analogicznym określeniem może być „zejście
wyjątku po łańcuchu” do napotkania metody catch().
Począwszy od wersji języka ES2018 obiekt Promise definiuje również metodę finally(), której
przeznaczenie jest podobne do instrukcji finally w konstrukcji try/catch/finally. Po
dodaniu metody finally() do łańcucha umieszczona w jej argumencie funkcja zwrotna jest
wywoływana w momencie rozstrzygnięcia promesy, tj. jej spełnienia lub odrzucenia. Funkcja ta
nie ma argumentów, więc nie można w niej określić rodzaju rozstrzygnięcia. Idealnie za to
nadaje się do uruchamiania (niezależnie od przypadku) kodu porządkującego, na przykład
zamykającego otwarte pliki lub połączenia sieciowe. Metoda finally(), podobnie jak then() i
catch(), zwraca obiekt promesy. Wynik zwracany przez funkcję zwrotną umieszczoną w
argumencie tej metody jest zazwyczaj ignorowany, a zwracana przez nią promesa jest spełniana
lub odrzucana z taką samą wartością, z jaką została spełniona lub odrzucona promesa, do której
ta metoda należy. Jeżeli jednak funkcja zwrotna umieszczona w argumencie metody zgłosi
wyjątek, wówczas promesa zwrócona przez metodę jest odrzucana z uzyskaną wartością.
W opisanym w poprzednim przykładzie kodzie przetwarzającym zapytanie HTTP nie była
zaimplementowana obsługa błędów. Naprawmy to i utwórzmy jego bardziej realistyczną wersję:

fetch("/api/user/profile") // Wysłanie zapytania HTTP.


.then(response => { // Wywołanie funkcji w chwili, gdy będą
dostępne status i nagłówki.
if (!response.ok) { // Jeżeli pojawi się status 404 lub inny błąd,
return null; // zwracana jest wartość null.

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

if (type !== "application/json") {


throw new TypeError(`Oczekiwany format JSON, otrzymany ${type}`);
}
// W tym miejscu status ma kod 2xx, a treść jest zapisana w formacie
JSON.
// Można więc bezpiecznie zwrócić promesę i obiekt JSON.

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 if (e instanceof TypeError) {


// W tym miejscu został zgłoszony wyjątek TypeError.
displayErrorMessage("Z serwerem dzieje się coś niedobrego!");
}

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().

Zanim zakończymy temat obsługi błędów, chciałbym podkreślić, że choć idiomatycznym


podejściem jest umieszczanie na końcu łańcucha promes metody catch(), która obsługuje (lub
przynajmniej rejestruje) wszystkie błędy, jakie mogą się pojawić, to całkowicie poprawne jest
umieszczanie tej metody w dowolnym miejscu łańcucha. Jeżeli na jednym z etapów pojawi się
naprawialny błąd, który nie musi wstrzymywać pozostałych etapów łańcucha, wówczas można
w jego środku umieścić metodę catch(). Uzyskany w ten sposób kod będzie miał następującą
postać:

startAsyncOperation()
.then(doStageTwo)
.catch(recoverFromStageTwoError)
.then(doStageThree)

.then(doStageFour)
.catch(logStageThreeAndFourErrors);

Pamiętaj, że funkcja zwrotna umieszczona w argumencie metody catch() jest wywoływana


tylko wtedy, gdy funkcja zwrotna umieszczona w argumencie poprzedniej metody w łańcuchu
zgłosi błąd. W przeciwnym razie funkcja zwrotna metody catch() nie zostanie wywołana, a
wynik poprzedniej funkcji zwrotnej stanie się wartością wejściową funkcji zwrotnej następnej
metody then(). Pamiętaj również, że zadaniem funkcji zwrotnej metody catch() jest nie tylko
raportowanie błędów, ale też ich obsługiwanie i naprawianie. Obiekt błędu umieszczony w
argumencie funkcji zwrotnej metody catch() nie jest eskalowany w dół łańcucha promes.
Funkcja ta może zgłosić własny błąd, ale jeżeli zakończy działanie w normalny sposób,
zwrócony przez nią wynik zostanie użyty do zdeterminowania lub spełnienia związanej z tą
funkcją promesy, a błąd nie będzie dalej eskalowany.
Mówiąc bardziej konkretnie, jeżeli metoda startAsyncOperation() lub doStageTwo() w
powyższym przykładzie zgłosi błąd, to zostanie wywołana funkcja
recoverFromStageTwoError(). Jeżeli funkcja ta zakończy działanie normalnie, zwrócony przez
nią wynik zostanie umieszczony w argumencie funkcji doStageThree() i zostaną wykonane
następne operacje asynchroniczne. Jeżeli natomiast funkcja recoverFromStageTwoError() nie
będzie w stanie naprawić błędu, wówczas sama zgłosi nowy błąd (lub ten sam, który otrzymała
w argumencie). Jednak w tym przypadku nie zostanie wywołana funkcja doStageThree() ani
doStageFour(), natomiast zgłoszony błąd zostanie umieszczony w argumencie funkcji
logStageThreeAndFourErrors().
Czasami w skomplikowanych środowiskach sieciowych błędy mogą pojawiać się w mniej lub
bardziej przypadkowych momentach. W takich sytuacjach właściwym rozwiązaniem jest
ponowne asynchroniczne wysłanie zapytania. Załóżmy, że tworzymy program wykorzystujący
promesy do wysyłania zapytań do bazy danych:
queryDatabase()
.then(displayTable)
.catch(displayDatabaseError);

Załóżmy dodatkowo, że 1% wywołań kończy się niepowodzeniem z powodu przejściowych


problemów z siecią. Prostym rozwiązaniem może być ponowne wysłanie zapytania za pomocą
metody catch():
queryDatabase()
.catch(e => wait(500).then(queryDatabase)) // W przypadku błędu czekamy
i próbujemy ponownie.
.then(displayTable)
.catch(displayDatabaseError);
Jeżeli problemy naprawdę pojawiają się losowo, to dodanie powyższego wiersza powinno
zmniejszyć częstość występowania błędów z 1% do 0,01%.

Wyniki funkcji zwrotnych


Wróćmy ostatni raz do przykładu z wysyłaniem zapytań HTTP i przeanalizujmy funkcję
zwrotną c1() umieszczoną w argumencie pierwszej metody then(). Zwróć uwagę, że
funkcja ta może zakończyć działanie na trzy sposoby. Może zakończyć się normalnie i
zwrócić promesę zwróconą przez metodę json(). W takim wypadku promesa p2 będzie
zdeterminowana, ale o tym, czy zostanie spełniona, czy odrzucona, będzie decydować
zwrócona wcześniej promesa. Funkcja może również zwrócić wartość null, co spowoduje
natychmiastowe spełnienie promesy p2. I wreszcie funkcja c1() może zgłosić błąd, przez
co promesa p2 zostanie odrzucona. Są to trzy możliwe stany promesy, a kod funkcji c1()
pokazuje, jak funkcja zwrotna może określać każdy z nich.
W łańcuchu promes zwracana wartość lub zgłaszany błąd na danym etapie staje się
wartością wejściową dla następnego etapu. Dlatego ważne jest, aby ten mechanizm
działał poprawnie. W praktyce brak wyniku funkcji zwrotnej jest częstym powodem
błędów, szczególnie gdy są stosowane funkcje strzałkowe. Przeanalizujmy użyty w
naszym przykładzie następujący wiersz:
.catch(e => wait(500).then(queryDatabase))

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:

.catch(e => { wait(500).then(queryDatabase) })


Po dodaniu nawiasów klamrowych wartość wyrażenia nie jest zwracana automatycznie.
Teraz funkcja nie zwraca promesy, tylko wartość undefined. Oznacza to, że wartością
wejściową w następnym etapie łańcucha promes będzie undefined, a nie wynik
ponownie wysłanego zapytania. Jest to subtelny, niełatwy do wykrycia błąd.

13.2.5. Promesy równoległe


Sporo miejsca poświęciłem sekwencyjnemu realizowaniu promes w większej asynchronicznej
operacji. Czasami jednak pojawia się potrzeba równoległego realizowania kilku promes. Można
to osiągnąć za pomocą metody Promise.all(), której argumentem jest tablica promes, a
zwracanym wynikiem pojedyncza promesa. W przypadku odrzucenia przynajmniej jednej
promesy wejściowej odrzucona zostanie również zwrócona promesa. Jeżeli natomiast zostaną
spełnione wszystkie wejściowe promesy, to zostanie spełniona również promesa wyjściowa. Na
przykład aby pobrać tekstowe treści z kilku adresów URL jednocześnie, można użyć
następującego kodu:
// Utworzenie tablicy adresów URL.
const urls = [ /* Zero lub kilka adresów. */ ];
// Przekształcenie tablicy adresów w tablicę promes.

promises = urls.map(url => fetch(url).then(r => r.text()));


// Równoległa realizacja wszystkich promes i uzyskanie jednej promesy
wynikowej.
Promise.all(promises)
.then(bodies => { /* Operacje wykonywane na tablicy ciągów znaków. */ })

.catch(e => console.error(e));


Metoda Promise.all() jest w rzeczywistości bardziej elastyczna niż w powyższym przykładzie,
ponieważ tablica w argumencie może zawierać wartości inne niż promesy. W takim wypadku
element tablicy jest traktowany jako wynik spełnionej promesy i kopiowany bez zmian do
tablicy wynikowej.
Promesa zwrócona przez metodę Promise.all() jest odrzucana, gdy zostanie odrzucona jedna
z promes wejściowych. Odbywa się to natychmiast po odrzuceniu pierwszej promesy, nawet
jeżeli pozostałe są wciąż w trakcie realizacji. W wersji języka ES2020 została wprowadzona
metoda Promise.allSettled(), której argumentem jest tablica promes, a wynikiem jedna
promesa, tak jak w przypadku metody Promise.all(). Jednak metoda Promise.allSettled()
nie odrzuca ani nie spełnia wynikowej promesy, dopóki nie zostaną rozstrzygnięte wszystkie
promesy wejściowe. Wynikową promesę można przekształcić w tablicę obiektów
odpowiadających poszczególnym promesom wejściowym. Każdy taki obiekt posiada właściwość
status o wartości "fulfilled" (promesa spełniona) lub "rejected" (odrzucona). W pierwszym
przypadku obiekt posiada również właściwość value zawierającą wartość spełniającą promesę.
Jeżeli właściwość status ma wartość "rejected", obiekt ma również właściwość reason
zawierającą informacje o błędzie lub wartość odrzuconej promesy. Ilustruje to poniższy kod:
Promise.allSettled([Promise.resolve(1), Promise.reject(2), 3]).then(results
=> {
results[0] // => { status: "fulfilled", value: 1 }

results[1] // => { status: "rejected", reason: 2 }


results[2] // => { status: "fulfilled", value: 3 }
});
Czasami trzeba uruchamiać kilka promes jednocześnie, przy czym ważna jest tylko ta, która
zostanie spełniona jako pierwsza. W takim wypadku należy użyć metody Promise.race()
zamiast Promise.all(). Metoda ta zwraca promesę, która jest spełniana lub odrzucana, gdy
tylko zostanie spełniona lub odrzucona jedna z promes w tablicy wejściowej. Jeżeli tablica
zawiera wartości inne niż promesy, to metoda zwraca pierwszą z nich.

13.2.6. Tworzenie promes


W wielu opisanych wyżej przykładach była wykorzystywana metoda fetch(). Jest to
najprostsza, wbudowana w przeglądarkę funkcja zwracająca promesę. Przykłady opierały się
też na hipotetycznych funkcjach getJSON() i wait(), które również zwracały promesy. Tego
rodzaju funkcje są bardzo przydatne i w tym rozdziale dowiesz się, jak tworzy się własny
interfejs API oparty na promesach. W szczególności poznasz implementacje funkcji getJSON() i
wait().

Promesy oparte na innych promesach


Funkcję zwracającą promesę łatwo się tworzy, jeżeli jest dostępna inna tego rodzaju funkcja.
Na podstawie istniejącej promesy można utworzyć nową, wywołując metodę then().
Przyjmując więc jako punkt wyjścia metodę fetch(), można funkcję getJSON() zakodować w
następujący sposób:
function getJSON(url) {

return fetch(url).then(response => response.json());


}
Jest to trywialny kod, ponieważ zwracany obiekt ma predefiniowaną metodę json(). Metoda ta
zwraca promesę, którą z kolei zwraca nasza funkcja zwrotna (jest nią funkcja strzałkowa
składająca się z jednego wyrażenia, więc wynik jest zwracany niejawnie). Zatem promesa
zwracana przez funkcję getJSON() jest zdeterminowana przez promesę zwracaną przez metodę
response.json(). Jeżeli jej promesa zostanie spełniona, spełniona będzie również, z tą samą
wartością, promesa zwrócona przez funkcję getJSON(). Zwróć uwagę, że metoda ta nie ma
zaimplementowanej obsługi błędów. Nie jest sprawdzana wartość właściwości response.ok ani
zawartość nagłówka Content-Type. Zamiast tego pozwalamy metodzie json()odrzucić
promesę z wartością SyntaxError, jeżeli treść odpowiedzi nie będzie zapisana w formacie
JSON.

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.

Promesy w operacjach synchronicznych


Czasami w kodzie trzeba użyć istniejącego, opartego na promesach interfejsu API, choć wyniku
nie trzeba wyliczać asynchronicznie. W takich sytuacjach można użyć statycznych metod
Promise.resolve() i Promise.reject(). Pierwsza z nich ma jeden argument i zwraca
promesę, która jest natychmiast, choć asynchronicznie, spełniana przy użyciu zadanej wartości.
Metoda Promise.reject() też ma jeden argument, ale zwraca promesę odrzuconą z powodu
zadanej wartości. Mówiąc ściślej, promesy zwracane przez obie statyczne metody nie są
spełniane ani odrzucane zawczasu, tylko natychmiast po wykonaniu synchronicznego
fragmentu kodu, w którym te metody zostały użyte. Zazwyczaj dzieje się to po upływie kilku
milisekund, chyba że na wykonanie czeka wiele innych asynchronicznych zadań.
Jak pamiętasz z punktu 13.2.3, promesa zdeterminowana nie jest tym samym co promesa
spełniona. W argumencie metody Promise.resolve() zazwyczaj umieszcza się wartość, która
zostanie użyta do spełnienia nowo utworzonej promesy. Jednak metoda nie nazywa się
Promise.fulfill(). Jeżeli w jej argumencie umieści się promesę p1, metoda zwróci promesę
p2, która zostanie natychmiast zdeterminowana, ale zostanie spełniona lub odrzucona dopiero
wtedy, gdy zostanie spełniona lub odrzucona promesa p1.
Można również, choć jest to nietypowe rozwiązanie, utworzyć funkcję, która synchronicznie
zwraca wynik uzyskany asynchronicznie za pomocą metody Promise.resolve(). Bardzo często
za to funkcje asynchroniczne muszą obsługiwać specjalne, synchroniczne przypadki, w których
niezbędne są metody Promise.resolve() i Promise.reject(). W szczególności, gdy przed
rozpoczęciem operacji asynchronicznej pojawi się problem, na przykład błędna wartość
argumentu, można tego typu przypadek sygnalizować, zwracając promesę utworzoną za
pomocą metody Promise.reject(). (W takim wypadku można również zgłosić błąd
synchronicznie, ale byłoby to złe rozwiązanie, ponieważ kod wywołujący funkcję musiałby
obsługiwać błędy za pomocą synchronicznej instrukcji catch i asynchronicznej metody
catch()). Oprócz tego metoda Promise.resolve() przydaje się do tworzenia promesy w
łańcuchu promes. Za chwilę poznasz kilka przykładów takiego użycia powyższej metody.

Tworzenie promes od podstaw


W funkcjach getJSON() i getHighScore() do uzyskania początkowej promesy była
wykorzystywana istniejąca funkcja. Następnie poprzez wywołanie metody then() tej promesy
była tworzona i zwracana nowa promesa. Jak jednak napisać funkcję zwracającą promesę, jeżeli
nie można użyć innej funkcji zwracającej promesę? W takim wypadku należy użyć konstruktora
Promise(). Nad tak utworzonym obiektem promesy uzyskuje się pełną kontrolę. Cały proces
wygląda tak: wywołując konstruktor Promise(), należy w jego jedynym argumencie umieścić
funkcję. Funkcja ta musi mieć dwa argumenty, których nazwy, zgodnie z przyjętą konwencją,
powinny brzmieć resolve i reject. Konstruktor umieszcza w tych argumentach inne funkcje,
wywołuje synchronicznie funkcję umieszczoną w swoim argumencie i zwraca nowo utworzoną
promesę. Nad tą promesą ma pełną kontrolę funkcja umieszczona w argumencie konstruktora.
Funkcja musi wykonać pewne asynchroniczne operacje, a następnie wywołać funkcję
umieszczoną w argumencie resolve lub reject, odpowiednio, w celu spełnienia lub odrzucenia
zwracanej promesy. Funkcja ta nie musi być asynchroniczna, tj. może synchronicznie
wywoływać funkcje podane w jej argumentach. Jednak promesa zostanie asynchronicznie
zdeterminowana, spełniona lub odrzucona, jeżeli zrobi to kod.
Na podstawie powyższego opisu może być trudno zrozumieć działanie funkcji umieszczonych w
argumentach funkcji umieszczonej w argumencie konstruktora, dlatego poniżej jest
przedstawiony przykład, który pomoże to wyjaśnić. Jest to oparta na promesie funkcja wait(),
która pojawiała się wcześniej w różnych przykładach w tym rozdziale.
function wait(duration) {
// Utworzenie i zwrócenie nowej promesy.
return new Promise((resolve, reject) => { // Argumenty kontrolujące
promesę.

// Jeżeli argument jest błędny, promesa jest odrzucana.


if (duration < 0) {
reject(new Error("Podróże w czasie są na razie niemożliwe"));
}

// W przeciwnym wypadku czekamy asynchronicznie, a następnie


determinujemy promesę.
// Metoda setTimeout wywoła metodę resolve() bez argumentów, co oznacza,
// że promesa będzie spełniona z niezdefiniowaną wartością.
setTimeout(resolve, duration);

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

return new Promise((resolve, reject) => {


// Wysłanie zapytania HTTP GET na zadany adres URL.
request = http.get(url, response => { // Funkcja wywoływana z chwilą
nadejścia odpowiedzi.
// Odrzucenie promesy, jeżeli stan HTTP jest niewłaściwy.

if (response.statusCode !== 200) {


reject(new Error(`HTTP status ${response.statusCode}`));
response.resume(); // Zapobieżenie wyciekowi pamięci.
}
// Odrzucenie promesy, jeżeli nagłówek odpowiedzi jest niewłaściwy.
else if (response.headers["content-type"] !== "application/json") {

reject(new Error("Niewłaściwy nagłówek content-type"));


response.resume(); // Zapobieżenie wyciekowi pamięci.
}
else {

// Jeżeli wszystko jest dobrze, rejestrujemy zdarzenie inicjujące


odczytanie treści odpowiedzi.
let body = "";
response.setEncoding("utf-8");
response.on("data", chunk => { body += chunk; });

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,

// na przykład z powodu niedostępności sieci.


request.on("error", error => {
reject(error);
});

});
}

13.2.7. Promesy sekwencyjne


Za pomocą metody Promise.all() można łatwo uruchamiać kilka promes równolegle. Z kolei
tworząc łańcuch promes, w prosty sposób koduje się sekwencję określonej liczby promes.
Natomiast uruchamianie sekwencji złożonej z nieznanej zawczasu liczby promes jest już nieco
trudniejsze. Załóżmy, że mamy tablicę adresów URL stron, które trzeba pobrać. Aby nie
przeciążyć sieci, trzeba zapytania wysłać pojedynczo. Nie znając z góry liczby ani zawartości
elementów tablicy, nie sposób zakodować łańcucha promes. Trzeba go utworzyć dynamicznie w
następujący sposób:
function fetchSequentially(urls) {
// Odbierane odpowiedzi będą zapisane w tej zmiennej.

const bodies = [];


// Funkcja pobierająca treść jednej strony i zwracająca promesę.
function fetchOne(url) {
return fetch(url)
.then(response => response.text())

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

// Przetworzenie w pętli adresów URL i utworzenie łańcucha promes o


odpowiedniej długości.
// Każda promesa w łańcuchu obsługuje jedno zapytanie.
for(url of urls) {
p = p.then(() => fetchOne(url));

}
// 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,

// ponieważ chcemy, aby były eskalowane do kodu wywołującego funkcję.


return p.then(() => bodies);
}
Za pomocą tak zdefiniowanej funkcji fetchSequentially() można napisać kod, który będzie
wysyłał pojedyncze zapytania HTTP. Jest on bardzo podobny do pokazanego wcześniej
przykładu demonstrującego równoległe wysyłanie zapytań za pomocą metody Promise.all().
fetchSequentially(urls)

.then(bodies => { /* Przetworzenie tablicy ciągów znaków. */ })


.catch(e => console.error(e));
Funkcja fetchSequentially() tworzy najpierw promesę, która będzie spełniona natychmiast
po jej zwróceniu. Funkcja tworzy długi łańcuch promes, a następnie zwraca ostatnią z nich.
Przypomina to ustawienie szeregu klocków domina i przewrócenie pierwszego.
Istnieje jeszcze inne, bardziej eleganckie rozwiązanie. Zamiast zawczasu tworzyć wszystkie
promesy, można użyć funkcji zwrotnej, która będzie tworzyła tylko jedną, następną w kolejności
promesę. W efekcie zamiast łańcucha promes będą tworzone promesy determinujące kolejne
promesy. Nie będzie to łańcuch podobny do szeregu klocków domina, tylko sekwencja
zagnieżdżonych promes podobna do lalek matrioszek. Tak utworzony kod może zwracać
pierwszą, zewnętrzną promesę, która zostanie ostatecznie spełniona lub odrzucona z tą samą
wartością co ostatnia promesa w sekwencji. Przedstawiona niżej funkcja promiseSequence()
jest napisana w sposób uniwersalny, aby można jej było używać nie tylko do wysyłania zapytań
HTTP. Na tym kończy się opis promes, ponieważ kod jest dość skomplikowany. Jeżeli jednak
uważnie przeczytałeś ten rozdział, na pewno zrozumiesz jego działanie. Zwróć szczególną
uwagę, że zagnieżdżona wewnętrzna funkcja pozornie wywołuje rekurencyjnie samą siebie.
Jednak ponieważ do tego celu jest wykorzystywana metoda then(), nie jest to typowa
rekurencja.
// Argumentami tej funkcji jest tablica i funkcja promiseMaker. Dla każdej
wartości x tablicy

// funkcja promiseMaker(x) musi zwracać promesę, którą spełnia wynikowa


wartość.
// Główna funkcja zwraca promesę, którą spełnia wynikowa tablica.
//
// Funkcja promiseSequence() nie tworzy od razu wszystkich promes i nie
uruchamia ich równolegle.
// Zamiast tego uruchamia promesy pojedynczo.
// Nie wywołuje funkcji promiseMaker() z kolejną wartością, dopóki nie
zostanie spełniona poprzednia promesa.
function promiseSequence(inputs, promiseMaker) {

// Utworzenie prywatnej kopii tablicy, którą będzie można modyfikować.


inputs = [...inputs];
// Funkcja, która będzie wykorzystywana jako funkcja zwrotna promesy.
// Jest to rekurencyjna magia, dzięki której wszystko będzie działać.
function handleNextInput(outputs) {

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.

let nextInput = inputs.shift(); // Pobranie następnej wartości


wejściowej.
return promiseMaker(nextInput) // Wyliczenie następnej wartości
wyjściowej.
// Utworzenie tablicy wynikowej z nową wartością.
.then(output => outputs.concat(output))
// "Rekurencyjne" wywołanie funkcji z nową, większą tablicą wynikową.
.then(handleNextInput);

}
}
// 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.

function fetchBody(url) { return fetch(url).then(r => r.text()); }


// Za pomocą tej funkcji są sekwencyjnie odczytywane adresy URL.
promiseSequence(urls, fetchBody)
.then(bodies => { /* Przetworzenie tablicy ciągów znaków. */ })
.catch(console.error);

13.3. Słowa kluczowe async i await


W wersji języka ES2017 pojawiły się dwa słowa kluczowe, async i await, wprowadzające
istotne zmiany w programowaniu asynchronicznym w języku JavaScript. Dzięki nim korzystanie
z promes jest znacznie prostsze, a asynchroniczny, oparty na promesach kod wygląda jak kod
synchroniczny, wstrzymujący działanie na czas oczekiwania na odpowiedź sieciową lub inne
zdarzenie. Do korzystania ze słów async i await wciąż jest niezbędna znajomość zasad
działania promes, ale znika cała związana z nimi złożoność, a nawet przestaje być potrzebna ich
obecność.
Jak się dowiedziałeś wcześniej w tym rozdziale, kod asynchroniczny nie zwraca wyników ani nie
zgłasza wyjątków tak jak zwykły kod synchroniczny. Dlatego właśnie promesy są takie, jakie są.
Wartość spełnionej promesy można porównać do wyniku zwracanego przez synchroniczną
funkcję, a odrzuconej — do wyjątku zgłoszonego przez taką funkcję. Tę ostatnią analogię
potwierdza nazwa metody catch(). Stosując słowa kluczowe async i await, można ukryć
promesy użyte w gotowym, asynchronicznym kodzie, który dzięki temu stanie się równie prosty
i zrozumiały jak jego synchroniczny, blokujący się odpowiednik.

13.3.1. Słowo kluczowe await


Słowo kluczowe await przekształca promesę z powrotem w zwracaną wartość lub zgłaszany
wyjątek. Wyrażenie await p, gdzie p jest obiektem promesy, oczekuje, aż promesa zostanie
rozstrzygnięta. Jeżeli zostanie spełniona, wyrażenie await p zwraca wartość spełniającą
promesę. Jeżeli natomiast promesa zostanie odrzucona, wyrażenie zgłasza wyjątek z wartością,
która spowodowała odrzucenie promesy. Zazwyczaj nie stosuje się słowa await ze zmienną
zawierającą promesę, tylko z funkcją, która ją zwraca:
let response = await fetch("/api/user/profile");
let profile = await response.json();
Bardzo ważna jest świadomość, że słowo kluczowe await nie wstrzymuje działania kodu. W
rzeczywistości nie robi absolutnie nic, dopóki promesa nie zostanie rozstrzygnięta. Kod jest
dalej asynchroniczny, ale słowo await maskuje ten fakt. Oznacza to, że każdy kod, w którym
jest użyte słowo await, jest z definicji asynchroniczny.

13.3.2. Funkcje asynchroniczne


Ponieważ każdy kod, w którym użyte jest słowo kluczowe await, jest asynchroniczny, należy
pamiętać o podstawowej zasadzie, że słowo to można stosować wyłącznie w funkcjach
zadeklarowanych z użyciem słowa async. Poniżej znajduje się odmiana pokazanej wcześniej
funkcji getHighScore(). Wykorzystane są w niej słowa kluczowe await i async:
async function getHighScore() {
let response = await fetch("/api/user/profile");
let profile = await response.json();
return profile.highScore;
}
Zadeklarowanie funkcji jako asynchronicznej oznacza, że zwracanym przez nią wynikiem jest
promesa, nawet jeżeli w jej kodzie promesy nie są stosowane. Jeżeli funkcja zakończy działanie
normalnie, to jej rzeczywistym wynikiem będzie promesa zdeterminowana wartością użytą z
instrukcją return. Jeżeli natomiast funkcja zgłosi wyjątek, wynikiem będzie promesa odrzucona
z powodu zgłoszonego wyjątku.

Funkcja getHighScore() jest zadeklarowana jako asynchroniczna, więc zwraca promesę.


Można więc tej funkcji użyć ze słowem kluczowym await:
displayHighScore(await getHighScore());
Pamiętaj jednak, że powyższy wiersz można umieścić wyłącznie w innej funkcji
asynchronicznej. Wyrażenia ze słowem await można dowolnie głęboko zagnieżdżać w funkcjach
asynchronicznych. Jednak w kodzie najwyższego poziomu[2] lub w kodzie funkcji, która z
jakiegoś powodu nie jest asynchroniczna, nie można stosować słowa await. Zamiast niego
należy obsługiwać zwracaną promesę w zwykły sposób:
getHighScore().then(displayHighScore).catch(console.error);
Słowo async można stosować z dowolnymi funkcjami, zarówno zadeklarowanymi za pomocą
słowa function, jak i w formie wyrażeń. Mogą to być funkcje strzałkowe, jak również metody
zadeklarowane przy użyciu skróconej składni w klasach i literałach obiektowych. (Więcej
informacji na temat różnych sposobów kodowania funkcji zawiera rozdział 8.).

13.3.3. Oczekiwanie na kilka promes


Załóżmy, że mamy następującą funkcję getJSON() zadeklarowaną z użyciem słowa async:
async function getJSON(url) {
let response = await fetch(url);
let body = await response.json();
return body;
}

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

13.3.4. Szczegóły implementacji


W zrozumieniu działania funkcji asynchronicznych może pomóc opis tego, co się dzieje w tle
programu.
Załóżmy, że mamy następującą funkcję asynchroniczną:
async function f(x) { /* Ciało funkcji. */ }
Można sobie wyobrazić, że jest to funkcja opakowująca inną funkcję zwracającą promesę:
function f(x) {
return new Promise(function(resolve, reject) {
try {
resolve((function(x) { /* Ciało funkcji. */ })(x));
}

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.

13.4. Iteracje asynchroniczne


Ten rozdział rozpoczął się od opisu asynchroniczności opartej na funkcji zwrotnych i
zdarzeniach. W części poświęconej promesom wspomniałem, że są one przeznaczone do
wykonywania jednorazowych operacji asynchronicznych i nie należy ich stosować ze źródłami
serii zdarzeń, takich jak funkcja setInterval(), kliknięcia w przeglądarce lub strumień danych
w środowisku Node. Ponieważ promes nie można używać do obsługiwania sekwencji
asynchronicznych zdarzeń, nie można również do powyższych celów stosować funkcji ze
słowami async i await.
Wraz z wersją języka ES2018 pojawiło się rozwiązanie tego problemu w postaci iteratorów
asynchronicznych. Są one podobne do iteratorów synchronicznych, ale opierają swoje działanie
na promesach i stosuje się je z nową odmianą pętli for/of, czyli z pętlą for/await.

13.4.1. Pętla for/await


W wersji środowiska Node 12 strumienie danych można odczytywać za pomocą iteratorów
asynchronicznych. Oznacza to, że kolejne porcje danych odbieranych ze strumienia można
przetwarzać za pomocą następującej pętli for/await:
const fs = require("fs");
async function parseFile(filename) {
let stream = fs.createReadStream(filename, { encoding: "utf-8"});
for await (let chunk of stream) {
parseChunk(chunk); // Przyjęte jest założenie, że funkcja
parseChunk() jest zdefiniowana.
}
}
Pętla for/await, tak jak zwykłe wyrażenie await, jest oparta na promesie. Ogólnie mówiąc,
asynchroniczny iterator tworzy promesę, pętla for/await czeka, aż promesa zostanie
spełniona, następnie spełniającą ją wartość przypisuje do zmiennej i wykonuje kod. Potem
proces się powtarza: iterator tworzy następną promesę i czeka na jej spełnienie.
Załóżmy, że mamy następującą tablicę adresów URL:
const urls = [url1, url2, url3];
Aby utworzyć tablicę promes, można dla każdego adresu wywołać metodę fetch() w
następujący sposób:

const promises = urls.map(url => fetch(url));


Wcześniej w tym rozdziale dowiedziałeś się, że metoda Promise.all() czeka, aż zostaną
spełnione wszystkie promesy umieszczone w tablicy. Załóżmy, że potrzebny jest wynik
pierwszego zrealizowanego zapytania, a odpowiedzi na pozostałe zapytania nie są ważne.
(Oczywiście pierwsze zapytanie może być odsługiwane dłużej niż pozostałe, więc opisywane
rozwiązanie nie zawsze jest szybsze niż metoda Promise.all()). Tablica jest iterowalna, więc
tablicę promes można iterować za pomocą zwykłej pętli for/of:
for(const promise of promises) {
response = await promise;
handle(response);
}

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.

Ważne jest, że w powyższym przykładzie w pętli for/await jest wykorzystywany zwykły


iterator. Sytuacja robi się ciekawsza, jeżeli użyje się iteratora asynchronicznego.

13.4.2. Iteratory asynchroniczne


Przypomnijmy sobie niektóre pojęcia z rozdziału 12. Obiekt iterowalny to taki, który można
stosować w pętli for/of. Zawiera on metodę o symbolicznej nazwie [Symbol.iterator]().
Metoda ta zwraca obiekt iteratora. Ten obiekt z kolei posiada metodę next(), wywoływaną
wielokrotnie w celu uzyskania wartości z obiektu iterowalnego. Metoda ta zwraca wynik
iteracji. Obiekt ten posiada właściwość value i ewentualnie done.
Iterator asynchroniczny jest bardzo podobny do zwykłego iteratora, choć różni się od niego
dwiema ważnymi cechami. Po pierwsze, asynchroniczny obiekt iterowalny implementuje
metodę o symbolicznej nazwie [Symbol.asyncIterator](), a nie [Symbol.iterator](). Jak
już wiesz, pętla for/await jest kompatybilna ze zwykłym obiektem iterowalnym, ale preferuje
obiekty asynchroniczne i w pierwszej kolejności usiłuje wywołać metodę
[Symbol.asyncIterator](), a dopiero potem [Symbol.iterator](). Po drugie, metoda
next() iteratora asynchronicznego zwraca promesę, którą determinuje wynik iteracji, a nie
bezpośrednio obiekt będący wynikiem iteracji.

W poprzednim punkcie, w którym pętla for/await była użyta ze zwykłą,


synchroniczną, iterowalną tablicą promes, wykorzystane były zwykłe wyniki iteracji
synchronicznej. Wynikowy obiekt posiadał właściwość value, której wartością była
promesa. Jednak właściwość done miała wartość synchroniczną. Prawdziwy iterator
asynchroniczny zwraca promesę jako wynik iteracji, natomiast właściwości value i
done są synchroniczne. Różnica jest subtelna: w przypadku iteratora
asynchronicznego decyzja o zakończeniu iteracji może być podjęta synchronicznie.
13.4.3. Generatory asynchroniczne
W rozdziale 12. dowiedziałeś się, że iterator najprościej można zaimplementować za pomocą
generatora. Ta sama zasada dotyczy iteratora asynchronicznego, który implementuje się za
pomocą funkcji generatora zadeklarowanej przy użyciu słowa kluczowego async. Generator
asynchroniczny ma cechy zarówno funkcji asynchronicznej, jak i zwykłego generatora, tj.
można z nim używać słowa kluczowego await — jak ze zwykłą funkcją asynchroniczną, oraz
yield — jak ze zwykłym generatorem. Jednak zwracane wartości są automatycznie
opakowywane w promesy. Składnia generatora jest złożeniem zapisów async function i
function *, czyli async function *. Poniższy kod pokazuje, jak za pomocą generatora
asynchronicznego i pętli for/await można zamiast funkcji zwrotnej setInterval()
uruchamiać kod w regularnych odstępach czasu.
// Oparta na promesie funkcja opakowująca funkcję setTimeout(). Można ją
stosować ze słowem await.
// Funkcja zwraca promesę, która jest spełniana po upływie zadanej liczby
milisekund.
function elapsedTime(ms) {

return new Promise(resolve => setTimeout(resolve, ms));


}
// Asynchroniczny generator zwiększający licznik i zwracający go
// zadaną (lub nieograniczoną) liczbę razy w zadanych odstępach czasu.
async function* clock(interval, max=Infinity) {
for(let count = 1; count <= max; count++) { // Zwykła pętla for.
await elapsedTime(interval); // Oczekiwanie przez zadany
czas.
yield count; // Zwrócenie licznika.

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

13.4.4. Implementacja iteratora asynchronicznego


Asynchroniczny iterator można zaimplementować nie tylko za pomocą asynchronicznego
generatora. Można to również zrobić, bezpośrednio definiując obiekt zawierający metodę
[Symbol.asyncIterator](). Metoda ta zwraca obiekt zawierający metodę next(). Ta metoda z
kolei zwraca promesę, którą determinuje obiekt będący wynikiem iteracji. W poniższym kodzie
ponownie jest zaimplementowana funkcja clock() z poprzedniego przykładu, ale tym razem
nie jest to generator. Zwracanym wynikiem jest po prostu asynchronicznie iterowalny obiekt.
Zauważ, że metoda next() nie zwraca jawnie promesy. Zamiast tego jest zadeklarowana jako
metoda asynchroniczna.
function clock(interval, max=Infinity) {

// Oparta na promesie odmiana funkcji setTimeout(). Można ją stosować ze


słowem await.
// Zwróć uwagę, że argumentem jest bezwzględny czas, a nie interwał.
function until(time) {
return new Promise(resolve => setTimeout(resolve, time - Date.now()));
}
// Zwrócenie asynchronicznie iterowalnego obiektu.
return {
startTime: Date.now(), // Zapamiętanie czasu uruchomienia.

count: 1, // Zapamiętanie numeru iteracji.


async next() { // Metoda next() tworzy iterator.
if (this.count > max) { // Czy to koniec?
return { done: true }; // Wynik oznaczający zakończenie
iteracji.
}
// Określenie momentu rozpoczęcia następnej iteracji.
let targetTime = this.startTime + this.count * interval;
// Oczekiwanie, aż ten moment nastąpi.
await until(targetTime);

// Zwrócenie w wynikowym obiekcie iteracji wartości zmiennej count.


return { value: this.count++ };
},
// Poniższa metoda sprawia, że obiekt iteratora jest iterowalny.
[Symbol.asyncIterator]() { return this; }
};
}
Powyższa wersja funkcji clock() jest pozbawiona wady pierwotnej wersji opartej na
generatorze. Zwróć uwagę, że wymaga ona określenia bezwzględnego czasu wykonania
kolejnej iteracji. Od uzyskanej wartości odejmowany jest bieżący czas i wyliczany interwał
umieszczany następnie w argumencie funkcji setTimeout(). Funkcja clock() użyta z pętlą
for/await dokładniej wykonuje iteracje w zadanych odstępach czasu, ponieważ uwzględnia
czas potrzebny na wykonanie kodu wewnątrz pętli. Ta zmiana poprawia nie tylko dokładność
odmierzania czasu. Pętla for/await zawsze przed rozpoczęciem kolejnej iteracji czeka na
spełnienie promesy zwróconej w poprzedniej iteracji. W przypadku użycia asynchronicznego
iteratora bez pętli for/await nic nie stoi na przeszkodzie, aby metodę next() wywoływać w
dowolnym momencie. Jeżeli w wersji funkcji clock() opartej na generatorze metoda next()
zostanie wywołana trzykrotnie, powstaną trzy promesy, które zostaną spełnione niemal w tym
samym momencie, co nie jest pożądanym efektem. W wersji opartej na iteratorze ten problem
nie istnieje.
Iterator asynchroniczny ma tę cenną cechę, że może reprezentować strumień asynchronicznych
zdarzeń lub danych. Opisana wyżej funkcja clock() jest bardzo prosta, ponieważ źródłem
asynchroniczności jest wywoływana przez nią samą funkcja setTimeout(). Jednak w przypadku
innych asynchronicznych źródeł, na przykład zgłaszanych zdarzeń, implementacja
asynchronicznego iteratora jest znacznie trudniejsza. Zazwyczaj jest jedna funkcja obsługująca
zdarzenia, natomiast metoda next() za każdym razem musi zwracać inną promesę. Ponadto
metoda może zostać wywołana wielokrotnie, zanim zostanie zdeterminowana pierwsza
promesa. Oznacza to, że wszystkie metody asynchronicznego iteratora muszą utrzymywać
wewnętrzną kolejkę promes i kolejno je determinować w miarę pojawiających się
asynchronicznych zdarzeń. Jeżeli opakuje się taką kolejkę promes w klasę AsyncQueue,
tworzenie asynchronicznych iteratorów za jej pomocą będzie znacznie łatwiejsze[3].
Przedstawiona niżej klasa AsyncQueue posiada metody enqueue() (umieść w kolejce) i
dequeue() (usuń z kolejki), jak przystało na klasę implementującą kolejkę. Metoda dequeue()
zwraca promesę, a nie wartość. Oznacza to, że metodę dequeue()można wywołać przed metodą
enqueue(). Klasa jest również asynchronicznym iteratorem, przeznaczonym do stosowania w
pętli for/await wykonującej jeden obieg przy każdorazowym dodaniu elementu do kolejki. Jest
jeszcze metoda close(), którą należy wywołać, gdy w kolejce nie będzie więcej umieszczanych
elementów. Po opróżnieniu zamkniętej kolejki pętla for/await kończy działanie.
Zwróć uwagę, że w kodzie klasy nie są użyte słowa async i await, za to są wykonywane
bezpośrednie operacje na promesach, przez co kod jest dość skomplikowany. Możesz go użyć
do sprawdzenia swojej wiedzy nabytej w tym długim rozdziale. Nawet jeżeli nie wszystko będzie
dla Ciebie zrozumiałe, rzuć okiem na następny, krótszy przykład, w którym jest
zaimplementowany prosty, ale bardzo ciekawy asynchroniczny iterator wykorzystujący klasę
AsyncQueue.
/**
* Asynchronicznie iterowalna klasa implementująca kolejkę.
* Metoda enqueue() umieszcza elementy w kolejce, a dequeue() usuwa je.
* Metoda dequeue() zwraca promesę, co oznacza, że element można usunąć przed
umieszczeniem go w kolejce.
* Klasa posiada metody [Symbol.asyncIterator]() i next(), więc można ją
stosować w pętli for/await.

* Pętla ta działa do chwili wywołania metody close().


*/
class AsyncQueue {
constructor() {
// W tej zmiennej są zapisywane elementy umieszczone w kolejce.
this.values = [];
// W tej tablicy są umieszczane wartości determinujące promesy,
// które zostały usunięte z kolejki przed dodaniem odpowiadających im
wartości.
this.resolvers = [];
// Po zamknięciu kolejki nie można umieszczać w niej nowych elementów.

// Nie są również zwracane niespełnione promesy.


this.closed = false;
}
enqueue(value) {
if (this.closed) {
throw new Error("Kolejka AsyncQueue zamknięta");
}
if (this.resolvers.length > 0) {
// Jeżeli wartość została obiecana, determinujemy promesę.

const resolve = this.resolvers.shift();


resolve(value);
}
else {
// W przeciwnym razie umieszczamy ją w kolejce.
this.values.push(value);
}
}
dequeue() {

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.

// Zaległe promesy determinujemy znacznikami końca strumienia.


while(this.resolvers.length > 0) {
this.resolvers.shift()(AsyncQueue.EOS);
}
this.closed = true;
}
// Definicja metody sprawiającej, że klasa jest asynchronicznie iterowalna.
[Symbol.asyncIterator]() { return this; }
// Definicja metody sprawiającej, że klasa jest asynchronicznym iteratorem.
Promesa zwracana przez
// metodę dequeue()
// jest determinowana wartością lub znacznikiem końca strumienia, jeżeli
kolejka jest zamknięta.
// Dlatego zwracana promesa musi być determinowana wynikiem iteracji.
next() {
return this.dequeue().then(value => (value === AsyncQueue.EOS)
? { value: undefined, done: true }
: { value: value, done: false });
}

}
// 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

// i dotyczących określonego dokumentu. Funkcja zwraca kolejkę będącą


strumieniem zdarzeń.
function eventStream(elt, type) {
const q = new AsyncQueue(); // Utworzenie kolejki.
elt.addEventListener(type, e=>q.enqueue(e)); // Umieszczenie zdarzeń w
kolejce.
return q;
}
async function handleKeys() {
// Pobranie strumienia zdarzeń keypress i przetworzenie ich za pomocą
pętli.
for await (const event of eventStream(document, "keypress")) {

console.log(event.key);
}
}

13.5. Podsumowanie
W tym rozdziale zostały opisane następujące tematy:

Większość praktycznych programów napisanych w języku JavaScript jest


asynchronicznych.
Tradycyjnie asynchroniczność realizuje się za pomocą zdarzeń i funkcji zwrotnych. Jest to
jednak dość skomplikowane podejście, ponieważ wymaga tworzenia wielokrotnie
zagnieżdżonych funkcji zwrotnych. Trudno jest też w spójny sposób obsługiwać błędy.
Promesy oferują nowy sposób strukturyzacji funkcji zwrotnych. Poprawnie użyte (niestety
łatwo można ich użyć niepoprawnie) pozwalają przekształcić asynchroniczny kod w
łańcuch metod then(), reprezentujących następujące po sobie asynchroniczne operacje.
Ponadto za pomocą promes można scentralizować obsługę błędów w metodzie call()
umieszczonej na końcu łańcucha metod then().
Dzięki słowom kluczowym async i await można tworzyć asynchroniczny kod, który
wykorzystuje promesy w tle, wygląda jak kod synchroniczny oraz jest bardziej czytelny i
zrozumiały. Funkcja zadeklarowana za pomocą słowa async niejawnie zwraca promesę.
Wewnątrz takiej funkcji można umieścić słowo await z promesą lub funkcją zwracającą
promesę. W efekcie wartość promesy jest wyliczana asynchronicznie.
Obiekty iterowalne asynchronicznie można wykorzystywać w pętli for/await. Tego
rodzaju obiekt tworzy się, implementując metodę [Symbol.asyncIterator]() lub
wywołując generator zadeklarowany za pomocą instrukcji async function *. Iterator
asynchroniczny stanowi alternatywę dla strumienia zdarzeń danych w środowisku Node.
Użyty w kodzie klienckim może również reprezentować strumień zdarzeń generowanych
podczas wprowadzania danych przez użytkownika.

[1] Klasa XMLHttpRequest nie ma nic wspólnego z formatem XML. W nowoczesnych


programach klienckich zamiast niej powszechnie używa się metody fetch(), która będzie
opisana w punkcie 15.11.1. Pokazany tu kod jest ostatnim w tej książce przykładem użycia
klasy XMLHttpRequest.

[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:

kontrolowanie możliwości wyliczania, usuwania i konfigurowania właściwości obiektów,


kontrolowanie możliwości rozszerzania obiektów, tworzenie obiektów zapieczętowanych
i zamrożonych,
odczytywanie i konfigurowanie prototypów obiektów,
szczegółowe konfigurowanie funkcjonowania klas za pomocą popularnych symboli,
tworzenie języków DSL za pomocą funkcji znacznikowych,
testowanie obiektów za pomocą metod obiektu Reflect,
kontrolowanie działania obiektów za pomocą klasy Proxy.

14.1. Atrybuty właściwości


Właściwości obiektu w języku JavaScript mają nie tylko nazwy i wartości, ale również trzy
atrybuty określające ich funkcjonowanie i operacje, które można na nich wykonywać:

atrybut writable (zapisywalna) określający, czy wartość właściwości można zmieniać;


atrybut enumerable (wyliczalna) określający, czy właściwość można wyliczać za pomocą
pętli for/in i metody Object.keys();
atrybut configurable (konfigurowalna) określający, czy właściwość można usunąć, jak
również czy można zmieniać jej atrybuty.

Właściwości zdefiniowane za pomocą literału obiektowego lub zwykłego przypisania wartości są


zapisywalne, wyliczalne i konfigurowalne. Jednak wiele innych właściwości zdefiniowanych w
standardowej bibliotece ma inne atrybuty.

W tym podrozdziale opisany jest interfejs API umożliwiający odczytywanie i ustawianie


atrybutów właściwości. Jest on szczególnie ważny dla twórców bibliotek, ponieważ:

za jego pomocą można dodawać metody do prototypów obiektów i tworzyć metody


niewyliczalne, takie jak metody wbudowane;
umożliwia on „zakleszczanie” obiektów, tj. blokowanie możliwości modyfikowania i
usuwanie ich właściwości.

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.

Metody odczytujące i ustawiające atrybuty właściwości wykorzystują obiekt zwany


deskryptorem właściwości, reprezentujący cztery atrybuty. Obiekt ten ma właściwości o
nazwach takich samych jak opisywane przez niego atrybuty właściwości. Zatem deskryptor
właściwości danych ma właściwości o nazwach value, writable, enumerable i configurable,
natomiast deskryptor właściwości dostępowej zamiast właściwości value i writable ma get i
set.

Aby uzyskać deskryptor określonej właściwości obiektu, należy wywołać metodę


Object.getOwnPropertyDescriptor():
// Wynik: {value: 1, writable:true, enumerable:true, configurable:true}

Object.getOwnPropertyDescriptor({x: 1}, "x");


// Obiekt zawierający właściwość dostępową, przeznaczoną tylko do odczytu.

const random = {

get octet() { return Math.floor(Math.random()*256); },


};

// Wynik: { get: /*func*/, set:undefined, enumerable:true, configurable:true}


Object.getOwnPropertyDescriptor(random, "octet");

// Metoda zwraca wynik undefined, jeżeli właściwość jest odziedziczona lub


nie istnieje.

Object.getOwnPropertyDescriptor({}, "x") // => undefined; nie ma


takiej właściwości.

Object.getOwnPropertyDescriptor({}, "toString") // => undefined; właściwość


odziedziczona.
Metoda Object.getOwnPropertyDescriptor(), jak sugeruje jej nazwa, operuje wyłącznie na
własnych właściwościach obiektu. Aby odczytać atrybuty jego odziedziczonej właściwości,
należy jawnie przetworzyć łańcuch prototypów. W tym celu można użyć podobnej do powyższej
i opisanej w podrozdziale 14.6 metody Reflect.getOwnPropertyDescriptor()lub
Object.getPrototypeOf(), opisanej w podrozdziale 14.3.
Aby ustawić atrybut lub utworzyć nową właściwość z określonymi atrybutami, należy wywołać
metodę Object.defineProperty(), umieszczając w jej argumentach nazwę właściwości, która
ma być utworzona lub zmieniona, oraz obiekt deskryptora:
let o = {}; // Początkowy obiekt bez właściwości.

// Dodanie wyliczalnej właściwości x o wartości 1.

Object.defineProperty(o, "x", {

value: 1,
writable: true,
enumerable: false,

configurable: true

});

// Sprawdzenie, że właściwość istnieje, ale jest niewyliczalna.


o.x // => 1

Object.keys(o) // => []

// Modyfikacja właściwości x tak, aby można ją było wyłącznie odczytywać.

Object.defineProperty(o, "x", { writable: false });

// Próba zmiany wartości właściwości.


o.x = 2; // Nieudana operacja bez komunikatu lub zgłoszenie wyjątku
TypeError w trybie ścisłym.

o.x // => 1

// Właściwość jest jednak konfigurowalna, więc można zmienić jej wartość:

Object.defineProperty(o, "x", { value: 2 });


o.x // => 2

// Przekształcenie właściwości danych we właściwość dostępową.

Object.defineProperty(o, "x", { get: function() { return 0; } });

o.x // => 0

Deskryptor właściwości umieszczany w argumencie metody Object.defineProperty() nie


musi definiować wszystkich czterech atrybutów. Atrybuty pominięte przy tworzeniu właściwości
przyjmują wartości false lub undefined. W przypadku modyfikacji właściwości pominięte
atrybuty po prostu pozostają bez zmian. Zwróć uwagę, że powyższa metoda zmienia istniejącą
lub tworzy nową właściwość obiektu, ale nie zmienia właściwości odziedziczonej. W
podrozdziale 14.6 będzie opisana bardzo podobna metoda Reflect.defineProperty().

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 },

y: { value: 1, writable: true, enumerable: true, configurable: true },

r: {

get() { return Math.sqrt(this.x*this.x + this.y*this.y); },


enumerable: true,

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.

Zarówno metoda Object.defineProperty(), jak i Object.defineProperties() zgłasza


wyjątek TypeError, jeżeli utworzenie lub zmodyfikowanie właściwości nie jest możliwe. Ma to
miejsce na przykład w obiektach nierozszerzalnych (patrz podrozdział 14.2). Inny przypadek
jest związany z samymi atrybutami. Na przykład atrybut writable określa, czy można zmieniać
atrybut value, natomiast configurable, czy można zmieniać inne atrybuty oraz czy można
usunąć daną właściwość. Powyższe zasady nie są jednak do końca przejrzyste. Jeżeli na
przykład właściwość nie jest zapisywalna, ale jest konfigurowalna, można zmieniać jej wartość.
Ponadto właściwość zapisywalną można przekształcać w niezapisywalną, nawet jeżeli jest ona
niekonfigurowalna. Poniżej opisane są wszystkie zasady, których złamanie powoduje, że metody
Object.defineProperty() i Object.defineProperties() zgłaszają wyjątek TypeError.

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ą).

W podrozdziale 6.7 została opisana funkcja Object.assign(), która kopiuje wartości


właściwości z jednego lub kilku obiektów źródłowych do obiektu docelowego. Funkcja ta
operuje jednak na wartościach właściwości wyliczalnych, a nie na ich atrybutach. Zazwyczaj
jest to pożądany efekt, ale jeżeli jeden lub kilka obiektów źródłowych posiada właściwości
dostępowe, to do obiektu docelowego nie są kopiowane gettery, tylko zwracane przez nie
wartości. Listing 14.1 pokazuje, jak za pomocą funkcji Object.getOwnPropertyDescriptor() i
Object.defineProperty() można utworzyć odmianę funkcji Object.assign(), która kopiuje
całe deskryptory właściwości, a nie tylko ich wartości.

Listing 14.1. Kopiowanie wartości i atrybutów właściwości pomiędzy obiektami

/*
* Definicja funkcji Object.assignDescriptors() działającej podobnie jak
Object.assign() z tą różnicą,

* że kopiuje z obiektów źródłowych do obiektu docelowego deskryptory


właściwości, a nie tylko ich wartości.

* Funkcja kopiuje wszystkie własne właściwości obiektu, zarówno wyliczalne,


jak i niewyliczalne. Ponieważ operuje
* na deskryptorach, kopiuje gettery z obiektów źródłowych i nadpisuje
settery w obiekcie docelowym, zamiast je

* wywoływać.

* Funkcja Object.assignDescriptors() eskaluje wyjątek zgłaszany przez


funkcję Object.defineProperty().

* Taki przypadek ma miejsce wtedy, gdy obiekt docelowy jest zapieczętowany


lub zamrożony

* albo jest modyfikowana jego niekonfigurowalna właściwość.

* Zwróć uwagę, że do obiektu Object jest dodawana właściwość


assignDescriptors,

* dzięki czemu nowa funkcja staje się niewyliczalną właściwością, podobnie


jak Object.assign().

*/
Object.defineProperty(Object, "assignDescriptors", {

// Ustawienie takich samych atrybutów jak w funkcji Object.assign().

writable: true,

enumerable: false,

configurable: true,

// Funkcja będąca wartością właściwości assignDescriptors.

value: function(target, ...sources) {

for(let source of sources) {

for(let name of Object.getOwnPropertyNames(source)) {


let desc = Object.getOwnPropertyDescriptor(source, name);

Object.defineProperty(target, name, desc);

for(let symbol of Object.getOwnPropertySymbols(source)) {

let desc = Object.getOwnPropertyDescriptor(source, symbol);

Object.defineProperty(target, symbol, desc);

return target;
}

});

let o = {c: 1, get count() {return this.c++;}}; // Zdefiniowanie obiektu z


getterem.
let p = Object.assign({}, o); // Skopiowanie wartości
właściwości.
let q = Object.assignDescriptors({}, o); // Skopiowanie deskryptorów
właściwości.

p.count // => 1: Jest to właściwość danych, więc

p.count // => 1: właściwość count nie zmienia się.

q.count // => 2: Zmienia się ona przy pierwszym kopiowaniu obiektu,


q.count // => 3: jak również przy kopiowaniu getterów i setterów.

14.2. Rozszerzalność obiektów


Atrybut extensible (rozszerzalny) określa, czy do obiektu można dodawać właściwości. Zwykłe
obiekty są domyślnie rozszerzalne, ale można to zmienić za pomocą opisanej w tym
podrozdziale funkcji.

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).

Zwróć uwagę, że po przekształceniu obiektu rozszerzalnego w nierozszerzalny nie można go


z powrotem zamienić w rozszerzalny. Dodatkowo funkcja Object.preventExtensions()
obejmuje swoim działaniem sam obiekt. Oznacza to, że w przypadku dodania właściwości do
prototypu takiego obiektu, zostaną one przez ten obiekt odziedziczone, mimo że jest on
nierozszerzalny.

Wspomniane wyżej funkcje Reflect.isExtensible() i Reflect.preventExtensions() będą


opisane w podrozdziale 14.6.

Atrybut extensible służy do „zakleszczania” obiektu w określonym stanie i zabezpieczania go


przed próbami wprowadzania zmian z zewnątrz. Atrybut ten jest często stosowany razem z
atrybutami configurable i writable właściwości. Dostępne są dwie funkcje modyfikujące
powyższe atrybuty:

Funkcja Object.seal() (zapieczętuj), która działa podobnie jak


Object.preventExtensions(), ale oprócz tego, że przekształca obiekt w nierozszerzalny,
zamienia wszystkie jego właściwości na niekonfigurowalne. Oznacza to, że po jej użyciu
nie można do obiektu dodawać nowych właściwości, a istniejących nie można zmieniać ani
usuwać. Można jednak przypisywać wartości istniejącym, zapisywalnym właściwościom.
Zapieczętowanego obiektu nie można odpieczętować. Aby sprawdzić, czy obiekt jest
zapieczętowany, należy użyć funkcji Object.isSealed().
Funkcja Object.freeze() (zamroź), która jeszcze silniej zakleszcza obiekt. Oprócz tego,
że w wyniku jej użycia obiekt staje się nierozszerzalny, a jego właściwości
niekonfigurowalne, wszystkie jego właściwości danych są przestawiane w tryb tylko do
odczytu. Właściwości dostępowe nie są zmieniane, a metody get można wywoływać
poprzez zwykłe przypisanie wartości. Za pomocą funkcji Object.isFrozen() można
sprawdzić, czy obiekt jest zamrożony.

Ważne jest, że funkcje Object.seal() i Object.freeze() modyfikują jedynie obiekt


umieszczony w argumencie, tj. nie zmieniają jego prototypu. Aby skutecznie zablokować obiekt,
należy zapieczętować lub zamrozić również obiekty w łańcuchu prototypów.

Każda z funkcji, Object.preventExtensions(), Object.seal() i Object.freeze(), zwraca


obiekt podany w jej argumencie. Oznacza to, że funkcje te można zagnieżdżać jak niżej:

// Utworzenie zapieczętowanego obiektu z zamrożonym prototypem i


niewyliczalnymi właściwościami.

let o = Object.seal(Object.create(Object.freeze({x: 1}),

{y: {value: 2, writable: true}}));

Tworząc bibliotekę obiektów, które będą umieszczane w argumentach funkcji zwrotnych


tworzonych przez użytkowników, warto za pomocą funkcji Object.freeze() zabezpieczać te
obiekty przed modyfikacjami. Jest to proste i wygodne rozwiązanie, wywołujące jednak efekty
uboczne. Na przykład zamrożone obiekty mogą zakłócać typowe strategie testowe.

14.3. Atrybut prototype


Atrybut prototype określa prototyp, po którym obiekt dziedziczy właściwości (więcej informacji
o prototypach i dziedziczeniu właściwości zostało zawartych w punktach 6.2.3 i 6.3.2). Jest to
tak ważny atrybut, że zazwyczaj nazywa się go „prototypem obiektu o”, a nie „atrybutem
prototype obiektu o”. Pamiętaj, że słowo prototype użyte w kodzie oznacza zwykłą właściwość
obiektu, a nie atrybut. W rozdziale 9. wyjaśniłem, że właściwość prototype konstruktora
określa atrybut prototype tworzonych obiektów.
Atrybut prototype jest określany w momencie tworzenia obiektu. Jeżeli obiekt jest tworzony za
pomocą literału, jego prototypem staje się właściwość Object.prototype. Natomiast obiekt
tworzony za pomocą instrukcji new wykorzystuje wartość właściwości prototype konstruktora.
Z kolei prototypem obiektu tworzonego za pomocą funkcji Object.create() jest jej pierwszy
argument (który może mieć wartość null).

Prototyp obiektu można sprawdzić, umieszczając go w argumencie funkcji


Object.getPrototypeOf():
Object.getPrototypeOf({}) // => Object.prototype

Object.getPrototypeOf([]) // => Array.prototype


Object.getPrototypeOf(()=>{}) // => Function.prototype

W podrozdziale 14.6 będzie opisana bardzo podobna do powyższej funkcja


Reflect.getPrototypeOf().
Aby sprawdzić, czy dany obiekt jest prototypem innego obiektu lub jest częścią łańcucha
prototypów, należy użyć funkcji isPrototypeOf():
let p = {x: 1}; // Zdefiniowanie prototypu.

let o = Object.create(p); // Utworzenie obiektu na podstawie


prototypu.
p.isPrototypeOf(o) // => true: obiekt o dziedziczy właściwości
prototypu p.
Object.prototype.isPrototypeOf(p) // => true: prototyp p dziedziczy
właściwości obiektu Object.prototype.

Object.prototype.isPrototypeOf(o) // => true: podobnie jest zbudowany obiekt


o.
Zwróć uwagę, że funkcja isPrototypeOf() działa podobnie jak operator instanceof (patrz
punkt 4.9.4).
Atrybut prototype jest ustawiany podczas tworzenia obiektu i zazwyczaj nie jest później
zmieniany. Można to jednak zrobić za pomocą funkcji Object.setPrototypeOf():
let o = {x: 1};
let p = {y: 2};

Object.setPrototypeOf(o, p); // Ustawienie prototypu p obiektu o.


o.y // => 2: teraz obiekt o dziedziczy właściwość y.

let a = [1, 2, 3];


Object.setPrototypeOf(a, p); // Ustawienie prototypu p tablicy a.

a.join // => undefined: tablica a nie ma już metody join().


Zazwyczaj nie ma potrzeby korzystania z funkcji Object.setPrototypeOf(). Interpreter języka
JavaScript może stosować zaawansowane mechanizmy optymalizacyjne opierające się na
założeniu, że raz ustalone prototypy obiektów pozostają niezmienne. Oznacza to, że po
wywołaniu powyższej funkcji kod wykorzystujący zmienione obiekty może działać znacznie
wolniej niż normalnie.

W podrozdziale 14.6 będzie opisana podobna do powyższej funkcja


Reflect.setPrototypeOf().
Niektóre starsze interpretery języka JavaScript udostępniały atrybut prototype w postaci
właściwości __proto__ (z podwójnymi znakami podkreślenia na początku i końcu słowa). Choć
od dawna właściwość ta jest uznawana za przestarzałą, od jej dostępności jest uzależnionych
tak wiele kodów stosowanych w Internecie, że standard ECMAScript wymaga, aby obsługiwały
ją wszystkie przeglądarki. Środowisko Node też obsługuje tę właściwość, mimo że w tym
przypadku standard tego nie narzuca. W nowoczesnych implementacjach języka JavaScript
właściwość __proto__ jest odczytywalna oraz zapisywalna i można ją, choć nie jest to zalecane,
traktować jako alternatywę dla funkcji Object.getPrototypeOf() i Object.setPrototypeOf().
Jednym z ciekawych zastosowań tej właściwości jest definiowanie prototypu w literale
obiektowym:
let p = {z: 3};

let o = {
x: 1,
y: 2,

__proto__: p
};

o.z // => 3: obiekt o dziedziczy właściwości prototypu p.

14.4. Popularne symbole


Typ Symbol został wprowadzony w wersji języka ES6. Jednym z najważniejszych powodów tego
posunięcia było otwarcie możliwości bezpiecznego rozszerzania języka bez naruszania jego
kompatybilności z istniejącym kodem stosowanym w Internecie. Przykład użycia typu Symbol
poznałeś w rozdziale 12., gdzie metoda o nazwie [Symbol.iterator]() została zastosowana do
utworzenia iterowalnej klasy.

Nazwa Symbol.iterator jest najbardziej znanym symbolem. Funkcja fabryczna Symbol()


zawiera zestaw symboli zapisanych w postaci właściwości. Symbole te są stosowane do
niskopoziomowego sterowania działaniem obiektów i klas. W kolejnych punktach są opisane
popularne symbole i sposoby korzystania z nich.

14.4.1. Symbol.iterator i Symbol.asyncIterator


Symbole Symbol.iterator i Symbol.asyncIterator służą, odpowiednio, do tworzenia
iterowalnych w zwykły sposób i iterowalnych asynchronicznie obiektów i klas. Iteratory te
zostały szczegółowo opisane w rozdziale 12. i punkcie 13.4.2. Tutaj wspominam o nich jedynie
dla zachowania kompletności opisu.

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) {

return Number.isInteger(x) && x >= 0 && x <= 255;


}

};
128 instanceof uint8 // => true

256 instanceof uint8 // => false: liczba jest za duża.


Math.PI instanceof uint8 // => false: to nie jest liczba całkowita.
Zwróć uwagę, że jest to ciekawy, ale wprowadzający zamieszanie przykład, ponieważ w
miejscu, gdzie powinna być użyta klasa, znajduje się obiekt. Równie łatwo można napisać
funkcję isUint8(), dzięki której kod byłby bardziej zrozumiały i nie wykorzystywałby symbolu
Symbol.hasInstance.

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]"

Natomiast wywołując funkcję Object.prototype.toString() jako metodę instancji


wbudowanego typu, można otrzymać ciekawe wyniki:
Object.prototype.toString.call([]) // => "[object Array]"

Object.prototype.toString.call(/./) // => "[object RegExp]"


Object.prototype.toString.call(()=>{}) // => "[object Function]"

Object.prototype.toString.call("") // => "[object String]"


Object.prototype.toString.call(0) // => "[object Number]"

Object.prototype.toString.call(false) // => "[object Boolean]"


Okazuje się, że za pomocą metody Object.prototype.toString().call() można uzyskać
niedostępną w inny sposób wartość „atrybutu klasy” obiektu zawierającego informację o typie.
Przedstawiona niżej funkcja classof() jest zdecydowanie bardziej użyteczna niż operator
typeof, który nie rozróżnia typów obiektów:
function classof(o) {

return Object.prototype.toString.call(o).slice(8,-1);
}

classof(null) // => "Null"


classof(undefined) // => "Undefined"

classof(1) // => "Number"


classof(10n**100n) // => "BigInt"

classof("") // => "String"


classof(false) // => "Boolean"
classof(Symbol()) // => "Symbol"

classof({}) // => "Object"


classof([]) // => "Array"

classof(/./) // => "RegExp"


classof(()=>{}) // => "Function"

classof(new Map()) // => "Map"


classof(new Set()) // => "Set"
classof(new Date()) // => "Date"

W wersjach języka starszych niż ES6 metoda Object.prototype.toString() działała w


powyższy, niestandardowy sposób tylko w odniesieniu do wbudowanych typów. Wywołując
funkcję classof() z instancją samodzielnie zdefiniowanej klasy, uzyskiwało się po prostu ciąg
"Object". Jednak począwszy od wersji ES6 metoda Object.prototype.toString() szuka w
umieszczonym w jej argumencie obiekcie właściwości o symbolicznej nazwie
Symbol.toStringTag i jeżeli ją znajdzie, zwraca jej wartość. Oznacza to, że definiując własną
klasę, można ją łatwo przystosować do użycia z funkcją taką jak classof():
class Range {

get [Symbol.toStringTag]() { return "Range"; }


// Pozostały kod klasy pominięty.
}

let r = new Range(1, 10);


Object.prototype.toString.call(r) // => "[object Range]"

classof(r) // => "Range"

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:

// Prosta klasa pochodna od Array, zawierająca metody get zwracające,


odpowiednio, pierwszy i ostatni
// element tablicy.

class EZArray extends Array {


get first() { return this[0]; }

get last() { return this[this.length-1]; }


}

let e = new EZArray(1,2,3);


let f = e.map(x => x * x);
e.last // => 3: ostatni element tablicy e typu EZArray.

f.last // => 9: obiekt f również jest tablicą typu EZArray, zawierającą


właściwość last.

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:

Począwszy od wersji języka ES6 konstruktor Array() posiada właściwość o symbolicznej


nazwie Symbol.species. Zwróć uwagę, że symbol ten jest stosowany jako nazwa
właściwości konstruktora. Większość opisanych w tym rozdziale popularnych symboli jest
wykorzystywana jako nazwy metod prototypu.
Konstruktor podklasy utworzonej za pomocą słowa kluczowego extends dziedziczy
właściwości konstruktora klasy nadrzędnej. (Jest to dodatkowy efekt w porównaniu ze
zwykłym dziedziczeniem, w którym podklasa dziedziczy metody klasy nadrzędnej).
Oznacza to, że konstruktor każdej klasy pochodnej od Array też posiada właściwość
Symbol.species (ewentualnie w klasie pochodnej można zdefiniować właściwość o takiej
nazwie, jeżeli zajdzie taka potrzeba).
Począwszy od wersji języka ES6 metody takie jak map() i slice(), tworzące i zwracające
nowe tablice, działają nieco inaczej. Nie tworzą zwykłych tablic, tylko wykonują instrukcję
new this.constructor[Symbol.species]().

A teraz najciekawsza część. Załóżmy, że Array[Symbol.species] jest zwykłą właściwością


danych, zdefiniowaną w następujący sposób:
Array[Symbol.species] = Array;

W tym przypadku konstruktor podklasy powinien dziedziczyć konstruktor klasy Array, a


metoda map() podklasy zwracać instancję klasy nadrzędnej, a nie pochodnej. Jednak w wersji
języka ES6 tak nie jest. Właściwość Array[Symbol.species] jest właściwością dostępową,
którą można jedynie odczytywać, a jej metoda get() zwraca po prostu obiekt this. Konstruktor
podklasy dziedziczy getter, co oznacza, że domyślnie każdy konstruktor jest osobnym
„gatunkiem”.

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

// tylko do odczytu nie powiedzie się.


// Zamiast tego można użyć funkcji defineProperty():

Object.defineProperty(EZArray, Symbol.species, {value: Array});


Najprostszym rozwiązaniem jest jawne zdefiniowanie w podklasie własnego gettera
[Symbol.species]():
class EZArray extends Array {
static get [Symbol.species]() { return Array; }

get first() { return this[0]; }


get last() { return this[this.length-1]; }

}
let e = new EZArray(1,2,3);

let f = e.map(x => x - 1);


e.last // => 3

f.last // => undefined: obiekt f jest zwykłą tablicą, która nie ma


właściwości dostępowej last.
Potrzeba tworzenia użytecznych klas pochodnych od Array była głównym powodem
wprowadzenia symbolu Symbol.species. Nie jest to jednak jego jedyne zastosowanie. Klasy
tablic typowanych wykorzystują symbole w taki sam sposób jak klasa Array. Podobnie metoda
slice() klasy ArrayBuffer wykorzystuje właściwość Symbol.species obiektu
this.constructor, zamiast po prostu utworzyć nowy obiekt typu ArrayBuffer. Oprócz tego
metody promes, na przykład then(), również tworzą zwracane później obiekty, wykorzystując
opisany mechanizm dziedziczenia gatunków. Ponadto, na przykład w klasie pochodnej od Map,
można w metodach zwracających nowe obiekty typu Map z powodzeniem stosować symbol
Symbol.species.
14.4.5. Symbol.isConcatSpreadable
Metoda concat() klasy Array jest jedną z metod opisanych w poprzednim punkcie, która na
podstawie symbolu Symbol.species określa, jakiego konstruktora ma użyć ze zwróconą tablicą.
Jednak metoda ta wykorzystuje również symbol Symbol.isConcatSpreadable. Jak pamiętasz z
punktu 7.8.3, metoda concat() traktuje wartość właściwości this i argumenty tablicowe
inaczej niż argumenty nietablicowe. Argumenty inne niż tablice po prostu dołącza do nowej
tablicy, natomiast tablicę zapisaną we właściwości this i argumenty tablicowe spłaszcza
(rozciąga), aby dołączyć ich elementy, a nie całe tablice.
W wersjach języka starszych niż ES6 metoda concat() wykorzystywała metodę
Array.isArray() do sprawdzania, czy wartości ma traktować jak tablice. Począwszy od wersji
ES6 algorytm jest nieco inny: jeżeli argument metody concat() (lub wartość właściwości this)
jest obiektem posiadającym właściwość o symbolicznej nazwie Symbol.isConcatSpreadable,
wówczas jej logiczna wartość określa, czy argument można rozciągnąć. Jeżeli takiej właściwości
nie ma, stosowana jest funkcja Array.isArray(), tak jak w starszych wersjach języka.

Opisywany symbol przydaje się w dwóch przypadkach:

Podczas tworzenia obiektu podobnego do tablicy (patrz podrozdział 7.9), który po


umieszczeniu w argumencie metody concat() powinien zachowywać się tak jak zwykła
tablica. W tym celu wystarczy po prostu dodać do obiektu symboliczną właściwość:

let arraylike = {

length: 1,
0: 1,

[Symbol.isConcatSpreadable]: true
};

[].concat(arraylike) // => [1]: (bez rozciągnięcia byłby to obiekt [[1]])

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]:

class NonSpreadableArray extends Array {


get [Symbol.isConcatSpreadable]() { return false; }
}
let a = new NonSpreadableArray(1,2,3);

[].concat(a).length // => 1; (po rozciągnięciu byłyby to 3 elementy).

14.4.6. Symbole dopasowujące wzorce


W punkcie 11.3.2 zostały opisane metody klasy String dopasowujące wzorce tekstowe
zdefiniowane w postaci wyrażeń regularnych. Począwszy od wersji języka ES6 metody te
zostały uogólnione, dzięki czemu można je stosować z obiektami typu RegExp, jak również
innymi obiektami, zawierającymi symboliczne właściwości określające działania na wzorcach.
Każda z metod match(), matchAll(), search(), replace() i split() ma odpowiednik w
postaci symbolu Symbol.match, Symbol.search itp.
Wyrażenia regularne pozwalają w ogólny i bardzo skuteczny sposób definiować wzorce
tekstowe. Jednak w przypadku dopasowań rozmytych stają się bardzo skomplikowane i dlatego
nie najlepiej nadają się do takich zastosowań. Za pomocą uogólnionych metod tekstowych i
popularnych symboli można definiować własne klasy dopasowujące wzorce w nietypowy
sposób. Można na przykład za pomocą metody Intl.Collator() (patrz punkt 11.7.3)
porównywać ciągi znaków bez uwzględniania akcentów. Innym przykładem jest zdefiniowanie
klasy wykorzystującej algorytm Soundex do porównywania słów na podstawie ich brzmienia lub
klasy wykorzystującej odległość Levenshteina do luźnego porównywania tekstów.

Uogólniając, wywołanie każdej z pięciu powyższych metod obiektu tekstowego:


ciąg_znaków.metoda(wzorzec, argumenty)
jest zamieniane na wywołanie metody należącej do obiektu wzorzec o symbolicznej nazwie:
wzorzec[symbol](ciąg_znaków, argumenty)
Przeanalizujmy pokazaną niżej dopasowującą wzorce klasę, implementującą stosowane w
systemach plików symbole wieloznaczne * i ?. Tego rodzaju wzorce są wykorzystywane od
samego początku istnienia systemu operacyjnego Unix i często są nazywane globami:
class Glob {
constructor(glob) {
this.glob = glob;

// Wewnętrznie globy implementujemy za pomocą wyrażeń regularnych.


// Symbol ? odpowiada dowolnemu znakowi z wyjątkiem ukośnika, a *
odpowiada zeru lub dowolnej
// liczbie znaków.
// Wyrażenia umieszczamy w grupach przechwytujących.

let regexpText = glob.replace("?", "([^/])").replace("*", "([^/]*)");


// Stosujemy flagę u, aby w dopasowaniach były uwzględniane znaki
Unicode.
// Glob powinien być dopasowany do całego ciągu, więc umieszczamy
zakotwiczenia ^ i $.

// Nie implementujemy metod search() i matchAll(), ponieważ nie są one


odpowiednie dla tego
// rodzaju wzorców.
this.regexp = new RegExp(`^${regexpText}$`, "u");
}

toString() { return this.glob; }


[Symbol.search](s) { return s.search(this.regexp); }
[Symbol.match](s) { return s.match(this.regexp); }
[Symbol.replace](s, replacement) {
return s.replace(this.regexp, replacement);

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

match[0] // => "docs/js.txt"


match[1] // => "js"
match.inde // => 0
"docs/js.txt".replace(pattern, "web/$1.htm") // => "web/js.htm"

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:

"string" oznaczająca, że oczekiwanym lub preferowanym (ale nie wymaganym)


wynikiem konwersji jest ciąg znaków. Tak jest na przykład przy zamianie obiektu w literał
szablonu.
"number" oznaczająca, że oczekiwanym lub preferowanym (ale nie wymaganym)
wynikiem konwersji jest liczba. Przykładem jest wyrażenie zawierające obiekt i operator
porównania < lub >, jak również operator arytmetyczny – lub *.
"default" oznaczająca, że dopuszczalny jest wynik będący liczbą lub ciągiem znaków. Tak
jest w przypadku wyrażeń zawierających operatory +, == i !=.

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

14.5. Znaczniki szablonowe


W punkcie 3.3.4 zostały opisane literały szablonowe, czyli ciągi znaków zawierające grawisy.
Wyrażenie, którego wartością jest funkcja, wraz z umieszczonym za nim literałem jest
zamieniane na wywołanie funkcji i jest nazywane oznakowanym literałem szablonowym.
Definiowanie funkcji znacznikowej, przeznaczonej dla oznakowanego literału szablonowego,
można traktować jako metaprogramowanie, ponieważ oznakowane szablony są często
wykorzystywane do definiowania języków DSL (ang. domain-specific languages — języki
domenowe), a definiowanie nowej funkcji znacznikowej można traktować jako rozszerzanie
składni języka JavaScript. Oznakowane literały szablonowe są wykorzystywane w wielu
pakietach przeznaczonych do tworzenia stron internetowych. W języku GraphQL jest dostępna
funkcja znacznikowa gql`` umożliwiająca osadzanie zapytań w kodzie JavaScript. Z kolei
biblioteka Emotion zawiera funkcję znacznikową css`` służącą do osadzania stylów CSS. Ten
podrozdział opisuje, jak tworzy się własne tego rodzaju funkcje.
Funkcje znacznikowe nie wyróżniają się niczym szczególnym. Są to zwykłe funkcje, w których
definicjach nie stosuje się żadnej specjalnej składni. Jeżeli po wyrażeniu funkcyjnym umieści się
literał szablonowy, wywoływana jest funkcja zawarta w tym wyrażeniu. W jej pierwszym
argumencie jest umieszczana tablica znaków, a w kolejnych argumentach dodatkowe wartości
dowolnych typów. Liczba argumentów zależy od liczby wartości interpolowanych w literale
szablonowym. Jeżeli literał jest zwykłym ciągiem znaków, to funkcja zawarta w wyrażeniu jest
wywoływana tylko z jednym argumentem, w którym jest umieszczana tablica zawierająca tylko
ten ciąg. Jeżeli literał zawiera jedną interpolowaną wartość, to funkcja jest wywoływana z
dwoma argumentami. Pierwszy zawiera tablicę złożoną z dwóch ciągów, a drugi interpolowaną
wartość. Tablica zawiera ciągi znajdujące się po lewej i prawej stronie interpolowanej wartości,
przy czym każdy z nich może być pusty. Jeżeli literał szablonowy zawiera dwie interpolowane
wartości, funkcja jest wywoływana z trzema argumentami: tablicą zawierającą ciągi znaków i
dwiema interpolowanymi wartościami. Tablica zawiera ciągi znaków znajdujące się po lewej
stronie pierwszej wartości, pomiędzy wartościami oraz po prawej stronie drugiej wartości.
Każdy z tych ciągów może być pusty. W ogólnym przypadku, w którym literał szablonowy
zawiera n interpolowanych wartości, funkcja znacznikowa jest wywoływana z n + 1
argumentami. W pierwszym jest umieszczana tablica zawierająca n + 1 ciągów znaków, a w
kolejnych n argumentach są umieszczane interpolowane wartości w takiej kolejności, w jakiej
zostały użyte w literale.
Wartością literału szablonowego jest zawsze ciąg znaków, natomiast wartością oznakowanego
literału szablonowego jest wynik zwracany przez funkcję znacznikową. Może to być ciąg
znaków, ale jeżeli funkcja jest wykorzystywana w implementacji języka DSL, to jej wynikiem jest
zazwyczaj struktura danych reprezentująca przeanalizowany ciąg znaków.

Przeanalizujmy poniższy przykład funkcji znacznikowej html`` interpolującej w bezpieczny


sposób wartości w kodzie HTML i zwracającej go w postaci ciągu znaków. Przed utworzeniem
wynikowego ciągu specjalne symbole są zamieniane na odpowiadające im znaczniki.
function html(strings, ...values) {
// Konwersja wartości na ciągi znaków i zamiana znaków specjalnych na
znaczniki HTML.

let escaped = values.map(v => String(v)


.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
.replace('"', "&quot;")

.replace("'", "&#39;"));
// Zwrócenie połączonych ciągów znaków i znaczników.
let result = strings[0];
for(let i = 0; i < escaped.length; i++) {

result += escaped[i] + strings[i+1];


}
return result;
}
let operator = "<";

html`<b>x ${operator} y</b>` // => "<b>x &lt; y</b>"


let kind = "game", name = "D&D";
html`<div class="${kind}">${name}</div>` // =>'<div
class="game">D&amp;D</div>'
Rozważmy przykład funkcji znacznikowej, która nie zwraca ciągu znaków, tylko jego
reprezentację. W tym celu wróćmy do klasy Glob opisanej w punkcie 14.4.6. Ponieważ
konstruktor Glob() ma jeden argument, można zdefiniować funkcję znacznikową tworzącą
obiekt typu Glob:
function glob(strings, ...values) {
// Połączenie ciągów i wartości w jeden ciąg.

let s = strings[0];
for(let i = 0; i < values.length; i++) {
s += values[i] + strings[i+1];
}
// Zwrócenie reprezentacji ciągu.

return new Glob(s);


}
let root = "/tmp";
let filePattern = glob`${root}/*.html`; // Alternatywne wyrażenie regularne.

"/tmp/test.html".match(filePattern)[1] // => "test"


W punkcie 3.3.4 wspomniałem o funkcji znacznikowej String.raw``, która nie interpretuje
sekwencji ucieczki zawartych w ciągu znaków, tylko zwraca go w „surowej” formie. W jej
implementacji została wykorzystana funkcjonalność, której jeszcze nie opisałem. W pierwszym
argumencie funkcji znacznikowej jest umieszczana tablica ciągów znaków. Tablica ta posiada
właściwość o nazwie raw, której wartością jest inna tablica ciągów, o takiej samej liczbie
elementów. W tablicy umieszczonej w argumencie funkcji wszystkie ciągi zawierają sekwencje
ucieczki zinterpretowane w zwykły sposób. Natomiast w tablicy zapisanej we właściwości raw
sekwencje ucieczki nie są zinterpretowane. Ta mało znana cecha okazuje się ważna podczas
definiowania języka DSL, w którego składni są stosowane lewe ukośniki. Aby na przykład
funkcja glob`` obsługiwała wzorce ścieżek plików stosowanych w systemie Windows, w którym
stosowane są lewe, a nie prawe ukośniki, można w niej zamiast tablicy strings[] użyć
strings.raw[]. Dzięki temu nie trzeba podwajać każdego lewego ukośnika. Efektem ubocznym
jest jednak brak możliwości stosowania w literałach sekwencji ucieczki, na przykład \u.

14.6. Obiekt Reflect


Reflect nie jest klasą, tylko obiektem wprowadzonym w wersji języka ES6. Podobnie jak obiekt
Math, zawiera on kolekcję funkcji zdefiniowanych w postaci właściwości. Jest to interfejs API
służący do „zastanawiania się” nad obiektami i ich właściwościami. Wszystkie funkcje znajdują
się w jednej przestrzeni nazw, odzwierciedlają podstawową składnię języka i duplikują działanie
różnych istniejących funkcji zawartych w obiekcie Object.
Obiekt Reflect wprawdzie nie oferuje żadnych nowych funkcjonalności, ale za to grupuje je w
jednym wygodnym interfejsie API. Co ważne, zawarte w nim funkcje są ścisłymi
odpowiednikami metod klasy Proxy, która będzie opisana w podrozdziale 14.7.
Interfejs API obiektu Reflect składa się z następujących funkcji:
Reflect.apply(f, o, args)

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)

Funkcja usuwająca z obiektu o właściwość o nazwie określonej w argumencie name, którą


może być ciąg znaków lub symbol. Zwraca wynik true, jeżeli operacja zakończy się
pomyślnie lub jeśli właściwość nie istnieje, albo false, jeżeli właściwości nie można
usunąć. Wywołanie tej funkcji jest równoważne użyciu wyrażenia delete o[name].
Reflect.get(o, name, receiver)
Funkcja zwracająca wartość właściwości obiektu o. Nazwa właściwości (ciąg znaków lub
symbol) jest określona w argumencie name. Jeżeli właściwość jest getterem i dodatkowo
zostanie określony opcjonalny argument receiver, to getter jest wywoływany jako metoda
obiektu receiver, a nie obiektu o. Wywołanie tej funkcji jest równoważne użyciu wyrażenia
o[name].
Reflect.getOwnPropertyDescriptor(o, name)
Funkcja zwracająca deskryptor zawierający atrybuty właściwości obiektu o. Nazwa
właściwości jest określona w argumencie name. Jeżeli właściwość nie istnieje, funkcja
zwraca wynik undefined. Funkcja działa niemal tak samo jak
Object.getOwnPropertyDescriptor(). Różnica polega jedynie na tym, że w interfejsie API
obiektu Reflect pierwszym argumentem funkcji musi być obiekt. Jeżeli ten warunek nie
będzie spełniony, funkcja zgłosi wyjątek TypeError.
Reflect.getPrototypeOf(o)
Funkcja zwracająca prototyp obiektu o lub wartość null, jeżeli obiekt nie ma prototypu.
Jeżeli o nie jest obiektem, tylko wartością prymitywną, funkcja zgłasza wyjątek TypeError.
Funkcja działa niemal identycznie jak Object.getPrototypeOf(). Różnica polega na tym,
że funkcja Object.getPrototypeOf() zgłasza wyjątek TypeError jedynie wtedy, gdy
argumenty mają wartość null lub undefined. Poza tym funkcja ta przekształca wartości
prymitywne w obiekty opakowujące.
Reflect.has(o, name)
Funkcja zwracająca wynik true, jeżeli obiekt o ma właściwość o nazwie podanej w
argumencie name (musi to być ciąg znaków lub symbol). Wywołanie tej funkcji jest
równoważne użyciu wyrażenia name in o.
Reflect.isExtensible(o)
Funkcja zwracająca wynik true, jeżeli obiekt o jest rozszerzalny (patrz podrozdział 14.2),
lub false w przeciwnym razie. Jeżeli o nie jest obiektem, funkcja zgłasza wyjątek
TypeError. Funkcja jest podobna do Object.isExtensible(). Różnica polega jednie na
tym, że ta ostatnia zwraca wynik false, jeżeli jej argumentem nie jest obiekt.
Reflect.ownKeys(o)
Funkcja zwracająca tablicę nazw właściwości obiektu o lub zgłaszająca wyjątek TypeError,
jeżeli o nie jest obiektem. Nazwy zapisane w zwróconej tablicy są ciągami znaków lub
symbolami. Wywołanie funkcji jest równoważne wywołaniu funkcji
Object.getOwnPropertyNames() i Object.getOwnPropertySymbols() i złączeniu
zwróconych przez nie wyników.
Reflect.preventExtensions(o)
Funkcja przypisująca atrybutowi extensible obiektu o (patrz podrozdział 14.2) wartość
false i zwracająca wynik true oznaczający pomyślne przypisanie. Jeżeli o nie jest
obiektem, funkcja zgłasza wyjątek TypeError. Ten sam efekt daje wywołanie funkcji
Object.preventExtensions(), która pomyślnie wykonana zwraca obiekt o, a nie wartość
true, i która nie zgłasza wyjątku, jeżeli jej argumentem nie jest obiekt.
Reflect.set(o, name, value, receiver)
Funkcja przypisująca właściwości o nazwie określonej w argumencie name wartość
określoną w argumencie value. Funkcja zwraca wynik true, jeżeli przypisanie zostanie
pomyślnie wykonane, lub false w przeciwnym razie (na przykład gdy właściwość jest
przeznaczona wyłącznie do odczytu). Jeżeli argument o nie jest obiektem, funkcja zgłasza
wyjątek TypeError. Jeżeli właściwość jest setterem i zostanie określony opcjonalny
argument receiver, wówczas funkcja wywołuje setter jako metodę obiektu receiver, a nie
obiektu o. Wywołanie tej funkcji jest zazwyczaj równoważne użyciu wyrażenia o[name] =
value.
Reflect.setPrototypeOf(o, p)
Funkcja przypisująca obiektowi o prototyp p. Zwraca wartość true, jeżeli przypisanie
zostanie pomyślnie wykonane lub false w przeciwnym razie (gdy obiekt o nie jest
rozszerzalny lub operacja byłaby wykonywana cyklicznie w łańcuchu prototypów). Jeżeli o
nie jest obiektem, funkcja zgłasza wyjątek TypeError. Podobnie działa funkcja
Object.setPrototypeOf(), która w przypadku powodzenia zwraca obiekt o, a w
przeciwnym razie zgłasza wyjątek TypeError. Pamiętaj, że wywołanie każdej z tych funkcji
może zakłócić działanie mechanizmów optymalizacyjnych interpretera i spowolnić działanie
kodu.

14.7. Klasa Proxy


Dostępna w wersjach języka ES6 i nowszych klasa Proxy jest najważniejszym osiągnięciem w
rozwoju metaprogramowania. Dzięki niej można tworzyć kod zmieniający od podstaw działanie
obiektów. Opisany w podrozdziale 14.6 interfejs API obiektu Reflect jest zbiorem funkcji
umożliwiających bezpośrednie wykonywanie operacji na obiektach. Natomiast klasa Proxy
pozwala samodzielnie implementować tego rodzaju podstawowe operacje i tworzyć obiekty
działające w sposób niemożliwy do osiągnięcia dla zwykłych obiektów.
Tworząc obiekt typu Proxy, należy w argumentach konstruktora umieścić obiekty docelowy
i obsługujący:
let proxy = new Proxy(target, handlers);
Utworzony w ten sposób obiekt nie ma własnego stanu ani kodu. Wszelkie wykonywane na nim
operacje (odczytywanie, zapisywanie i definiowanie właściwości, wyszukiwanie prototypu) są
przekazywane do obiektu docelowego lub obsługującego.
Obiekt Proxy obsługuje te same operacje co interfejs API obiektu Reflect. Załóżmy, że p jest
obiektem Proxy i w kodzie została użyta instrukcja delete p.x. Funkcja
Reflect.deleteProperty() działa tak samo jak operator delete. Operator ten użyty z
obiektem Proxy wyszukuje metodę deleteProperty() obiektu obsługującego. Jeżeli ją znajdzie,
wywołuje ją. W przeciwnym razie obiekt Proxy usuwa właściwość obiektu docelowego.

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.

p.z = 3; // Zdefiniowanie nowej właściwości obiektu Proxy.


t.z // => 3: właściwość została zdefiniowana w obiekcie docelowym.
Tego rodzaju przezroczysty obiekt Proxy jest równoważny opakowanemu obiektowi
docelowemu. Oznacza to, że nie ma powodu, aby używać tego rodzaju obiektu Proxy.
Przezroczysty obiekt może być jednak przydatny przy tworzeniu „neutralizowanych” obiektów
pośredniczących. Zamiast tworzyć obiekt za pomocą konstruktora Proxy(), można użyć
fabrycznej funkcji Proxy.revocable(). Funkcja ta zwraca obiekt zawierający obiekt Proxy i
funkcję revoke(). Po jej wywołaniu obiekt pośredniczący natychmiast przestaje działać:
function accessTheDatabase() { /* implementacja pominięta */ return 42; }
let {proxy, revoke} = Proxy.revocable(accessTheDatabase, {});
proxy() // => 42: obiekt pośredniczący daje dostęp do funkcji obiektu
docelowego.
revoke(); // Dostęp może być jednak zablokowany w dowolnym momencie.
proxy(); // !TypeError: nie można już wywoływać powyższej funkcji.
Zwróć uwagę, że powyższy kod nie tylko demonstruje, jak używać neutralizowanego obiektu
pośredniczącego, ale również jak obiekt pośredniczący działa z obiektem docelowym i jego
funkcjami. Najważniejszy jest jednak fakt, że za pomocą neutralizowanego obiektu
pośredniczącego można utworzyć blok odizolowanego kodu, przydatnego na przykład podczas
korzystania z niezaufanych bibliotek. Jeżeli w argumencie zewnętrznej funkcji trzeba umieścić
własną funkcję, można zamiast niej użyć neutralizowanego obiektu Proxy i wyłączyć go po
zakończeniu korzystania z biblioteki. W ten sposób można zapobiec zachowaniu w bibliotece
referencji do własnych funkcji i wywoływaniu ich w nieoczekiwanych momentach. Tego rodzaju
defensywne programowanie nie jest jednak typowe w języku JavaScript.
Jeżeli obiekt obsługujący umieszczony w argumencie konstruktora Proxy() nie jest pusty,
obiekt Proxy nie jest tylko przezroczystym opakowaniem. W ten sposób implementuje się
niestandardowe działanie. Przy odpowiednio zakodowanym obiekcie obsługującym obiekt
docelowy praktycznie staje się nieistotny.
Poniższy kod pokazuje, jak można zaimplementować obiekt, który pozornie posiada
nieskończenie wiele właściwości przeznaczonych tylko do odczytu, przy czym wartość każdej z
nich jest taka sama jak nazwa właściwości.

// Za pomocą klasy Proxy tworzymy obiekt pozornie posiadający dowolne


właściwości,
// których wartości są takie same jak ich nazwy.
let identity = new Proxy({}, {
// Każda właściwość ma swoją nazwę i wartość.

get(o, name, target) { return name; },


// Każda nazwa właściwości jest zdefiniowana.
has(o, name) { return true; },
// Właściwości jest zbyt dużo, aby je wyliczyć, więc zgłaszamy wyjątek.
ownKeys(o) { throw new RangeError("Właściwości jest nieskończenie wiele");
},
// Żadna z właściwości nie jest zapisywalna, konfigurowalna ani wyliczalna.
getOwnPropertyDescriptor(o, name) {
return {
value: name,
enumerable: false,

writable: false,
configurable: false
};
},

// Wszystkie właściwości są przeznaczone tylko do odczytu, więc nie można


im przypisywać wartości.
set(o, name, value, target) { return false; },
// Wszystkie właściwości są niekonfigurowalne, więc nie można ich usuwać.
deleteProperty(o, name) { return false; },

// Obiekt ma wszystkie możliwe właściwości, więc nie można definiować


nowych.
defineProperty(o, name, desc) { return false; },
// Oznacza to, że obiekt jest nierozszerzalny.
isExtensible(o) { return false; },

// Obiekt ma wszystkie możliwe właściwości, więc nie może


// dziedziczyć nowych, nawet jeżeli ma swój prototyp.
getPrototypeOf(o) { return null; },
// Obiekt jest nierozszerzalny, więc nie można zmieniać jego prototypu.
setPrototypeOf(o, proto) { return false; },

});
identity.x // => "x"
identity.toString // => "toString"
identity[0] // => "0"

identity.x = 1; // Przypisanie wartości właściwości nie daje


żadnego efektu.
identity.x // => "x"
delete identity.x // => false: nie można usunąć właściwości.
identity.x // => "x"

Object.keys(identity); // !RangeError: nie można uzyskać listy wszystkich


kluczy.
for(let p of identity) ; // !RangeError
Obiekt Proxy może przejmować działanie od obiektu docelowego lub obsługującego. W
przedstawionych przykładach wykorzystywany był jeden lub drugi obiekt. Zazwyczaj jednak
bardziej przydatny jest obiekt Proxy wykorzystujący oba obiekty jednocześnie.
W poniższym kodzie obiekt Proxy jest opakowaniem umożliwiającym jedynie odczytywanie
zawartości obiektu docelowego. Wykonywane przez kod operacje odczytu są kierowane w
zwykły sposób do obiektu docelowego. Jednak przy próbie zmodyfikowania tego obiektu lub
jego właściwości obiekt obsługujący zgłosi wyjątek TypeError. Obiekt Proxy taki jak powyższy
przydaje się przy tworzeniu testów. Załóżmy, że mamy funkcję, której argumentem jest obiekt, i
chcemy mieć pewność, że nie jest on modyfikowany. Jeżeli kod testowy umieści w argumencie
funkcji obiekt opakowujący umożliwiający wyłącznie odczytywanie docelowego obiektu,
wówczas każda próba zapisu spowoduje zgłoszenie wyjątku i uzyskanie negatywnego wyniku
testu. Ilustruje to poniższy kod:

function readOnlyProxy(o) {
function readonly() { throw new TypeError("Tylko odczyt"); }
return new Proxy(o, {
set: readonly,
defineProperty: readonly,

deleteProperty: readonly,
setPrototypeOf: readonly,
});
}

let o = { x: 1, y: 2 }; // Zwykły, zapisywalny obiekt.


let p = readOnlyProxy(o); // Wersja tylko do odczytu.
p.x // => 1: właściwości można odczytywać.
p.x = 2; // !TypeError: ale nie można ich zmieniać,
delete p.y; // !TypeError: usuwać,

p.z = 3; // !TypeError: ani dodawać nowych,


p.__proto__ = {}; // !TypeError: nie można też zmieniać prototypu.
Inną techniką, w której jest wykorzystywany obiekt Proxy, jest definiowanie metod
przechwytujących operacje na obiekcie i kierujących je (delegujących) do obiektu docelowego.
Funkcje interfejsu API obiektu Reflect (patrz podrozdział 14.6) mają dokładnie takie same
sygnatury jak metody przechwytujące, więc zaimplementowanie tego rodzaju delegacji jest
proste.
Poniżej jest przedstawiony przykładowy obiekt Proxy delegujący wszystkie operacje do obiektu
docelowego i rejestrujący je za pomocą metod obiektu obsługującego.
/*
* Funkcja zwracająca obiekt Proxy opakowujący obiekt o, rejestrujący i
kierujący do niego wszystkie operacje.
* Ciąg znaków umieszczony w argumencie objname identyfikuje obiekt i jest
umieszczany w każdym komunikacie.
* Jeżeli obiekt o ma własne właściwości, których wartościami są inne obiekty
lub funkcje, podczas odczytywania
* tych właściwości zwracany jest obiekt Proxy. W efekcie rejestrowanie
operacji jest "zaraźliwe".
*

*/
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);

// Jeżeli jest to własna właściwość obiektu docelowego, a jej wartością


jest obiekt lub funkcja,
// to zwracanym wynikiem jest obiekt Proxy.
if (Reflect.ownKeys(target).includes(property) &&
(typeof value === "object" || typeof value === "function")) {

return loggingProxy(value, `${objname}.${property.toString()}`);


}
// W przeciwnym razie zwracana jest wartość właściwości.
return value;

},
// 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.

return Reflect[handlerName](target, ...args);


};
}
});

// Zwrócenie obiektu Proxy wykorzystującego metody rejestrujące.


return new Proxy(o, handlers);
}
Powyższa funkcja loggingProxy() tworzy obiekty Proxy rejestrujące wszystkie operacje
wykonywane na zadanym obiekcie. Aby dowiedzieć się, jak nieudokumentowana funkcja
wykorzystuje obiekt umieszczony w jej argumencie, należy użyć obiektu zwróconego przez tę
funkcję.
Przeanalizujmy poniższy kod dający wgląd w proces iteracji tablicy:
// Zdefiniowanie tablicy danych i obiektu zawierającego właściwość funkcyjną.
let data = [10,20];
let methods = { square: x => x*x };

// Utworzenie obiektów rejestrujących operacje wykonywane na tablicy i


zadanym obiekcie.
let proxyData = loggingProxy(data, "dane");
let proxyMethods = loggingProxy(methods, "metody");

// Załóżmy, że chcemy się dowiedzieć, jak działa metoda Array.map().


data.map(methods.square) // => [100, 400]
// Najpierw wypróbujmy ją z tablicą obiektów Proxy.
proxyData.map(methods.square) // => [100, 400]
// Wynik jest następujący:
// Metoda get(dane,map)
// Metoda get(dane,length)

// 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.

data.map(proxyMethods.square) // => [100, 400]


// Wynik:
// Metoda get(methods,square)
// Metoda methods.square(10,0,10,20)
// Metoda methods.square(20,1,10,20)
// Na koniec wykorzystajmy obiekt Proxy do zbadania procesu iteracji.
for(let x of proxyData) console.log("Wartość", x);
// Wynik:
// Metoda get(dane,Symbol(Symbol.iterator))

// 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.

Drugi blok wyników potwierdza, że funkcja umieszczona w argumencie metody Array.map()


jest wywoływana z trzema argumentami: wartością elementu, jego indeksem i samą tablicą. (W
wyświetlanych wynikach jest mały błąd, ponieważ metoda Array.toString() nie zwraca
nawiasów kwadratowych. Wyniki byłyby bardziej czytelne, gdyby lista argumentów zawierała te
nawiasy, tj. miała postać 10,0,[10,20]).
Trzeci blok wyników pokazuje, że pętla for/of szuka metody o symbolicznej nazwie
[Symbol.iterator](). Oprócz tego widać, że metoda iteratora klasy Array nie zakłada, że
długość tablicy jest stała i dlatego dokładnie ją sprawdza w każdej iteracji.

14.7.1. Obowiązujące zasady a obiekty pośredniczące


Zdefiniowana w poprzednim podrozdziale funkcja readOnlyProxy() tworzy zamrożony obiekt
Proxy. W efekcie każda próba zmiany wartości lub atrybutu właściwości, jak również dodania
lub usunięcia właściwości powoduje zgłoszenie wyjątku. Jeżeli jednak obiekt docelowy nie jest
zamrożony, funkcje Reflect.isExtensible() i Reflect.getOwnPropertyDescriptor()
wywołane z obiektem Proxy w argumencie zwracają wyniki informujące, że przypisywanie
wartości właściwościom, jak również ich dodawanie i usuwanie jest możliwe. Zatem funkcja
readOnlyProxy() tworzy obiekt znajdujący się w niespójnym stanie. Ten błąd można naprawić,
dodając metody isExtensible() i getOwnPropertyDescriptor().
Jednak obiekty zdefiniowane za pomocą interfejsu API klasy Proxy mogą wykazywać
poważniejsze niespójności. W opisanym przykładzie sama klasa Proxy chroni programistę przed
tworzeniem wyjątkowo niespójnych obiektów. Na początku podrozdziału napisałem, że obiekt
pośredniczący nie ma własnego kodu, ponieważ wszystkie operacje kieruje do obiektu
obsługującego lub docelowego. Nie do końca jest to jednak prawdą. Klasa Proxy sprawdza
poprawność wyniku, aby nie zostały naruszone ważne zasady obowiązujące w języku
JavaScript. Jeżeli obiekt Proxy wykryje niezgodność, zgłosi wyjątek i zablokuje operację.
Na przykład obiekt Proxy utworzony dla obiektu nierozszerzalnego zgłosi wyjątek TypeError,
nawet jeżeli metoda isExtensible() zwróci wynik true:
let target = Object.preventExtensions({});
let proxy = new Proxy(target, { isExtensible() { return true; }});

Reflect.isExtensible(proxy); // !TypeError: narauszenie zasady.


W efekcie obiekt Proxy utworzony dla nierozszerzalnego obiektu docelowego może nie mieć
metody getPrototypeOf() zwracającej wynik inny niż prototyp tego obiektu. Ponadto jeżeli
właściwości obiektu docelowego są niezapisywalne i niekonfigurowalne, to po zwróceniu przez
metodę get() wyniku innego niż wartość właściwości jest zgłaszany wyjątek TypeError:
let target = Object.freeze({x: 1});
let proxy = new Proxy(target, { get() { return 99; }});
proxy.x; // !TypeError: wartość zwrócona przez metodę get() nie
odpowiada obiektowi docelowemu.
Oprócz tego klasa Proxy sprawdza zgodność operacji z innymi obowiązującymi zasadami.
Niemal wszystkie dotyczą nierozszerzalnych obiektów docelowych i ich niekonfigurowalnych
właściwości.

14.8. Podsumowanie
W tym rozdziale zostały opisane następujące tematy:

Obiekt w języku JavaScript ma atrybut extensible, a właściwość obiektu ma atrybuty


writable, enumerable, configurable, value, get i set. Atrybuty te wykorzystuje się do
zakleszczania obiektu na różne sposoby, jak również do tworzenia obiektów
zapieczętowanych i zamrożonych.
Dostępne są funkcje umożliwiające przeglądanie łańcucha prototypów obiektu, a nawet
zmieniające jego prototyp (co może skutkować spowolnieniem działania kodu).
Wartościami właściwości obiektu Symbol są tzw. popularne symbole, które można
wykorzystywać jako nazwy właściwości i metod w definiowanych obiektach i klasach. W
ten sposób można kontrolować interakcje obiektów z funkcjonalnościami języka
JavaScript i jego podstawową biblioteką. Na przykład można tworzyć iterowalne klasy lub
kontrolować zawartość ciągu znaków zwracanego przez funkcję
Object.prototype.toString(). W wersjach języka starszych niż ES6 można było
dostosowywać w ten sposób tylko natywne, wbudowane klasy.
Oznakowany literał szablonowy jest funkcją, a zdefiniowanie nowej funkcji znacznikowej
można porównać do rozszerzenia składni literału. Definiując funkcję znacznikową,
analizującą szablon umieszczony w jej argumencie, można osadzać język DSL w kodzie
JavaScript. Za pomocą funkcji znacznikowych można również uzyskiwać dostęp do
surowych literałów, w których znaki specjalne i lewe ukośniki nie mają specjalnego
znaczenia.
Za pomocą klasy Proxy i interfejsu API obiektu Reflect można kontrolować
niskopoziomowe działanie obiektów. Obiekty Proxy można opcjonalnie wykorzystywać w
charakterze neutralizowanych opakowań usprawniających enkapsulację kodu, jak również
do implementowania niestandardowo funkcjonujących obiektów (tak jak to robią specjalne
interfejsy API zdefiniowane w starszych przeglądarkach).

[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:

kontrolować zawartość (15.3) i styl dokumentu (15.4),


określać położenie elementów dokumentu na ekranie (15.5),
tworzyć współdzielone komponenty interfejsu użytkownika (15.6),
tworzyć grafikę (15.7, 15.8),
odtwarzać i generować dźwięki (15.9),
zarządzać nawigacją i historią przeglądarki (15.10),
przesyłać dane przez sieć (15.11),
zapisywać dane na komputerze użytkownika (15.12),
wykonywać jednocześnie kilka operacji za pomocą wątków (15.13).

Kliencki kod JavaScript


W tej książce i w internecie jest często stosowane określenie „kliencki kod JavaScript”.
Oznacza ono po prostu kod przystosowany do uruchamiania w przeglądarkach, będący
przeciwieństwem kodu serwerowego, uruchamianego na serwerach WWW.
Słowa „klient” i „serwer” oznaczają końce połączenia sieciowego pomiędzy przeglądarką
a serwerem. Oprogramowanie stosowane w internecie zazwyczaj składa się z części
uruchamianych na obu końcach. Części kliencka i serwerowa są często określane,
odpowiednio, mianem frontonu (ang. frontend) i zaplecza (ang. backend).

W początkowych wydaniach książki starałem się wyczerpująco opisywać wszystkie


zdefiniowane dla przeglądarek interfejsy API, przez co już dekadę temu stała się ona zbyt
obszerna. Liczba i złożoność interfejsów nieustannie rośną i uważam, że opisywanie ich
wszystkich nie ma sensu. Od siódmego wydania moim celem jest dokładne przedstawienie
języka JavaScript i jego zastosowanie w środowisku Node oraz w przeglądarkach. W tym
rozdziale nie opisuję wszystkich interfejsów, tylko te najważniejsze, na tyle dokładnie, abyś
mógł od razu zacząć z nich korzystać. Po poznaniu podstaw będziesz umiał samodzielnie
wybierać odpowiednie interfejsy (na przykład podsumowanie w podrozdziale 15.15), gdy będą
Ci potrzebne.
Platforma Node ma tylko jedną implementację i rzetelną dokumentację. Natomiast interfejsy
API dla sieci WWW są efektem kompromisu między twórcami najpopularniejszych
przeglądarek. Z kolei autoryzowana dokumentacja jest raczej specyfikacją przeznaczoną dla
programistów C, którzy implementują interfejsy, a nie dla programistów JavaScript, którzy chcą
z niego korzystać. Na szczęście istnieje projekt MDN web docs (https://developer.mozilla.org)
będący wiarygodnym i wyczerpującym źródłem informacji o interfejsach API dla
przeglądarek[1].

Starsze interfejsy API


W ciągu 25 lat od chwili, gdy pojawił się język JavaScript, twórcy przeglądarek
zaimplementowali w swoich produktach wiele funkcjonalności i interfejsów API
przeznaczonych dla programistów. Część z nich wyszła już z użycia. Są to między innymi:
Własne interfejsy producentów, które nie zostały znormalizowane i przyjęte przez
innych dostawców. Na przykład mnóstwo takich interfejsów oferowała przeglądarka
Microsoft Internet Explorer. Niektóre z nich (na przykład właściwość innerHTML)
sprawdziły się w praktyce i ostatecznie zostały ustandaryzowane, natomiast inne (na
przykład metoda attachEvent()) nie są używane od lat.
Nieefektywne interfejsy (na przykład metoda document.write()) pogarszające
wydajność aplikacji do tego stopnia, że ich stosowanie stało się bezzasadne.

Nieaktualne interfejsy zastąpione już dawno temu nowszymi wersjami realizującymi te


same funkcjonalności. Przykładem jest właściwość document.bgColor, która była
wykorzystywana do definiowania tła dokumentu. Wraz z pojawieniem się arkuszy stylów
CSS powyższa właściwość stała się osobliwością bez praktycznego zastosowania.
Wadliwie zaprojektowane interfejsy, zastąpione lepszymi. Na początku ery sieci WWW
instytucje normalizacyjne zdefiniowały niezależny od języków programowania interfejs
DOM API, który z założenia miał służyć do przetwarzania dokumentów XML za pomocą
języka Java oraz dokumentów HTML za pomocą języka JavaScript. W efekcie powstał
interfejs nie najlepiej przystosowany do języka JavaScript i oferujący funkcjonalności
rzadko wykorzystywane przez programistów. Naprawienie popełnionych błędów zajęło
kilka dekad i dzisiejsze przeglądarki obsługują znacznie lepszy model DOM.
Przeglądarki jeszcze przez bliżej nieokreślony czas będą obsługiwały przestarzałe
interfejsy API w celu zapewnienia wstecznej kompatybilności ze starszymi kodami. Nie
ma jednak potrzeby, aby je tutaj opisywać i poznawać. Dzisiejsza platforma internetowa
jest dojrzała i ustabilizowana. Jeżeli jesteś doświadczonym programistą, pamiętającym
czwarte lub piąte wydanie tej książki, będziesz miał równie wiele przestarzałej wiedzy do
zapomnienia, co nowej do przyswojenia.

15.1. Podstawy programowania stron


WWW
W tym podrozdziale opisana jest struktura programów JavaScript stosowanych w internecie,
sposoby ich ładowania do przeglądarek, uzyskiwania za ich pomocą wyników i
asynchronicznego uruchamiania w odpowiedzi na zgłaszane zdarzenia.

15.1.1. Kod JavaScript w znacznikach HTML <script>


Przeglądarki internetowe służą do wyświetlania dokumentów HTML. Aby przeglądarka mogła
uruchomić kod JavaScript, należy go umieścić lub odwołać się do niego w kodzie HTML. Do
tego celu służy znacznik <script>.
Kod JavaScript można umieścić bezpośrednio w pliku HTML pomiędzy znacznikami <script> i
</script>. Poniżej jest przedstawiony taki przykładowy plik. Kod JavaScript dynamicznie
aktualizuje jeden z elementów, który dzięki temu funkcjonuje jak cyfrowy zegar.

<!DOCTYPE html> <!-- To jest plik HTML5. -->

<html> <!-- Główny element. -->

<head> <!-- Tu umieszcza się tytuł strony, skrypty i


style. -->

<title>Cyfrowy zegar</title>
<style> /* Arkusz stylów CSS dla zegara. */

#clock { /* Style dotyczą elementu o identyfikatorze


id="clock". */

font: bold 24px sans-serif; /* Duża, pogrubiona czcionka… */

background: #ddf; /* …na jasnoszarym tle. */

padding: 15px; /* Tekst ma marginesy… */


border: solid black 2px; /* …i jest umieszczony w ramce… */

border-radius: 10px; /* …z zaokrąglonymi rogami. */

</style>
</head>

<body> <!-- Ten znacznik zawiera treść dokumentu. -->

<h1>Cyfrowy zegar</h1> <!-- Wyświetlenie tytułu. -->

<span id="clock"></span> <!-- W tym elemencie będzie umieszczany bieżący


czas. -->

<script>
// Definicja funkcji wyświetlającej bieżący czas.

function displayTime() {

let clock = document.querySelector("#clock"); // Uzyskanie referencji do


elementu id="clock".

let now = new Date(); // Odczytanie bieżącego


czasu.

clock.textContent = now.toLocaleTimeString(); // Wyświetlenie czasu.


}

displayTime() // Wyświetlenie czasu,

setInterval(displayTime, 1000); // a następnie aktualizowanie go co sekundę.

</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/>.

Stosowanie atrybutu src daje kilka korzyści:

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:

określenia, że skrypt jest modułem,


osadzenia danych w kodzie strony bez ich wyświetlania (patrz punkt 15.3.4).

Asynchroniczne i odroczone uruchamianie skryptów


Gdy język JavaScript został wprowadzony do przeglądarek, nie istniał jeszcze interfejs API,
który umożliwiałby przeglądanie i przetwarzanie struktury i zawartości wyświetlonego
dokumentu. Jedynym sposobem osiągnięcia tego celu było generowanie treści dokumentu „w
locie”, tj. w trakcie jego ładowania. Do tego celu była wykorzystywana metoda
document.write() wstrzykująca treść HTML w miejscu, w którym znajdował się skrypt.
Obecnie stosowanie tej metody nie jest dobrą praktyką, ale interpreter języka HTML, zanim
przeanalizuje i wyświetli pozostałą część dokumentu, i tak musi najpierw uruchomić skrypt
zawarty w znaczniku <script>. Takie działanie drastycznie spowalnia przetwarzanie i
wyświetlanie stron internetowych.

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:

<script defer src="deferred.js"></script>

<script async src="async.js"></script>

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.

Odroczone skrypty są uruchamiane w kolejności, w jakiej są umieszczone w dokumencie,


natomiast skrypty asynchroniczne przeglądarka uruchamia zaraz po załadowaniu. Oznacza to,
że mogą być wykonywane w dowolnym porządku.

Skrypty oznaczone atrybutem type="module" są domyślnie uruchamiane po załadowaniu


dokumentu, tak jakby miały atrybut defer. To domyślne działanie można zmienić za pomocą
atrybutu async powodującego uruchomienie kodu zaraz po załadowaniu modułu i wszystkich
zależności.

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.

Ładowanie skryptów na żądanie


Czasami pojawia się potrzeba uruchamiania skryptu po wykonaniu przez użytkownika
określonej operacji, na przykład kliknięciu przycisku lub otwarciu menu, a nie zaraz po
załadowaniu dokumentu. Jeżeli kod jest podzielony na moduły, można je ładować na żądanie za
pomocą funkcji import() opisanej w punkcie 10.3.6.

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>:

// Funkcja ładująca asynchronicznie skrypt z zadanego adresu URL i


uruchamiająca go.
// Zwraca promesę determinowaną po załadowaniu dokumentu.

function importScript(url) {

return new Promise((resolve, reject) => {

let s = document.createElement("script"); // Utworzenie elementu


<script>.
s.onload = () => { resolve(); }; // Zdeterminowanie promesy
po załadowaniu modułu.

s.onerror = (e) => { reject(e); }; // Odrzucenie promesy w


przypadku błędu.
s.src = url; // Ustawienie adresu URL
skryptu.
document.head.append(s); // Dodanie elementu
<script> do dokumentu.

});

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).

15.1.2. Model DOM


Jednym z najważniejszych obiektów w kodzie klienckim JavaScript jest Document. Reprezentuje
on dokument HTML wyświetlany w oknie lub zakładce przeglądarki. Interfejs API służący do
przetwarzania dokumentów HTML nosi nazwę Document Object Model (obiektowy model
dokumentu), w skrócie DOM, który będzie opisany w podrozdziale 15.3. Model ten jest jednak
tak ważny w tworzeniu programów klienckich, że zasługuje na wprowadzenie już teraz.

Dokument HTML składa się z zagnieżdżonych elementów tworzących drzewiastą strukturę.


Przeanalizujmy następujący prosty dokument:

<html>

<head>

<title>Przykładowy dokument</title>

</head>

<body>

<h1>Dokument HTML</h1>

<p>To jest <i>prosty</i> dokument.</p>

</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

Jeżeli nie znasz stosowanego w programowaniu pojęcia struktury drzewiastej, w jego


zrozumieniu pomocna będzie informacja, że zostało zapożyczone z genealogii. Węzeł znajdujący
się bezpośrednio nad innym węzłem nosi nazwę rodzica. Węzły znajdujące się poziom niżej od
innego węzła to dzieci. Węzły znajdujące się na tym samym poziomie i mające tego samego
rodzica to bliźniaki. Węzły umieszczone na wszystkich poziomach poniżej danego węzła to jego
potomkowie. Rodzic, dziadek i inne węzły znajdujące się nad danym węzłem to jego
przodkowie.

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.

15.1.4. Wspólna przestrzeń nazw


Stałe, zmienne i klasy zdefiniowane na najwyższym poziomie modułu, tj. przed wszystkimi
definicjami funkcji i klas, są w danym module prywatne, chyba że zostaną jawnie
wyeksportowane. W takim wypadku można je selektywnie importować do innych modułów.
Pamiętaj, że ta cecha modułów jest uwzględniana w narzędziach do pakowania kodu.
Jednak w przypadku skryptów, które nie są modułami, sytuacja jest zupełnie inna. Stałe,
zmienne, funkcje i klasy zadeklarowane na najwyższym poziomie skryptu są dostępne dla
wszystkich skryptów zawartych w dokumencie. Jeżeli na przykład w jednym skrypcie jest
zdefiniowana funkcja f(), a w drugim klasa c, to w trzecim skrypcie można wywoływać tę
funkcję i tworzyć instancje powyższej klasy bez wykonywania jakichkolwiek operacji
importujących. Zatem niezależne skrypty, które nie wykorzystują modułów, współdzielą tę samą
przestrzeń nazw i funkcjonują tak, jakby stanowiły części jednego większego skryptu. W
przypadku mniejszych programów jest to wygodna cecha, ale w większych, szczególnie tych
wykorzystujących zewnętrzne biblioteki, może powodować konflikty nazw.

Ze wspólną przestrzenią nazw są związane pewne nietypowe zaszłości historyczne. Deklaracje


zawierające słowa var i function, użyte na najwyższym poziomie skryptu, powodują
utworzenie właściwości obiektu globalnego. Funkcja f() zdefiniowana w jednym skrypcie może
być w tym samym dokumencie wywoływana w innym skrypcie jako f() lub window.f().
Natomiast deklaracje zawierające słowa const, let i class, również użyte na najwyższym
poziomie, nie tworzą właściwości obiektu globalnego. Istnieją jednak we wspólnej przestrzeni
nazw. Jeżeli w jednym skrypcie zostanie zdefiniowana klasa C, w innym skrypcie będzie można
tworzyć jej instancje za pomocą instrukcji new C(), ale nie new window.C().
Podsumowując: deklaracje umieszczone na najwyższym poziomie modułu obejmują swoim
zasięgiem tylko ten moduł i mogą być jawnie eksportowane. Natomiast w skrypcie, który nie
jest modułem, deklaracje znajdujące się na najwyższym poziomie obejmują cały dokument i są
współdzielone przez wszystkie zawarte w nim skrypty. Starsze deklaracje, wykorzystujące
słowa var i function, są dostępne w postaci właściwości obiektu globalnego. Nowe deklaracje,
zawierające słowa const, let i class, również są dostępne i obejmują swoim zasięgiem cały
dokument, ale nie są właściwościami żadnego obiektu, do którego kod ma dostęp.
15.1.5. Uruchamianie programów JavaScript
Nie istnieje formalna definicja programu klienckiego JavaScript, ale można powiedzieć, że jest
to kod składający się ze wszystkich skryptów, które dokument zawiera lub do których się
odwołuje. Poszczególne fragmenty kodu współdzielą ten sam globalny obiekt Window dający
dostęp do wspólnego obiektu Document reprezentującego dokument HTML. Ponadto skrypty,
które nie są modułami, współdzielą przestrzeń nazw najwyższego poziomu.
Na stronie zawierającej osadzone ramki, tj. wykorzystującej element <iframe>, kod
umieszczony w osadzonym dokumencie ma inny obiekt globalny i klasę Document niż kod
znajdujący się w dokumencie nadrzędnym. Osadzony kod można więc traktować jako osobny
program. Pamiętaj jednak, że nie ma formalnej definicji granic programu. Jeżeli dokumenty
nadrzędny i osadzony są ładowane z tego samego serwera, to kody zawarte w obu
dokumentach mogą współpracować ze sobą, a więc można je traktować jako dwie części tego
samego programu. W punkcie 15.13.6 dowiesz się, jak program może wymieniać komunikaty z
kodem zawartym w elemencie <iframe>.

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.

Jednowątkowość oznacza również, że przeglądarka w trakcie działania skryptu lub procedury


obsługi zdarzenia przestaje reagować na operacje wykonywane przez użytkownika. Dla
programistów jest to pewien kłopot, ponieważ skrypty i procedury obsługi zdarzeń nie mogą
działać długo. Skrypt wykonujący długotrwałe operacje spowalnia ładowanie dokumentu,
którego treść użytkownik zobaczy dopiero po zakończeniu działania tego skryptu. Jeżeli
procedura obsługi zdarzenia działa długo, przeglądarka przestaje reagować, co sprawia
wrażenie, że się „zawiesiła”.

Platforma internetowa definiuje kontrolowaną formę współbieżności o nazwie „proces roboczy


przeglądarki” (ang. web worker). Jest to działający w tle przeglądarki wątek, który wykonuje
długotrwałe operacje bez blokowania interfejsu użytkownika. Kod uruchomiony w ramach
procesu roboczego nie ma dostępu do dokumentu, jak również nie udostępnia swojego stanu
głównemu wątkowi ani innym procesom. Może jednak wymieniać z nimi komunikaty za pomocą
asynchronicznych zdarzeń. Tego rodzaju współbieżność jest niewidoczna dla głównego wątku, a
proces roboczy nie narusza podstawowego, jednowątkowego modelu wykonywania programów
JavaScript. Szczegółowe informacje na temat mechanizmów bezpieczeństwa wątków
przeglądarki znajdziesz w podrozdziale 15.13.

Kolejność działań w kodzie klienckim


Wiesz już, że najpierw ma miejsce faza wykonania skryptu, która przechodzi w fazę sterowaną
zdarzeniami. Każdą z faz można podzielić na opisane niżej etapy.

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.

15.1.6. Wejście i wyjście programu


Program kliencki JavaScript, tak jak każdy inny program, przetwarza dane wejściowe i zwraca
dane wyjściowe. Istnieje kilka rodzajów danych wejściowych:

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ę.

15.1.8. Model bezpieczeństwa


Możliwość uruchamiania dowolnego kodu JavaScript na urządzeniu użytkownika ma oczywiste
konsekwencje związane z jego bezpieczeństwem. Dlatego twórcy przeglądarek dokładają wielu
starań, aby osiągnąć dwa przeciwstawne cele:

zdefiniowanie wszechstronnego interfejsu API umożliwiającego tworzenie użytecznych


aplikacji internetowych,
uniemożliwianie szkodliwemu kodowi odczytywania i modyfikowania danych, naruszania
prywatności, wyłudzania informacji i angażowania czasu użytkownika.

Kolejne punkty zawierają przegląd ograniczeń i problemów związanych z bezpieczeństwem,


które Ty, jako programista JavaScript, powinieneś znać.

Czego skrypt JavaScript nie jest w stanie zrobić?


Pierwszą linią obrony przeglądarki przed szkodliwym kodem jest po prostu brak pewnych
funkcjonalności. Na przykład kod kliencki nie jest w stanie zapisywać ani usuwać dowolnie
wybranych plików, jak również wyświetlać zawartości dowolnie wybranych katalogów w
systemie operacyjnym użytkownika. Oznacza to, że kod JavaScript nie może usuwać danych ani
wpuszczać wirusów.

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


Reguła tego samego pochodzenia to obszerny temat dotyczący ograniczeń przetwarzania
treści przez kod JavaScript. Zazwyczaj jest egzekwowana na stronach zawierających znacznik
<iframe>. Reguła określa w takim przypadku zasady przetwarzania treści zawartych w jednej
ramce przez kod JavaScript uruchomiony w innej ramce. Konkretnie zezwala jedynie na
odczytywanie właściwości okien i dokumentów pochodzących z tego samego źródła co
dokument zawierający skrypt.
Pochodzenie dokumentu definiuje się za pomocą protokołu, adresu serwera i numeru portu
wykorzystanych do załadowania dokumentu. Dokumenty załadowane z różnych serwerów mają
różne pochodzenie, podobnie jak dokumenty załadowane z tego samego serwera, ale z różnych
portów lub przy użyciu różnych protokołów, na przykład HTTP i HTTPS. Przeglądarki zazwyczaj
traktują adresy URL zaczynające się od prefiksu file: jako różne źródła. Oznacza to, że nie
jest możliwe testowanie programu przetwarzającego kilka lokalnie zapisanych dokumentów
o adresach URL rozpoczynających się od file:. Zamiast tego należy użyć lokalnego serwera
WWW.
Ważne jest, że reguła tego samego pochodzenia nie dotyczy skryptów, tj. istotne jest
pochodzenie dokumentu, w którym skrypt jest osadzony. Załóżmy, że do skryptu zapisanego na
serwerze A odwołuje się za pomocą atrybutu src elementu <script> dokument zapisany na
serwerze B. Miejscem pochodzenia skryptu jest serwer B i skrypt ma pełny dostęp do treści
dokumentu, w którym jest zawarty. Jeżeli w tym dokumencie znajduje się znacznik <iframe>
zawierający inny dokument zapisany na serwerze B, wówczas skrypt również ma pełny dostęp
do jego treści. Jeżeli jednak inny znacznik zawiera dokument załadowany z serwera C, czy
nawet z serwera A, to zgodnie z regułą tego samego pochodzenia skrypt nie ma dostępu do
tego dokumentu.

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.

Skrypty międzydomenowe zagrażają stronom, których dokumenty są tworzone dynamicznie na


podstawie wysyłanych przez użytkowników danych, tj. nieoczyszczonych ze znaczników HTML.
Przeanalizujmy bardzo prosty przykład strony wyświetlającej powitanie użytkownika:
<script>
let name = new URL(document.URL).searchParams.get("name");

document.querySelector('h1').innerHTML = "Cześć, " + name;


</script>
Ten dwuwierszowy skrypt wyodrębnia z adresu URL wartość parametru name. Następnie za
pomocą interfejsu DOM API umieszcza w znaczniku <h1> dokumentu ciąg znaków. Tę stronę
powinno się otwierać, wpisując w przeglądarce następujący adres URL:

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:

Cześć, <img src="x.png" onload="alert('wirus')"/>


Zostanie załadowany obraz, a następnie uruchomiony skrypt przypisany atrybutowi onload.
Globalna funkcja alert() wyświetli okno dialogowe. Ten przykład jest nieszkodliwy, ale
ilustruje możliwość uruchamiania dowolnego kodu na stronie wyświetlającej nieoczyszczony
ciąg HTML.
Skrypt międzydomenowy zawdzięcza swoją nazwę temu, że do jego wykonania jest
angażowanych kilka stron. Strona B zawiera specjalnie przygotowany odnośnik (na przykład
taki jak w powyższym przykładzie) do strony A. Gdy użytkownik go kliknie, zostanie
przeniesiony do strony A, na której zostanie uruchomiony kod pochodzący ze strony B. Skrypt
może zmienić stronę lub spowodować jej awarię. Co gorsza, może również odczytać ciasteczka
zapisane podczas przetwarzania strony A (zawierające na przykład numer konta lub inną
poufną informację osobową) i wysłać je do serwera B. Wstrzyknięty kod może nawet
rejestrować naciskane przez użytkownika klawisze.

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, "&amp;")

.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#x27;")

.replace(/\//g, "&#x2F;")
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ń

Te terminy oznaczają funkcję obsługującą zdarzenie (odpowiadającą na nie)[2]. Aplikacja


rejestruje w przeglądarce funkcję obsługi zdarzenia, określając przy tym jego typ i cel. Gdy
pojawi się zdarzenie zadanego typu i skojarzone z zadanym celem, wywoływana jest
funkcja obsługująca to zdarzenie. Mówi się, że przeglądarka zgłosiła lub wyzwoliła
zdarzenie. Zdarzenia można rejestrować na kilka sposobów, których szczegóły będą
opisane w punktach 15.2.2 i 15.2.3.
Obiekt zdarzenia

Jest to obiekt skojarzony z określonym zdarzeniem i zawierający informacje o nim. Obiekt


ten jest umieszczany w argumencie funkcji obsługującej zdarzenie. Posiada właściwość
type opisującą typ zdarzenia oraz właściwość target opisującą jego cel. Prócz tego obiekty
reprezentujące zdarzenia różnych typów posiadają charakterystyczne dla nich właściwości.
Na przykład obiekt skojarzony z ruchem wskaźnika myszy posiada właściwości zawierające
współrzędne wskaźnika, a obiekt skojarzony z naciśnięciem klawisza ma właściwości
opisujące ten klawisz i ewentualne klawisze modyfikujące. Wiele obiektów ma tylko
standardowe właściwości, tj. type oraz target, i nie zawiera żadnych dodatkowych,
użytecznych informacji. W przypadku tego typu zdarzeń ważne jest jedynie, że miały one
miejsce, a nie jakie były ich szczegóły.
Propagacja zdarzenia
Jest to proces, w którym przeglądarka decyduje, którego obiektu ma dotyczyć zgłaszane
zdarzenie. Jeżeli zdarzenie jest związane tylko z jednym obiektem, jak na przykład "load"
z obiektem Windows czy "message" z obiektem Worker, wówczas propagacja nie ma
miejsca. Jeżeli jednak zdarzenie jest związane z elementem dokumentu HTML, jest
propagowane, czyli „bąbelkowane” w górę drzewiastej struktury dokumentu. Na przykład
gdy użytkownik umieści wskaźnik myszy nad odnośnikiem, najpierw jest zgłaszane
zdarzenie "mousemove" związane z obiektem reprezentującym znacznik <a> zawierającym
odnośnik, a następnie są zgłaszane zdarzenia związane z nadrzędnymi obiektami,
reprezentującymi znaczniki <p>, <section> i cały dokument. Czasami wygodniej jest
zarejestrować jedną procedurę obsługi zdarzenia obiektu Document lub obiektu kontenera
niż kilka procedur dla poszczególnych elementów. Procedura obsługi zdarzenia może
przerwać jego propagację. Wtedy procedury obsługi w nadrzędnych elementach nie są
wywoływane. W celu przerwania propagacji zdarzenia należy wywołać odpowiednią metodę
reprezentującego go obiektu. W innej formie propagacji zdarzeń, tzw. przechwytywaniu,
odpowiednio zarejestrowane procedury mają możliwość rejestrowania zdarzeń w obiektach
nadrzędnych, zanim zostaną zgłoszone dla docelowych obiektów. Propagowanie i
przechwytywanie zdarzeń będzie szczegółowo opisane w punkcie 15.2.4.
Z niektórymi zdarzeniami są skojarzone domyślnie wykonywane operacje. Na przykład gdy
użytkownik kliknie odnośnik, domyślnie wykonywaną przez przeglądarkę operacją jest
załadowanie strony, do której ten odnośnik prowadzi. Za pomocą procedury obsługi zdarzenia
można anulować domyślną operację i wywołać metodę obiektu skojarzonego ze zdarzeniem.
Proces ten jest nazywany anulowaniem zdarzenia i będzie opisany w punkcie 15.2.5.

15.2.1. Kategorie zdarzeń


Kod kliencki JavaScript obsługuje tak wiele typów zdarzeń, że nie sposób jest ich wszystkie
opisać w tym rozdziale. Warto jednak pogrupować zdarzenia na kilka ogólnych kategorii, aby
uświadomić sobie ich zakres i różnorodność.

Zdarzenia wejściowe zależne od urządzenia


Tego typu zdarzenia są ściśle związane z określonym urządzeniem wejściowym, na przykład
myszą lub klawiaturą. Są to m.in. zdarzenia "mousedown", "mousemove", "mouseup",
"touchstart", "touchmove", "touchend", "keydown" i "keyup".
Zdarzenia wejściowe niezależne od urządzenia

Są to zdarzenia niezwiązane z żadnym konkretnym urządzeniem wejściowym. Na przykład


zdarzenie "click" oznacza aktywację odnośnika, przycisku lub innego elementu
dokumentu. Zazwyczaj jest ono wynikiem kliknięcia myszy, ale może być również zgłaszane
po naciśnięciu klawisza lub dotknięciu ekranu. Z kolei "input" jest niezależnym od
urządzenia zdarzeniem alternatywnym dla "keydown". Oznacza wprowadzanie danych za
pomocą klawiatury, operacji kopiuj-wklej oraz różnych metod wpisywania ideogramów.
Zdarzenia "pointerdown", "pointermove" i "pointerup" są niezależnymi od urządzenia
odpowiednikami kliknięć i dotknięć ekranu. Dotyczą takich urządzeń jak mysz, ekran
dotykowy i rysik.
Zdarzenia interfejsu użytkownika
Zdarzenia interfejsu użytkownika są zgłaszane na wyższym poziomie. Zazwyczaj są
związane z elementami formularza HTML definiującego interfejs graficzny aplikacji WWW.
Są to zdarzenia "focus" (zgłaszane po wyróżnieniu pola do wprowadzania tekstu),
"change" (po zmianie wartości elementu formularza) i "submit" (gdy użytkownik kliknie
przycisk Wyślij).
Zdarzenia zmiany stanu
Niektóre zdarzenia nie są wynikiem aktywności użytkownika, tylko przeglądarki lub sieci.
Oznaczają one zmiany zachodzące w cyklu życia lub w stanie aplikacji. Najczęściej
wykorzystywanymi tego typu zdarzeniami są "load" i "DOMContentLoaded" związane,
odpowiednio, z obiektami Window i Document po załadowaniu dokumentu (patrz punkt
„Kolejność działań w kodzie klienckim” wyżej). Przeglądarka zgłasza w obiekcie Window
zdarzenia "online" i "offline", gdy zmieni się stan połączenia sieciowego. Natomiast
mechanizm zarządzania historią (patrz punkt 15.10.4) zgłasza zdarzenie "popstate" w
odpowiedzi na kliknięcie przycisku Wstecz.
Zdarzenia interfejsu API

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ń.

15.2.2. Rejestrowanie procedury obsługi zdarzeń


Są dwa podstawowe sposoby rejestrowania procedur obsługi zdarzeń. Pierwszy, pochodzący
z epoki początku sieci WWW, polega na przypisaniu funkcji do właściwości obiektu lub
elementu dokumentu, z którym jest związane dane zdarzenie. Drugi, nowocześniejszy i bardziej
ogólny sposób, to umieszczenie funkcji obsługującej zdarzenie w argumencie metody
addEventListener() należącej do wybranego obiektu lub elementu.

Rejestracja poprzez ustawienie właściwości obiektu


Najprostszym sposobem zarejestrowania procedury obsługi zdarzenia jest przypisanie funkcji
do właściwości obiektu związanego ze zdarzeniem. Zgodnie z przyjętą konwencją nazwy tych
właściwości składają się z prefiksu on oraz nazwy zdarzenia, na przykład onclick, onchange,
onload, lub onmouseover. Zwróć uwagę, że ma znaczenie wielkość liter w nazwach. Wszystkie
litery są małe[3], nawet jeżeli nazwa składa się z kliku słów, na przykład "mousedown". Poniższy
kod rejestruje dwie procedury obsługi zdarzeń:

// Przypisanie funkcji właściwości onload obiektu Window.


// Funkcja ta jest procedurą obsługi zdarzenia, wywoływaną po załadowaniu
dokumentu.
window.onload = function() {
// Wyszukanie elementu <form>.

let form = document.querySelector("form#shipping");


// Zarejestrowanie procedury obsługi zdarzenia, która będzie wywoływana
przed wysłaniem formularza.
// Przyjęte jest założenie, że w innym miejscu kodu jest zdefiniowana
funkcja isFormValid().

form.onsubmit = function(event) { // Gdy użytkownik postanowi wysłać


formularz,
if (!isFormValid(this)) { // funkcja sprawdza, czy pola są
poprawnie wypełnione.
event.preventDefault(); // Jeżeli nie, funkcja blokuje wysłanie
formularza.
}
};

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

Rejestracja poprzez ustawienie atrybutu


Do właściwości elementów dokumentu, wykorzystywanych do obsługi zdarzeń, można również
odwoływać się bezpośrednio w kodzie HTML za pomocą atrybutów znaczników. (Procedury
obsługi zdarzeń dotyczących elementu Window definiuje się za pomocą atrybutów znacznika
<body>). Sposób ten jest wprawdzie źle widziany w nowoczesnym programowaniu aplikacji
WWW, ale można go stosować. Został tu opisany, ponieważ wciąż można się z nim spotkać w
pracy z istniejącymi kodami.
Aby zdefiniować procedurę obsługi zdarzenia, należy atrybutowi znacznika przypisać ciąg
znaków będący kodem JavaScript. Kod powinien być ciałem funkcji, a nie jej pełną deklaracją,
tj. nie może zawierać nawiasów klamrowych ani słowa kluczowego function. Poniżej jest
przedstawiony przykład:
<button onclick="console.log('Dziękuję');">Kliknij tutaj</button>
Jeżeli kod przypisany atrybutowi składa się z kilku instrukcji, należy je oddzielić średnikami lub
podzielić kod na kilka wierszy.
Ciąg zawierający kod JavaScript przypisany atrybutowi obsługi zdarzenia jest przez
przeglądarkę przekształcany na przykład w taką funkcję:
function(event) {
with(document) {
with(this.form || {}) {
with(this) {

/* Tu jest Twój kod.*/


}
}
}

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

Wywołanie metody addEventListener() z ciągiem "click" w pierwszym argumencie nie


powoduje zmiany wartości właściwości onclick. Gdy użytkownik kliknie przycisk, wtedy
powyższy kod wyświetla w konsoli dwa komunikaty. Gdyby najpierw została wywołana metoda
addEventListener(), a następnie przypisana wartość właściwości onclick, wówczas również
byłyby wyświetlone dwa komunikaty, ale w odwrotnej kolejności. Co ważne, powyższą metodę
można wywoływać wielokrotnie i w ten sposób rejestrować kilka procedur obsługi zdarzenia
danego typu i dotyczącego danego obiektu. Po zgłoszeniu zdarzenia wszystkie procedury
zostaną wywołane w kolejności rejestracji. Wielokrotne wywołanie metody
addEventListener() należącej do tego samego obiektu i z takimi samymi argumentami nie
daje żadnego efektu, ponieważ procedura zostanie zarejestrowana tylko raz, a kolejność
wywołania procedur nie zostanie zmieniona.
Metoda addEventListener() jest stosowana w parze z metodą removeEventListener(). Dwa
pierwsze i opcjonalny trzeci argument obu metod są takie same, ale druga metoda usuwa z
obiektu procedurę obsługi zdarzenia. Przydatną techniką jest tymczasowe rejestrowanie
procedury. Na przykład po zgłoszeniu zdarzenia "mousedown" można czasowo zarejestrować
obsługę zdarzeń "mousemove" i "mouseup", aby sprawdzić, czy użytkownik przeciągnął coś za
pomocą myszy. Obie procedury można wyrejestrować po zgłoszeniu zdarzenia "mouseup".
Odpowiedni kod wyglądałby tak:
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);

Opcjonalnym trzecim argumentem metody addEventListener() jest wartość logiczna lub


obiekt. Jeżeli jest to wartość true, zadana funkcja jest rejestrowana jako procedura
przechwytująca zdarzenie i jest wywoływana w innej fazie jego obsługi. Przechwytywanie
zdarzeń zostanie opisane w punkcie 15.2.4. Aby wyrejestrować tak zarejestrowaną procedurę,
należy w trzecim argumencie metody removeEventListener() również umieścić wartość true.
Rejestrowanie procedur przechwytujących zdarzenia jest jednym z trzech trybów wywoływania
metody addEventListener(). W trzecim argumencie można zamiast wartości logicznej
umieścić obiekt typu Options, jawnie wskazujący tryb rejestracji:
document.addEventListener("click", handleClick, {
capture: true,
once: true,

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.

Obiekt Options można też umieszczać w argumencie metody removeEventListener(), która


uwzględnia tylko wartość właściwości capture. Wartości właściwości once i passive są
pomijane, nawet jeżeli zostaną określone.

15.2.3. Wywołanie procedury obsługi zdarzenia


Gdy procedura obsługi zdarzenia zostanie zarejestrowana, przeglądarka będzie ją
automatycznie wywoływać w chwili zgłoszenia zdarzenia zadanego typu i dotyczącego
zadanego obiektu. W tym punkcie szczegółowo opisana jest operacja wywołania procedury, jej
argumenty, kontekst wywołania i zwracany wynik.

Argument procedury obsługi zdarzenia


Procedura obsługi zdarzenia jest wywoływana z jednym argumentem, którym jest obiekt Event.
Właściwości tego obiektu zawierają szczegółowe informacje o zdarzeniu:
type
Typ zgłoszonego zdarzenia.
target

Obiekt, którego dotyczy zdarzenie.


currentTarget
Jeżeli zdarzenie jest propagowane, wartością tej właściwości jest obiekt, w którym została
zarejestrowana procedura obsługi.
timeStamp
Znacznik czasu, wyrażony w milisekundach, opisujący moment zgłoszenia zdarzenia. Nie
jest to jednak czas bezwzględny. Tę właściwość wykorzystuje się do wyliczenia interwału
pomiędzy dwoma zdarzeniami, odejmując wartość pierwszego znacznika od drugiego.
isTrusted
Właściwość przyjmująca wartość true, jeżeli zdarzenie zostało zgłoszone przez
przeglądarkę, lub false, jeżeli przez kod JavaScript.
Zdarzenia niektórych typów mają dodatkowe właściwości. Na przykład zdarzenia związane z
myszą mają właściwości clientX i clientY opisujące współrzędne wskaźnika na ekranie.

Kontekst wywołania procedury


Rejestracja procedury obsługi zdarzenia poprzez przypisanie wartości odpowiedniej
właściwości jest równoznaczna ze zdefiniowaniem nowej metody w obiekcie docelowym:
target.onclick = function() { /* Kod obsługi zdarzenia. */ };
Nie powinien więc dziwić fakt, że procedura obsługi zdarzenia jest wywoływana tak jak metoda
obiektu, którego zdarzenie dotyczy. Oznacza to, że identyfikator this użyty w ciele procedury
obsługi reprezentuje obiekt, w którym ta procedura została zarejestrowana.

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.

Wynik procedury obsługi zdarzenia


W nowoczesnych programach procedury obsługi zdarzeń nie zwracają wyników. Jednak w
starszych kodach można napotkać procedury zwracające wyniki oznaczające zazwyczaj, że
przeglądarka nie powinna wykonywać domyślnych operacji związanych z danym zdarzeniem.
Jeżeli na przykład funkcja przypisana właściwości onclick, wywołana po kliknięciu przez
użytkownika przycisku Wyślij, zwróci wartość false (zazwyczaj z powodu znalezienia błędów
we wprowadzonych danych), wówczas przeglądarka nie wyśle formularza.
Standardowym i preferowanym sposobem informowania przeglądarki, aby nie wykonywała
domyślnych operacji, jest wywoływanie metody preventDefault() obiektu Event (patrz punkt
15.2.5).

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.

15.2.4. Propagacja zdarzeń


Jeżeli docelowym obiektem jest Window lub inny niezależny obiekt, to przeglądarka reaguje na
zdarzenia, po prostu wywołując odpowiednie procedury obsługi zarejestrowane w tym obiekcie.
Sytuacja się jednak komplikuje, jeżeli tym obiektem jest Document lub Element.
Gdy zostanie wywołana procedura obsługi zdarzenia zarejestrowana w określonym obiekcie
docelowym, w większości przypadków zdarzenie jest „bąbelkowane” w górę drzewiastej
struktury modelu DOM. W efekcie są wywoływane procedury zarejestrowane w nadrzędnych
obiektach, następnie w kolejnych nadrzędnych itd. Proces jest kontynuowany do poziomu
obiektu Document i dalej do obiektu Window. Bąbelkowanie zdarzeń jest alternatywą dla
rejestrowania wielu procedur obsługi w poszczególnych elementach dokumentu. Dzięki niemu
można zarejestrować tylko jedną procedurę w nadrzędnym elemencie, wspólnym dla
wszystkich elementów. Na przykład zamiast rejestrować procedury obsługi zdarzeń dla
wszystkich elementów formularza, można zarejestrować jedną procedurę obsługi zdarzenia
"change" dla znacznika <form>.
Bąbelkowana jest większość zdarzeń dotyczących elementów dokumentu. Ważnymi wyjątkami
są zdarzenia "focus", "blur" i "scroll". Zdarzenie "load" dotyczące dokumentu też jest
bąbelkowane, ale tylko do poziomu obiektu Document, więc nie dociera do obiektu Window.
(Procedura obsługi zdarzenia "load" dotyczącego tego obiektu jest wywoływana jedynie po
załadowaniu dokumentu).
Bąbelkowanie zdarzeń jest trzecią fazą ich propagacji. Drugą fazą jest wywołanie procedury
obsługi zarejestrowanej w obiekcie związanym z danym zdarzeniem. Natomiast pierwsza faza,
nazywana przechwyceniem zdarzenia, ma miejsce przed wywołaniem procedury. Jak
pamiętasz, metoda addEventListener() ma opcjonalny trzeci argument. Jeżeli jest to wartość
true lub obiekt {capture:true}, procedura obsługi jest rejestrowana jako procedura
przechwytująca zdarzenie i jest wywoływana w pierwszej fazie jego propagacji. Można
powiedzieć, że faza przechwytywania jest bąbelkowaniem zdarzenia w przeciwną stronę.
Najpierw są wywoływane procedury obsługi zdarzeń obiektu Window, następnie Document,
znacznika <body> i dalej w dół struktury modelu DOM aż do obiektu nadrzędnego dla obiektu
docelowego. Procedury przechwytujące zarejestrowane w obiekcie docelowym nie są
wywoływane.

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.

15.2.5. Anulowanie zdarzenia


Przeglądarka reaguje na wiele zdarzeń zgłaszanych w wyniku operacji wykonanych przez
użytkownika, nawet jeżeli nie są zarejestrowane procedury ich obsługi. Na przykład gdy
użytkownik kliknie odnośnik, przeglądarka otwiera wskazywaną przez niego stronę. Gdy
użytkownik wyróżni pole formularza i zacznie naciskać klawisze, przeglądarka będzie
umieszczała w tym polu znaki. Jeżeli użytkownik przeciągnie palcem po ekranie, przeglądarka
przewinie jego zawartość. W procedurach obsługi tego typu zdarzeń można blokować operacje
domyślnie wykonywane przez przeglądarkę. W tym celu należy wywołać metodę
preventDefault() obiektu związanego z danym zdarzeniem. (Metoda preventDefault() jest
nieskuteczna, jeżeli procedura została zarejestrowana w trybie pasywnym).
Anulowanie domyślnej operacji jest jednym z kilku sposobów przerwania obsługi zdarzenia.
Wywołując metodę stopPropagation() obiektu związanego z danym zdarzeniem, można
przerwać jego propagację. Jeżeli w obiekcie jest zarejestrowanych kilka procedur, zostaną one
wywołane w odróżnieniu od procedur zarejestrowanych we wszystkich innych obiektach.
Metoda stopPropagation() dotyczy fazy przechwytywania zdarzenia w danym obiekcie oraz
fazy bąbelkowania. Podobnie działa metoda stopImmediatePropagation() z tą różnicą, że
blokuje wywołania wszystkich pozostałych procedur obsługi zarejestrowanych w danym
obiekcie.

15.2.6. Zgłaszanie własnych zdarzeń


Interfejs API przeznaczony do zarządzania zdarzeniami w kodzie klienckim jest niezwykle
skuteczny. Za jego pomocą można również zgłaszać własne zdarzenia. Załóżmy, że program
musi w regularnych odstępach czasu wykonywać długotrwałe obliczenia lub wysyłać zapytania
przez sieć. Na czas wykonywania tych operacji inne operacje muszą być wstrzymywane. Należy
o tym informować użytkownika, wyświetlając wirujący kursor oznaczający, że aplikacja jest
zajęta. Jednak zajęty moduł nie musi zajmować się wyświetlaniem wirującego kursora. Zamiast
tego może najpierw zgłaszać zdarzenie informujące, że jest zajęty, a potem inne zdarzenie, że
jest już wolny. Z kolei w module interfejsu graficznego należy zarejestrować procedury obsługi
tych zdarzeń, odpowiednio informujących użytkownika.
Obiekt posiadający metodę addEventListener() jest celem zdarzenia, co oznacza, że ma
również metodę dispatchEvent(). Niestandardowy obiekt zdarzenia najpierw tworzy się za
pomocą konstruktora CustomEvent(), a następnie umieszcza w argumencie metody
dispatchEvent(). Pierwszym argumentem konstruktora jest ciąg znaków opisujący typ
zdarzenia, a drugim jest obiekt definiujący właściwości obiektu zdarzenia. Właściwości detail
można przypisać ciąg znaków, obiekt lub inną wartość reprezentującą treść zdarzenia. Jeżeli
zdarzenie ma dotyczyć dokumentu i ma być bąbelkowane, obiekt umieszczony w drugim
argumencie konstruktora musi mieć właściwość bubbles o wartości true. Poniższy kod
ilustruje zgłoszenie niestandardowego zdarzenia.
// Zgłoszenie zdarzenia informującego użytkownika, że aplikacja jest zajęta.

document.dispatchEvent(new CustomEvent("busy", { detail: true }));


// Wykonanie operacji sieciowej.
fetch(url)
.then(handleNetworkResponse)
.catch(handleNetworkError)

.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:

jak się odwoływać do poszczególnych elementów dokumentu i je wybierać,


jak przeglądać dokument, wyszukiwać elementy nadrzędne, bliźniacze i potomne dla
danego elementu,
jak odczytywać i przypisywać wartości atrybutom elementu dokumentu,
jak odczytywać, umieszczać i zmieniać treść dokumentu,
jak modyfikować strukturę dokumentu poprzez tworzenie, wstawianie i usuwanie jej
węzłów.

15.3.1. Wybieranie elementów dokumentu


Program kliencki często musi przetwarzać jeden lub kilka elementów dokumentu. Globalna
właściwość document zawiera obiekt Document, którego właściwości head i body zawierają
obiekty Element reprezentujące, odpowiednio, znaczniki <head> i <body>. Jednak aby móc
przetwarzać elementy umieszczone głębiej w strukturze dokumentu, trzeba w jakiś sposób
uzyskać dostęp do reprezentujących je obiektów typu Element.

Wybieranie elementów za pomocą selektorów CSS


Arkusz stylów zawiera bardzo przydatne sekwencje zwane selektorami, opisujące pojedyncze
elementy lub całe zbiory elementów dokumentu. Za pomocą metod querySelector()
i querySelectorAll() można wyszukiwać w dokumencie elementy zgodne z zadanym
selektorem CSS. Zanim się przyjrzymy tym metodom, zapoznajmy się ogólnie ze składnią
selektora.

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".

Symbol # oznacza wybieranie elementu po atrybucie id, a kropka — po atrybucie class.


Elementy można również wybierać po atrybutach w bardziej ogólny sposób:
p[lang="fr"] // Akapit napisany w języku francuskim: <p lang="fr">.
*[name="x"] // Dowolny element z atrybutem name="x".
Zwróć uwagę, że w powyższych przykładach są stosowane łącznie selektor nazwy znacznika
(lub symbol wieloznaczny *) oraz selektor atrybutu. Można też stosować bardziej
skomplikowane kombinacje:
span.fatal.error // Dowolny znacznik <span> zawierający w atrybucie
class słowa "fatal" i "error".

span[lang="fr"].warning // Dowolny znacznik <span> zawierający treść w języku


francuskim
// i słowo "warning" w atrybucie class.
Selektor może określać też strukturę dokumentu:
#log span // Dowolny znacznik <span> podrzędny dla znacznika o
atrybucie id="log".
#log>span // Dowolny znacznik <span> bezpośrednio podrzędny dla
znacznika o atrybucie #id="log".
body>h1:first-child // Pierwszy znacznik <h1> podrzędny dla znacznika
<body>.

img + p.caption // Znacznik <p> z atrybutem class równym "caption",


// umieszczony bezpośrednio po znaczniku <img>.
h2 ~ p // Dowolny znacznik <p> umieszczony po znaczniku <h2>
i bliźniaczy dla niego.
Dwa selektory oddzielone przecinkiem powodują wybranie elementów dopasowanych do
przynajmniej jednego z nich:

button, input[type="button"] // Wszystkie znaczniki <button> i <input


type="button">.
Jak widać, za pomocą selektorów można wybierać elementy dokumentu według ich typów,
identyfikatorów, klas i położenia. Metoda querySelector(), w której argumencie umieszcza się
ciąg selektora, zwraca pierwszy odpowiadający mu element lub wartość null, jeżeli nie
znajdzie żadnego:

// Wyszukanie znacznika z atrybutem id="spinner".


let spinner = document.querySelector("#spinner");
Metoda querySelectorAll() działa podobnie, ale zwraca wszystkie pasujące elementy, a nie
tylko pierwszy:
// Wyszukanie wszystkich znaków <h1>, <h2> i <h3>.

let titles = document.querySelectorAll("h1, h2, h3");


Wynikiem zwracanym przez metodę querySelectorAll() nie jest tablica obiektów, tylko
podobny do tablicy obiekt NodeList. Obiekt ten posiada właściwość length i obsługuje indeksy,
podobnie jak tablica. Można więc go przetwarzać w zwykły sposób za pomocą pętli for. Obiekt
jest również iterowalny, więc można go używać z pętlą for/of. Aby przekształcić go w zwykłą
tablicę, wystarczy umieścić go w argumencie funkcji Array.from().

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.

Zwróć uwagę, że standard CSS definiuje pseudoelementy ::first-line i ::first-letter.


Odpowiadają one fragmentom węzłów tekstowych, a nie elementom i nie można ich stosować z
metodami querySelectorAll() i querySelector(). Ponadto w wielu przeglądarkach metody te
nie zwracają wyników dla pseudoklas :link i :visited, ponieważ mogą one zawierać
informacje o historii przeglądanych stron.
Inną metodą wybierającą elementy za pomocą selektorów CSS jest closest(). Jest ona
zdefiniowana w klasie Element, a jej jedynym argumentem jest selektor. Jeżeli selektor pasuje
do elementu reprezentowanego przez obiekt, do którego ta metoda należy, to zwróconym
wynikiem jest ten element. W przeciwnym razie metoda zwraca najbliższy pasujący element
nadrzędny lub null, jeżeli żaden nie jest dopasowany. W pewnym sensie metoda closest() jest
przeciwieństwem metody querySelector(). Rozpoczyna wyszukiwanie od bieżącego obiektu
i kontynuuje je w górę struktury dokumentu. Natomiast metoda querySelector() również
zaczyna od bieżącego obiektu, ale kontynuuje wyszukiwanie w dół struktury. Metoda closest()
przydaje się w sytuacjach, gdy procedura obsługi zdarzenia jest zarejestrowana na wysokim
poziomie struktury. Na przykład dla procedury obsługi zdarzenia "click" może być ważna
informacja, czy klikniętym elementem jest odnośnik. Obiekt event zawiera informację o
obiekcie docelowym, ale może nim być tekst odnośnika, a nie sam znacznik <a>. Procedura
obsługi zdarzenia może więc wyszukać najbliższy element zawierający dany odnośnik w
następujący sposób:
// Wyszukanie najbliższego nadrzędnego znacznika <a> zawierającego atrybut
href.
let hyperlink = event.target.closest("a[href]");
A oto inny przykład użycia metody closest():
// Funkcja zwracająca wartość true, jeżeli element e znajduje się wewnątrz
elementu listy.
function insideList(e) {
return e.closest("ul,ol,dl") !== null;
}
Pokrewna metoda matches() nie zwraca elementów nadrzędnych ani podrzędnych. Po prostu
zwraca wartość true, jeżeli bieżący element jest zgodny z zadanym selektorem CSS, lub false
w przeciwnym razie:

// Funkcja zwracająca wynik true, jeżeli obiekt e reprezentuje nagłówek HTML.


function isHeading(e) {
return e.matches("h1,h2,h3,h4,h5,h6");
}

Inne metody wybierające elementy


Oprócz metod querySelector() i querySelectorAll() w interfejsie modelu DOM jest
dostępnych kilka starszych metod wybierających elementy, dzisiaj rzadziej stosowanych.
Możesz spotkać się z następującymi metodami (szczególnie z metodą getElementById()):
// Wyszukanie elementu po identyfikatorze. Argumentem jest po prostu
identyfikator,
// bez poprzedzającego go znaku #. Metoda analogiczna do
document.querySelector("#sect1").

let sect1 = document.getElementById("sect1");


// Wyszukanie wszystkich znaczników (na przykład pól wyboru w formularzu),
posiadających
// atrybut name="color". Metoda analogiczna do document.querySelectorAll('*
[name="color"]');
let colors = document.getElementsByName("color");
// Wyszukanie wszystkich znaczników <h1> w dokumencie.
// Metoda analogiczna do document.querySelectorAll("h1").
let headings = document.getElementsByTagName("h1");

// Obiekt elementu zawiera również metodę getElementsByTagName().


// Wyszukanie wszystkich znaczników <h2> zawartych w elemencie sect1.
let subheads = sect1.getElementsByTagName("h2");
// Wyszukanie wszystkich znaczników w klasie "tooltip".
// Metoda analogiczna do document.querySelectorAll(".tooltip").
let tooltips = document.getElementsByClassName("tooltip");
// Wyszukanie wszystkich znaczników w klasie "sidebar", podrzędnych dla
sect1.
// Metoda analogiczna do sect1.querySelectorAll(".sidebar").

let sidebars = sect1.getElementsByClassName("sidebar");


Większość użytych wyżej metod zwraca, podobnie jak querySelectorAll(), obiekt NodeList.
Wyjątkiem jest getElementById(), która zwraca obiekt Element. Jednak metody te, w
odróżnieniu od querySelectorAll(), zwracają „żywe” obiekty NodeList, tzn. zawierające listy,
które mogą zmieniać swoje długości i zawartości wraz ze zmieniającą się strukturą dokumentu.

Wstępnie wybrane elementy


Historyczną zaszłością są właściwości klasy Document dające dostęp do określonych typów
węzłów. Na przykład właściwości images, forms i links pozwalają szybko uzyskać dostęp,
odpowiednio, do znaczników <img>, <form> i <a>, przy czym ten ostatni musi posiadać atrybut
href. Każda z tych właściwości zawiera obiekt HTMLCollection, który jest bardzo podobny do
NodeList, ale można odwoływać się do jego elementów za pomocą ich identyfikatorów lub
nazw. Na przykład do znacznika <form id="address"> zawartego we właściwości
document.forms można się odwołać w następujący sposób:
document.forms.address;
Jeszcze starszą właściwością służącą do wybierania elementów jest document.all, która
podobnie jak obiekt HTMLCollection zawiera wszystkie elementy dokumentu. Właściwość ta
jest obecnie uznana za przestarzałą i nie należy jej używać.

15.3.2. Struktura dokumentu i jej przeglądanie


Po wybraniu elementu pojawia się czasami potrzeba wyszukania strukturalnie powiązanych
z nim fragmentów dokumentu, na przykład elementów nadrzędnych, podrzędnych lub
bliźniaczych. Jeżeli ważne są przede wszystkim elementy dokumentu, a nie zawarte w nich
teksty (włącznie ze spacjami, które też są traktowane jako tekst), można użyć interfejsu API,
który traktuje dokument jako drzewiastą strukturę elementów, ale pomija wchodzące w jego
skład węzły tekstowe. Interfejs ten nie oferuje żadnych metod. Jest to po prostu zbiór
właściwości obiektu Element, za pomocą których można odwoływać się do elementów
rodzicielskich, potomnych i bliźniaczych dla danego elementu. Są to następujące właściwości:
parentNode
Właściwość zawierająca obiekt Element lub Document, reprezentujący element rodzicielski
dla bieżącego elementu.
children
Właściwość zawierająca obiekt NodeList zawierający listę obiektów Element
reprezentujących elementy potomne z wyjątkiem węzłów typu Text i Comment.
childElementCount
Właściwość zawierająca liczbę elementów potomnych. Tę samą wartość ma właściwość
children.length.
firstElementChild, lastElementChild

Właściwości odwołujące się, odpowiednio, do pierwszego i ostatniego elementu potomnego


dla bieżącego elementu. Jeżeli tych elementów nie ma, właściwość ma wartość null.
nextElementSibling, previousElementSibling
Właściwości odwołujące się do elementu bliźniaczego znajdującego się, odpowiednio, przed
bieżącym elementem lub za nim. Jeżeli takiego elementu nie ma, właściwość ma wartość
null.
Wykorzystując powyższe właściwości, można na przykład odwołać się do drugiego elementu
potomnego względem pierwszego elementu dokumentu za pomocą następujących wyrażeń:
document.children[0].children[1]
document.firstElementChild.firstElementChild.nextElementSibling
W typowym dokumencie HTML oba wyrażenia odwołują się do znacznika <body>.

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.

traverse(child, f); // Rekurencyjne przeglądanie każdego


elementu.
}
}
function traverse2(e, f) {
f(e); // Wywołanie funkcji f() z obiektem e w
argumencie.
let child = e.firstElementChild; // Iterowanie listy połączonych
elementów.
while(child !== null) {
traverse2(child, f); // Rekurencyjne przeglądanie każdego
elementu.
child = child.nextElementSibling;

}
}

Dokument jako drzewiasta struktura węzłów


Aby przejrzeć dokument lub jego fragment bez pomijania węzłów typu Text, należy użyć
właściwości obiektów Node. W ten sposób można uzyskać dostęp do obiektów Element, Text, a
nawet Comment, reprezentujących komentarze umieszczone w dokumencie HTML.
Obiekt Node posiada następujące właściwości:
parentNode
Właściwość zawierająca węzeł rodzicielski dla bieżącego węzła. Jeżeli węzeł nie ma rodzica
(na przykład jest to obiekt Document), właściwość ma wartość null.
childNodes

Właściwość przeznaczona tylko do odczytu, zawierająca obiekt NodeList ze wszystkimi


elementami potomnymi (nie tylko typu Element).
firstChild, lastChild
Właściwości zawierające, odpowiednio, pierwszy i ostatni węzeł potomny dla danego węzła.
Jeżeli go nie ma, właściwość ma wartość null.
nextSibling, previousSibling
Właściwości zawierające, odpowiednio, poprzedni i następny węzeł bliźniaczy dla danego
węzła. Jeżeli go nie ma, właściwość ma wartość null.
nodeType
Właściwość zawierająca liczbę opisującą rodzaj węzła. Dla obiektu Document jest to liczba
9, dla obiektu Element liczba 1, dla obiektu Text liczba 3, a dla obiektu Comment liczba 8.

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.

// Wynik jest podobny do zawartości właściwości textContent.


function textContent(e) {
let s = ""; // Zmienna zawierająca skumulowany
tekst.
for(let child = e.firstChild; child !== null; child = child.nextSibling) {
let type = child.nodeType;
if (type === 3) { // Jeżeli węzeł jest typu Text,
s += child.nodeValue; // dopisujemy do zmiennej zawarty w
nim tekst.

} else if (type === 1) { // Jeżeli jest to element,


s += textContent(child); // przeglądamy rekurencyjnie jego
elementy potomne.
}
}
return s;
}
Powyższa funkcja jest tylko ilustracją. W praktyce, w celu uzyskania tekstowej zawartości
elementu e, wystarczy po prostu użyć właściwości e.textContent.

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.

Atrybuty HTML jako właściwości elementu


Obiekt Element reprezentujący znacznik HTML zazwyczaj definiuje właściwości odpowiadające
jego atrybutom. Wśród nich są uniwersalne właściwości, takie jak id, title, lang, dir, oraz
właściwości wykorzystywane do obsługi zdarzeń, na przykład onclick. Inne elementy posiadają
właściwości charakterystyczne dla danego typu znacznika. Na przykład aby odczytać adres
URL obrazu, należy użyć właściwości src obiektu HTMLElement reprezentującego znacznik
<img>:
let image = document.querySelector("#main_image");
let url = image.src; // Atrybut src zawiera adres URL obrazu.
image.id === "main_image" // => true; wyszukanie obrazu po jego
identyfikatorze.
Analogicznie argumenty znacznika <form> można ustawić za pomocą następującego kodu:
let f = document.querySelector("form"); // Pierwszy znacznik <form>
dokumentu.
f.action = "https://www.example.com/submit"; // Ustawienie adresu URL, na
który ma być

// 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");

Atrybuty zbioru danych


Czasami warto przypisywać znacznikom HTML dodatkowe informacje, szczególnie wtedy, gdy
za pomocą kodu JavaScript mają one być wybierane i przetwarzane w niestandardowy sposób.
Specyfikacja języka HTML dopuszcza stosowanie atrybutów o nazwach składających się z
małych liter i rozpoczynających się prefiksem data-. Tego rodzaju „atrybuty zbioru danych”
pozwalają w oficjalnie przyjęty sposób przypisywać elementom dodatkowe dane bez zaburzania
struktury dokumentu. Ponadto atrybuty te nie wpływają na wygląd znaczników i można je
wykorzystywać do dowolnych celów.

Obiekt Element posiada właściwość dataset odwołującą się do obiektu posiadającego


właściwości odpowiadające poszczególnym atrybutom danych. Nazwy tych właściwości są takie
same jak nazwy atrybutów po usunięciu prefiksu data-. Na przykład właściwość dataset.x
zawiera wartość atrybutu data-x. Jeżeli nazwa atrybutu zawiera myślniki, to nazwa
właściwości jest „wielbłądzia”. Na przykład atrybutowi data-section-number odpowiada
właściwość o nazwie dataset.sectionNumber.
Załóżmy, że dokument HTML zawiera następujący tekst:
<h2 id="title" data-section-number="16.1">Atrybuty</h2>
Aby odwołać się do numeru sekcji, należy użyć następującego kodu:
let number = document.querySelector("#title").dataset.sectionNumber;

15.3.4. Zawartość elementu


Przyjrzyjmy się ponownie drzewiastej strukturze dokumentu przedstawionej na rysunku 15.1
i zadajmy sobie pytanie, jaka jest zawartość znacznika <p>? Odpowiedzi mogą być dwie:

Jest to ciąg HTML "To jest <i>prosty</i> dokument".


Jest to zwykły ciąg "To jest prosty dokument".

Obie odpowiedzi są poprawne i na swój sposób przydatne. W kolejnych punktach opisane są


sposoby przetwarzania zawartości elementów zapisanych w formacie HTML i zwykłym formacie
tekstowym.

Zawartość w formacie HTML


Właściwość innerHTML obiektu Element zawiera treść znacznika zapisaną w formacie HTML.
Po przypisaniu wartości tej właściwości przeglądarka analizuje jej zawartość i umieszcza ją w
miejscu aktualnej zawartości znacznika. Można to sprawdzić, wpisując w konsoli przeglądarki
następujący kod:
document.body.innerHTML = "<h1>Test</h1>";
Bieżąca strona zniknie, a w jej miejscu pojawi się nagłówek o treści Test. Przeglądarki bardzo
dobrze sobie radzą z analizowaniem treści HTML i przypisywanie wartości właściwości
innerHTML jest zazwyczaj bardzo skuteczną techniką. Zwróć jednak uwagę, że dołączanie za
pomocą operatora += tekstu do aktualnej zawartości tej właściwości nie jest wydajnym
rozwiązaniem, ponieważ wymaga przekształcenia zawartości elementu w ciąg znaków, a
następnie przeanalizowania nowego ciągu i przekształcenia go z powrotem na treść elementu.

Podczas korzystania z opisanego interfejsu API należy pamiętać o bardzo ważnej


kwestii — nie wolno w dokumencie umieszczać treści wprowadzanych przez
użytkownika, ponieważ w ten sposób można w aplikacji uruchomić szkodliwy kod.
Szczegółowe informacje na ten temat zostały podane w punkcie „Skrypty
międzydomenowe”

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.

Rysunek 15.2. Miejsca wstawiania kodu za pomocą metody insertAdjacentHTML()

Zawartość w formacie zwykłego tekstu


Czasami trzeba pozyskać zawartość określonego elementu w formacie zwykłego tekstu lub taki
tekst, bez zamieniania znaków <, > i & na specjalne sekwencje, umieścić w dokumencie.
Najczęściej do tego celu wykorzystuje się właściwość textContent:
let para = document.querySelector("p"); // Pierwszy znacznik <p> w
dokumencie.
let text = para.textContent; // Uzyskanie tekstu akapitu.
para.textContent = "Witaj, świecie!"; // Zmiana tekstu akapitu.
Właściwość textContent jest zdefiniowana w klasie Node, więc jest dostępna w węzłach Text
i Element. W węźle Element właściwość ta zawiera scalone teksty umieszczone we wszystkich
elementach potomnych.
Klasa Element definiuje również właściwość innerText, podobną do textContent. Jej działanie
jest dość nietypowe i skomplikowane. Na przykład jej wartość zawiera informacje o formacie
tabeli. Specyfikacja tej właściwości nie jest precyzyjna, a implementacja różni się w zależności
od przeglądarki. Dlatego korzystanie z niej nie jest zalecane.
Tekst w znaczniku <script>
Obiekt reprezentujący znacznik <script>, który nie posiada atrybutu src, ma właściwość
text. Wykorzystuje się ją do odczytywania tekstu zawartego wewnątrz znacznika.
Zawartość tego znacznika nie jest wyświetlana w oknie przeglądarki, a symbole <, > i &
nie są interpretowane jako specjalne znaki HTML. Dzięki temu znacznik ten idealnie
nadaje się do osadzania w kodzie strony dowolnych tekstowych treści wykorzystywanych
w aplikacji. W tym celu wystarczy przypisać atrybutowi type określony tekst (na przykład
"text/x-custom-data") informujący przeglądarkę, że nie jest to kod JavaScript.
Interpreter języka pominie zawartość znacznika, ale umieści ją w drzewiastej strukturze
dokumentu i będzie można ją odczytać za pomocą właściwości text.

15.3.5. Tworzenie, wstawianie i usuwanie węzłów


Wiesz już, jak odczytywać i modyfikować zawartość dokumentu, wykorzystując ciągi znaków
zapisane w formacie HTML i zwykłego tekstu. Dowiedziałeś się również, że można przeglądać
strukturę obiektu Document i zawartość umieszczonych w nim węzłów Text i Element. Ale
oprócz tego można też modyfikować dokument na poziomie poszczególnych węzłów. Klasa
Document definiuje metody umożliwiające tworzenie obiektów typu Element, a obiekty Element
i Text posiadają metody umożliwiające wstawianie, usuwanie i zastępowanie węzłów.

Utwórzmy teraz element za pomocą metody createElement() zawartej w klasie Document, a


następnie dołączmy do niego tekst i inne elementy za pomocą metod append() i prepend():
let paragraph = document.createElement("p"); // Utworzenie pustego znacznika
<p>.
let emphasis = document.createElement("em"); // Utworzenie pustego znacznika
<em>.
emphasis.append("świecie") // Dodanie tekstu do znacznika
<em>.
paragraph.append("Witaj, ", emphasis, "!"); // Dodanie tekstu do znaczników
<em> i <p>.
paragraph.prepend("¡"); // Umieszczenie dodatkowego
tekstu na początku znacznika <p>.
paragraph.innerHTML // => "¡Witaj,
<em>świecie</em>!"
Metody append() i prepend() można wywoływać z dowolną liczbą argumentów, którymi mogą
być obiekty Node i ciągi znaków. Ciągi są automatycznie przekształcane w węzły Text. (Węzeł
Text można utworzyć jawnie za pomocą metody document.createTextNode(), jednak jest to
rzadko stosowany sposób, niemający racjonalnego uzasadnienia). Metoda append() umieszcza
swoje argumenty na końcu listy elementów potomnych, natomiast metoda prepend() na
początku listy.
Powyższe metody nie nadają się jednak do umieszczania węzłów Element i Text w środku listy
elementów potomnych. Aby to zrobić, należy uzyskać referencję do węzła bliźniaczego, a
następnie wywołać jego metodę before() lub after() umieszczającą zadaną treść,
odpowiednio, przed tym węzłem lub po nim. Ilustruje to poniższy kod:

// Wyszukanie znacznika nagłówka z atrybutem class="greetings".


let greetings = document.querySelector("h2.greetings");
// Wstawienie akapitu i poziomej linii poniżej nagłówka.
greetings.after(paragraph, document.createElement("hr"));
Metody after() i before(), podobnie jak append() i prepend(), można wywoływać z dowolną
liczbą argumentów, którymi mogą być ciągi znaków lub obiekty. Wszystkie argumenty są
umieszczane w dokumencie po uprzednim przekształceniu ciągów znaków w obiekty Text.
Metody append() i prepend() są zdefiniowane wyłącznie z klasie Element, natomiast after() i
before() są dostępne zarówno w klasie Element, jak i Text, więc można ich używać do
wstawiania treści przed elementem Text lub za nim.

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ć.

15.3.6. Przykład: tworzenie spisu treści


Listing 15.1 pokazuje, jak można dynamicznie tworzyć spis treści dokumentu. W kodzie jest
wykorzystanych wiele opisanych wcześniej technik. Jest on dokładnie udokumentowany, więc
nie powinieneś mieć problemów z jego zrozumieniem.
Listing 15.1. Tworzenie spisu treści dokumentu za pomocą interfejsu DOM API
/**
* TOC.js: Utworzenie spisu treści dokumentu.
*
* Ten skrypt jest uruchamiany po zgłoszeniu zdarzenia DOMContentLoaded.

* Automatycznie tworzy spis treści dokumentu. Nie definiuje żadnych


globalnych symboli,
* więc nie powinien kolidować z innymi skryptami.
*
* Skrypt najpierw szuka w dokumencie elementu z atrybutem id="TOC". Jeżeli
go nie znajdzie,
* tworzy nowy na początku dokumentu. Następnie wyszukuje wszystkie znaczniki
od <h1> do <h6>,
* traktuje je jako tytuły sekcji i wykorzystuje do utworzenia spisu treści w
elemencie TOC.
* Do każdego tytułu dodaje numer sekcji i przekształca w nazwane odnośniki.

* Nazwy odnośników zaczynają się od prefiksu "TOC", którego nie należy


stosować w dokumencie.
*
* Utworzonym wpisom w spisie można nadać style CSS. Wszystkie wpisy mają
atrybut class="TOCEntry".
* Są im również przypisane klasy odpowiadające poziomom sekcji. Dla
znacznika <h1> jest tworzony
* wpis z klasą "TOCLevel1",
* dla znacznika <h2> wpis z klasą "TOCLevel2" itd. Każdy numer sekcji ma
przypisany atrybut
* class="TOCSectNum".

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

* .TOCSectNum:after { content: ": "; }


*
* Aby ukryć numery sekcji, należy użyć następującego stylu:
*
* .TOCSectNum { display: none }
**/
document.addEventListener("DOMContentLoaded", () => {
// Wyszukanie elementu, w którym zostanie umieszczony spis treści.
// Jeżeli element nie istnieje, jest tworzony na początku dokumentu.
let toc = document.querySelector("#TOC");
if (!toc) {

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

// Zainicjowanie tablicy, w której będą umieszczane numery sekcji.


let sectionNumbers = [0,0,0,0,0];
// Przetwarzanie w pętli znalezionych elementów nagłówków.
for(let heading of headings) {
// Pominięcie nagłówków umieszczonych w elemencie spisu treści.
if (heading.parentNode === toc) {
continue;
}
// Sprawdzenie poziomu nagłówka.

// Odjęcie jedności, ponieważ <h2> oznacza nagłówek na poziomie 1.


let level = parseInt(heading.tagName.charAt(1)) - 1;
// Zwiększenie numeru sekcji i ustawienie wszystkich poniższych numerów
na 0.
sectionNumbers[level-1]++;
for(let i = level; i < sectionNumbers.length; i++) {
sectionNumbers[i] = 0;
}
// Złączenie numerów sekcji ze wszystkich poziomów i utworzenie
oznaczenia typu "2.3.1".

let sectionNumber = sectionNumbers.slice(0, level).join(".");


// Dołączenie numeru sekcji do treści nagłówka.
// Umieszczenie numeru w znaczniku <span>, aby można mu było nadać styl.
let span = document.createElement("span");
span.className = "TOCSectNum";
span.textContent = sectionNumber;
heading.prepend(span);
// Umieszczenie nagłówka wewnątrz nazwanego odnośnika.
let anchor = document.createElement("a");
let fragmentName = `TOC${sectionNumber}`;
anchor.name = fragmentName;

heading.before(anchor); // Umieszczenie odnośnika przed nagłówkiem


anchor.append(heading); // i przeniesienie nagłówka do odnośnika.
// Utworzenie odnośnika do sekcji.
let link = document.createElement("a");
link.href = `#${fragmentName}`; // Miejsce docelowe odnośnika.
// Skopiowanie tekstu nagłówka do odnośnika. Jest to przykład
bezpiecznego
// użycia właściwości innerHTML. Nie przypisujemy jej nieznanego ciągu
znaków.
link.innerHTML = heading.innerHTML;

[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.

Podstawową cechą platformy Node jest współbieżność oparta na zdarzeniach obsługiwanych


w jednym wątku, implementowana w programach za pomocą asynchronicznego z założenia
interfejsu API. Jeżeli znasz inne języki programowania, ale niewiele kodowałeś w JavaScripcie
albo jesteś doświadczonym programistą klienckich programów dla przeglądarek, platforma
Node będzie dla Ciebie pewnym novum, tak jak każdy język czy środowisko programistyczne.
Ten rozdział rozpoczyna się od opisu modelu programowania w środowisku Node, ze
szczególnym uwzględnieniem współbieżności, strumieni danych i przetwarzania danych
binarnych za pomocą klasy Buffer. W kolejnych podrozdziałach przedstawione są
najważniejsze interfejsy API platformy Node, służące m.in. do wykonywania operacji na plikach,
sieciach, procesach i wątkach.

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.

Instalacja platformy Node


Platforma Node jest otwartym oprogramowaniem. Programy instalacyjne dla systemów
Windows i macOS można pobrać ze strony https://nodejs.org. W systemie Linux
platformę instaluje się za pomocą zwykłego menedżera pakietów. Można również ze
strony https://nodejs.org/en/download pobrać pliki binarne i zainstalować je
bezpośrednio. Jeżeli używasz środowiska kontenerowego Docker, oficjalne obrazy
znajdziesz na stronie https://hub.docker.com.
Plik instalacyjny platformy zawiera również menedżera pakietów npm, dającego dostęp do
mnóstwa narzędzi i bibliotek JavaScriptu. W przykładach opisanych w tej książce
wykorzystane są wyłącznie wbudowane pakiety, które nie wymagają stosowania
menedżera npm ani zewnętrznych bibliotek.
Nie zapominaj też o oficjalnej dokumentacji dostępnej na stronach https://nodejs.org/api
i https://nodejs.org/docs/guides, moim zdaniem uporządkowanej i dobrze napisanej.
16.1. Podstawy programowania w
środowisku Node
Ten podrozdział zaczyna się od krótkiego przeglądu struktury programu przeznaczonego dla
środowiska Node oraz jego interakcji z systemem operacyjnym.

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().

16.1.2. Argumenty poleceń i zmienne środowiskowe


Dobrze wiesz, że w tworzonych programach przeznaczonych do uruchamiania w terminalu
systemu Unix lub w innego rodzaju interfejsie konsolowym, dane wejściowe podaje się przede
wszystkim w argumentach, a dodatkowo w zmiennych środowiskowych.

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ń.

Załóżmy, że mamy plik argv.js zawierający prosty kod:

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'
]

Zwróć uwagę na kilka rzeczy:

Dwa pierwsze elementy tablicy zawierają pełnie ścieżki pliku uruchomieniowego


środowiska Node oraz pliku zawierającego uruchamiany kod JavaScript, choć nie zostały
jawnie wpisane.
Argumenty przeznaczone dla interpretera Node nie są umieszczane w tablicy
process.argv. Użyty w tym przykładzie argument --trace-uncaught nie jest do niczego
potrzebny. Został umieszczony tylko po to, aby pokazać, że nie pojawia się w
wyświetlanym wyniku. Wszystkie pozostałe argumenty wpisane po nazwie pliku
JavaScript, tutaj --arg1, --arg2 i nazwapliku, są umieszczane w tablicy process.argv.

Program uruchomiony w środowisku Node może również odczytywać dane z uniksowych


zmiennych środowiskowych. Zmienne te są dostępne w obiekcie process.env. Nazwy
właściwości tego obiektu są takie same jak nazwy zmiennych, a wartościami właściwości są
wartości tych zmiennych.

Częściowa lista zmiennych środowiskowych w moim systemie operacyjnym wygląda tak:

$ 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.

16.1.3. Cykl życia programu


Argumentem polecenia node jest nazwa pliku zawierającego kod przeznaczony do
uruchomienia. Ten plik zazwyczaj importuje moduły JavaScript oraz definiuje własne klasy i
funkcje. Zgodnie z ogólną zasadą wiersze kodu są wykonywane w kolejności od góry do dołu.
Niektóre programy kończą działanie po wykonaniu ostatniego wiersza, częściej jednak
kontynuują działanie po wykonaniu głównego pliku. Jak się dowiesz w kolejnych punktach,
wiele programów jest asynchronicznych, wykorzystujących funkcje zwrotne i procedury obsługi
zdarzeń. Nie kończą działania, dopóki jest wykonywany kod zawarty w głównym pliku i kod
funkcji zwrotnych oraz dopóki są zadania oczekujące na realizację. Program serwerowy w
środowisku Node czeka na zapytania wysyłane do niego przez sieć i teoretycznie może działać
w nieskończoność, ponieważ zawsze będą pojawiać się jakieś zapytania.
Program może również zakończyć działanie, wywołując metodę process.exit(). Użytkownik
zazwyczaj przerywa program, naciskając klawisze Ctrl+C w oknie terminala. Kod może
zignorować tę czynność, rejestrując procedurę obsługi zdarzenia za pomocą instrukcji
process.on("SIGINT", ()=>{}).
Jeżeli program zgłosi wyjątek, który nie zostanie obsłużony za pomocą instrukcji catch,
wyświetli stos wywołań i zakończy działanie. Ze względu na asynchroniczną naturę środowiska
Node wyjątki zgłaszane w funkcjach zwrotnych i procedurach obsługi zdarzeń muszą być
obsługiwane lokalnie, ewentualnie nie należy ich obsługiwać wcale. Oznacza to, że obsługa
wyjątków zgłaszanych w asynchronicznych częściach programu może być trudnym zadaniem.
Aby zapobiec awariom wywoływanym przez takie wyjątki, należy zarejestrować ich globalną
procedurę obsługi:

process.setUncaughtExceptionCaptureCallback(e => {

console.error("Nieobsłużony wyjątek:", 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:

process.on("unhandledRejection", (reason, promise) => {

// Argument reason zawiera wartość, która byłaby umieszczona w argumencie


metody catch().

// Argument promise zawiera obiekt odrzuconej promesy.

});

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.

Najprostszym sposobem poinformowania środowiska o rodzaju ładowanego modułu jest użycie


odpowiedniego rozszerzenia pliku. Jeżeli kod zostanie zapisany w pliku z rozszerzeniem .mjs,
zostanie potraktowany jako moduł ES6, w którym są stosowane instrukcje import i export, a
nie funkcja require(). Natomiast rozszerzenie .cjs będzie oznaczać moduł CommonJS, w
którym stosowana jest funkcja require(). Jeżeli taki moduł będzie zawierał instrukcję import
lub export, zostanie zgłoszony wyjątek SyntaxError.

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().

16.1.5. Node Package Manager


Wraz ze środowiskiem Node jest zazwyczaj instalowany program npm, czyli Node Package
Manager, ułatwiający pobieranie bibliotek stosowanych w programach i zarządzanie nimi.
Menedżer npm rejestruje w pliku package.json, umieszczonym w głównym katalogu projektu,
zależności między bibliotekami i inne informacje o programie. W tym pliku należy umieścić
wiersz "type":"module", jeżeli są stosowane moduły ES6.

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:

$ npm install express

npm notice created a lockfile as package-lock.json. You should commit this


file.

npm WARN my-server@1.0.0 No description


npm WARN my-server@1.0.0 No repository field.

+ express@4.17.1

added 50 packages from 37 contributors and audited 126 packages in 3.058s

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.

16.2. Domyślna asynchroniczność


JavaScript jest językiem programowania ogólnego przeznaczenia, więc można bez przeszkód
pisać w nim programy intensywnie wykorzystujące procesor, na przykład mnożące duże
macierze lub wykonujące skomplikowane obliczenia statystyczne. Jednak środowisko Node jest
zaprojektowane i zoptymalizowane pod kątem programów wykonujących dużo operacji
wejścia/wyjścia, na przykład uruchamianych na serwerach sieciowych. W szczególności łatwo
implementuje się programy szeroko wykorzystujące współbieżność, obsługujące wiele zapytań
jednocześnie.

Jednak Node, w odróżnieniu od podobnych mu środowisk, nie opiera współbieżności na


wątkach. Pisanie i diagnozowanie programów wielowątkowych jest bardzo trudne. Ponadto
wątki obciążają system operacyjny. Program serwerowy wykorzystujący setki wątków do
jednoczesnego przetwarzania setek zapytań wymaga ogromnej ilości pamięci. Dlatego w
środowisku Node stosowany jest model jednowątkowy, co stanowi znaczne ułatwienie, dzięki
któremu tworzenie programów serwerowych staje się rutynową czynnością, niewymagającą
posiadania tajemnej wiedzy.

Równoległość w środowisku Node


Program działający w środowisku Node może uruchamiać wiele procesów systemu
operacyjnego. Wersje środowiska Node 10 i nowsze obsługują obiekty Worker (patrz
podrozdział 16.11), podobne do wykorzystywanych w przeglądarkach. Program, który
uruchamia kilka procesów lub wątków roboczych na komputerze wyposażonym w kilka
procesorów, nie musi być jednowątkowy i bez przeszkód może na poszczególnych
procesorach uruchamiać różne kody. Jest to przydatna technika, jeżeli wykonywane są
operacje intensywnie wykorzystujące procesory, jednak rzadko stosowana w programach
serwerowych aktywnie korzystających z urządzeń wejścia/wyjścia.
Warto wspomnieć, że złożoność typowa dla programów wielowątkowych nie jest cechą
procesów i wątków roboczych działających w środowisku Node, ponieważ procesy i wątki
do komunikacji między sobą wykorzystują komunikaty, dzięki czemu nie muszą
współdzielić pamięci.

Wysoką współbieżność środowiska Node przy jednoczesnym zachowaniu jednowątkowego


modelu programowania osiąga się dzięki domyślnie asynchronicznym, nieblokującym
interfejsom API. Asynchroniczne podejście jest w środowisku Node traktowane bardzo
poważnie, a jego rygor może czasami zaskakiwać. Na pewno spodziewasz się, że funkcje
sieciowe wysyłające i odbierające dane są asynchroniczne, ale Node idzie dalej i oferuje funkcje
do asynchronicznego odczytywania i zapisywania plików w lokalnym systemie. Jeżeli się nad
tym zastanowić, ma to głębszy sens. Interfejs API środowiska Node powstał w czasach, gdy
standardem były wolnoobrotowe dyski magnetyczne i trzeba było wstrzymywać program na
długie milisekundy, aż się obrócą do położenia umożliwiającego rozpoczęcie wykonywania
operacji na pliku. W nowoczesnych centrach danych „lokalny” system plików może się w
rzeczywistości znajdować gdzie indziej, a sieć, za pomocą której jest podłączony, może
wprowadzać dodatkowe opóźnienia. Asynchroniczne odczytywanie plików jest normą, ale
platforma Node idzie jeszcze dalej. Na przykład funkcje inicjujące połączenia sieciowe lub
odczytujące czas modyfikacji pliku też są nieblokujące.

Niektóre funkcje dostępne w interfejsie API są synchroniczne, ale nieblokujące. Kończą


działanie po wykonaniu kodu, ale nie blokują innych operacji. Jednak większość ważnych
funkcji wykonuje operacje wejścia/wyjścia. Funkcje te są asynchroniczne, dzięki czemu nawet w
najmniejszym stopniu nie blokują innych operacji. Środowisko Node powstało, zanim w języku
JavaScript pojawiła się klasa Promise, dlatego interfejs API środowiska jest oparty na funkcjach
zwrotnych. (Jeżeli jeszcze o nich nie przeczytałeś lub już o nich zapomniałeś, teraz jest dobry
moment, aby wrócić do rozdziału 13.). W większości przypadków ostatnim argumentem funkcji
jest asynchroniczna funkcja zwrotna. W środowisku Node są stosowane tzw. funkcje zwrotne
error-first, posiadające dwa argumenty. W pierwszym jest umieszczana wartość null, jeżeli nie
pojawi się błąd, a w drugim dowolne dane lub wyniki, które są następnie zwracane przez
wywołaną funkcję asynchroniczną. Dzięki temu, że pierwszy argument zawiera informacje o
błędzie, nie ma możliwości pominięcia go i programista musi sprawdzać, czy argument zawiera
wartość inną niż null. Jeżeli argument zawiera obiekt Error, liczbę całkowitą albo ciąg
oznaczający błąd, to oznacza, że coś poszło źle. W takim przypadku zazwyczaj drugi argument
funkcji zwrotnej zawiera wartość null.
Poniższy kod demonstruje, jak można odczytać plik konfiguracyjny za pomocą nieblokującej
funkcji readFile(), przeanalizować jego zawartość zapisaną w formacie JSON i na koniec
przekazać utworzony obiekt innej funkcji zwrotnej.

const fs = require("fs"); // Import modułu odsługującego system plików.

// Funkcja odczytująca plik konfiguracyjny, analizująca jego zawartość


zapisaną w formacie JSON

// i umieszczająca wynikowy obiekt w argumencie funkcji zwrotnej.

// Jeżeli pojawi się błąd, funkcja wysyła komunikat do strumienia stderr i


wywołuje funkcję zwrotną

// z wartością null w argumencie.

function readConfigFile(path, callback) {


fs.readFile(path, "utf8", (err, text) => {

if (err) { // Coś poszło źle podczas odczytywania pliku.

console.error(err);

callback(null);
return;
}

let data = null;


try {

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

const fs = require("fs"); // Import modułu odsługującego system plików.


const pfs = { // Oparte na promesach odmiany funkcji wykonujące
operacje na plikach.
readFile: util.promisify(fs.readFile)
};

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

Za pomocą metody util.promisify() można tworzyć oparte na promesach odmiany wielu


funkcji. Dostępny w wersjach środowiska Node 10 i nowszych obiekt fs.promises posiada
wiele metod wykorzystujących promesy do wykonywania różnych operacji na plikach. Metody
te zostaną opisane w dalszej części rozdziału, ale teraz warto zwrócić uwagę, że w powyższym
przykładzie można zamiast metody pfs.readFile() użyć fs.promises.readFile().
Wspomniałem wcześniej, że model programowania w środowisku Node jest domyślnie
asynchroniczny. Jednak dla wygody programisty zdefiniowane są synchroniczne odmiany wielu
funkcji, szczególnie w module do obsługi systemu plików. Nazwy tych funkcji są zazwyczaj
czytelnie oznaczone sufiksem Sync.

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

Oprócz dwuargumentowych funkcji zwrotnych error-first środowisko Node oferuje wiele


asynchronicznych, opartych na zdarzeniach interfejsów API przeznaczonych głównie do
strumieniowego przetwarzania danych. Zdarzenia będą dokładniej opisane w dalszej części
rozdziału.

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.

W przypadku programów serwerowych i aplikacji, które większość czasu spędzają na


oczekiwaniu na odebranie i wysłanie danych, opisana współbieżność oparta na zdarzeniach jest
bardzo wydajna. Serwer wykorzystujący nieblokujące interfejsy API i gniazda sieciowe
powiązane z funkcjami JavaScript wywoływanymi, gdy pojawi się zadanie do wykonania, może
obsługiwać na przykład 50 użytkowników jednocześnie bez konieczności uruchamiania 50
wątków.

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"

Kodowanie 7-bitowych znaków ASCII, będące częścią kodowania "utf8".


"hex"
Kodowanie, w którym każdy bajt jest zamieniany na parę cyfr szesnastkowych.

"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>

b.toString() // => "ABC"; domyślnie


"utf8"

b.toString("hex") // => "414243"


let computer = Buffer.from("IBM3111", "ascii"); // Zamiana ciągu znaków na
obiekt Buffer.

for(let i = 0; i < computer.length; i++) { // Użycie obiektu Buffer


jako tablicy bajtów.
computer[i]--; // Obiekt Buffer jest
mutowalny.
}

computer.toString("ascii") // => "HAL2000"


computer.subarray(0,3).map(x=>x+1).toString() // => "IBM"
// Utworzenie "pustego" bufora za pomocą metody Buffer.alloc().

let zeros = Buffer.alloc(1024); // 1024 zera.


let ones = Buffer.alloc(128, 1); // 128 jedynek.

let dead = Buffer.alloc(1024, "DEADBEEF", "hex"); // Powtórzenie wzorca


bajtów.
// Klasa Buffer ma metody odczytujące i zapisujące wielobajtowe liczby w
dowolnym miejscu bufora.
dead.readUInt32BE(0) // => 0xDEADBEEF

dead.readUInt32BE(1) // => 0xADBEEFDE


dead.readBigUInt64BE(6) // => 0xBEEFDEADBEEFDEADn

dead.readUInt32LE(1020) // => 0xEFBEADDE


Jeżeli będziesz pisał programy przetwarzające dane bajtowe, prawdopodobnie będziesz
intensywnie korzystał z klasy Buffer. Natomiast w programach odczytujących tekst z plików
lub sieci, klasę tę będziesz stosował do tymczasowego przechowywania danych. W środowisku
Node jest dostępnych kilka interfejsów API, w których danymi wejściowymi lub wyjściowymi są
ciągi znaków lub obiekt Buffer. Zazwyczaj wywołując metodę, której argumentem lub
zwracanym wynikiem jest ciąg znaków, trzeba określić nazwę wymaganego kodowania. W
takich sytuacjach może się okazać, że klasa Buffer w ogóle nie jest potrzebna.
16.4. Zdarzenia i klasa EventEmitter
Jak pisałem wcześniej, wszystkie interfejsy API dostępne w środowisku Node są domyślnie
asynchroniczne. Asynchroniczność polega na tym, że wiele metod po wykonaniu określonej
operacji wywołuje dwuargumentową funkcję zwrotną error-first. Jednak kilka bardziej
zaawansowanych interfejsów opiera swoje działanie na zdarzeniach. Dotyczy to w szczególności
interfejsów wykorzystujących obiekty zamiast funkcji, interfejsów wielokrotnie wywołujących
funkcje zwrotne oraz interfejsów wywołujących kilka różnych funkcji zwrotnych.
Przeanalizujmy klasę net.Server. Jej instancja reprezentuje gniazdo sieciowe służące do
nawiązywania połączeń z klientami. Instancja tej klasy zaraz po utworzeniu zgłasza zdarzenie
"listening", następnie w chwili nawiązania połączenia z klientem zgłasza zdarzenie
"connection", a po zamknięciu połączenia — zdarzenie "close".

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.

const EventEmitter = require("events"); // Nazwa modułu nie musi być taka


sama jak nazwa klasy.
const net = require("net");

let server = new net.Server(); // Utworzenie obiektu Server.


server instanceof EventEmitter // => true: obiekt Server jest
instancją klasy EventEmitters.

Najważniejszą cechą klasy EventEmitter jest możliwość rejestrowania procedur obsługi


zdarzeń za pomocą metody on(). Klasa ta może zgłaszać różnego rodzaju zdarzenia opisywane
za pomocą nazw. Aby zarejestrować procedurę obsługi, należy wywołać metodę on(),
umieszczając w jej argumencie typ zdarzenia. Procedura obsługi zdarzenia może być
wywoływana z dowolną liczbą argumentów. Informacje o argumentach procedur obsługujących
zdarzenia poszczególnych typów są dostępne w dokumentacji. Poniższy kod demonstruje użycie
klasy EventEmitter.
const net = require("net");
let server = new net.Server(); // Utworzenie obiektu Server.

server.on("connection", socket => { // Oczekiwanie na zdarzenie


"connection".

// W argumencie procedury obsługi zdarzenia "connection" jest umieszczany


obiekt reprezentujący gniazdo,
// do którego podłączył się klient. Wysyłamy klientowi dane i zamykamy
połączenie.
socket.end("Witaj, świecie!", "utf8");
});

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ń.

Klasa EventEmitter definiuje również metodę emit() wywołującą zarejestrowane procedury


obsługi zdarzeń. Wykorzystuje się ją do definiowania własnych interfejsów API opartych na
zdarzeniach. Rzadko się ją jednak stosuje w programach wykorzystujących istniejące interfejsy.
W pierwszym argumencie metody emit() umieszcza się nazwę zdarzenia. Dodatkowe
argumenty metoda umieszcza w argumentach wywoływanych procedur obsługi. Identyfikator
this w tych procedurach odwołuje się do obiektu EventEmitter, co jest wygodnym
rozwiązaniem. Pamiętaj jednak, że w przypadku użycia funkcji strzałkowej wartość tego
identyfikatora zależy od kontekstu, w którym funkcja została zdefiniowana, i nie może się
zmieniać. Mimo tego procedury obsługi zdarzeń najwygodniej definiuje się przy użyciu funkcji
strzałkowych.
Wyniki zwracane przez procedury obsługi zdarzeń są pomijane. Jednak wyjątek zgłoszony przez
procedurę jest propagowany poza metodę emit(), co uniemożliwia wykonywanie kolejnych
procedur obsługujących to samo zdarzenie.
Jak już wiesz, interfejsy API w platformie Node wykorzystują do obsługi błędów funkcje
zwrotne. Dlatego zawsze należy sprawdzać, czy pierwszy argument takiej funkcji zawiera
informacje o błędzie. Odpowiednikiem takiej funkcji w interfejsie API opartym na zdarzeniach
jest procedura obsługi zdarzenia "error". Ponieważ tego rodzaju interfejsy są często
wykorzystywane do przesyłania danych przez sieć i wykonywania różnych strumieniowych
operacji wejścia/wyjścia, są narażone na nieoczekiwane, asynchroniczne problemy. Dlatego w
przypadku wystąpienia błędu klasa EventEmitter zgłasza zdarzenie "error". Podczas
korzystania z interfejsów API opartych na zdarzeniach należy wypracować sobie nawyk
rejestrowania procedury obsługi tego zdarzenia. Zdarzenie "error" jest w klasie EventEmitter
traktowanie w specjalny sposób. Gdy zostanie wywołana metoda emit() w celu zgłoszenia
zdarzenia "error" i nie będzie zarejestrowana procedura jego obsługi, zostanie zgłoszony
wyjątek. Ponieważ operacja jest wykonywana asynchronicznie, nie ma możliwości obsłużenia
wyjątku za pomocą instrukcji catch i w efekcie program zazwyczaj przerywa działanie.

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

// Asynchroniczna, ale niestrumieniowa (więc niewydajna) funkcja.


function copyFile(sourceFilename, destinationFilename, callback) {
fs.readFile(sourceFilename, (err, buffer) => {

if (err) {
callback(err);

} else {
fs.writeFile(destinationFilename, buffer, callback);
}

});
}

Powyższa funkcja wykorzystuje funkcje asynchroniczne i zwrotne, a więc jest nieblokująca i


można ją stosować w programach współbieżnych, na przykład serwerowych. Zwróć jednak
uwagę, że funkcja alokuje dużą ilość pamięci, w której umieszcza całą zawartość pliku. Czasami
to jest pożądany efekt, ale gdy kopiowany plik jest duży albo program jest współbieżny i kopiuje
wiele plików jednocześnie, może pojawić się problem. Kolejny mankament polega na tym, że
funkcja nie może zacząć zapisywać danych w nowym pliku, dopóki nie odczyta zawartości pliku
źródłowego.

Remedium na powyższe problemy jest algorytm strumieniowy, w którym dane „wpływają” do


programu, następnie są przetwarzane i „wypływają” z niego. Ideą tego algorytmu jest
przetwarzanie danych małymi porcjami, dzięki czemu nie trzeba w pamięci umieszczać całego
pliku. Podejście strumieniowe, jeżeli można je zastosować, oszczędza pamięć, jak również może
być szybsze niż tradycyjne. Interfejsy API w środowisku Node wykorzystują strumienie, a moduł
filesystem definiuje strumieniowe interfejsy API do odczytywania i zapisywania plików.
Dlatego jest bardzo prawdopodobne, że w wielu swoich programach będziesz je wykorzystywał.
Strumieniowa wersja funkcji copyFile() będzie opisana w punkcie „Tryb płynny”.
Środowisko Node obsługuje następujące rodzaje strumieni:

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).

W środowiskach wielowątkowych strumieniowe interfejsy API są zazwyczaj blokujące. Metoda


odczytująca dane może wstrzymywać swoje działanie do chwili ich nadejścia, a metoda
zapisująca może wstrzymywać działanie, dopóki w wewnętrznym buforze zrobi się miejsce na
nowe dane. W modelu współbieżności opartej na zdarzeniach metody blokujące nie mają racji
bytu, dlatego strumieniowe interfejsy API w środowisku Node są oparte na zdarzeniach i
funkcjach zwrotnych. Inaczej niż w przypadku innych interfejsów nie ma metod opisanych
wcześniej, których nazwy kończą się na "Sync".
Konieczność koordynowania operacji odczytu (bufor nie jest pusty) i zapisu (bufor nie jest
pełny) danych w strumieniu za pomocą zdarzeń powoduje, że służący do tego celu interfejs API
jest dość skomplikowany. Problem potęguje fakt, że interfejs ten ewoluował na przestrzeni lat.
Do odczytywania danych służą dwa zupełnie niezależne od siebie interfejsy. Jednak pomimo
złożoności tych interfejsów warto je dokładnie poznać, ponieważ dzięki nim można przesyłać
dane z dużą szybkością.

W kolejnych punktach zostało opisane odczytywanie i zapisywanie danych za pomocą klas


strumieniowych.

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) {

// Najpierw implementujemy obsługę błędów.


function handleError(err) {
readable.close();
writable.close();

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

Do wykonywania operacji na potokach, szczególnie obejmujących więcej niż dwa strumienie,


dobrze nadaje się klasa Transform. Na przykład poniższa funkcja kompresuje plik:
const fs = require("fs");
const zlib = require("zlib");
function gzip(filename, 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)

.on("error", callback) // Wywołanie funkcji zwrotnej w przypadku


błędu zapisu.
.on("finish", callback); // Wywołanie funkcji zwrotnej po zakończeniu
operacji.
}
Kopiowanie danych za pomocą metody pipe() ze strumienia Readable do Writable jest łatwe,
ale w praktyce często trzeba przepływające przez program dane przetwarzać w określony
sposób. Jedno z rozwiązań polega na zaimplementowaniu własnego strumienia Transform,
dzięki któremu unika się ręcznego kodowania odczytu i zapisu danych. Poniższy przykład
przedstawia funkcję wykonującą podobną operację jak polecenie grep w systemie Unix, tj.
odczytującą tekst ze strumienia wejściowego, a w strumieniu wyjściowym zapisującą wyłącznie
wiersze pasujące do zadanego wyrażenia regularnego:

const stream = require("stream");


class GrepStream extends stream.Transform {
constructor(pattern) {
super({decodeStrings: false}); // Nie przekształcamy ciągów znaków z
powrotem na bajty.

this.pattern = pattern; // Wykorzystywane wyrażenie regularne.


this.incompleteLine = ""; // Fragment ostatniej porcji danych.
}
// Metoda wywoływana w chwili, gdy jest gotowy ciąg przeznaczony do
przekształcenia.

// Przetworzone dane umieszcza w argumencie funkcji zwrotnej.


// W strumieniu wejściowym powinny pojawiać się wyłącznie dane tekstowe,
// dlatego należy wywołać metodę setEncoding() tego strumienia.
_transform(chunk, encoding, callback) {
if (typeof chunk !== "string") {

callback(new Error("Oczekiwane dane tekstowe, a nie binarne"));


return;
}
// Dodanie fragmentu ostatniego niepełnego wiersza i podzielenie danych
na osobne wiersze.

let lines = (this.incompleteLine + chunk).split("\n");


// Ostatni element tablicy zawiera nowy, niekompletny wiersz.
this.incompleteLine = lines.pop();
// Wyszukanie wszystkich dopasowanych wierszy.

let output = lines // Bierzemy wszystkie pełne


wiersze,…
.filter(l => this.pattern.test(l)) // …zostawiamy tylko
dopasowane…
.join("\n"); // …i łączymy je ze sobą.
// Jeżeli są dopasowane dane, dodajemy podział wiersza.
if (output) {

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) {

// Jeżeli jest niepełny, ale dopasowany wiersz, umieszczamy go w


argumencie funkcji zwrotnej.
if (this.pattern.test(this.incompleteLine)) {
callback(null, this.incompleteLine + "\n");
}

}
}
// 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ń.

process.stdin // Otwarcie standardowego wejścia.


.setEncoding("utf8") // Odczytanie danych jako ciągu
znaków Unicode.
.pipe(new GrepStream(pattern)) // Skierowanie danych do obiektu
GrepStream.

.pipe(process.stdout) // Wysłanie danych do


standardowego wyjścia.
.on("error", () => process.exit()); // Zakończenie programu po
zamknięciu strumienia stdout.

16.5.2. Iteracje asynchroniczne


W wersjach środowiska Node 12 i nowszych obiekt Readable jest asynchronicznym iteratorem.
Oznacza to, że za pomocą kodu podobnego do synchronicznego można w pętli for/await
odczytywać ze strumienia wejściowego dane tekstowe lub binarne. (Więcej informacji o
iteratorach asynchronicznych i pętli for/await znajdziesz w podrozdziale 13.4)
Z iteratorów asynchronicznych korzysta się niemal równie łatwo jak z metody pipe(), a jeszcze
łatwiej, gdy trzeba przetwarzać w określony sposób odczytywane porcje danych. Poniższy
przykład przedstawia zmodyfikowany program grep z poprzedniego punktu. Wykorzystana jest
w nim funkcja asynchroniczna i pętla for/await.
// Funkcja odczytująca tekst ze strumienia wejściowego i zapisująca
// w strumieniu wyjściowym wiersze dopasowane do zadanego wzorca.
async function grep(source, destination, pattern, encoding="utf8") {
// Utworzenie strumienia wejściowego przeznaczonego do odczytywania danych
tekstowych, a nie binarnych.
source.setEncoding(encoding);
// Rejestracja procedury obsługi błędów wywoływanej w przypadku
nieoczekiwanego
// zamknięcia strumienia wyjściowego (np. po przekierowaniu go do polecenia
'head').
destination.on("error", err => process.exit());
// Porcja danych raczej nie zawiera na końcu znaku podziału wiersza,
// tylko część następnego wiersza. Sprawdzamy to tutaj.
let incompleteLine = "";

// Pętla for/await odczytująca asynchronicznie porcje danych ze strumienia


wejściowego.
for await (let chunk of source) {
// Podzielenie połączonej ostatniej i nowej porcji danych na osobne
wiersze.

let lines = (incompleteLine + chunk).split("\n");


// Ostatni wiersz jest niepełny.
incompleteLine = lines.pop();
// Przetworzenie wierszy za pomocą pętli i zapisanie pasujących w
strumieniu wyjściowym.

for(let line of lines) {


if (pattern.test(line)) {
destination.write(line + "\n", encoding);
}
}

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

16.5.3. Zapisywanie danych w strumieniu i obsługa


nacisku zwrotnego
Opisana w poprzednim rozdziale funkcja grep() jest przykładem użycia obiektu Readable
w charakterze asynchronicznego iteratora. Demonstruje również zapisywanie danych za
pomocą metody write() obiektu Writable. Pierwszym argumentem tej metody jest bufor lub
ciąg znaków. (Do przetwarzania innego rodzaju obiektów służą strumienie obiektowe, ale ten
temat wykracza poza zakres niniejszego rozdziału). Jeżeli jest to bufor, zawarte w nim bajty
danych są bezpośrednio zapisywane w strumieniu. Jeżeli natomiast jest to ciąg znaków, jest on
najpierw kodowany, a dopiero potem przekształcany w bufor bajtów. Jeżeli metoda write()
zostanie wywołana z jednym argumentem, stosowane jest domyślnie kodowanie "utf8", które
można zmienić, wywołując metodę setDefaultEncoding() obiektu Writable. Innym
rozwiązaniem jest umieszczenie nazwy kodowania w drugim argumencie metody.
Trzecim, opcjonalnym argumentem metody write() jest funkcja zwrotna wywoływana po
zapisaniu danych i opróżnieniu wewnętrznego bufora obiektu Writable. Funkcja jest również
wywoływana, choć nie zawsze, w przypadku pojawienia się błędu. Aby wykrywać błędy, należy
w obiekcie Writable zarejestrować procedurę obsługi zdarzenia "error".
Bardzo ważny jest wynik zwracany przez metodę write(). Jeżeli wewnętrzny bufor po
umieszczeniu w nim porcji danych nie będzie pełny, metoda zwróci wynik true. Jeżeli będzie
pełny jeszcze przed umieszczeniem w nim danych lub zapełni się po ich umieszczeniu, metoda
zwróci wynik false. Wynik ten jest jedynie wskazówką, którą można pominąć, ponieważ obiekt
Writable powiększa wewnętrzny bufor odpowiednio do zapotrzebowania. Pamiętaj jednak, że
strumieniowy interfejs API stosuje się przede wszystkim po to, aby uniknąć umieszczania
dużych ilości danych w pamięci.
Wynik false metody write() oznacza nacisk zwrotny, czyli komunikat wysyłany przez
strumień, że dane są w nim zapisywane szybciej, niż mogą być obsłużone. Właściwą reakcją na
taki nacisk jest zaprzestanie wywoływania metody write() do chwili zgłoszenia przez strumień
zdarzenia "drain" oznaczającego, że jest już wolne miejsce w buforze. Poniższy przykład
przedstawia funkcję zapisującą dane i wywołującą funkcję zwrotną, gdy w strumieniu można
zapisywać kolejne dane.
function write(stream, chunk, callback) {

// Zapisane porcji danych we wskazanym strumieniu.


let hasMoreRoom = stream.write(chunk);
// Sprawdzenie wyniku zwróconego przez metodę write().
if (hasMoreRoom) { // Jeżeli jest to true,…
setImmediate(callback); // …asynchronicznie wywołujemy
funkcję zwrotną.
} else { // Jeżeli jest to false,…

stream.once("drain", callback); // …wywołujemy funkcję zwrotną po


zgłoszeniu
// …zdarzenia "drain".
}
}

Możliwość wielokrotnego wywoływania metody write() i konieczność oczekiwania na


zdarzenie "drain" komplikuje algorytm zapisywania danych. Metoda pipe() jest tak atrakcyjna
między innymi dlatego, że reagowaniem na nacisk zwrotny zajmuje się środowisko Node.
Używając instrukcji await i async oraz asynchronicznego iteratora Readable, można łatwo
napisać opartą na promesie i poprawnie obsługującą nacisk zwrotny odmianę powyższej funkcji
write(). Opisana wcześniej asynchroniczna funkcja grep() nie obsługuje nacisku zwrotnego.
Poniższa asynchroniczna funkcja copy() pokazuje, jak należy to robić. Zwróć uwagę, że funkcja
kopiuje porcje danych ze strumienia źródłowego do docelowego, więc wywołanie copy(source,
destination) jest bardzo podobne do wywołania source.pipe(destination).
// Funkcja zapisująca porcję danych we wskazanym strumieniu i zwracająca
promesę, która jest spełniana,
// gdy można zapisywać kolejne dane. Dzięki temu funkcję można stosować z
instrukcją await.

function write(stream, chunk) {


// Zapisanie porcji danych we wskazanym strumieniu.
let hasMoreRoom = stream.write(chunk);
if (hasMoreRoom) { // Jeżeli bufor jest pełny…

return Promise.resolve(null); // …zwracamy spełnioną promesę.


} else {
return new Promise(resolve => { // W przeciwnym razie zwracamy
promesę…
stream.once("drain", resolve); // …spełnianą po zgłoszeniu
zdarzenia "drain".

});
}
}
// 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').

destination.on("error", err => process.exit());


// Asynchroniczne odczytywanie danych ze strumienia wejściowego za pomocą
pętli for/await.
for await (let chunk of source) {
// Zapisane danych i oczekiwanie na wolne miejsce w buforze.

await write(destination, chunk);


}
}
// Kopiowane danych ze standardowego wejścia do standardowego wyjścia.

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.

16.5.4. Odczytywanie danych ze strumienia za


pomocą zdarzeń
Dane ze strumienia można odczytywać w dwóch trybach, z których każdy ma własny interfejs
API. Jeżeli w programie nie można zastosować potoków ani asynchronicznej iteracji, można
użyć jednego z tych interfejsów. Ważne jest, aby wybrać tylko jeden. Nie można stosować
dwóch interfejsów jednocześnie.

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ć.

Jeżeli program odczytuje w trybie płynnym dane ze strumienia Readable, przetwarza je i


zapisuje w strumieniu Writable, musi obsługiwać nacisk zwrotny. Gdy metoda write() zwróci
wynik false oznaczający, że bufor jest pełny, należy wywołać metodę pause() obiektu
Readable, aby czasowo wstrzymać zgłaszanie zdarzeń "data". Gdy obiekt Writable zgłosi
zdarzenie "drain", można wywołać metodę resume() obiektu Readable, dzięki czemu
zdarzenia "data" zaczną się znowu pojawiać.
Gdy zostanie osiągnięty koniec danych, strumień zgłasza zdarzenie "end" oznaczające również,
że nie będzie więcej zgłaszanych zdarzeń "data". Ponadto, tak jak w przypadku wszystkich
strumieni, jeżeli wystąpi błąd, jest zgłaszane zdarzenie "error".
Na początku podrozdziału poświęconego strumieniom opisałem niestrumieniową funkcję
copyFile() i wspomniałem, że pokażę jej ulepszoną wersję. Poniżej jest przedstawiona
strumieniowa wersja tej funkcji, wykorzystująca tryb płynny i obsługująca nacisk zwrotny.
Łatwiej byłoby ją zaimplementować za pomocą funkcji pipe(), ale jest to przede wszystkim
przykład użycia kilku procedur obsługi zdarzeń do koordynowania przepływu danych z jednego
strumienia do drugiego.
const fs = require("fs");
// Strumieniowa funkcja kopiująca w trybie płynnym plik źródłowy o zadanej
nazwie do pliku
// docelowego o zadanej nazwie.

// Po pomyślnym skopiowaniu wywołuje funkcję zwrotną z argumentem null, a w


przypadku błędu – z obiektem
// Error.
function copyFile(sourceFilename, destinationFilename, callback) {
let input = fs.createReadStream(sourceFilename);

let output = fs.createWriteStream(destinationFilename);


input.on("data", (chunk) => { // Gdy pojawią się nowe dane…
let hasRoom = output.write(chunk); // …zapisujemy je w strumieniu
wyjściowym.

if (!hasRoom) { // Jeżeli wyjściowy bufor jest


pełny…
input.pause(); // …wstrzymujemy zapisywanie.
}
});

input.on("end", () => { // Gdy zostanie osiągnięty koniec


danych…
output.end(); // …zamykamy strumień.
});
input.on("error", err => { // Jeżeli podczas odczytu pojawi
się błąd…

callback(err); // …wywołujemy funkcję zwrotną z


obiektem
// …błędu w argumencie…
process.exit(); // …i kończymy działanie.
});

output.on("drain", () => { // Gdy w buforze wyjściowym zrobi


się miejsce…
input.resume(); // …wznawiamy obsługę zdarzeń
strumienia wejściowego.
});
output.on("error", err => { // Jeżeli podczas zapisu danych
pojawi się błąd…
callback(err); // …wywołujemy funkcję zwrotną z
obiektem
// …błędu w argumencie…
process.exit(); // …i kończymy działanie.

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

input.on("readable", () => { // Gdy dane są gotowe do odczytania…


let chunk;
while(chunk = input.read()) { // …odczytujemy je. Jeżeli metoda
zwróci wynik inny niż null…
hasher.update(chunk); // …dane umieszczamy w argumencie
metody wyliczającej

// …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.
});

input.on("error", callback); // W przypadku błędu również


wywołujemy funkcję zwrotną.
}
// Kod prostego narzędzia wyliczającego sumę kontrolną pliku.
sha256(process.argv[2], (err, hash) => { // W wierszu polecenia należy
umieścić nazwę pliku.
if (err) { // Jeżeli pojawi się błąd…

console.error(err.toString()); // …wyświetlamy komunikat.


} else { // W przeciwnym razie…
console.log(hash); // …wyświetlamy wyliczoną sumę.
}
});

16.6. Procesy, procesory i szczegóły


systemu operacyjnego
Globalny obiekt Process posiada wiele właściwości i funkcji umożliwiających wykonywanie
różnych operacji na procesie aktualnie uruchomionego programu. Szczegółowe informacje są
zawarte w dokumentacji środowiska Node, natomiast poniżej jest wymienionych kilka
właściwości i funkcji, które warto znać.
process.argv // Tablica zawierająca argumenty wpisane w wierszu
poleceń.

process.arch // Architektura procesora, np. "x64".


process.cwd() // Funkcja zwracająca katalog roboczy.
process.chdir() // Funkcja zmieniająca katalog roboczy.
process.cpuUsage() // Funkcja zwracająca stopień wykorzystania
procesora.

process.env // Obiekt zawierający zmienne środowiskowe.


process.execPath // Bezwzględna ścieżka pliku wykonywalnego środowiska
Node.
process.exit() // Funkcja przerywająca działanie programu.
process.exitCode // Liczba całkowita zwracana przez program po
zakończeniu działania.
process.getuid() // Funkcja zwracająca identyfikator bieżącego
użytkownika w systemie Unix.
process.hrtime.bigint() // Funkcja zwracająca znacznik bieżącego czasu z
nanosekundową dokładnością.
process.kill() // Funkcja wysyłająca sygnał do innego procesu.

process.memoryUsage() // Funkcja zwracająca obiekt zawierający informacje o


wykorzystaniu pamięci.
process.nextTick() // Funkcja podobna do setImmediate(), wywołująca
zadaną
// funkcję z niewielkim opóźnieniem.

process.pid // Identyfikator bieżącego procesu.


process.ppid // Identyfikator nadrzędnego procesu.
process.platform // Oznaczenie systemu operacyjnego, np. "linux",
"darwin", "win32".
process.resourceUsage() // Funkcja zwracająca obiekt zawierający informacje o
wykorzystaniu zasobów.

process.setuid() // Ustawienie nazwy lub identyfikatora bieżącego


użytkownika.
process.title // Nazwa procesu, wyświetlana po wpisaniu polecenia
'ps'.
process.umask() // Funkcja ustawiająca lub zwracająca domyślnie
uprawnienia

// dostępu do nowych plików.


process.uptime() // Funkcja zwracająca czas działania środowiska Node.

process.version // Ciąg znaków zawierający numer wersji środowiska


Node.
process.versions // Ciągi znaków zawierające numery wersji bibliotek
wykorzystywanych
// w środowisku Node.
Moduł "os", który w odróżnieniu od modułu "process" trzeba jawnie ładować za pomocą
metody require(), dostarcza szczegółowych, niskopoziomowych informacji o komputerze i
systemie operacyjnym, w których działa środowisko Node. Z tych informacji korzysta się raczej
rzadko, ale warto wiedzieć, że są one dostępne.
const os = require("os");
os.arch() // Metoda zwracająca oznaczenie architektury
procesora, np. "x64" lub "arm".

os.constants // Przydatne stałe, m.in. os.constants.signals.SIGINT.


os.cpus() // Informacje o rdzeniach procesora, m.in. czas
wykorzystania.
os.endianness() // Natywna kolejność bajtów (endianess) stosowana w
procesorze: "BE" lub "LE".
os.EOL // Natywny koniec wiersza w systemie operacyjnym: "\n"
lub "\r\n".
os.freemem() // Metoda zwracająca ilość wolnej pamięci RAM wyrażoną
w bajtach.
os.getPriority() // Metoda zwracająca priorytet procesu w systemie
operacyjnym.

os.homedir() // Metoda zwracająca katalog domowy bieżącego


użytkownika.
os.hostname() // Metoda zwracająca nazwę komputera.
os.loadavg() // Metoda zwracająca obciążenie systemu, uśrednione w
interwałach
// 1-, 5- i 15-minutowych.
os.networkInterfaces() // Metoda zwracająca informacje o dostępnych
interfejsach sieciowych.
os.platform() // Metoda zwracająca oznaczenia systemu operacyjnego,
np. "linux",
// "darwin", "win32".
os.release() // Metoda zwracająca numer wersji systemu
operacyjnego.

os.setPriority() // Metoda ustawiająca priorytet uruchamianego procesu.


os.tmpdir() // Metoda zwracająca domyślny katalog tymczasowy.
os.totalmem() // Metoda zwracająca wielkość pamięci RAM wyrażoną w
bajtach.
os.type() // Metoda zwracająca oznaczenia systemu operacyjnego,
np. "Linux",
// "Darwin", "Windows_NT".
os.uptime() // Metoda zwracająca czas działania systemu
operacyjnego w sekundach.
os.userInfo() // Metoda zwracająca nazwę użytkownika, jego katalogu

// domowego i używanej powłoki.

16.7. Operacje na plikach


Moduł "fs" zawiera rozbudowany interfejs API do wykonywania operacji na plikach i
katalogach. Uzupełnia go moduł "path", który definiuje funkcje pomocnicze przetwarzające
nazwy plików i katalogów. Moduł "fs" zawiera wiele wysokopoziomowych funkcji ułatwiających
odczytywanie, zapisywanie i kopiowanie plików. Większość z nich jest powiązana z
niskopoziomowymi funkcjami systemu Unix lub ich odpowiednikami w systemie Windows. Jeżeli
miałeś już do czynienia z tego rodzaju systemowymi funkcjami (na przykład w języku C),
interfejs API środowiska Node będzie wyglądał znajomo. Jeżeli nie znasz tych funkcji, niektóre
elementy modułu "fs" mogą Ci się wydać lakoniczne i nieintuicyjne, jak na przykład funkcja
unlink() służąca do usuwania plików.
Interfejs API modułu "fs" jest rozbudowany, głównie dlatego, że wiele podstawowych operacji
można wykonywać na kilka sposobów. Jak wspomniałem na początku rozdziału, większość
funkcji, m.in. fs.readFile(), jest nieblokująca, oparta na zdarzeniach i asynchroniczna.
Przeważająca część z nich ma swoje synchroniczne odpowiedniki, na przykład
fs.readFileSync(). W wersjach środowiska Node 10 i nowszych wiele funkcji ma
asynchroniczne odmiany oparte na promesach, na przykład fs.promises.readFile().
Pierwszym argumentem większości funkcji jest ciąg znaków zawierający ścieżkę (opcjonalną
nazwę katalogu i nazwę pliku), na której jest wykonywana określona operacja. Oprócz tego jest
wiele odmian funkcji, których pierwszym argumentem jest liczba całkowita, tzw. deskryptor
pliku, a nie ścieżka. Nazwy tego rodzaju funkcji zaczynają się na literę „f”. Na przykład funkcja
fs.truncate() przycina plik wskazany za pomocą ścieżki, a funkcja fs.ftruncate() przycina
plik wskazany za pomocą deskryptora. Jest jeszcze oparta na promesie odmiana
fs.promises.truncate(), której pierwszym argumentem jest ścieżka, oraz inna odmiana,
również wykorzystująca promesę, zaimplementowana jako metoda obiektu FileHandle. Obiekt
ten jest odpowiednikiem deskryptora pliku w interfejsie API opartym na promesach. I wreszcie
jest kilka odmian funkcji, których nazwy zaczynają się na literę „l”. W większości są to funkcje
bazowe, które nie operują na plikach wskazywanych przez symboliczne odnośniki, tylko na
samych odnośnikach.

16.7.1. Ścieżki, deskryptory i klasa FileHandle


Aby móc wykonywać operacje na plikach za pomocą funkcji zawartych w module "fs",
potrzebne są nazwy tych plików. Plik najczęściej wskazuje się za pomocą ścieżki składającej się
z hierarchicznego ciągu katalogów oraz nazwy tego pliku. Ścieżka bezwzględna zawiera
nazwy wszystkich katalogów w hierarchii, począwszy od głównego. Natomiast ścieżka
względna jest stosowana w odniesieniu do innej ścieżki, zazwyczaj wskazującej bieżący katalog
roboczy. Wykonywanie operacji na ścieżkach jest dość trudne, ponieważ nazwy katalogów są w
różnych systemach operacyjnych oddzielane różnymi znakami. Podczas łączenia ścieżek łatwo
można przypadkowo powielić te znaki, a nazwa katalogu nadrzędnego (../) wymaga specjalnego
traktowania. Z pomocą w takich sytuacjach przychodzi moduł "path" oraz kilka innych
ważnych funkcjonalności środowiska Node. Ilustruje je poniższy przykład.
// Kilka ważnych ścieżek.

process.cwd() // Bezwzględna ścieżka katalogu roboczego.


__filename // Bezwzględna ścieżka pliku zawierającego bieżący kod.
__dirname // Bezwzględna ścieżka katalogu zawierającego plik
__filename.
os.homedir() // Katalog domowy użytkownika.
const path = require("path");
path.sep // Separator "/" lub "\", zależny od systemu
operacyjnego.
// Moduł "path" zawiera proste funkcje analizujące ścieżki.

let p = "src/pkg/test.js"; // Przykładowa ścieżka.


path.basename(p) // => "test.js"
path.extname(p) // => ".js"
path.dirname(p) // => "src/pkg"
path.basename(path.dirname(p)) // => "pkg"
path.dirname(path.dirname(p)) // => "src"
// Funkcja normalize() porządkuje ścieżkę:
path.normalize("a/b/c/../d/") // => "a/b/d/": przetwarza fragmenty "../".
path.normalize("a/./b") // => "a/b": usuwa fragmenty "./"

path.normalize("//a//b//") // => "/a/b/": usuwa nadmiarowe ukośniki.


// Funkcja join() łączy segmenty ścieżki, dodaje separatory i porządkuje
ścieżkę.
path.join("src", "pkg", "t.js") // => "src/pkg/t.js"
// W argumentach funkcji resolve() można umieścić kilka segmentów ścieżki.
Funkcja zwraca bezwzględną ścieżkę.
// Operacje wykonuje w kolejności od ostatniego argumentu do pierwszego.
Przerywa działanie, gdy utworzy
// bezwzględną ścieżkę lub względną w odniesieniu do ścieżki zwróconej przez
funkcję process.cwd().

path.resolve() // => process.cwd()


path.resolve("t.js") // => path.join(process.cwd(), "t.js")
path.resolve("/tmp", "t.js") // => "/tmp/t.js"
path.resolve("/a", "/b", "t.js") // => "/b/t.js"
Zwróć uwagę, że funkcja path.normalize() wykonuje po prostu operacje na ciągu znaków i w
rzeczywistości nie odwołuje się do systemu plików. Funkcje fs.realpath() i
fs.realpathSync() przetwarzają odnośniki symboliczne i interpretują względne ścieżki w
odniesieniu do bieżącego katalogu roboczego.
W opisanych wyżej przykładach zostało przyjęte założenie, że kod działa w systemie Unix lub
podobnym, w którym separatorem jest zwykły ukośnik „/”. Jeżeli ścieżki zapisane w stylu
uniksowym mają być poprawnie interpretowane w systemie Windows, należy zamiast obiektu
path użyć path.posix. Analogicznie, jeżeli ścieżki typowe dla systemu Windows mają
funkcjonować poprawnie w systemie Unix, należy użyć obiektu path.win32. Obiekty
path.posix i path.win32 definiują takie same właściwości i funkcje jak obiekt path.
Pierwszym argumentem niektórych funkcji modułu "fs", opisanych w kolejnych punktach, jest
deskryptor pliku, a nie jego nazwa. Deskryptor jest liczbą całkowitą, będącą referencją do pliku
dla systemu operacyjnego. Deskryptor pliku o zadanej nazwie uzyskuje się za pomocą funkcji
fs.open() lub fs.openSync(). Procesy mogą wykonywać operacje na ograniczonej liczbie
jednocześnie otwartych plików, dlatego ważne jest, aby na koniec zamknąć niepotrzebny plik,
wywołując funkcję fs.close() z deskryptorem w argumencie. Pliki otwiera się wtedy, gdy
trzeba za pomocą funkcji fs.read() i fs.write()wykonać niskopoziomowe operacje odczytu i
zapisu danych w różnych miejscach i w różnych momentach. Moduł "fs" zawiera również
funkcje wykorzystujące deskryptory plików. Nazwy tych funkcji są różne, w zależności od wersji
środowiska, i warto je stosować wyłącznie wtedy, gdy pliki mają być odczytywane i zapisywane.
Każda funkcja zdefiniowana w obiekcie fs.promises (na przykład fs.promises.open() będąca
odmianą fs.open()) zwraca promesę tworzącą obiekt FileHandle, który pełni taką samą rolę
jak deskryptor. Jednak, jak poprzednio, zazwyczaj nie ma potrzeby korzystania z tego obiektu,
chyba że są potrzebne jego niskopoziomowe metody read() i write(). Korzystając z obiektu
FileHandle, należy pamiętać, aby po zakończeniu przetwarzania pliku zamknąć go za pomocą
metody close().

16.7.2. Odczytywanie plików


W środowisku Node zawartość pliku można odczytać od razu w całości za pomocą strumienia
lub niskopoziomowego interfejsu API.
Jeżeli plik jest mały albo oszczędne korzystanie z pamięci komputera nie jest najważniejsze,
najczęściej zawartość pliku odczytuje się w całości za jednym razem, wywołując odpowiednią
funkcję. Może to być funkcja synchroniczna, funkcja zwrotna lub asynchroniczna
wykorzystująca promesę. Domyślnie dane są odczytywane jako bajty, ale można określić
kodowanie i odczytywać ciągi znaków. Ilustruje to poniższy kod.

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 {

// Odczytane bajty znajdują się w buforze.


}
});
// Asynchroniczne odczytywanie pliku za pomocą promesy.
fs.promises
.readFile("data.csv", "utf8")
.then(processFileText)
.catch(handleReadError);
// Można również użyć funkcji opartej na promesie oraz instrukcjach await i
async.
async function processText(filename, encoding="utf8") {
let text = await fs.promises.readFile(filename, encoding);
// Kod przetwarzający tekst.
}
Jeżeli jest dopuszczalne sekwencyjne przetwarzanie pliku, tj. nie trzeba jego całej zawartości
umieszczać w pamięci, to najbardziej efektywnym rozwiązaniem jest użycie strumienia.
(Strumienie zostały dokładnie opisane wcześniej). Poniżej jest pokazany przykład funkcji
wysyłającej zawartość wskazanego pliku do standardowego wyjścia za pomocą strumienia i
metody pipe().
function printFile(filename, encoding="utf8") {

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) {

throw new Error("Niewłaściwy typ pliku");


}
// Określenie na podstawie nagłówka położenia danych w pliku i ich
ilości.
let offset = header.readInt32LE(4);
let length = header.readInt32LE(8);
// Odczytanie danych z pliku.
let data = Buffer.alloc(length);
fs.readSync(fd, data, 0, length, offset);
return data;
} finally {

// Plik trzeba zamykać zawsze, nawet gdy zostanie zgłoszony wyjątek.


fs.closeSync(fd);
}
}

16.7.3. Zapisywanie plików


W środowisku Node zapisywanie danych w pliku jest bardzo podobne do ich odczytywania. Obie
operacje różnią się kilkoma szczegółami, o których należy pamiętać. Na przykład w celu
utworzenia nowego pliku trzeba wykonać operację zapisu z użyciem unikatowej nazwy.
Podobnie jak w przypadku odczytu, dane można zapisywać na trzy podstawowe sposoby. Jeżeli
cała zawartość pliku znajduje się w ciągu znaków lub buforze, można ją zapisać za jednym
razem za pomocą funkcji fs.writeFile() (wykorzystującej funkcje zwrotne), synchronicznej
funkcji fs.writeFileSync() lub opartej na promesie funkcji fs.promises.writeFile().
Poniżej jest przedstawiony przykład:
fs.writeFileSync(path.resolve(__dirname, "settings.json"),
JSON.stringify(settings));
W celu zapisania ciągu znaków z użyciem kodowania "utf8" należy tę nazwę umieścić w
trzecim, opcjonalnym argumencie funkcji.
Podobnie działają powiązane funkcje fs.appendFile(), fs.appendFileSync() i
fs.promises.appendFile(). Jeżeli wskazany plik istnieje, jego zawartość nie jest zastępowana,
tylko uzupełniana nowymi danymi.
Jeżeli wszystkie dane przeznaczone do zapisania nie są zebrane w jednym miejscu, to dobrym
podejściem jest użycie strumienia Writable, pod warunkiem, że dane mają być zapisywane
sekwencyjnie, a nie w różnych miejscach pliku. Demonstruje to poniższy przykład.

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().

Tryby otwarcia pliku


Aby odczytać zawartość pliku za pomocą niskopoziomowego interfejsu API, należy użyć
opisanych wcześniej funkcji fs.open() lub fs.openSync(), umieszczając w argumencie
tylko nazwę pliku. Jednak aby zapisać dane, trzeba określić drugi, tekstowy argument,
definiujący sposób użycia deskryptora pliku. Poniżej są opisane dopuszczalne wartości
tego argumentu
"w"
Otwarcie pliku do zapisu danych.
"w+"
Otwarcie pliku do zapisu i odczytu danych.
"wx"
Utworzenie i otwarcie nowego pliku. Jeżeli plik o podanej nazwie już istnieje, zgłaszany jest
wyjątek.
"wx+"
Utworzenie i otwarcie nowego pliku do zapisu i odczytu. Jeżeli plik o podanej nazwie już
istnieje, zgłaszany jest wyjątek.
"a"
Otwarcie pliku w celu dołączania danych. Istniejąca zawartość nie jest zastępowana nową.
"a+"
Otwarcie pliku w celu dołączania danych, z możliwością ich odczytywania.
Jeżeli żaden z powyższych ciągów nie zostanie umieszczony w argumencie funkcji
fs.open() lub fs.openSync(),domyślnie stosowany jest ciąg "r" oznaczający, że plik
będzie otwarty w trybie tylko do odczytu. Zwróć uwagę, że powyższe ciągi można
umieszczać również w argumentach innych funkcji zapisujących dane, jak w poniższym
przykładzie:
// Dołączenie danych do istniejącej zawartości pliku, podobnie jak za
pomocą funkcji fs.appendFileSync().
fs.writeFileSync("komunikaty.log", "Cześć!", { flag: "a" });
// Otwarcie strumienia do zapisu i zgłoszenie wyjątku, jeżeli plik
istnieje. Nie chcemy przypadkowo nadpisać
// istniejących danych!
// Zwróć uwagę, że w powyższym wierszu właściwość ma nazwę "flag", a w
poniższym "flags".
fs.createWriteStream("komunikaty.log", { flags: "wx" });

Funkcje fs.truncate(), fs.truncateSync() i fs.promises.truncate() służą do „odcinania”


końca pliku. Pierwszym argumentem każdej z nich jest ścieżka, a drugim długość pliku. Plik jest
modyfikowany tak, aby uzyskał zadaną długość. Jeżeli drugi argument zostanie pominięty,
zostanie przyjęta domyślna długość zero i usunięta cała zawartość pliku. Za pomocą tych
funkcji, pomimo nazwy (ang. truncate — przytnij), można również powiększać pliki. Jeżeli w
drugim argumencie umieści się wartość większą niż bieżąca długość pliku, zostanie do niego
dopisana odpowiednia liczba zer, aby plik osiągnął żądaną wielkość. Aby zmodyfikować otwarty
plik, należy użyć funkcji ftruncate() lub ftruncateSync() oraz deskryptora pliku lub obiektu
FileHandle.
Opisane wyżej funkcje zapisują dane w plikach w ten sposób, że zlecają systemowi
operacyjnemu wykonanie tych operacji. Wywołują wtedy funkcje zwrotne lub determinują
zwracane przez siebie promesy. Nie oznacza to jednak, że dane są natychmiast trwale
zapisywane. Część z nich może wciąż znajdować się w buforze systemu lub sterownika i
oczekiwać na zapisanie na dysku. Jeżeli zaraz po wywołaniu synchronicznej funkcji
fs.writeSync() nastąpi przerwa w zasilaniu komputera, dane zostaną utracone. Aby wymusić
ich zapisanie i mieć pewność, że są bezpieczne, należy użyć funkcji fs.fsync() i
fs.fsyncSync(). Funkcje te wymagają podania w argumencie deskryptora pliku. Nie ma
odmian dopuszczających podanie ścieżki.

16.7.4. Operacje na plikach


W opisie klas strumieniowych zostały przedstawione dwa przykłady funkcji copyFile(). Nie
należy ich jednak stosować w praktyce, ponieważ moduł "fs" definiuje funkcje fs.copyFile(),
fs.copyFileSync() i fs.promises.copyFile() wykonujące tę samą operację.
Pierwszym argumentem każdej z powyższych funkcji jest oryginalna nazwa pliku, a drugim
nazwa jego kopii. W argumentach można umieszczać ciągi znaków, obiekty URL i Buffer.
Trzeci, opcjonalny argument jest liczbą całkowitą kontrolującą operację kopiowania.
Dodatkowo funkcja fs.copyFile() ma czwarty argument, którym jest funkcja zwrotna
wywoływana po skopiowaniu pliku lub wystąpieniu błędu (w tym drugim przypadku w jej
argumencie jest umieszczany obiekt błędu). Poniżej jest przedstawionych kilka przykładów
użycia tych funkcji.
// Proste, asynchroniczne kopiowanie pliku.
fs.copyFileSync("ch15.txt", "ch15.bak");
// Użycie stałej COPYFILE_EXCL powoduje, że plik jest kopiowany tylko wtedy,
gdy plik o docelowej

// nazwie nie istnieje.


// Jest to zabezpieczenie przed przypadkowym nadpisaniem pliku.
fs.copyFile("ch15.txt", "ch16.txt", fs.constants.COPYFILE_EXCL, err => {
// Ta funkcja zwrotna jest wywoływana po skopiowaniu pliku lub
// wystąpieniu błędu (argument err ma wtedy wartość różną od null).
});
// Poniższy kod demonstruje odmianę funkcji copyFile() opartą na promesie.
// Za pomocą operatora | są łączone dwie flagi chroniące istniejący plik
przed nadpisaniem, a kopia jest

// 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");

16.7.5. Metadane pliku


Funkcje fs.stat(), fs.statSync() i fs.promises.stat() dostarczają metadanych o
wskazanym pliku lub katalogu. Ilustruje to poniższy przykład:
const fs = require("fs");
let stats = fs.statSync("book/ch15.md");
stats.isFile() // => true: to jest zwykły plik.
stats.isDirectory() // => false: to nie jest katalog.
stats.size // Wielkość pliku w bajtach.
stats.atime // Czas dostępu do pliku, czyli kiedy plik został
ostatni raz odczytany.
stats.mtime // Czas ostatniej modyfikacji pliku, czyli zapisania w
nim danych.

stats.uid // Identyfikator właściciela pliku.


stats.gid // Identyfikator grupy, do której należy właściciel
pliku.
stats.mode.toString(8) // Uprawnienia dostępu do pliku (liczba ósemkowa
zamieniona na ciąg znaków).
Zwracany obiekt Stats zawiera dodatkowe, mniej czytelne właściwości i metody, jednak
powyższy przykład demonstruje te najczęściej stosowane.
Funkcja fs.lstat() i jej odmiany działają podobnie jak fs.stat() z tą różnicą, że zwracają
metadane odnośnika o podanej nazwie, a nie wskazywanego przez niego pliku.
Jeżeli plik został otwarty i utworzony został jego deskryptor lub obiekt FileHandle, można użyć
funkcji fs.fstat() lub jednej z jej odmian bez ponownego określania nazwy pliku.

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).

16.7.6. Operacje na katalogach


Do tworzenia katalogów służą funkcje fs.mkdir(), fs.mkdirSync() i fs.promises.mkdir().
W pierwszym argumencie należy umieścić ścieżkę tworzonego katalogu. W drugim,
opcjonalnym argumencie można umieścić liczbę całkowitą określającą tryb (uprawnienia)
dostępu lub obiekt posiadający właściwości mode i recursive. Jeżeli druga właściwość ma
wartość true, funkcja tworzy katalogi wraz z podkatalogami wymienionymi w ścieżce. Ilustruje
to poniższy przykład:
// Utworzenie katalogów dist/ i dist/lib/.
fs.mkdirSync("dist/lib", { recursive: true });
Funkcja fs.mkdtemp() i jej odmiany dołączają do podanej w argumencie ścieżki losowo
wybrane znaki (jest to ważne ze względów bezpieczeństwa) i tworzą katalog o tak powstałej
nazwie. Nazwa ta jest zwracana jako wynik lub umieszczana w argumencie funkcji zwrotnej.
Aby usunąć katalog, należy użyć funkcji fs.rmdir() lub jednej z jej odmian. Pamiętaj, że
katalog przeznaczony do usunięcia musi być pusty. Poniższy kod przedstawia przykład użycia
wyżej opisanych funkcji.
// Utworzenie katalogu o losowo wybranej nazwie i usunięcie go, gdy
przestanie być potrzebny.
let tempDirPath;
try {
tempDirPath = fs.mkdtempSync(path.join(os.tmpdir(), "d"));
// Tutaj jest kod wykonujący operacje na katalogu.

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

const path = require("path");


async function listDirectory(dirpath) {
let dir = await fs.promises.opendir(dirpath);
for await (let entry of dir) {
let name = entry.name;
if (entry.isDirectory()) {
name += "/"; // Dodanie ukośnika na końcu nazwy katalogu.
}
let stats = await fs.promises.stat(path.join(dirpath, name));
let size = stats.size;

console.log(String(size).padStart(10), name);
}
}

16.8. Klienty i serwery HTTP


Moduły "http", "https" i "http2" są kompletnymi, ale niskopoziomowymi implementacjami
protokołu HTTP. Definiują rozbudowane interfejsy API do tworzenia klientów i serwerów
wykorzystujących ten protokół. Ponieważ są to niskopoziomowe implementacje, nie można ich
wszystkich opisać w tym rozdziale. Jednak przedstawione niżej przykłady pokazują, jak przy
użyciu tych modułów pisze się proste programy klienckie i serwerowe.
Podstawowe zapytanie HTTP GET najprościej wysyła się za pomocą funkcji http.get() lub
https.get(). W pierwszym argumencie należy umieścić adres URL (jeżeli zaczyna się od
prefiksu http://, należy użyć modułu "http", a jeżeli od prefiksu https:// — modułu "https").
Drugim argumentem jest funkcja zwrotna, wywoływana z obiektem IncomingMessage w
argumencie w chwili rozpoczęcia odbierania odpowiedzi. Dostępne są wtedy nagłówki status i
headers, ale treść odpowiedzi jeszcze nie. Obiekt IncomingMessage jest strumieniem Readable,
więc treść odpowiedzi można odczytać, stosując techniki opisane wcześniej w tym rozdziale.
W przedstawionej na końcu punktu 13.2.6 funkcji getJSON() w konstruktorze Promise()
została użyta funkcja http.get(). Teraz, gdy lepiej znasz strumienie i model programowania w
środowisku Node, warto ponownie przyjrzeć się temu przykładowi i użyciu funkcji http.get().
Funkcje http.get() i https.get() są odmianami bardziej ogólnych funkcji http.request()
i https.request(). Przedstawiony niżej przykład funkcji postJSON() demonstruje wysyłanie za
pomocą funkcji https.request() zapytania HTTPS POST zawierającego treść zapisaną w
formacie JSON. Poniższa funkcja, podobnie jak getJSON() z rozdziału 13., zakłada, że
odpowiedź jest zapisana w formacie JSON, i zwraca promesę generującą przetworzoną treść tej
odpowiedzi.
const https = require("https");
/*
* Funkcja przekształcająca argument 'body' w ciąg JSON i wysyłająca go w
zapytaniu HTTPS POST
* do serwera 'host' na adres wskazany w argumencie 'endpoint'.
* Funkcja zwraca promesę generującą ciąg znaków zawierający przetworzoną
odpowiedź zapisaną

* 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 = {

method: "POST", // Lub "GET", "PUT", "DELETE" itp.


host: host, // Komputer, do którego jest wysyłane
zapytanie.
path: endpoint, // Adres URL.
headers: { // Nagłówki zapytania HTTP.
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(bodyText)
}
};

if (port) { // Jeżeli jest określony argument


'port'…
requestOptions.port = port; // …stosujemy go w zapytaniu.
}
// Jeżeli podane są poświadczenia, umieszczamy je w nagłówku
Authorization.
if (username && password) {
requestOptions.auth = `${username}:${password}`;
}
// Przygotowanie obiektu zapytania na podstawie zadanych argumentów.

let request = https.request(requestOptions);


// Zapisanie treści zapytania i zamknięcie go.
request.write(bodyText);
request.end();
// Obsługa błędu, np. braku połączenia sieciowego.
request.on("error", e => reject(e));
// Przetwarzanie pojawiających się danych odpowiedzi.
request.on("response", response => {
if (response.statusCode !== 200) {
reject(new Error(`Status HTTP ${response.statusCode}`));

// W tym momencie nie przetwarzamy jeszcze treści, ale aby nie


została umieszczona w buforze,
// przełączamy strumień w tryb płynny bez rejestrowania procedury
obsługi zdarzenia "data".
response.resume();
return;
}
// Oczekujemy tekstu, a nie bajtów. Zakładamy, że tekst odpowiedzi jest
zapisany w formacie JSON,
// więc nie sprawdzamy zawartości nagłówka Content-Type.
response.setEncoding("utf8");

// Środowisko Node nie pozwala strumieniowo analizować danych JSON,


// dlatego całą treść odpowiedzi umieszczamy w ciągu znaków.
let body = "";
response.on("data", chunk => { body += chunk; });
// Przetworzenie całej odebranej odpowiedzi.
response.on("end", () => { // Gdy zostanie odebrana cała
odpowiedź…
try { // …analizujemy jej treść
zapisaną w formacie

// …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:

utworzenie obiektu Server,


wywołanie metody listen() powyższego obiektu i oczekiwanie na zapytania wysyłane na
wskazany port,
zarejestrowanie procedury obsługi zdarzenia "request". Procedura ta odbiera zapytania
wysyłane przez klientów (w szczególności odczytuje zawartość właściwości request.url)
i odsyła odpowiedź.

Poniżej jest przedstawiony prosty program serwerowy udostępniający za pomocą protokołu


HTTP pliki zapisane w lokalnym systemie. Program implementuje również specjalny adres
diagnostyczny. Zapytanie wysłane na ten adres jest z powrotem odsyłane do klienta.
// Prosty program serwerowy udostępniający pliki zapisane w określonym
katalogu.
// Implementuje specjalny adres diagnostyczny /test/mirror powodujący
zwrócenie wysłanego
// na ten adres zapytania.
const http = require("http"); // Jeżeli jest certyfikat, można użyć modułu
"https".
const url = require("url"); // Moduł do analizowana adresów URL.
const path = require("path"); // Moduł do przetwarzania ścieżek.
const fs = require("fs"); // Moduł do odczytywania plików.
// Funkcja udostępniająca za pomocą protokołu HTTP i wskazanego portu pliki
zapisane we wskazanym

// 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).

let endpoint = url.parse(request.url).pathname;


// Jeżeli zapytanie zostało wysłane na adres /test/mirror, odsyłamy je z
powrotem.
// Funkcjonalność ta pozwala sprawdzać nagłówki i treści zapytania.
if (endpoint === "/test/mirror") {
// Ustawienie nagłówka odpowiedzi.
response.setHeader("Content-Type", "text/plain; charset=UTF-8");
// Ustawienie kodu odpowiedzi.
response.writeHead(200); // 200 OK

// Umieszczenie w odpowiedzi informacji o zapytaniu.


response.write(`${request.method} ${request.url} HTTP/${

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

// Treść zapytania umieszczamy w treści odpowiedzi. Ponieważ


wykorzystywane są strumienie,
// możemy użyć potoku.
request.pipe(response);
}
// Jeżeli adres jest inny, udostępniamy plik z lokalnego katalogu.
else {
// Powiązanie adresu z plikiem.
let filename = endpoint.substring(1); // Usunięcie wiodącego
ukośnika.

// Adres nie może zawierać ciągu "../", ponieważ udostępniony byłby


plik
// spoza wskazanego katalogu, co stanowiłoby zagrożenie bezpieczeństwa.
filename = filename.replace(/\.\.\//g, "");
// Przekształcenie ścieżki względnej w bezwzględną.
filename = path.resolve(rootDirectory, filename);
// Określenie typu zawartości pliku na podstawie jego rozszerzenia.
let type;
switch(path.extname(filename)) {

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.

// Następnie kierujemy strumień zapytania do strumienia odpowiedzi.


// Potok automatycznie wywoła metodę response.end() po odebraniu
wszystkich danych.
response.setHeader("Content-Type", type);
response.writeHead(200);
stream.pipe(response);
});
stream.on("error", (err) => {
// Błąd podczas otwierania strumienia prawdopodobnie oznacza, że
żądany plik nie istnieje

// lub nie można go odczytać.


// Wysyłamy wtedy zwykły tekst zawierający komunikat o błędzie i
ustawiamy kod 404 stanu
// odpowiedzi.
response.setHeader("Content-Type", "text/plain; charset=UTF-8");
response.writeHead(404);
response.end(err.message);
});
}

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

16.9. Klienty i serwery inne niż HTTP


Serwery i klienty WWW są dzisiaj tak rozpowszechnione, że nie sposób sobie wyobrazić, aby
mogły wykorzystywać inne protokoły niż HTTP. Środowisko Node jest znane z tego, że pozwala
tworzyć dobre oprogramowanie dla serwerów WWW, ale w pełni obsługuje również inne rodzaje
serwerów i klientów.
Jeżeli dobrze znasz strumienie, to komunikacja sieciowa będzie dla Ciebie prosta, ponieważ
gniazdo sieciowe jest odmianą strumienia Duplex. Moduł "net" definiuje klasy Server i Socket.
Aby utworzyć program serwerowy, należy użyć funkcji net.createServer(), a następnie
metody listen() zwróconego obiektu, umieszczając w jej argumencie numer portu, który ma
być wykorzystywany do nawiązywania połączeń. Gdy klient nawiąże połączenie z tym portem,
obiekt Server zgłasza zdarzenie "connection", a w argumencie procedury jego obsługi jest
umieszczany obiekt Socket. Obiekt ten jest strumieniem Duplex, za pomocą którego można
odbierać dane od klienta, jak również wysyłać je do niego. Aby zakończyć połączenie, należy
wywołać metodę end() obiektu Socket.
Tworzenie programu klienckiego jest jeszcze prostsze. Należy wywołać funkcję
net.createConnection(), umieszczając w jej argumentach numer portu i nazwę hosta. Za
pomocą zwróconego przez nią obiektu Socket można komunikować się z wybranym serwerem,
używając wskazanego portu.
Poniższy przykład pokazuje, jak tworzy się program serwerowy za pomocą modułu "net". Gdy
klient nawiąże połączenie, serwer rozpoczyna grę słowną „puk, puk”.
// Serwer TCP grający w grę słowną "puk, puk" na porcie 6789.
const net = require("net");
const readline = require("readline");
// Utworzenie obiektu Server i oczekiwanie na połączenie.

let server = net.createServer();


server.listen(6789, () => console.log("Gra puk, puk wykorzystująca port
6789"));
// Po nawiązaniu połączenia z klientem zaczynamy grę.
server.on("connection", socket => {
tellJoke(socket)
.then(() => socket.end()) // Po zakończeniu gry zamykamy połączenie.
.catch((err) => {
console.error(err); // Zarejestrowanie błędu, jeżeli się
pojawi.
socket.end(); // Mimo błędu trzeba zamknąć połączenie.
});
});
// Gry do wyboru:
const jokes = {
"Sąsiadki": "Nie ma żadnych siatek!",
"Nożyce": "No, życę ci wszystkiego najlepszego!",
"Damy": "Damy radę!"
};
// Interaktywny dialog przy życiu gniazda sieciowego i nieblokujących
funkcji.
async function tellJoke(socket) {
// Losowe wybranie jednej gry.
let randomElement = a => a[Math.floor(Math.random() * a.length)];
let who = randomElement(Object.keys(jokes));
let punchline = jokes[who];
// Odczytanie wpisanego przez użytkownika wiersza za pomocą modułu
"readline".
let lineReader = readline.createInterface({
input: socket,

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

// Gra polega na zadawaniu pytań i udzielaniu odpowiedzi.


// Użytkownik na kolejnych etapach zadaje pytania, po czym wykonywane są
różne operacje.
let stage = 0;
// Gra zaczyna się w zwykły sposób.
output("Puk, puk!");
// Asynchroniczne odczytywanie pytań użytkownika aż do zakończenia gry.
for await (let inputLine of lineReader) {
if (stage === 0) {
if (inputLine.toLowerCase() === "kto tam?") {

// Jeżeli użytkownik zadał właściwe pytanie na etapie 0,


// udzielana jest pierwsza odpowiedź i następuje przejście do etapu
1.
output(who);
stage = 1;
} else {
// Jeżeli pytanie jest niewłaściwe, wyświetlamy podpowiedź.
output('Wpisz "Kto tam?".');
}
} else if (stage === 1) {

if (inputLine.toLowerCase() === `Jakie ${who.toLowerCase()}?`) {


// Jeżeli na etapie 1. użytkownik wpisał właściwe pytanie,
wyświetlamy drugą
// odpowiedź i kończymy grę.
output(`${punchline}`, false);
return;
} else {
// Podpowiedź dla użytkownika.
output(`Wpisz "Jakie ${who}?"`);
}

}
}
}
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.

16.10. Procesy potomne


Środowisko Node dobrze nadaje się nie tylko do tworzenia współbieżnych serwerów, ale
również skryptów uruchamiających różne programy. Moduł "child_process" definiuje wiele
funkcji umożliwiających uruchamianie różnych programów jako procesów potomnych. W tym
podrozdziale opisanych jest kilka tego rodzaju funkcji, począwszy od najprostszych, a
skończywszy na bardziej skomplikowanych.

16.10.1. Funkcje execSync() i execFileSync()


Inny program najprościej można uruchomić za pomocą funkcji child_process.execSync().
W pierwszym argumencie należy umieścić polecenie, które ma być wykonane. Funkcja tworzy
proces potomny, uruchamia w nim powłokę, którą wykorzystuje z kolei do uruchomienia
podanego polecenia. Funkcja wstrzymuje działanie kodu (i powłoki), dopóki nie zakończy
działania. Jeżeli podczas wykonywania polecenia pojawi się błąd, funkcja execSync() zgłosi
wyjątek. W przeciwnym razie zwróci wszystkie dane, jakie polecenie skieruje do strumienia
stdout. Domyślnie jest to bufor danych, ale jeżeli w drugim, opcjonalnym argumencie funkcji
określi się kodowanie, zwróci ona ciąg znaków. Jeżeli polecenie wyśle jakiekolwiek dane do
strumienia stderr, zostaną one umieszczone w strumieniu stderr procesu nadrzędnego.
Załóżmy, że tworzymy skrypt, którego wydajność nie jest istotna, a do wykonania w powłoce
Unix polecenia wyświetlającego zawartość katalogu została użyta funkcja
child_process.execSync() zamiast fs.readdirSync():
const child_process = require("child_process");
let listing = child_process.execSync("ls -l web/*.html", {encoding: "utf8"});
Uruchomienie powłoki Unix przez funkcję execSync() oznacza, że umieszczony w jej
argumencie ciąg znaków może zawierać wiele oddzielonych średnikami poleceń, jak również
może wykorzystywać różne funkcjonalności powłoki, takie jak symbole wieloznaczne,
strumienie i przekierowanie wyjścia. Oznacza również, że ciąg umieszczany w jej argumencie
nie może zawierać danych wprowadzonych przez użytkownika lub pochodzących z
niezaufanego źródła, ponieważ haker mógłby wykorzystać skomplikowaną składnię poleceń
powłoki do uruchamiania własnego kodu.
Jeżeli w programie nie są potrzebne funkcjonalności powłoki systemu, można uniknąć
wprowadzanego przez nią dodatkowego obciążenia, stosując funkcję
child_process.execFileSync(). Funkcja ta uruchamia zadany program bezpośrednio, bez
wcześniejszego otwierania powłoki. Z tego powodu pierwszy argument nie jest analizowany tak
jak polecenie powłoki. Musi to być nazwa wykonywalnego pliku. W drugim argumencie należy
umieścić tablicę zawierającą argumenty dla tego pliku:
let listing = child_process.execFileSync("ls", ["-l", "web/"],
{encoding: "utf8"});

Opcje procesu potomnego


execSync() i wiele innych funkcji zawartych w module "child_process" ma opcjonalny,
trzeci argument, w którym można umieścić obiekt zawierający dodatkowe opcje
uruchomienia procesu potomnego. Użyta w powyższym przykładzie właściwość encoding
tego obiektu powoduje, że funkcja zwraca dane generowane przez proces w formie ciągu
znaków, a nie bufora bajtów. Poniżej są wymienione inne dostępne ważne właściwości
(pamiętaj, że nie wszystkie można stosować z każdą funkcją):
Właściwość cwd wskazująca katalog roboczy procesu potomnego. Jeżeli nie zostanie
określona, przyjmowana jest wartość zwracana przez funkcję process.cwd().
Właściwość env zawierająca zmienne środowiskowe, do których proces potomny będzie
miał dostęp. Domyślnie są to zmienne określone we właściwości process.env, ale
można użyć innego obiektu, jeżeli jest taka potrzeba.
Właściwość input zawierająca ciąg znaków lub bufor bajtów, który zostanie użyty jako
standardowe wejście dla procesu potomnego. Właściwość ta jest dostępna wyłącznie dla
funkcji synchronicznych, które nie zwracają obiektu ChildProcess.
Właściwość maxBuffer określająca maksymalną liczbę bajtów danych wyjściowych,
które odbierze funkcja (nie dotyczy to funkcji spawn() i fork(), które wykorzystują
strumienie). Jeżeli proces potomny wygeneruje więcej danych, niż określa ta
właściwość, funkcja przerwie jego działanie i zgłosi błąd.
Właściwość shell zawierająca ścieżkę wykonywalnego pliku powłoki lub wartość true.
W przypadku użycia funkcji uruchamiającej polecenie powłoki za pomocą tej właściwość
można określić, jaka to ma być powłoka. Jeżeli funkcja standardowo nie korzysta z
powłoki, wartość true tej właściwości powoduje, że stosowana jest domyślna powłoka.
Ewentualnie można określić konkretną powłokę.
Właściwość timeout określająca w milisekundach maksymalny czas działania procesu
potomnego. Jeżeli przed upływem tego czasu proces nie zakończy działania, funkcja go
przerwie i zgłosi błąd. Właściwość ta nie dotyczy funkcji spawn() i fork().
Właściwość uid określająca identyfikator konta użytkownika, które ma być użyte do
uruchomienia procesu. Jeżeli proces nadrzędny wykorzystuje konto z szerokimi
uprawnieniami, można je ograniczyć za pomocą tej właściwości.

16.10.2. Funkcje exec() i execFile()


Funkcje execSync() i execFileSync(), jak sugerują ich nazwy, są funkcjami synchronicznymi,
tj. wstrzymują wykonywanie kodu, dopóki proces potomny nie zakończy działania.
Wywoływanie tych funkcji daje podobny efekt jak wpisywanie w oknie terminala wykonywanych
pojedynczo poleceń. Jeżeli jednak program musi wykonywać kilka całkowicie niezależnych od
siebie zadań, można je zrównoleglić, tj. uruchomić kilka poleceń jednocześnie. Służą do tego
celu funkcje child_process.exec() i child_process.execFile().

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

const execP = util.promisify(child_process.exec);


function parallelExec(commands) {
// Na podstawie tablicy poleceń jest tworzona tablica obiektów Promise.
let promises = commands.map(command => execP(command, {encoding: "utf8"}));
// Zwracanym wynikiem jest promesa tworząca tablicę obiektów utworzonych
przez poszczególne promesy
// (zamiast obiektów zawierających właściwości stdout i stderr zwracana
jest po prostu wartość
// właściwości stdout).
return Promise.all(promises)

.then(outputs => outputs.map(out => out.stdout));


}
module.exports = parallelExec;

16.10.3. Funkcja spawn()


Opisane wcześniej synchroniczne i asynchroniczne funkcje służą do uruchamiania procesów
potomnych, które mają działać krótko i nie generować dużych ilości danych. Nawet funkcje
exec() i execFile() nie są strumieniowe. Zwracają od razu wszystkie dane wygenerowane
przez proces zaraz po zakończeniu jego działania.
Funkcja child_process.spawn() daje strumieniowy dostęp do danych generowanych przez
proces potomny jeszcze w trakcie jego działania. Pozwala również wysyłać dane do procesu,
który je może odbierać ze swojego standardowego wejścia. Oznacza to, że za pomocą tej funkcji
można dynamicznie komunikować się z procesem potomnym i wysyłać do niego różne dane w
zależności od generowanych przez niego wyników.
Funkcja spawn() domyślnie nie korzysta z powłoki, dlatego aby ją otworzyć, należy użyć funkcji
execFile() lub podobnej, umieszczając w jej argumencie ścieżkę pliku wykonywalnego oraz
tablicę argumentów dla wiersza poleceń. Funkcja spawn() zwraca obiekt ChildProcess, tak jak
execFile(), ale nie można w jej argumencie umieścić funkcji zwrotnej. Zamiast tego należy
zarejestrować procedurę obsługi zdarzeń zgłaszanych przez obiekt ChildProcess i skojarzone z
nim strumienie.
Obiekt ChildProcess zwracany przez funkcję spawn() zgłasza zdarzenia. Zdarzenie "exit"
pojawia się w chwili zakończenia działania procesu potomnego. Obiekt ten ma również trzy
właściwości, których wartościami są strumienie. Właściwości stdout i stderr są strumieniami
Readable. Dane wysyłane przez proces potomny do jego strumienia stdout lub stderr są
dostępne w strumieniach zawartych w obiekcie ChildProcess. Zwróć uwagę na odwrócone
nazewnictwo. Dla procesu potomnego stdout jest strumieniem wyjściowym Writable, a dla
procesu nadrzędnego właściwość stdout obiektu ChildProcess jest wejściowym strumieniem
Readable.
Analogicznie właściwość stdin obiektu ChildProcess zawiera strumień Writable. Wszystkie
wysyłane do tego strumienia dane są dostępne dla procesu potomnego w jego standardowym
strumieniu wejściowym.
Obiekt ChildProcess definiuje również właściwość pid zawierającą identyfikator procesu
potomnego oraz metodę kill(), za pomocą której można przerwać działanie procesu.

16.10.4. Funkcja fork()


child_process.fork() jest specjalną funkcją umożliwiającą uruchamianie kodu JavaScript
w procesie potomnym. Ma podobne argumenty jak funkcja spawn() z tą jedyną różnicą, że
w pierwszym argumencie umieszcza się ścieżkę pliku zawierającego kod JavaScript, a nie pliku
wykonywanego.
Uruchomiony za pomocą funkcji fork() proces potomny może komunikować się z procesem
nadrzędnym za pomocą standardowych strumieni wejściowych i wyjściowych w sposób opisany
w poprzednim punkcie, poświęconym funkcji spawn(). Jednak funkcja fork() udostępnia
dodatkowo znacznie prostszy w użyciu kanał komunikacyjny dla obu procesów.
Do procesu potomnego, uruchomionego za pomocą funkcji fork(), można za pomocą
zwróconego przez tę funkcję obiektu ChildProcess i metody send() wysłać kopię zadanego
obiektu. Natomiast przy użyciu procedury obsługi zdarzenia "message" zgłaszanego dla obiektu
ChildProcess można odbierać komunikaty wysyłane przez proces potomny. Komunikaty te
proces może wysyłać do procesu nadrzędnego za pomocą funkcji process.send(), a
komunikaty wysyłane przez proces nadrzędny może odbierać za pomocą procedury obsługi
zdarzenia "message".
Poniższy kod uruchamia proces potomny za pomocą funkcji fork(), następnie wysyła do niego
komunikat i czeka na odpowiedź.
const child_process = require("child_process");

// Uruchomienie w procesie potomnym kodu JavaScript zapisanego w pliku


child.js znajdującym
// się w bieżącym katalogu.
let child = child_process.fork(`${__dirname}/child.js`);
// Wysłanie komunikatu do procesu potomnego.
child.send({x: 4, y: 3});
// Wyświetlenie odpowiedzi procesu potomnego.
child.on("message", message => {
console.log(message.hypotenuse); // Powinien pojawić się ciąg "5".

// Ponieważ wysyłamy jeden komunikat, oczekujemy tylko jednej odpowiedzi.


Po jej otrzymaniu wywołujemy
// funkcję disconnect() w celu zamknięcia połączenia pomiędzy procesem
nadrzędnym i potomnym.
// Dzięki temu oba procesy będą mogły w zwykły sposób zakończyć działanie.
child.disconnect();
});
W procesie potomnym jest uruchamiany następujący kod:
// Oczekiwanie na komunikat z procesu nadrzędnego.
process.on("message", message => {

// Po odebraniu komunikatu wykonujemy obliczenia i wysyłamy wynik do


procesu nadrzędnego.
process.send({hypotenuse: Math.hypot(message.x, message.y)});
});
Uruchomienie procesu potomnego za pomocą funkcji fork() i nawiązanie komunikacji między
procesami w opisany sposób jest kosztowną operacją, dlatego warto ją wykonywać tylko wtedy,
gdy proces potomny wykonuje czynności kilka rzędów wielkości kosztowniejsze. Jeżeli program
ma szybko reagować na zgłaszane zdarzenia, a jednocześnie wykonywać czasochłonne
operacje, można wykorzystać proces potomny, który nie będzie blokował pętli zdarzeń i
poprawi responsywność głównego programu. Jeszcze lepszym rozwiązaniem w takim wypadku
byłoby użycie wątku opisanego w podrozdziale 16.11.
Wartość umieszczona w pierwszym argumencie funkcji send() jest serializowana za pomocą
funkcji JSON.stringify(), a następnie deserializowana dla procesu potomnego za pomocą
funkcji JSON.parse(). Dlatego mogą to być wyłącznie wartości, które można przekształcać do
formatu JSON. Funkcja send() ma jednak specjalny drugi argument, służący do przesyłania do
procesu potomnego obiektów Socket i Server zdefiniowanych w module "net". Programy
serwerowe zazwyczaj intensywnie wykorzystują interfejsy wejścia/wyjścia, a nie procesor. Jeżeli
program musi wykonywać więcej operacji, niż jest w stanie obsłużyć jeden procesor, a w
systemie jest dostępnych kilka procesorów, wówczas za pomocą funkcji fork() można
uruchomić wiele procesów potomnych obsługujących zapytania. Proces nadrzędny może
obsługiwać zdarzenie "connection" zgłaszane dla obiektu Server, następnie z obiektu
reprezentującego to zdarzenie wyodrębniać obiekt Socket i na koniec wysyłać do jednego z
procesów potomnych, umieszczając go w drugim, specjalnym argumencie funkcji send().
Pamiętaj jednak, że jest to nietypowa sytuacja i rzadko stosowane rozwiązanie. W przypadku
środowiska produkcyjnego lepszym podejściem jest napisanie jednowątkowego programu
serwerowego i uruchomienie kilku jego instancji w osobnych wątkach.

16.11. Wątki robocze


Jak wspomniałem na początku rozdziału, model współbieżności środowiska Node jest
jednowątkowy i oparty na zdarzeniach. Jednak w wersjach Node 10 i nowszych można
uruchamiać programy wielowątkowe z prawdziwego zdarzenia, a wykorzystywany w tym celu
interfejs API jest bardzo podobny do stosowanego w przeglądarkach interfejsu Web Workers
API (patrz podrozdział 15.13). Tworzenie programów wielowątkowych jest słusznie uznawane
za trudne zadanie przede wszystkim dlatego, że trzeba starannie synchronizować dostęp
wątków do współdzielonych obszarów pamięci. Jednak wątki w programie JavaScript, zarówno
w środowisku Node, jak i w przeglądarce, domyślnie nie współdzielą pamięci, dlatego nie
dotyczą ich związane z tym niebezpieczeństwa i trudności.
Wątki robocze JavaScript nie wykorzystują do przesyłania informacji między sobą
współdzielonej pamięci, tylko komunikaty. Główny wątek może wysyłać komunikaty do wątku
roboczego za pomocą reprezentującego go obiektu Worker i metody postMessage(). Wątek
może odbierać te komunikaty za pomocą procedury obsługi zdarzenia "message", a wysyłać za
pomocą własnej odmiany metody postMessage(). Wątek nadrzędny odbiera komunikaty za
pomocą własnej procedury obsługi zdarzenia "message". Przedstawiony w dalszej części
podrozdziału przykład wyjaśnia działanie tego mechanizmu.
Są trzy przypadki, w których stosuje się wątki robocze:

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.

16.11.1. Tworzenie wątków roboczych i przesyłanie


komunikatów
Do uruchamiania wątków służy moduł "worker_threads". W tym punkcie będzie stosowany
odwołujący się do niego identyfikator threads:
const threads = require("worker_threads");

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

// Moduł "worker_threads" eksportuje właściwość logiczną isMainThread


przyjmującą wartość true,

// jeżeli kod jest uruchomiony w głównym wątku, lub false, jeżeli w wątku
roboczym.

// Właściwość ta jest wykorzystana do uruchomienia w obu wątkach tego samego


kodu.
if (threads.isMainThread) {
// Jeżeli kod jest uruchomiony w głównym wątku, wystarczy jedynie
wyeksportować funkcję.

// Funkcja ta zamiast wykonywać czasochłonne operacje w głównym wątku,


wykonuje zadanie w wątku

// roboczym
// i zwraca promesę determinowaną po zakończeniu jego działania.

module.exports = function reticulateSplines(splines) {

return new Promise((resolve,reject) => {


// Utworzenie wątku roboczego ładującego i uruchamiającego kod z tego
samego pliku co wątek główny.
// Zwróć uwagę na użycie specjalnej zmiennej __filename.

let reticulator = new threads.Worker(__filename);


// Przekazanie wątkowi roboczemu kopii tablicy splines.

reticulator.postMessage(splines);
// Spełnienie promesy po odebraniu komunikatu z wątku roboczego lub
odrzucenie jej w przypadku

// pojawienia się błędu.


reticulator.on("message", resolve);

reticulator.on("error", reject);
});

};
} else {

// Ta część kodu jest wykonywana w wątku roboczym.


// Rejestrujemy procedurę obsługi komunikatów wysyłanych przez główny
wątek.
// Odebrany ma być tylko jeden komunikat, więc procedurę rejestrujemy za
pomocą funkcji once(), a nie on().

// Dzięki temu wątek po wykonaniu zadania zakończy działanie w zwykły


sposób.

threads.parentPort.once("message", splines => {


// Po odebraniu tablicy splines wysłanej przez główny wątek przetwarzamy
jej elementy za pomocą pętli.
for(let spline of splines) {

// Na potrzeby tego przykładu przyjęto założenie, że obiekt spline


// ma metodę reticulate() wykonującą czasochłonne obliczenia.

spline.reticulate ? spline.reticulate() : spline.reticulated = true;

}
// 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".

Obiekt umieszczony w argumencie metody postMessage() nie jest współdzielony w wątkiem


roboczym. Zamiast tego jest tworzona kopia obiektu. Dzięki temu wątki główny i roboczy nie
muszą współdzielić tego samego obszaru pamięci. Może się wydawać, że współdzielenie jest
realizowane za pomocą funkcji JSON.stringify() i JSON.parse() (patrz podrozdział 11.6).
Jednak w rzeczywistości stosowana jest bardziej zaawansowana technika, w przeglądarkach
zwana algorytmem strukturalnego klonowania. Algorytm ten umożliwia serializowanie
większości typów danych dostępnych w języka JavaScript, w tym Map, Set, Date, RegExp i tablic
typowanych, ale nie obejmuje typów zdefiniowanych w środowisku Node, na przykład gniazd i
strumieni. Częściowo jest jednak obsługiwany typ Buffer. W przypadku umieszczenia obiektu
Buffer w argumencie metody postMessage() zostanie utworzony obiekt Uint8Array, który
można z powrotem przekształcić w Buffer za pomocą funkcji Buffer.from(). Więcej informacji
na temat algorytmu strukturalnego klonowania znajdziesz w rozdziale 15. w punkcie „Algorytm
strukturalnego klonowania”.

16.11.2. Środowisko uruchomieniowe wątków


roboczych
W większości przypadków kod JavaScript uruchomiony w wątku roboczym działa tak samo jak
w głównym wątku. Istnieje jednak kilka różnic, o których należy pamiętać. Kilka z nich jest
związanych z właściwościami obiektu umieszczanego w drugim argumencie konstruktora
Worker().

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.

16.11.3. Kanały komunikacyjne i klasa MessagePort


Wraz z wątkiem roboczym tworzony jest kanał komunikacyjny umożliwiający przesyłanie
komunikatów w obie strony pomiędzy wątkami roboczym i głównym. Jak już wiesz, wątek
roboczy wykorzystuje w tym celu właściwość threads.parentPort, a wątek główny obiekt
Worker(). Oprócz tego za pomocą klasy MessageChannel, opisanej w punkcie 15.13.5, można
definiować niestandardowe kanały komunikacyjne. Jeżeli przeczytałeś tamten punkt, poniższy
opis będzie brzmiał znajomo.
Załóżmy, że wątek roboczy musi przetwarzać komunikaty dwóch różnych rodzajów, wysyłane
przez dwa moduły wątku głównego. Moduły te mogą współdzielić ten sam kanał i wysyłać
komunikaty za pomocą metody worker.postMessage(), ale lepszym rozwiązaniem byłoby
zdefiniowanie prywatnego kanału dla każdego z modułów. Przeanalizujmy przypadek, w którym
główny wątek tworzy dwa niezależne wątki robocze. Wątki te mogłyby komunikować się ze
sobą za pomocą niestandardowego kanału, zamiast przesyłać komunikaty za pośrednictwem
widoku głównego.
Kanał komunikacyjny tworzy się za pomocą konstruktora MessageChannel(). Obiekt
MessageChannel ma dwie właściwości, port1 i port2, odwołujące się do obiektów MessagePort.
Wywołanie funkcji postMessage() z jednym z tych obiektów w argumencie powoduje
zgłoszenie zdarzenia "message" dla drugiego obiektu i utworzenie strukturalnej kopii obiektu
Message:
const threads = require("worker_threads");

let channel = new threads.MessageChannel();


channel.port2.on("message", console.log); // Wyświetlanie wszystkich
odbieranych komunikatów.

channel.port1.postMessage("Cześć!"); // Wyświetlenie ciągu "Cześć!".


Aby zamknąć kanał, należy wywołać metodę close() jednego z obiektów MessagePort.
Komunikaty nie będą wtedy przesyłane. Wywołanie tej metody w jednym obiekcie powoduje
zgłoszenie zdarzenia "close" dla obu obiektów.

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ć.

16.11.4. Przenoszenie obiektów MessagePort i


typowanych tablic
Funkcja postMessage() wykorzystuje algorytm klonowania strukturalnego, ale jak
wspomniałem, nie kopiuje obiektów takich jak Socket czy Stream. Przetwarza natomiast
obiekty MessagePort, traktując je jako szczególny przypadek tej specjalnej techniki. Metoda
postMessage() (zarówno obiektu Worker, jak i threads.parentPort oraz MessagePort) ma
drugi, opcjonalny argument. Ma on nazwę transferList i umieszcza się w nim tablicę
obiektów, które mają być przesłane pomiędzy wątkami bez kopiowania.

Obiektu MessagePort nie można skopiować za pomocą algorytmu klonowania strukturalnego,


ale można go przenieść. Jeżeli w pierwszym argumencie metody postMessage() umieści się
jeden lub kilka obiektów MessagePorts (dowolnie zagnieżdżonych w obiekcie Message), obiekty
te trzeba również umieścić w tablicy w drugim argumencie metody. Dla środowiska Node jest
to informacja, aby nie tworzyć kopii obiektu MessagePort, tylko drugiemu wątkowi przekazać
istniejący obiekt. Podczas przenoszenia danych pomiędzy wątkami należy pamiętać o ważnej
zasadzie, że po wywołaniu metody postMessage() dane te nie są już dostępne w bieżącym
wątku.

Poniższy przykład pokazuje, jak tworzy się obiekt MessageChannel i przenosi jeden z obiektów
MessagePorts do wątku roboczego.

// Utworzenie niestandardowego kanału komunikacyjnego.

const threads = require("worker_threads");


let channel = new threads.MessageChannel();

// Do przeniesienia jednego z obiektów wykorzystany jest domyślny kanał


komunikacyjny.

// Przyjęto założenie, że docelowy wątek natychmiast po odebraniu obiektu


wykorzystuje go do odbierania

// 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?");

// Odbieranie odpowiedzi wysyłanych przez wątek roboczy.


channel.port2.on("message", handleMessagesFromWorker);

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:

let pixels = new Uint32Array(1024*1024); // Tablica zajmująca 4 MB pamięci.


// Załóżmy, że w tej tablicy umieszczone są dane, które trzeba przenieść do
wątku roboczego bez kopiowania.

// Zwróć uwagę, że w argumencie metody nie jest umieszczana tablica, tylko


wykorzystywany przez nią

// 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.

16.11.5. Współdzielenie typowanej tablicy przez


wątki
Tablice typowane można nie tylko przenosić między wątkami, ale również je współdzielić. W
tym celu należy najpierw utworzyć obiekt SharedArrayBuffer o odpowiedniej wielkości, a
następnie użyć go do utworzenia typowanej tablicy. Po umieszczeniu obiektu
SharedArrayBuffer w argumencie metody postMessage() zajmowana przez tablicę pamięć
będzie współdzielona przez dwa wątki. W takim przypadku nie należy tablicy umieszczać w
drugim argumencie powyższej metody.

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.

const threads = require("worker_threads");


if (threads.isMainThread) {

// W głównym wątku tworzymy współdzieloną typowaną tablicę zawierającą


jeden element.
// Dwa wątki będą mogły odczytywać i zapisywać element sharedArray[0] w tym
samym czasie.
let sharedBuffer = new SharedArrayBuffer(4);

let sharedArray = new Int32Array(sharedBuffer);


// Tworzymy wątek roboczy i przekazujemy mu tablicę jako wartość
właściwości workerData,
// ponieważ wątek nie będzie odbierał ani wysyłał komunikatów.

let worker = new threads.Worker(__filename, { workerData: sharedArray });

// Oczekujemy na uruchomienie wątku, a następnie zwiększamy współdzielony


element 10 milionów razy.

worker.on("online", () => {
for(let i = 0; i < 10_000_000; i++) sharedArray[0]++;

// Po zakończeniu zwiększania czekamy na zgłoszenie zdarzenia "message"


oznaczającego

// zakończenie działania wątku.


worker.on("message", () => {

// Współdzielony element tablicy został zwiększony 20 milionów razy,


ale jego rzeczywista wartość

// jest znacznie mniejsza.

// W moim przypadku nie przekracza 12 milionów.


console.log(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.

let sharedArray = threads.workerData;

for(let i = 0; i < 10_000_000; i++) sharedArray[0]++;


// Powiadamiamy wątek główny o zakończeniu operacji.

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

let sharedArray = new Int32Array(sharedBuffer);


let worker = new threads.Worker(__filename, { workerData: sharedArray });

worker.on("online", () => {

for(let i = 0; i < 10_000_000; i++) {


Atomics.add(sharedArray, 0, 1); // Atomiczne, wątkowo bezpieczne
zwiększenie wartości.
}

worker.on("message", (message) => {


// Po zakończeniu działania obu wątków odczytujemy element za pomocą
bezpiecznej wątkowo funkcji
// i sprawdzamy, czy wynikowa wartość jest równa 20 000 000.

console.log(Atomics.load(sharedArray, 0));

});
});

} else {
let sharedArray = threads.workerData;

for(let i = 0; i < 10_000_000; i++) {


Atomics.add(sharedArray, 0, 1); // Atomiczne, wątkowo bezpieczne
zwiększenie wartości.
}

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.

W tym rozdziale zostały opisane następujące tematy:

Domyślnie asynchroniczne interfejsy API środowiska Node i asynchroniczność oparta na


jednym wątku, funkcji zwrotnych i zdarzeniach.
Podstawowe typy danych, bufory i strumienie w środowisku Node.
Moduły "fs" i "path" do wykonywania operacji na systemie plików.
Moduły "http" i "https" do tworzenia programów klienckich i serwerowych
wykorzystujących protokół HTTP.
Moduł "net" do tworzenia programów klienckich i serwerowych wykorzystujących inne
protokoły niż HTTP.
Moduł "child_process" do tworzenia procesów potomnych i komunikowania się z nimi.
Moduł "worker_threads" do tworzenia programów wielowątkowych wykorzystujących
komunikaty zamiast współdzielonej pamięci.

[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:

narzędzie ESLint do wyszukiwania potencjalnych błędów i niewłaściwego stylu kodu,


narzędzie Prettier do formatowania kodu zgodnie z przyjętymi standardami,
narzędzie Jest do pisania testów jednostkowych,
narzędzie npm do instalowania bibliotek wykorzystywanych w kodzie i do zarządzania
nimi,
narzędzia webpack, Rollup i Parcel do scalania osobnych modułów w jeden duży moduł,
gotowy do użycia w przeglądarce,
narzędzie Babel do tłumaczenia kodu wykorzystującego najnowsze funkcjonalności języka
(lub jego rozszerzenia) na kod, który można uruchamiać w przeglądarce,
rozszerzenie JSX (stosowane w platformie React) umożliwiające kodowanie interfejsu
użytkownika za pomocą wyrażeń JavaScriptu podobnych do znaczników HTML,
rozszerzenie Flow (podobne do TypeScriptu) umożliwiające opatrywanie kodu
adnotacjami i sprawdzanie poprawności typów danych.

Niniejszy rozdział nie stanowi wyczerpującej dokumentacji powyższych narzędzi i rozszerzeń.


Jego celem jest opisanie ich na tyle dokładnie, abyś wiedział, do czego i kiedy się je stosuje.
Wszystkie przedstawione tu narzędzia i rozszerzenia są szeroko wykorzystywane w świecie
JavaScriptu. Jeżeli zdecydujesz się użyć któregoś z nich, w internecie znajdziesz dokumentację i
mnóstwo przewodników.

17.1. Inspekcja kodu za pomocą


narzędzia ESLint
W programistycznej nomenklaturze znane jest słowo lint (kłaczek) oznaczające fragment kodu,
który z technicznego punktu widzenia jest poprawny, ale nieestetyczny, nieoptymalny i zawiera
potencjalne błędy. Linter jest narzędziem wykrywającym tego rodzaju mankamenty, a
lintowanie oznacza czynność polegającą na wielokrotnym uruchamianiu lintera i poprawianiu
błędów do momentu, aż przestaną się pojawiać komunikaty ostrzegawcze.

Obecnie najpopularniejszym linterem dla języka JavaScript jest ESLint (https://eslint.org). Po


uruchomieniu go i poświęceniu chwili na poprawienie wykrytych przez niego mankamentów
kod staje się bardziej czytelny i odporniejszy na błędy. Przeanalizujmy poniższy przykład:
var x = 'unused';
export function factorial(x) {

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

1:1 error Unexpected var, use let or const instead no-var


1:5 error 'x' is assigned a value but never used no-unused-vars

1:9 warning Strings must use doublequote quotes


4:11 error Expected '===' and instead saw '==' eqeqeq

5:1 error Expected indentation of 8 spaces but found 6 indent

7:28 error Missing semicolon semi


6 problems (5 errors, 1 warning)

3 errors and 1 warning potentially fixable with the `--fix` option.


Niektóre zalecenia lintera mogą irytować. Czy rzeczywiście ma znaczenie, czy ciąg znaków jest
ujęty w apostrofy, a nie cudzysłowy? Niemniej jednolite wcięcia poprawiają czytelność kodu, a
stosowanie operatora === i słowa kluczowego var zamiast == i let zabezpiecza kod przed
subtelnymi błędami. Dodatkowo nieużywane zmienne stanowią niepotrzebny balast.

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.

17.2. Formatowanie kodu za pomocą


narzędzia Prettier
Jednym z powodów stosowania linterów w projektach jest egzekwowanie wśród programistów
pracujących w zespole nad wspólnym kodem przestrzegania ustalonych konwencji i stylów.
Obejmuje to m.in. stosowanie wcięć o określonej głębokości, ujmowanie ciągów znaków w
apostrofy lub cudzysłowy oraz wstawianie spacji pomiędzy słowami kluczowymi a nawiasami
otwierającymi.

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 {

return x * factorial(x - 1);

}
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.

17.3. Tworzenie testów jednostkowych


za pomocą narzędzia Jest
Testy jednostkowe stanowią ważną cześć każdego poważniejszego projektu programistycznego.
Dla dynamicznych języków programowania, takich jak JavaScript, są dostępne platformy
testowe, dzięki którym tworzenie testów jest znacznie łatwiejsze, a testowanie staje się wręcz
przyjemnością. Istnieją liczne narzędzia i biblioteki testowe dla języka JavaScript. Wiele z nich
jest modułowych, dzięki czemu można stosować osobne biblioteki do uruchamia testów,
tworzenia asercji i atrap. W tym podrozdziale opisane jest popularne narzędzie Jest
(https://jestjs.io), zawierające w jednym pakiecie wszystko, co potrzebne.

Załóżmy, że mamy następującą funkcję:


const getJSON = require("./getJSON.js");

/**

* Argumentem funkcji getTemperature() jest nazwa miasta, a zwracanym


wynikiem promesa generująca liczbę

* oznaczającą aktualną temperaturę w danym mieście w skali Fahrenheita.


Funkcja wykorzystuje hipotetyczny

* serwis internetowy dostarczający informacji o temperaturze w stopniach


Celsjusza w różnych miejscach na świecie.

*/

module.exports = async function getTemperature(city) {

// Odczytanie z serwisu temperatury w stopniach Celsjusza.

let c = await getJSON(

`https://globaltemps.example.com/api/city/${city.toLowerCase()}`

);

// Przeliczenie temperatury na stopnie Fahrenheita i zwrócenie wyniku.


return (c * 5 / 9) + 32; // TODO: formuła do sprawdzenia.

};

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.

// Import testowanej funkcji.

const getTemperature = require("./getTemperature.js");


// Atrapa modułu getJSON wykorzystywanego przez funkcję getTemperature().

jest.mock("./getJSON");

const getJSON = require("./getJSON.js");

// Atrapa funkcji getJSON() zwraca spełnioną promesę generującą wartość 0.

getJSON.mockResolvedValue(0);

// Początek testów funkcji getTemperature().

describe("getTemperature()", () => {

// Pierwszy test sprawdzający, czy funkcja getTemperature() wywołuje

// funkcję getJSON() z odpowiednim adresem URL w argumencie.


test("Invokes the correct API", async () => {
let expectedURL = "https://globaltemps.example.com/api/city/vancouver";

let t = await(getTemperature("Vancouver"));

// Atrapa utworzona za pomocą narzędzia Jest zapamiętuje argumenty, które


można sprawdzić.

expect(getJSON).toHaveBeenCalledWith(expectedURL);

});

// Drugi test sprawdza, czy funkcja getTemperature() poprawnie przelicza


stopnie Celsjusza na Fahrenheita.

test("Converts C to F correctly", async () => {

getJSON.mockResolvedValue(0); // Jeżeli getJSON() zwraca


0…

expect(await getTemperature("x")).toBe(32); // …oczekujemy 32.


// 100 stopni Celsjusza odpowiada 212 stopniom Fahrenheita.

getJSON.mockResolvedValue(100); // Jeżeli getJSON() zwraca


100…

expect(await getTemperature("x")).toBe(212); // …oczekujemy 212.

});

});
Po przygotowaniu testu wpisujemy polecenie jest. Okazuje się, że jeden test wypadł
niepomyślnie.

$ jest getTemperature

FAIL ch17/getTemperature.test.js

getTemperature()

✓ Invokes the correct API (4ms)


✕ Converts C to F correctly (3ms)

● getTemperature() › Converts C to F correctly

expect(received).toBe(expected) // równość Object.is

Expected: 212

Received: 87.55555555555556

29 | // 100 stopni Celsjusza odpowiada 212 stopniom


Fahrenheita.

30 | getJSON.mockResolvedValue(100); // Jeżeli getJSON() zwraca


100...

> 31 | expect(await getTemperature("x")).toBe(212); //


...oczekujemy 212.

| ^

32 | });

33 | });
34 |

at Object.<anonymous> (ch17/getTemperature.test.js:31:43)

Test Suites: 1 failed, 1 total

Tests: 1 failed, 1 passed, 2 total

Snapshots: 0 total
Time: 1.403s

Ran all test suites matching /getTemperature/i.

Funkcja getTemperature() wykorzystuje błędną formułę do przeliczania stopni Celsjusza na


stopnie Fahrenheita. Mnoży wejściową wartość przez 5, a następnie dzieli przez 9, a powinna
mnożyć przez 9 i dzielić przez 5. Po poprawieniu błędu i ponownym uruchomieniu narzędzia
Jest oba testy kończą się pomyślnie. Dodatkowo polecenie jest użyte z argumentem --
coverage wylicza i wyświetla stopień pokrycia kodu testami:

$ jest --coverage getTemperature

PASS ch17/getTemperature.test.js

getTemperature()

✓ Invokes the correct API (3ms)

✓ Converts C to F correctly (1ms)

------------------|--------|---------|---------|---------|------------------|

File | % Stmts| % Branch| % Funcs| % Lines| Uncovered Line #s|


------------------|--------|---------|---------|---------|------------------|

All files | 71.43| 100| 33.33| 83.33| |

getJSON.js | 33.33| 100| 0| 50| 2|

getTemperature.js| 100| 100| 100| 100| |

------------------|--------|---------|---------|---------|------------------|

Test Suites: 1 passed, 1 total

Tests: 2 passed, 2 total

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.

17.4. Zarządzanie pakietami za pomocą


narzędzia npm
W nowoczesnym programowaniu jest rzeczą normalną, że w nietrywialnym projekcie
wykorzystuje się zewnętrzne biblioteki. Na przykład pisząc program serwerowy dla środowiska
Node, stosuje się platformę Express, a tworząc interfejs graficzny przeznaczony dla
przeglądarki, stosuje się platformy takie jak React, LitElement lub Angular. Tego rodzaju
zewnętrzne platformy łatwo się wyszukuje i instaluje za pomocą menedżera pakietów. Ważne
jest również, że menedżer rejestruje wykorzystywane w kodzie pakiety i zapisuje te informacje
w pliku. Dzięki temu, gdy inny programista będzie chciał użyć programu, pobierze kod i listę
zależności, a następnie za pomocą własnego menedżera zainstaluje wszystkie niezbędne
zewnętrzne pakiety.

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.

Aby zainstalować konkretny pakiet i zapisać go w katalogu node_modules, należy wpisać


polecenie npm install nazwa_pakietu, na przykład:

$ npm install express


Program npm po zainstalowaniu wskazanego pakietu rejestruje go w pliku package.json. Dzięki
temu inny programista będzie mógł zainstalować wszystkie zależności za pomocą prostego
polecenia npm install.
Innym rodzajem zależności wykorzystywanych w kodzie są narzędzia programistyczne, które
nie są wymagane do jego uruchomienia, ale są potrzebne programistom w pracy nad
projektem. Przykładem jest narzędzie Prettier zapewniające jednolity format całego kodu. Aby
je zainstalować i zarejestrować, należy użyć menedżera pakietów z argumentem --save-dev:
$ npm install --save-dev prettier

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):

$ npm install -g eslint jest


/usr/local/bin/eslint -> /usr/local/lib/node_modules/eslint/bin/eslint.js
/usr/local/bin/jest -> /usr/local/lib/node_modules/jest/bin/jest.js

+ jest@24.9.0
+ eslint@6.7.2

added 653 packages from 414 contributors in 25.596s


$ which eslint

/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

Główny skrypt narzędzia ESLint zainstalowanego w lokalnym systemie nosi niewygodną w


użyciu nazwę ./node_modules/.bin/eslint. Na szczęście razem z menedżerem npm jest
dostarczany program npx, dzięki któremu można uruchamiać lokalnie zainstalowane narzędzia
za pomocą poleceń na przykład npx eslint lub npx jest. Co więcej, próba użycia w ten sposób
narzędzia, które nie jest zainstalowane, powoduje, że program npx instaluje je.
Zespół rozwijający menedżera npm utrzymuje również repozytorium setek tysięcy otwartych
pakietów. Jest ono dostępne pod adresem https://npmjs.com i można z niego korzystać nie tylko
za pomocą programu npm, ale również yarn (https://yarnpkg.com) i pnpm (https://pnpm.js.org).

17.5. Pakowanie kodu


Gdy będziesz pisał duży, wykorzystujący zewnętrzne moduły program dla przeglądarek,
prawdopodobnie będziesz potrzebował narzędzia do pakowania kodu. Moduły ES6 (patrz
podrozdział 10.3) pojawiły się dawno temu, dużo wcześniej, zanim przeglądarki zaczęły
obsługiwać instrukcje import i export. Aby móc korzystać z modułów, programiści stosowali
narzędzia pakujące, które analizowały kod począwszy od jego głównego punktu wejścia,
śledziły instrukcje import, wyszukiwały wszystkie wykorzystywane moduły i łączyły
poszczególne pliki w jeden duży plik oraz usuwały instrukcje import i export. Tak uzyskany
plik mógł być załadowany do przeglądarki, która nie obsługiwała modułów.
Dzisiaj moduły ES6 są obsługiwane przez wszystkie przeglądarki, jednak programiści wciąż
używają narzędzi pakujących, szczególnie do tworzenia kodu produkcyjnego. Okazuje się, że
wrażenia użytkownika są lepsze, gdy przeglądarka, otwierając stronę, ładuje jeden średniej
wielkości pakiet kodu niż wiele małych modułów.

Wydajność aplikacji internetowych to niezwykle trudny temat. Optymalizacja


wydajności wymaga uwzględnienie wielu czynników, a twórcy przeglądarek
nieustannie wprowadzają nowe udoskonalenia. Aby strona ładowała się jak
najszybciej, trzeba ją systematycznie testować i mierzyć jej czas otwarcia. Pamiętaj,
że całkowitą kontrolę masz tylko nad jednym czynnikiem: wielkością kodu. Krótszy
kod zawsze ładuje się i działa szybciej niż dłuższy!

Dostępnych jest wiele dobrych narzędzi do pakowania kodu. Najpopularniejsze z nich to


webpack (https://webpack.js.org), Rollup (https://rollupjs.org/guide/en) i Parcel
(https://parceljs.org). Podstawowe funkcjonalności każdego z nich są mniej więcej takie same,
różnice pojawiają się w konfiguracji i użytkowaniu. Narzędzie webpack istnieje już od długiego
czasu, obsługuje starsze, niemodułowe biblioteki i dostępnych jest dla niego wiele wtyczek. Jest
jednak skomplikowane i trudne w konfiguracji. Na drugim końcu spektrum znajduje się
narzędzie Parcel, które z założenia nie powinno wymagać żadnej konfiguracji, tylko wykonywać
to, co do niego należy.

Narzędzia pakujące oferują również dodatkowe funkcjonalności.

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.

17.6. Transpilacja kodu za pomocą


narzędzia Babel
Babel (https://babeljs.io) to narzędzie, które przekształca kod JavaScript wykorzystujący
najnowsze funkcjonalności języka w kod JavaScript, który tych funkcjonalności nie
wykorzystuje. Ponieważ narzędzie to przekształca kod JavaScript w kod JavaScript, jest czasami
nazywane transpilatorem. Powstało po to, aby można było uruchamiać kod wykorzystujący
funkcjonalności wersji języka ES6 i nowszych w przeglądarkach, które obsługują jedynie wersję
ES5.

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.

17.7. Rozszerzenie JSX: znaczniki w


kodzie JavaScript
JSX jest rozszerzeniem języka JavaScript umożliwiającym definiowanie drzewa elementów za
pomocą znaczników podobnych do stosowanych w kodzie HTML. Rozszerzenie to jest silnie
powiązane z platformą React do tworzenia przeglądarkowych interfejsów aplikacji. Platforma
React przekształca drzewo elementów zdefiniowanych za pomocą rozszerzenia JSX w kod
HTML strony wyświetlanej w przeglądarce. Nawet jeżeli nie zamierzasz korzystać z tej
platformy, na pewno spotkasz się z nią w innych projektach, ponieważ jest ona bardzo
popularna. Ten podrozdział zawiera informacje niezbędne do zrozumienia funkcjonowania
platformy React. Jest głównie poświęcony rozszerzeniu JSX, dlatego opisuje platformę w
zakresie niezbędnym do poznania składni tego rozszerzenia.

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:

let line = <hr/>;


Razem z rozszerzeniem JSX należy stosować narzędzie Babel (lub podobne), aby zamieniać
wyrażenia JSX w zwykły kod JavaScript. Przekształcenia są na tyle proste, że część
programistów używa platformy React bez rozszerzenia JSX. Narzędzie Babel przekształca
powyższe wyrażenie JSX w wywołanie następującej funkcji:

let line = React.createElement("hr", null);


Składnia rozszerzenia JSX jest podobna do języka HTML. Elementy zdefiniowane za pomocą
platformy React mogą mieć atrybuty, podobnie jak elementy HTML:
let image = <img src="logo.png" alt="Logo JSX" hidden/>;

Atrybuty elementu są przekształcane we właściwości obiektu, który jest następnie umieszczany


w drugim argumencie funkcji createElement():
let image = React.createElement("img", {

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:

let sidebar = React.createElement(


"div", { className: "sidebar"}, // W pierwszym wywołaniu jest tworzony
element <div>.

React.createElement("h1", null, // Pierwszy element potomny dla <div/>…


"Title"), // …oraz kolejny.

React.createElement("hr", null), // Drugi element potomny dla <div/>…


React.createElement("p", null, // …i trzeci

"To jest pasek boczny"));


Wynikiem zwracanym przez funkcję React.createElement() jest zwykły obiekt, który jest
wykorzystywany przez platformę React do wyświetlania treści w oknie przeglądarki. Ponieważ
ten podrozdział jest poświęcony rozszerzeniu JSX, a nie platformie React, nie będziemy
zagłębiać się w szczegóły zwracanego przez powyższą funkcję obiektu ani w proces
wyświetlania treści. Warto jednak wiedzieć, że narzędzie Babel można skonfigurować tak, aby
przekształcało elementy JSX w wywołania innych funkcji. Jeżeli zatem uznasz, że składnię JSX
można wykorzystać do definiowania innego rodzaju struktur danych, możesz ją przystosować
do własnych celów.
Ważną cechą składni JSX jest możliwość osadzania w niej wyrażeń JavaScript. Tekst
umieszczony wewnątrz nawiasów klamrowych jest traktowany jako zwykły kod JavaScript. Tego
rodzaju osadzone wyrażenia mogą być wartościami atrybutów lub elementami potomnymi, na
przykład:
function sidebar(className, title, content, drawLine=true) {
return (

<div className={className}>
<h1>{title}</h1>
{ drawLine && <hr/> }

<p>{content}</p>
</div>

);
}

Funkcja sidebar() zwraca element JSX. Ma cztery argumenty, które wykorzystuje do


utworzenia tego elementu. Nawiasy klamrowe mogą się kojarzyć z literałem szablonowym, w
którym za pomocą sekwencji ${} umieszcza się wyrażenia w tekście. Ponieważ wyrażenia JSX
są przekształcane w wywołania funkcji, nie powinno dziwić, że można w nich stosować dowolne
wyrażenia JavaScript. W argumentach funkcji można przecież umieszczać również wyrażenia.
Narzędzie Babel przekształca przedstawiony wyżej kod w następujący:
function sidebar(className, title, content, drawLine=true) {

return React.createElement("div", { className: className },


React.createElement("h1", null, title),

drawLine && React.createElement("hr", null),


React.createElement("p", null, content));

}
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:

// Argumentami poniższej funkcji jest ciąg znaków i funkcja zwrotna. Na ich


podstawie funkcja tworzy
// i zwraca element JSX reprezentujący element HTML <ul> zawierający listę
elementów potomnych <li>.
function list(items, callback) {

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):

function list(items, callback) {


return React.createElement(

"ul",
{ style: { padding: 10, border: "solid red 4px" } },

items.map((item, index) =>


React.createElement(

"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:

let hebrew = { lang: "he", dir: "rtl" }; // Specyfikacja języka i kierunku


pisma.
let shalom = <span className="emphasis" {...hebrew}>íåìù</span>;
Narzędzie Babel utworzy na podstawie powyższego kodu funkcję _extends() (pominiętą tutaj),
łączącą atrybut className z atrybutami zdefiniowanymi w obiekcie hebrew:

let shalom = React.createElement("span",


_extends({className: "emphasis"}, hebrew),

"\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:

let sidebar = React.createElement(Sidebar, {


title: "Tytuł paska",
content: "Zawartość paska"
});
Platforma React, przekształcając to proste wyrażenie JSX, umieści drugi argument, czyli obiekt
właściwości, w argumencie funkcji Sidebar(), umieszczonej w pierwszym argumencie funkcji
createElement(), a zwrócony przez nią wynik użyje w miejscu wyrażenia <Sidebar>.
17.8. Sprawdzanie typów danych
za pomocą rozszerzenia Flow
Flow (https://flow.org) jest rozszerzeniem umożliwiającym umieszczanie w kodzie adnotacji o
stosowanych typach danych i sprawdzanie ich zgodności, zarówno danych z adnotacjami, jak i
bez nich. Po umieszczeniu adnotacji w kodzie należy uruchomić narzędzie, które sprawdzi
zgodność typów i zgłosi ewentualne błędy. Następnie, po poprawieniu błędów, adnotacje usuwa
się za pomocą narzędzia Babel (najlepiej uruchamianego automatycznie w procesie pakowania
kodu). Rozszerzenie Flow ma tę ważną cechę, że nie definiuje nowej składni, którą trzeba w
specjalny sposób kompilować lub przekształcać. Korzystanie z niego polega po prostu na
umieszczaniu adnotacji w kodzie, a następnie usuwaniu ich za pomocą narzędzia Babel w celu
uzyskania zwykłego kodu JavaScript.

TypeScript kontra Flow


Rozszerzenie TypeScript jest bardzo popularną alternatywą dla Flow. Oprócz typów
danych wprowadza ono szereg innych funkcjonalności. Kompilator tsc przekształca kod
TypeScript w JavaScript, analizuje go i zgłasza niezgodności typów w bardzo podobny
sposób, jak to robi rozszerzenie Flow. Program tsc nie jest wtyczką dla narzędzia Babel,
tylko samodzielnym kompilatorem.
Proste adnotacje typów, zdefiniowane w rozszerzeniu TypeScript, są niemal identyczne
jak w rozszerzeniu Flow. Jednak w przypadku bardziej złożonych typów danych składnie
stosowane w obu rozszerzeniach różnią się. Niemniej przeznaczenie obu narzędzi i
korzyści płynące z ich stosowania są takie same. Moim celem jest opisane zalet adnotacji
typów i statycznej analizy kodu. W przykładach stosuję rozszerzenie Flow, ale wszystkie
opisane techniki, po wprowadzeniu niewielkich zmian w składni, można również stosować
w rozszerzeniu TypeScript.
Rozszerzenie TypeScript pojawiło się w 2012 r. przed wprowadzeniem wersji języka ES6,
słowa kluczowego class, pętli for/of, modułów i promes. Flow jest niewielkim
rozszerzeniem wprowadzającym do języka JavaScript adnotacje typów danych i nic
więcej. Natomiast TypeScript można traktować niemal jak nowy język. Jak sugeruje
nazwa, jego głównym przeznaczeniem jest wprowadzenie typów danych do języka
JavaScript. Z tego też powodu jest bardzo popularne wśród programistów. Jednak typy nie
są jego jedyną funkcjonalnością. Oprócz tego pozwala ono stosować nieistniejące w
języku JavaScript słowa kluczowe enum i namespace. W 2010 r. rozszerzenie TypeScript
było lepiej zintegrowane ze środowiskami programistycznymi i edytorami kodu (w
szczególności z VSCode, również opracowanym przez Microsoft) niż Flow.
Niemniej jednak niniejsza książka jest poświęcona językowi JavaScript, dlatego aby nie
odsunąć go na drugi plan, w tym podrozdziale jest wykorzystane rozszerzenie Flow.
Jednak wszystko, czego się tutaj dowiesz na temat typów, będziesz mógł wykorzystać w
projektach opartych na rozszerzeniu TypeScript.

Korzystanie z rozszerzenia Flow wymaga pewnego zaangażowania, które moim zdaniem, w


przypadku średnich i dużych projektów, jest tego warte. Umieszczanie adnotacji w kodzie,
uruchamianie rozszerzenia po każdorazowym wprowadzeniu zmian i poprawianie znalezionych
błędów rzeczywiście zajmują czas. Jednak jest to sposób na zdyscyplinowane programowanie,
które nie pozwala iść na skróty prowadzące do pomyłek. Pracowałem nad wieloma projektami,
w których było stosowane rozszerzenie Flow, i byłem zaskoczony liczbą błędów, jakie
popełniałem we własnym kodzie. Możliwość ich poprawienia, zanim doprowadzą do poważnych
skutków, dawało mi poczucie komfortu i pewności, że kod jest poprawny.
Gdy zacząłem korzystać z tego rozszerzenia, czasami trudno było mi zrozumieć niektóre uwagi
zgłaszane do kodu. Jednak po nabraniu praktyki zacząłem rozumieć wyświetlane komunikaty
i przekonałem się, że czasami wystarczy wprowadzić niewielką zmianę, aby kod stał się lepszy
i spełniał wymogi rozszerzenia[1]. Nie polecam jednak stosowania rozszerzenia Flow w trakcie
uczenia się języka JavaScript. Niemniej gdy poczujesz się w nim pewnie, wprowadzenie
rozszerzenia do projektów przeniesie Twoje programistyczne umiejętności na wyższy poziom. Z
tego właśnie powodu ostatni podrozdział tej książki poświęciłem rozszerzeniu Flow. Poznanie
systemu typów danych w języku JavaScript stanowi kolejny stopień wtajemniczenia i zmianę
stylu programowania.

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.

17.8.1. Instalacja i korzystanie z rozszerzenia Flow


Rozszerzenie Flow, tak jak inne narzędzia opisane w tym rozdziale, instaluje się za pomocą
menedżera pakietów, wpisując polecenie npm install -g flow-bin lub npm install --save-
dev flow-bin. Jeżeli zainstaluje się je globalnie za pomocą opcji -g, można je uruchomić,
wpisując polecenie flow. Natomiast w przypadku lokalnej instalacji przy użyciu opcji --save-
dev rozszerzenie uruchamia się za pomocą polecenia npx flow. Ponieważ rozszerzenie służy do
sprawdzania zgodności typów, trzeba je najpierw uruchomić w głównym katalogu projektu z
parametrem --init i utworzyć w ten sposób plik konfiguracyjny .flowconfig.
Prawdopodobnie nigdy nie będziesz musiał zmieniać tego pliku, ale dzięki niemu rozszerzenie
będzie „wiedzieć”, gdzie znajduje się główny katalog projektu.
Rozszerzenie po uruchomieniu wyszukuje wszystkie pliki JavaScript umieszczone w projekcie,
ale sprawdza tylko te, które na samym początku zawierają komentarz // @flow oznaczający, że
plik ma być weryfikowany. Jest to ważna funkcjonalność, ponieważ umożliwia poprawianie
plików projektu pojedynczo, bez rozpraszania się błędami i ostrzeżeniami dotyczącymi innych
plików, które dopiero czekają na sprawdzenie.

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.

17.8.2. Stosowanie adnotacji typów


Aby dodać adnotację typu, należy w deklaracji zmiennej wpisać po jej nazwie dwukropek
i oznaczenie typu:

let message: string = "Witaj, świecie!";


let flag: boolean = false;
let n: number = 42;
Rozszerzenie Flow rozpoznaje typy zmiennych, nawet tych nieopatrzonych adnotacjami. Robi to
na podstawie przypisywanych im wartości. Jednak adnotacja nie tylko oznacza typ zmiennej, ale
również zawiera informację, że dana zmienna może przyjmować wyłącznie wartości
wskazanego typu. Jeżeli zmiennej zostanie przypisana wartość innego typu, rozszerzenie Flow
zgłosi błąd. Adnotacje typów są szczególnie przydatne wtedy, gdy wszystkie zmienne są
zadeklarowane na początku funkcji.
Argumenty funkcji opatruje się adnotacjami w podobny sposób jak zmienne. Po nazwie
argumentu wpisuje się dwukropek, a po nim oznaczenie typu. Adnotacja funkcji dotyczy typu
zawracanego przez nią wyniku. Umieszcza się ją pomiędzy zwykłym nawiasem zamykającym a
klamrowym nawiasem otwierającym. Jeżeli funkcja nie zwraca wyniku, należy użyć oznaczenia
void.
W poprzednim przykładzie była zdefiniowana funkcja size(), której argumentem powinien być
obiekt zawierający właściwość length. Poniżej jest pokazany przykład, jak za pomocą adnotacji
można ściśle określić, że argumentem funkcji jest ciąg znaków, a zwracanym wynikiem liczba.
Zwróć uwagę, że w przypadku umieszczenia tablicy w argumencie, rozszerzenie Flow zgłosi
błąd, mimo że funkcja będzie działać poprawnie:

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:

const size = (s: string): number => s.length;


Należy pamiętać o ważnej kwestii, że null jest w kontekście rozszerzenia Flow wartością typu
null, a wartość undefined typu void. Żadna z tych wartości nie jest jednak częścią innego typu
(chyba że zostanie jawnie dodana). W argumencie oznaczonym typem string można
umieszczać wyłącznie ciągi znaków. Błędem jest w takim przypadków pominięcie argumentu
lub umieszczenie w nim wartości null lub undefined (która również oznacza pominięcie
argumentu):
Error -------------------------------------------------------------------
size3.js:3:18
Cannot call size with null bound to s because null [1] is incompatible
with string [2].

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.

// W przeciwnym razie funkcja zwraca wynik false. Np. const isTodayChristmas


= dateMatches
// (new Date(), /^\d{4}-12-25T/);
export function dateMatches(d: Date, p: RegExp): boolean {
return p.test(d.toISOString());

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

// Argumenty konstruktora wykorzystywane do inicjowania właściwości muszą


być opatrzone adnotacjami.
this.r = r;
this.i = i;
}

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.

export default function distance(point: {x:number, y:number}): number {


return Math.hypot(point.x, point.y);
}
W powyższym przykładzie zapis {x:number, y:number} jest oznaczeniem typu, podobnie jak
string lub Date. Tak jak w przypadku każdego innego oznaczenia typu, można przed nim
umieścić znak zapytania informujący, że dopuszczalne jest stosowanie wartości null i
undefined.
W oznaczeniu typu obiektowego można po nazwie każdej właściwości umieszczać znak
zapytania informujący, że dana właściwość jest opcjonalna i może zostać pominięta. Na
przykład oznaczenie typu obiektowego reprezentującego punkt w przestrzeni dwu- lub
trójwymiarowej może wyglądać tak:

{x: number, y: number, z?: number}


Właściwość nieoznaczona znakiem zapytania jest obowiązkowa. Jeżeli obiekt nie będzie
zawierał tej właściwości, rozszerzenie Flow zgłosi błąd. Natomiast rozszerzenie toleruje
dodatkowe właściwości. Jeżeli na przykład w argumencie powyższej funkcji distance()
zostanie umieszczony obiekt posiadający właściwość w, rozszerzenie Flow nie zgłosi błędu.
Jeżeli rozszerzenie Flow ma uniemożliwiać stosowanie obiektów z właściwościami innymi niż
jawnie oznaczone, należy w definicji typu obiektowego umieścić wewnątrz nawiasów
klamrowych pionowe kreski:
{| x: number, y: number |}
Obiekty czasami wykorzystuje się w charakterze słowników lub map ciąg znaków — wartość. W
takich przypadkach nazwy właściwości nie są zawczasu znane, więc nie można zadeklarować
ich typów. Jednak mimo tego istnieje możliwość opisywania struktury danych za pomocą
rozszerzenia Flow. Załóżmy, że mamy obiekt, którego właściwości są nazwami największych
miast, a wartościami współrzędne geograficzne. Odpowiednią strukturę danych można
zadeklarować w następujący sposób:
// @flow
const cityLocations : {[string]: {longitude:number, latitude:number}} = {
"Seattle": { longitude: 47.6062, latitude: -122.3321 },

// TODO: W tym miejscu trzeba dodać inne ważne miasta.


};

17.8.5. Aliasy typów


Obiekty mogą posiadać wiele właściwości, przez co wpisywanie oznaczeń ich typów może być
żmudną i czasochłonną czynnością. Ale nawet oznaczenia niewielkich typów obiektowych mogą
być mylące z powodu swojego podobieństwa do literałów obiektowych. Jeżeli oprócz prostych
oznaczeń, na przykład number lub ?string, są stosowane bardziej złożone typy, warto nadać im
osobne nazwy. Rozszerzenie Flow oferuje specjalnie przeznaczone do tego celu słowo kluczowe
type. Poniżej jest pokazana funkcja distance() z poprzedniego przykładu, tym razem
wykorzystująca jawnie zdefiniowany typ Point:
// @flow
export type Point = {

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;

4│ for(let x of data) sum += x;


5│ return sum/data.length;
6│ }
7│
[1] 8│ average([1, 2, "three"]);

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:

function getStatus():[number, string] {


return [getStatusCode(), getStatusMessage()];
}
Funkcja zwracająca krotkę jest dość niewygodna w użyciu, chyba że zastosuje się przypisanie
destrukturyzujące:

let [code, message] = getStatus();


Dzięki takiemu przypisaniu i aliasom typów korzystanie z krotek staje się na tyle proste, że
można je traktować jako alternatywę dla prostych klas:
// @flow
export type Color = [number, number, number, number]; // [r, g, b, opacity]

function gray(level: number): Color {


return [level, level, level, 1];
}
function fade([r,g,b,a]: Color, factor: number): Color {
return [r, g, b, a/factor];

}
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.

17.8.7. Inne parametryzowane typy


Jak się przekonałeś, wraz z oznaczeniem Array trzeba w stosować oznaczenie typu elementów
tablicy. To dodatkowe oznaczenie nosi nazwę parametru typu. Jednak klasa Array nie jest
jedynym parametryzowanym typem.
Klasa Set jest kolekcją elementów, podobnie jak tablica, i nie można stosować jako oznaczenia
typu samego słowa Set. Trzeba dodatkowo użyć znaków < i > wraz z parametrem oznaczającym
typ elementów. Jeżeli zbiór zawiera elementy różnych typów, można stosować oznaczenia mixed
lub any. Poniżej jest przedstawiony przykład:
// @flow
// Funkcja zwracająca zbiór liczb będących dwukrotnościami elementów zbioru
wejściowego.

function double(s: Set<number>): Set<number> {


let doubled: Set<number> = new Set();
for(let n of s) doubled.add(n * 2);
return doubled;

}
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

import type { Color } from "./Color.js";


let colorNames: Map<string, Color> = new Map([
["red", [1, 0, 0, 1]],
["green", [0, 1, 0, 1]],
["blue", [0, 0, 1, 1]]

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

Poniżej jest przedstawiona definicja parametryzowanej klasy:


// @flow
// Klasa reprezentująca wynik operacji, którym może być błąd typu E lub
wartość typu V.

export class Result<E, V> {


error: ?E;
value: ?V;
constructor(error: ?E, value: ?V) {
this.error = error;

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]> = [];

let len = Math.max(a.length, b.length);


for(let i = 0; i < len; i++) {
result.push([a[i], b[i]]);
}
return result;

}
// Utworzenie tablicy [[1,'a'], [2,'b'], [3,'c'], [4,undefined]].
let pairs: Array<[?number,?string]> = zip([1,2,3,4], ['a','b','c'])

17.8.8. Typy danych przeznaczonych tylko do odczytu


Rozszerzenie Flow definiuje specjalne „pomocnicze” parametryzowane typy, których nazwy
zaczynają się od znaku $. Większość z nich jest przeznaczona do zaawansowanych zastosowań,
które nie są tu opisane. Jednak dwa z nich są bardzo przydatne. Aby utworzyć odmianę typu T
przeznaczoną tylko do odczytu, wystarczy wpisać $ReadOnly<T>. Podobnie oznaczenie
$ReadOnlyArray<T> opisuje przeznaczoną tylko do odczytu tablicę elementów typu T.
Powyższe typy stosuje się nie tylko dlatego, że stanowią swego rodzaju zabezpieczenie tablicy
lub obiektu przed zmianami (aby otworzyć prawdziwy obiekt przeznaczony tylko do odczytu,
należy użyć funkcji Object.freeze() opisanej w podrozdziale 14.2), ale dlatego, że pozwalają
uniknąć błędów wynikających z niezamierzonych modyfikacji. Jeżeli tworzona funkcja nie może
wprowadzać zmian w elementach tablicy lub właściwościach obiektu umieszczonego w jej
argumencie, należy oznaczyć ten parametr jednym z typów przeznaczonych tylko do odczytu.
Wtedy przy próbie zmiany wartości parametru rozszerzenie Flow zgłosi błąd. Poniżej są
przedstawione dwa przykłady:
// @flow
type Point = {x:number, y:number};
// Argumentem jest obiekt Point, którego funkcja nie może zmieniać.
function distance(p: $ReadOnly<Point>): number {
return Math.hypot(p.x, p.y);
}

let p: Point = {x:3, y:4};


distance(p) // => 5
// Argumentem jest tablica liczb, której funkcja nie może zmieniać.
function average(data: $ReadOnlyArray<number>): number {

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ą.

17.8.11. Typy wyliczeniowe i unie dyskryminowane


Rozszerzenie Flow pozwala używać prymitywnych literałów jako oznaczeń typów
dopuszczających stosowanie tylko jednej wartości. Zatem zapis let x:3; powoduje, że zmiennej
x nie można przypisać wartości innej niż 3. Typy dopuszczające stosowanie tylko jednej
wartości zazwyczaj nie są przydatne, ale unie tego rodzaju typów owszem. Na pewno potrafisz
wyobrazić sobie zastosowanie typów takich jak poniższe:
type Answer = "tak" | "nie";
type Digit = 0|1|2|3|4|5|6|7|8|9;
W oznaczeniach typów wykorzystujących literały nie można stosować wyrażeń:
let a: Answer = "Yes".toLowerCase(); // Błąd: zmiennej typu Answer nie można
przypisać ciągu znaków.
let d: Digit = 3+4; // Błąd: zmiennej typu Error nie można
przypisać liczby.
Rozszerzenie Flow podczas sprawdzania kodu nie wykonuje obliczeń, tylko sprawdza typy ich
wyników. Rozszerzenie „wie”, że metoda toLowerCase() zwraca ciąg znaków, a wynikiem
użycia operatora + jest liczba. „Nie wie” jednak, że oba wyrażenia zwracają wartości
dopuszczalnych typów, i dlatego wyświetla komunikaty o błędach w obu wierszach.
Unia literałów, taka jak Answer lub Digit, jest przykładem typu wyliczeniowego.
Charakterystycznym przykładem użycia tego rodzaju typu jest definicja kolorów kart do gry:
type Suit = "pik" | "trefl" | "karo" | "kier";
Bardziej użytecznym przykładem jest definicja kodów HTTP:
type HTTPStatus =
| 200 // OK
| 304 // Nie zmodyfikowano

| 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()

4. 11.4. Daty i czas


1. 11.4.1. Znaczniki czasu
2. 11.4.2. Operacje na datach
3. 11.4.3. Formatowanie dat i analizowanie ciągów znaków
5. 11.5. Klasy błędów
6. 11.6. Format JSON, serializacja i analiza składni
1. 11.6.1. Dostosowywanie formatu JSON
7. 11.7. Internacjonalizacja aplikacji
1. 11.7.1. Formatowanie liczb
2. 11.7.2. Formatowanie daty i czasu
3. 11.7.3. Porównywanie ciągów znaków
8. 11.8. Interfejs API konsoli
1. 11.8.1. Formatowanie danych w konsoli
9. 11.9. Interfejs API klasy URL
1. 11.9.1. Starsze funkcje URL
10. 11.10. Czasomierze
11. 11.11. Podsumowanie
14. Rozdział 12. Iteratory i generatory
1. 12.1. Jak działają iteratory?
2. 12.2. Implementowanie obiektów iterowalnych
1. 12.2.1. „Zamknięcie” iteratora: metoda return()
3. 12.3. Generatory
1. 12.3.1. Przykłady generatorów
2. 12.3.2. Instrukcja yield* i generatory rekurencyjne
4. 12.4. Zaawansowane funkcjonalności generatorów
1. 12.4.1. Wartość zwracana przez funkcję generatora
2. 12.4.2. Wartość wyrażenia yield
3. 12.4.3. Metody return() i throw() generatora
4. 12.4.4. Końcowe uwagi dotyczące generatorów
5. 12.5. Podsumowanie
15. Rozdział 13. Asynchroniczność w języku JavaScript
1. 13.1. Programowanie asynchroniczne i funkcje zwrotne
1. 13.1.1. Czasomierze
2. 13.1.2. Zdarzenia
3. 13.1.3. Zdarzenia sieciowe
4. 13.1.4. Funkcje zwrotne i zdarzenia w środowisku Node
2. 13.2. Promesy
1. 13.2.1. Korzystanie z promes
1. Obsługa błędów za pomocą promes
2. 13.2.2. Łańcuch promes
3. 13.2.3. Determinowanie promes
4. 13.2.4. Więcej o promesach i błędach
1. Metody catch() i finally()
5. 13.2.5. Promesy równoległe
6. 13.2.6. Tworzenie promes
1. Promesy oparte na innych promesach
2. Promesy w operacjach synchronicznych
3. Tworzenie promes od podstaw
7. 13.2.7. Promesy sekwencyjne
3. 13.3. Słowa kluczowe async i await
1. 13.3.1. Słowo kluczowe await
2. 13.3.2. Funkcje asynchroniczne
3. 13.3.3. Oczekiwanie na kilka promes
4. 13.3.4. Szczegóły implementacji
4. 13.4. Iteracje asynchroniczne
1. 13.4.1. Pętla for/await
2. 13.4.2. Iteratory asynchroniczne
3. 13.4.3. Generatory asynchroniczne
4. 13.4.4. Implementacja iteratora asynchronicznego
5. 13.5. Podsumowanie
16. Rozdział 14. Metaprogramowanie
1. 14.1. Atrybuty właściwości
2. 14.2. Rozszerzalność obiektów
3. 14.3. Atrybut prototype
4. 14.4. Popularne symbole
1. 14.4.1. Symbol.iterator i Symbol.asyncIterator
2. 14.4.2. Symbol.hasInstance
3. 14.4.3. Symbol.toStringTag
4. 14.4.4. Symbol.species
5. 14.4.5. Symbol.isConcatSpreadable
6. 14.4.6. Symbole dopasowujące wzorce
7. 14.4.7. Symbol.toPrimitive
8. 14.4.8. Symbol.unscopables
5. 14.5. Znaczniki szablonowe
6. 14.6. Obiekt Reflect
7. 14.7. Klasa Proxy
1. 14.7.1. Obowiązujące zasady a obiekty pośredniczące
8. 14.8. Podsumowanie
17. Rozdział 15. JavaScript w przeglądarkach
1. 15.1. Podstawy programowania stron WWW
1. 15.1.1. Kod JavaScript w znacznikach HTML <script>
1. Moduły
2. Typ skryptu
3. Asynchroniczne i odroczone uruchamianie skryptów
4. Ładowanie skryptów na żądanie
2. 15.1.2. Model DOM
3. 15.1.3. Obiekt globalny w przeglądarce
4. 15.1.4. Wspólna przestrzeń nazw
5. 15.1.5. Uruchamianie programów JavaScript
1. Wątkowy model kodu klienckiego
2. Kolejność działań w kodzie klienckim
6. 15.1.6. Wejście i wyjście programu
7. 15.1.7. Błędy
8. 15.1.8. Model bezpieczeństwa
1. Czego skrypt JavaScript nie jest w stanie zrobić?
2. Reguła tego samego pochodzenia
3. Skrypty międzydomenowe
2. 15.2. Zdarzenia
1. 15.2.1. Kategorie zdarzeń
2. 15.2.2. Rejestrowanie procedury obsługi zdarzeń
1. Rejestracja poprzez ustawienie właściwości obiektu
2. Rejestracja poprzez ustawienie atrybutu
3. Metoda addEventListener()
3. 15.2.3. Wywołanie procedury obsługi zdarzenia
1. Argument procedury obsługi zdarzenia
2. Kontekst wywołania procedury
3. Wynik procedury obsługi zdarzenia
4. Kolejność wywołań
4. 15.2.4. Propagacja zdarzeń
5. 15.2.5. Anulowanie zdarzenia
6. 15.2.6. Zgłaszanie własnych zdarzeń
3. 15.3. Przetwarzanie dokumentów
1. 15.3.1. Wybieranie elementów dokumentu
1. Wybieranie elementów za pomocą selektorów CSS
2. Inne metody wybierające elementy
3. Wstępnie wybrane elementy
2. 15.3.2. Struktura dokumentu i jej przeglądanie
1. Dokument jako drzewiasta struktura węzłów
3. 15.3.3. Atrybuty
1. Atrybuty HTML jako właściwości elementu
2. Atrybut class
3. Atrybuty zbioru danych
4. 15.3.4. Zawartość elementu
1. Zawartość w formacie HTML
2. Zawartość w formacie zwykłego tekstu
5. 15.3.5. Tworzenie, wstawianie i usuwanie węzłów
6. 15.3.6. Przykład: tworzenie spisu treści
18. Rozdział 16. Serwery w środowisku Node
1. 16.1. Podstawy programowania w środowisku Node
1. 16.1.1. Konsola
2. 16.1.2. Argumenty poleceń i zmienne środowiskowe
3. 16.1.3. Cykl życia programu
4. 16.1.4. Moduły
5. 16.1.5. Node Package Manager
2. 16.2. Domyślna asynchroniczność
3. 16.3. Bufory
4. 16.4. Zdarzenia i klasa EventEmitter
5. 16.5. Strumienie
1. 16.5.1. Potoki
2. 16.5.2. Iteracje asynchroniczne
3. 16.5.3. Zapisywanie danych w strumieniu i obsługa nacisku zwrotnego
4. 16.5.4. Odczytywanie danych ze strumienia za pomocą zdarzeń
1. Tryb płynny
2. Tryb wstrzymywany

6. 16.6. Procesy, procesory i szczegóły systemu operacyjnego


7. 16.7. Operacje na plikach
1. 16.7.1. Ścieżki, deskryptory i klasa FileHandle
2. 16.7.2. Odczytywanie plików
3. 16.7.3. Zapisywanie plików
4. 16.7.4. Operacje na plikach
5. 16.7.5. Metadane pliku
6. 16.7.6. Operacje na katalogach
8. 16.8. Klienty i serwery HTTP
9. 16.9. Klienty i serwery inne niż HTTP
10. 16.10. Procesy potomne
1. 16.10.1. Funkcje execSync() i execFileSync()
2. 16.10.2. Funkcje exec() i execFile()
3. 16.10.3. Funkcja spawn()
4. 16.10.4. Funkcja fork()
11. 16.11. Wątki robocze
1. 16.11.1. Tworzenie wątków roboczych i przesyłanie komunikatów
2. 16.11.2. Środowisko uruchomieniowe wątków roboczych
3. 16.11.3. Kanały komunikacyjne i klasa MessagePort
4. 16.11.4. Przenoszenie obiektów MessagePort i typowanych tablic
5. 16.11.5. Współdzielenie typowanej tablicy przez wątki
12. 16.12. Podsumowanie
19. Rozdział 17. Narzędzia i rozszerzenia
1. 17.1. Inspekcja kodu za pomocą narzędzia ESLint
2. 17.2. Formatowanie kodu za pomocą narzędzia Prettier
3. 17.3. Tworzenie testów jednostkowych za pomocą narzędzia Jest
4. 17.4. Zarządzanie pakietami za pomocą narzędzia npm
5. 17.5. Pakowanie kodu
6. 17.6. Transpilacja kodu za pomocą narzędzia Babel
7. 17.7. Rozszerzenie JSX: znaczniki w kodzie JavaScript
8. 17.8. Sprawdzanie typów danych za pomocą rozszerzenia Flow
1. 17.8.1. Instalacja i korzystanie z rozszerzenia Flow
2. 17.8.2. Stosowanie adnotacji typów
3. 17.8.3. Klasy
4. 17.8.4. Obiekty
5. 17.8.5. Aliasy typów
6. 17.8.6. Tablice
7. 17.8.7. Inne parametryzowane typy
8. 17.8.8. Typy danych przeznaczonych tylko do odczytu
9. 17.8.9. Funkcje
10. 17.8.10. Unie
11. 17.8.11. Typy wyliczeniowe i unie dyskryminowane
9. 17.9. Podsumowanie
10. O autorze
11. Kolofon

You might also like