ES6 bez tajemnic to cykl artykułów poświęconych nowym składnikom języka JavaScript, które pojawiły się w 6. edycji standardu ECMAScript, w skrócie ES6.
Gdy w 2007 r. zaczynałem pracować w zespole JavaScript Mozilli, żartowało się, że przeciętna długość programu napisanego w JavaScripcie to jedna linijka.
Było to dwa lata po uruchomieniu usługi Google Maps. W okresie nieco wcześniejszym JavaScript wykorzystywany był przede wszystkim do sprawdzania poprawności formularzy, a typowa procedura obsługi <input onchange=>
zajmowała… jedną linijkę kodu.
Od tej pory trochę się pozmieniało. Projekty JavaScriptowe osiągają teraz zdumiewające rozmiary, opracowano także narzędzia pomocne w pracy nad dużymi projektami. Jedną z podstawowych rzeczy, jakich potrzebują programiści jest system modułów, sposób na rozdzielenie projektu na kilka plików i katalogów – tak, by wszystkie fragmenty kodu mogły się ze sobą w razie potrzeby komunikować, a wczytywanie kodu było wydajne. Oczywiście JavaScript zawiera system modułów. Konkretniej rzecz biorąc, jest ich kilka. Istnieje także kilka systemów zarządzania pakietami, narzędzi do instalowania oprogramowania i zarządzania wysokopoziomowymi zależnościami. Wydawać by się zatem mogło, że ES6 ze swoją nową składnią modułową zbyt późno włącza się do gry.
Dziś przekonamy się, czy ES6 może wzbogacić istniejące systemy i czy przyszłe standardy oraz narzędzia będą mogły bazować na rozwiązaniach z ES6. Na początek jednak zobaczmy, czym są moduły ES6.
Moduły w JavaScript – podstawy
Moduł ES6 to plik zawierający kod JS. Nie oznacza się go specjalnym słowem kluczowym module
i odczytywany jest w zasadzie tak jak skrypt. Pomiędzy skryptami a modułami istnieją jednak dwie różnice:
- Moduły ES6 domyślnie działają w trybie ścisłym, nawet jeśli nie zawierają dyrektywy
"use strict";
. - W modułach można korzystać ze słów kluczowych
import
iexport
.
Najpierw omówię słowo export
. Wszystkie znajdujące się w module deklaracje są domyślnie lokalne. Jeśli zatem chcemy, by składnik zadeklarowany w module był publiczny, tak by inne moduły miały do niego dostęp, musimy go wyeksportować. Można to zrobić na kilka sposobów. Najprościej jest dodać słowo kluczowe export
.
// kittydar.js – znajdź położenie wszystkich kotów na obrazku.
// (Autorką oryginalnej biblioteki jest Heather Arthur.)
// (Nie korzystała ona jednak z modułów, ponieważ było to jeszcze w 2013 r.)
export function detectCats(canvas, options) {
var kittydar = new Kittydar(options);
return kittydar.detectCats(canvas);
}
export class Kittydar {
... kilka metod przetwarzających obraz...
}
// Ta funkcja pomocnicza nie jest eksportowana.
function resizeCanvas() {
...
}
...
Za pomocą słowa kluczowego export
można wyeksportować dowolną globalną funkcję, klasę, a także zmienne var
, let
i const
.
To tyle co trzeba wiedzieć, by napisać moduł! Nie musisz umieszczać wszystkiego w samowykonującej się funkcji czy wywołaniu zwrotnym. Możesz zadeklarować wszystko, czego potrzebujesz. Ponieważ kod jest modułem a nie skryptem, wszystkie deklaracje znajdują się w lokalnym zasięgu modułu – nie są globalnie widoczne dla wszystkich skryptów i modułów. Wystarczy wyeksportować deklaracje składające się na publiczne API modułu i gotowe.
Pomijając eksportowanie, kod modułu nie różni się praktycznie niczym od zwykłego kodu. Może korzystać z elementów globalnych takich jak obiekty czy tablice. Jeśli jest uruchamiany w przeglądarce internetowej, można w nim użyć obiektów document
i XMLHttpRequest
.
Możemy w osobnym pliku zaimportować funkcję detectCats()
i z niej skorzystać:
// demo.js – wersja demo programu Kittydar
import {detectCats} from "kittydar.js";
function go() {
var canvas = document.getElementById("catpix");
var cats = detectCats(canvas);
drawRectangles(canvas, cats);
}
Aby zaimportować z modułu wiele nazw, napiszemy:
import {detectCats, Kittydar} from "kittydar.js";
W przypadku uruchamiania modułu zawierającego deklarację import
moduły importowane są wczytywane jako pierwsze, a następnie kod każdego modułu jest wykonywany w ramach przeglądania grafu zależności w głąb, z pominięciem kodu już wykonanego, co pozwala uniknąć zapętlenia.
To są właśnie podstawy modułów. Naprawdę nie ma tu nic trudnego 😉
Listy eksportowe
Zamiast oznaczać każdy eksportowany element z osobna, możemy umieścić w nawiasie klamrowym pojedynczą listę wszystkich nazw, które chcemy wyeksportować:
export {detectCats, Kittydar};
// słowo kluczowe 'export' nie jest tu wymagane
function detectCats(canvas, options) { ... }
class Kittydar { ... }
Lista export
nie musi znajdować się na samym początku pliku – można ją umieścić w dowolnym miejscu w najwyższym zakresie modułu. Można utworzyć kilka list eksportowych lub łączyć je z innymi deklaracjami export
, o ile żadna nazwa nie jest eksportowana więcej niż raz.
Zmiana nazw wartości importowanych i eksportowanych
Raz na jakiś czas może się zdarzyć, że nazwa importowanego elementu koliduje z inną potrzebną nam nazwą. W ES6 możliwa jest zmiana nazwy importowanej wartości:
// suburbia.js
// Oba moduły eksportują coś o nazwie "flip".
// Aby obydwa elementy zostały zaimportowane, musimy zmienić nazwę przynajmniej jednego z nich.
import {flip as flipOmelet} from "eggs.js";
import {flip as flipHouse} from "real-estate.js";
...
Analogicznie można zmieniać nazwy elementów eksportowanych. Przydaje się to, jeśli chcemy wyeksportować tę samą wartość pod dwoma różnymi nazwami, co czasem się zdarza:
// unlicensed_nuclear_accelerator.js - przesyłanie strumieniowe bez zabezpieczeń DRM
// (nie jest to prawdziwa biblioteka, chociaż może powinna istnieć)
function v1() { ... }
function v2() { ... }
export {
v1 as streamV1,
v2 as streamV2,
v2 as streamLatestVersion
};
Eksportowanie domyślne
Nowy standard został zaprojektowany tak, by współpracować z istniejącymi modułami CommonJS i AMD. Załóżmy więc, że mamy projekt Node i wykonaliśmy już polecenie npm install lodash
. Kod ES6 może zaimportować pojedyncze funkcje z biblioteki Lodash:
import {each, map} from "lodash";
each([3, 2, 1], x => console.log(x));
Być może jednak przyzwyczaiłeś się już do zapisu _.each
zamiast each
i chciałbyś dalej go stosować. A może chciałbyś użyć _
jako funkcji, co stanowi użyteczne rozwiązanie, jeśli korzystasz z biblioteki Lodash.
W takich przypadkach możesz posłużyć się nieco inną składnią i zaimportować moduł bez nawiasu klamrowego.
import _ from "lodash";
Taki skrócony zapis jest równoważny z import {default as _} from "lodash";
. Wszystkie moduły CommonJS i AMD są traktowane przez ES6 jako mające export
domyślny (default
) – to tak, jakbyśmy pobrali moduł, a raczej obiekt exports
za pomocą funkcji require()
.
Moduły ES6 pozwalają eksportować wiele wartości, lecz w przypadku istniejących modułów CommonJS możliwe jest jedynie eksportowanie domyślne. Na przykład, z tego co widzę, słynny pakiet colors na tę chwilę nie jest jakoś szczególnie przystosowany do ES6. To kolekcja modułów CommonJS, jak większość pakietów npm
. Można ją jednak zaimportować bezpośrednio do kodu ES6.
// kod ES6 równoważny z 'var colors = require("colors/safe");'
import colors from "colors/safe";
Jeśli chciałbyś, by twój własny moduł ES6 miał export
domyślny, to można to łatwo zrobić. Eksportowanie domyślne nie jest niczym specjalnym, po prostu ma w nazwie default
. Możemy użyć wspomnianej już składni umożliwiającej zmianę nazwy wartości:
let mójObiekt = {
pole1: wartość1,
pole2: wartość2
};
export {mójObiekt as default};
Albo zastosować jeszcze lepsze rozwiązanie, czyli poniższy skrót:
export default {
pole1: wartość1,
pole2: wartość2
};
Po słowach kluczowych export default
może następować dowolna wartość: funkcja, klasa, literał obiektowy itd.
Obiekt modułu
Wybaczcie, że tak się rozpisuję. JavaScript nie jest jednak sam: z jakiegoś powodu systemy modułów w innych językach mają masę drobnych, nudnych elementów funkcjonalności ułatwiających życie. Na szczęście została nam do omówienia tylko jedna rzecz. No, dwie rzeczy.
import * as cows from "cows";
Jeśli korzystamy z wyrażenia import *
, importowany jest obiekt przestrzeni nazw modułu. Jego własności są wartościami eksportowanymi z modułu. Jeśli zatem moduł cows
eksportuje funkcję o nazwie moo()
, to po zaimportowaniu cows
w wyżej omówiony sposób możemy napisać: cows.moo()
.
Moduły agregujące
Czasami główny moduł pakietu zajmuje się przede wszystkim importowaniem pozostałych modułów pakietu i eksportowaniem ich w jednolity sposób. Tego rodzaju kod można uprościć, stosując skrótowy zapis na importowanie i eksportowanie:
// world-foods.js – dobry towar zewsząd
// importuje moduł "sri-lanka" i ponownie eksportuje jego wybrane wartości eksportowane
export {Tea, Cinnamon} from "sri-lanka";
// importuje moduł "equatorial-guinea" i ponownie eksportuje jego wybrane wartości eksportowane
export {Coffee, Cocoa} from "equatorial-guinea";
// importuje "singapore" i eksportuje WSZYSTKIE jego wartości eksportowane
export * from "singapore";
Każda z wymienionych instrukcji export-from
przypomina dyrektywę import-from
, po której następuje słowo export
. W przeciwieństwie do standardowego importowania, w powyższym kodzie ponownie eksportowane wiązania nie są dodawane do zakresu. Nie korzystaj więc z tego skrótu, jeśli miałbyś w module world-foods.js
umieścić kod korzystający z wartości Tea
. Nie będzie ona dostępna.
Jeśli nazwa eksportowana z "singapore"
będzie kolidować z nazwami innych eksportowanych wartości, to wystąpi błąd. Dlatego z wyrażenia export *
należy korzystać uważnie.
Uff! Przebrnęliśmy przez składnię! Teraz możemy zająć się bardziej interesującymi aspektami.
Co tak naprawdę robi wyrażenie import
Jesteś w stanie uwierzyć, że… nie robi nic?
Oczywiście, nie jesteś taki naiwny. A czy uwierzysz, że standard w zasadzie nie określa tego, co robi wyrażenie import
? I że to dobrze?
W ES6 szczegółowe aspekty wczytywania modułu są całkowicie zależne od konkretnej implementacji. Cała reszta dotycząca wykonywania modułu jest dokładnie określona w specyfikacji.
Ogólnie rzecz ujmując, kiedy każemy silnikowi JS uruchomić moduł, musi się on zachować tak, jakby proces ten przechodził przez cztery etapy:
- Parsowanie: implementacja czyta kod źródłowy modułu i sprawdza go pod kątem błędów składniowych.
- Wczytywanie: implementacja wczytuje wszystkie zaimportowane moduły (rekurencyjnie). Ten etap nie został jeszcze ustandaryzowany.
- Łączenie: dla każdego nowo wczytanego modułu implementacja tworzy zakres modułu i wypełnia go wszystkimi wiązaniami zadeklarowanymi w danym module, łącznie z tymi zaimportowanymi z innych modułów.
Jeśli na tym etapie spróbujemy wykonać instrukcję
import {cake} from "paleo"
, a moduł"paleo"
nie eksportuje wartości o nazwiecake
, to zwrócony zostanie błąd. To zła wiadomość, ponieważ byliśmy tak blisko wykonania kodu JS. I prawie dostaliśmy ciastko! - Wykonanie: w końcu implementacja wykonuje instrukcje zawarte w każdym nowo wczytanym module. Do tego czasu przetwarzanie instrukcji
import
zdążyło się już zakończyć, zatem kiedy przychodzi do wykonania linijki kodu zawierającej deklaracjęimport
, to… nic się nie dzieje!
Widzisz? Mówiłem, że import
„nic” nie robi. Nie kłamię na temat języków programowania.
Teraz jednak dochodzimy do najciekawszej części tego systemu. Można w nim zastosować fajną sztuczkę. Ponieważ system nie precyzuje jak ma przebiegać wczytywanie i ponieważ można zawczasu wydedukować wszystkie zależności na podstawie deklaracji import
w kodzie źródłowym, to implementacja ES6 może swobodnie wykonać całą pracę w czasie kompilacji i umieścić wszystkie moduły w pojedynczym pliku, który będzie można udostępnić przez sieć! Do tego właśnie służą narzędzia takie jak webpack.
To istotne, ponieważ wczytywanie skryptów przez sieć trwa, a po pobraniu jednego skryptu zawsze może się okazać, że zawiera on deklaracje import
, które wymagają wczytania wielu kolejnych. „Naiwny” program wczytujący przesyłałby dane przez sieć wiele razy w tę i z powrotem. Jednak dzięki narzędziu webpack możemy nie tylko już teraz używać modułów ES6, ale też czerpać wszelkie korzyści z inżynierii oprogramowania bez spadku wydajności wykonywanego kodu.
Szczegółowa specyfikacja wczytywania modułów w ES6 została pierwotnie zaplanowana – i opracowana. Jednym z powodów, dla którego nie znalazła się w ostatecznej wersji standardu był brak konsensusu co do tego, jak wprowadzić możliwość łączenia modułów w pakiety. Mam nadzieję, że ktoś wpadnie na jakieś rozwiązanie, bo jak się przekonamy, wczytywanie modułów naprawdę powinno zostać ustandaryzowane. Poza tym tworzenie pakietów jest zbyt użyteczne, by z niego rezygnować.
Statyka i dynamika albo zasady i sposoby ich łamania
Jak na język dynamiczny JavaScript ma zaskakująco statyczny system modułów.
- Wszelkie importowanie i eksportowanie jest możliwe jedynie w najwyższym zakresie modułu. Nie istnieje importowanie ani eksportowanie warunkowe, a instrukcji
import
nie można użyć w zakresie funkcji. - Wszystkie eksportowane identyfikatory muszą być wyeksportowane bezpośrednio poprzez nazwę w kodzie źródłowym. Nie można programowo przejrzeć tablicy i wyeksportować zestawu nazw na podstawie pobranych danych.
- Obiekty modułów mają charakter zamknięty. Do obiektu modułu nie da się „przemycić” nowej własności tak jak robi to wypełniacz.
- Wszystkie zależności modułu muszą zostać wczytane, przetworzone i zachłannie połączone nim zostanie wykonana jakakolwiek porcja modułu. Nie ma takiej instrukcji
import
, którą można by wczytać leniwie, na żądanie. - W instrukcji
import
nie działa żaden mechanizm naprawiania błędów. Aplikacja może zawierać setki modułów, a jeśli któryś z nich się nie wczyta lub nie połączy, to program w ogóle nie będzie działał. Instrukcjiimport
nie można umieścić w blokutry-catch
. (Jest jednak pewien plus: ponieważ system jest tak statyczny, webpack wykryje błędy za nas podczas kompilacji). - Nie istnieje żaden punkt zaczepienia, który umożliwiłby modułowi wykonanie jakiegoś kodu zanim zostaną wczytane jego zależności. Oznacza to, że moduły nie mają zupełnie wpływu na to, jak ich zależności są wczytywane.
To niezły system, o ile nasze potrzeby są statyczne. Ale czasem będziemy przecież chcieli zastosować jakieś obejście, prawda?
Dlatego też niezależnie od użytego systemu wczytywania modułów, obok statycznych instrukcji ES6 import-export
zawsze będzie dostępny interfejs API. Przykładowo webpack zawiera API, za pomocą którego możesz podzielić kod na części, leniwie wczytując wybrane pakiety modułów na żądanie. To samo API możesz wykorzystać do złamania większości wymienionych powyżej zasad.
Składnia modułów ES6 jest bardzo statyczna. To dobrze – rekompensują nam to potężne narzędzia czasu kompilacji. Statyczna składnia ma jednak współpracować z rozbudowanym, dynamicznym API programu wczytującego.
Kiedy będę mógł zacząć korzystać z modułów ES6
Aby używać modułów już dziś, trzeba skorzystać z kompilatora, np. Traceur lub Babel. W jednym z poprzednich artykułów Gastón I. Silva zademonstrował jak za pomocą narzędzi Babel i Broccoli można skompilować sieciowy kod ES6. W oparciu o ten artykuł Gastón opracował działający przykład z obsługą modułów ES6. Z kolei niniejszy artykuł Axela Rauschmayera opisuje rozwiązanie wykorzystujące kompilator Babel i webpack.
Głównymi projektantami systemu modułów ES6 byli Dave Herman i Sam Tobin-Hochstadt, którzy bronili jego statycznych elementów i odpierali krytykę (w tym moją) przez lata kontrowersji. Jon Coppeard pracuje nad implementacją modułów w Firefoksie. Trwają również dodatkowe prace nad standaryzacją wczytywania modułów JS. W następnej kolejności zostanie zapewne podjęta próba wprowadzenia do języka HTML czegoś w rodzaju <script type=module>
.
To właśnie ES6.
Tak dobrze się bawiłem, że nie chcę jeszcze kończyć naszej serii. Może moglibyśmy poświęcić nowemu standardowi jeszcze jeden artykuł. Porozmawialibyśmy o pozostałych elementach specyfikacji ES6, które były zbyt drobne, by stanowić temat osobnego artykułu. I może pomówilibyśmy trochę o tym, co czeka nas w przyszłości. Odwiedzajcie nas zatem regularnie, by nie przegapić niezwykłego podsumowania serii ES6 bez tajemnic.