Mężczyzna w bordowej koszuli pakujący pudełko w pracowni.

Moduły ES6 – Powtórka przed ReactJS #6

Brak wbudowanego systemu modułów w JavaScript stanowił problem od dawien dawna. Różne wzorce projektowe i techniki stanowiły zastępcze rozwiązanie problemu. Najpopularniejsze z nich to moduły opakowane w biblioteki, loader RequireJS, wstrzykiwanie zależności w AngularJS oraz powszechnie uznawany król – CommonJS. To właśnie w oparciu o CommonJS zaimplementowano natywną funkcjonalność w ramach specyfikacji ES6.

Problematyczne moduły

Jak się okazało, wprowadzenie ładowania modułów do środowiska przeglądarek było nadzwyczaj trudnym zadaniem. Pierwszą przeglądarką, która przełamała impas była (o dziwo) Safari. Google nadgoniło zaległości dopiero wiosną tego roku. Reszta stawki ograniczyła się do wspierania modułów ES6 w trybach eksperymentalnych. Ponadto, praca z modułami pomnaża ilość plików, którymi musimy zarządzać w projekcie. Próba zachowania porządku w pliku html posiadającym dziesiątki script tagów graniczy z niemożliwością. Stąd, pojawiło się zapotrzebowanie na nowy typ narzędzi – bundlery.

Najbardziej rozpoznawalnymi z nich są Webpack i Browserify. Ich głównym zadaniem jest scalanie wszystkich plików naszego programu w łatwiejsze do zarządzania paczki. W przypadku Webpacka mamy do dyspozycji liczne pluginy, dzięki nim możemy dodatkowo poddać nasz kod transpilacji, minifikacji itd. Fakt, że wymóg znajomości bundlerów pojawia się w większości ofert pracy jest dowodem na to jak istotny element stosu technologicznego stanowią te narzędzia. Niestety, sama konfiguracja Webpacka spędza sen z oczu wielu początkującym. Moje dotychczasowe doświadczenie ogranicza się do korzystania z gotowych rozwiązań takich jak create-react-app. Wykorzystywanie bundlerów zdecydowanie wykracza poza zakres naszych dzisiejszych rozważań. W przyszłości poświęcę im osobne wpisy.

Póki co, ograniczymy się do omówienia samej składni modułów ES6, bez wdawania się w szczegóły konfiguracji ekosystemu, który pozwala na ich wykorzystywanie. Poznamy dwa nowe wyrażenia – export i import. Jak nietrudno się domyślić, to one pozwalają na eksportowanie i importowanie modułów.

Eksportowanie modułów

Wyrażenie export pozwala na utworzenie modułu poprzez eksportowanie funkcji, obiektu, wartości prymitywnych co pozwala na ich zaimportowanie do innego programu.

Mamy do dyspozycji dwa typy eksportów. Pierwszy z nich to nazwane eksporty (ang. named export). Drugi określa się jako eksport domyślny (ang. default export).

Nazwane eksporty

Są dwa sposoby na wykonanie nazwanego eksportu poszczególnych członków modułu:

  • Pierwszy to poprzedzenie deklaracji słowem kluczowym export.
  • export var myVar1 = ···;
    export let myVar2 = ···;
    export const MY_CONST = ···;
    

  • Druga możliwość to wymienienie wszystkich nazw na końcu naszego modułu (podobnie jak w przypadku revealing module pattern). Jest to możliwe dzięki zastosowaniu destrukturyzacji.
  • const MY_CONST = ···;
    function myFunc() {
        ···
    } 
    
    export { MY_CONST, myFunc };
    

Ważne: jeżeli korzystamy z nazwanego eksportowania, to podczas importowania musimy użyć takich samych identifikatorów.

Podczas eksportowania możemy nadawać alternatywne nazwy (aliasy) członkom.

export { myNumbers, myLogger as Logger, Alligator }

Eksport domyślny

Drugi sposób, eksport domyślny znosi to ograniczenie. W ten sposób możemy zastosować inną, wybraną przez nas nazwę.

//--- meaning-of-universe.js ---
export default a = 42; // in file test.js

//--- main.js ---
import s from 'meaning-of-universe'  

console.log(s); // 42 

Niestety, wiąże się to z innym ograniczeniem. Możemy wykonać wyłącznie jeden default export.

//------ myFunc.js ------
export default function () { ··· } // Bez średnika! 

//------ main1.js ------
import myFunc from 'myFunc';
myFunc();

Domyślny eksport można zastosować wobec: funkcji, klas oraz wyrażeń. default export nie działa w połączeniu z var, let czy const.

//------ MyClass.js ------
export default class { ··· } // Bez średnika!

Warto zwrócić uwagę, że możliwość pominięcie nazw w przypadku zastosowania export default prowadzi do powstania ciekawego wyjątku. To jedyna sytuacja, w której możemy zadeklarować funkcję/klasę anonimową.

Możemy wyeksportować zewnętrzny moduł, jako domyślny eksport obecnego moduł.

export { default } from 'foo';

Default to tak naprawdę unikalna NAZWA modułu (niczym nie różniąca się od tych, które wykorzystujemy w nazwanych eksportach). Poniższe przykłady są takie same.

//------ module1.js ------
export default function foo() {} // function declaration!

//------ module2.js ------
function foo() {}
export { foo as default };

Słowa kluczowego default można używać tylko podczas eksportowania, nie nadaje się na nazwę zmiennej wykorzystywanej w imporcie.
W związku z tym, można wyróżnić następujące zasady dotyczące stosowania default podczas eksportowania i importowania.

  1. Default może pojawić się tylko po prawej stronie eksportu zmieniającego nazwę.
  2. export { foo as default };

  3. Default może pojawić się tylko po lewej stronie importu zmieniajacego nazwę.
  4. import { default as foo } from 'some_module';

W ramach eksportowania jednego modułu, możemy również wyeksportować zawartość innych modułów.

export * from 'src/other_module';

Ważne: domyślne eksporty są ignorowane przez *.

Mamy możliwość połączenia nazwanych eksportów z eksportem domyślnym.

//------ underscore.js ------
export default function (obj) {
    ···
}
export function each(obj, iterator, context) {
    ···
}
export { each as forEach };

Zaleca się oddzielanie modułów wykorzystujących eksporty nazwane i domyślny. Dzięki temu, nasze intencje co do tego co powinno być zaimportowane, będą łatwiejsze do odczytania dla programistów wykorzystujących nasz kod.

Jednakże, są sytuacje, w których połączenie tych dwóch formuł jest jak najbardziej sensowne. Zatroszczmy się wtedy o odpowiednią komunikację za pomocą komentarzy.

Importowanie

Nie będzie zaskoczeniem (mam nadzieję), że przy użyciu wyrażenia import możemy wykorzystać wcześniej wyeksportowane moduły w innym programie.

//------ MyClass.js ------
export class MyClass;
//------ main2.js ------
import MyClass from 'MyClass';
const inst = new MyClass();

Możemy importować konkretnych nazwanych członków wykorzystując destrukturyzację

//------ main.js ------
import { square, diag } from 'lib';
console.log(square(11)); // 121
console.log(diag(4, 3)); // 5

… albo cały moduł. Wtedy dostajemy do dyspozycji przestrzeń nazw opakowaną w obiekt (jego nazwa jest zależna od aliasu). Każdy nazwany eksport jest reprezentowany jako oddzielna właściwość tego obiektu.

//------ main.js ------
import * as lib from 'lib';
console.log(lib.square(11)); // 121
console.log(lib.diag(4, 3)); // 5

Ważne: Moduły ES6 są statyczne – tzn. nie możemy wykonywać warunkowych eksportów/importów. To ograniczenie jest wymuszone przez działanie silnika, który w takich sytuacjach nagrodzi nas wyrzuceniem błędu.

if (Math.random()) {
    import 'foo'; // SyntaxError
}

// Wyrażenia export/import nie mogą znajdywać się w jakimkolwiek bloku. 
{
    import 'foo'; // SyntaxError
}

Wynoszenie importów

Import modułu podlega wynoszeniu (ang. hoisting), tak samo jak deklaracje zmiennych (z użyciem słowa kluczowego var) i funkcji.

foo(); // Do something.

import { foo } from 'my_module';

Import stanowi swego rodzaju podgląd zawartości eksportowanego modułu. Na bieżąco widzimy zachodzące w nim zmiany.
//------ lib.js ------
export let counter = 3;
export function incCounter() {
    counter++;
}

//------ main.js ------
import { counter, incCounter } from './lib';

// The imported value `counter` is live
console.log(counter); // 3
incCounter();
console.log(counter); // 4

W przypadku importowania domyślnego eksportu możemy zastosować dowolną nazwę.

import localName from 'src/my_lib';

Importowanie nazwą z użyciem aliasów (alternatywnych nazw)

// Zmiana nazwy: `name1` na `localName1`.
import { name1 as localName1, name2 } from 'src/my_lib';

// Zmiana nazwy: default export na `foo`.
import { default as foo } from 'src/my_lib';

Jeżeli chcemy połączyć kilka styli importowania, to importowanie modułu domyślnego musi mieć pierwszeństwo.

// Default + cała przestrzeń nazw.
import theDefault, * as my_lib from 'src/my_lib';

// Default + nazwane importy.
import theDefault, { name1, name2 } from 'src/my_lib';

Najlepsze praktyki

Aby ułatwić pracę z przygotowanym przez nas modułem, warto zdecydować się na domyślny export jednego obiektu. Będzie on służył jako interfejs. Jest to technika mocno zainspirowana Revealing Module Pattern, o którym wspominałem na początku wpisu. Wzorzec ten był szeroko stosowany w przeszłości, więc korzystanie z takiego API, będzie intuicyjne dla wielu programistów.

const bar = () => console.log("Print something");

var api = { foo: 'algo', bar, baz: 'smart' } 

export { api as default }; 

API modułu najlepiej zadeklarować na samym końcu pliku, zaraz przed exportem. W ten sposób osobie analizującej moduł łatwo będzie zidentyfikować jaki obiekt jest eksportowany i co zawiera. Jeżeli zdecydowalibyśmy się na eksportowanie wybranych członków dużego modułu za pomocą nazwy, to nietrudno wyobrazić sobie o ile większy wysiłek spadłby na głowy naszych koleżanek i kolegów. Ponadto, narazilibyśmy się na przeoczenie jednego z kluczowych elementów modułu.

export default jest o tyle uniwersalny, że daje nam możliwośc wyeksportowania tylko JEDNEGO obiektu. To niezwykle zwiększa czytelność kodu.

Podsumowanie

Jeżeli chcecie przerobić zamieszczone w wpisie przykłady, to polecam skorzystać z ES6 Module Transpiler.

Mamy za sobą pierwszą część powtórki! Skoro jesteśmy na bieżąco z składnią ES6, to możemy zająć się najistotniejszymi zagadnieniami samego JavaScript. Wszystko idzie zgodnie z planem, więc wygląda na to, że od stycznia 2018 roku będziemy zbierali plony naszej ciężkiej pracy u podstaw. React co raz bliżej ;).

Zachęcam do śledzenia bloga na facebooku. Zapraszam do wspólnego ćwierkania na Twitterze.

W przyszłym tygodniu zrobię małą przerwę od postów technicznych. Podzielę się z Wami metodą, która miała największy wpływ na tempo moich postępów w nauce.

Źródła

Zdjęcie tytułowe autorstwa: Bench Accounting

Marcin Czarkowski

Cześć! Ja nazywam się Marcin Czarkowski, a to jest AlgoSmart - blog, na którym dzielę się wiedzą o ReactJS oraz JavaScript. Tworzę poradniki na YouTube i jestem współtwórcą przeprogramowani.pl

  • Ciekawy wpis, duży plusik!
    Uważałbym tylko z importowaniem modułu (w którym mamy export default) pod inną nazwą niż ta z modułu. Szczerze mówiąc nie przemawia do mnie taka praktyka, bo uważam, że z czasem może to utrudnić analizę kodu. Co innego importowanie z jawnym wskazaniem aliasu („as”), ale tutaj wyraźnie wskazujmy innym (i sobie), że „coś robimy” z oryginalną nazwą (tą z modułu). Ale to tylko oczywiście moja subiektywna opinia i w żaden sposób nie ujmuje artykułowi, wręcz przeciwnie, bardzo dobrze, że omówiłeś to zagadnienie i każdy świadomie może zdecydować czy będzie z tej możliwości korzystał czy nie (zmiana nazwy).
    Pozdrawiam i życzę powodzenia w blogowaniu 🙂

    • Zgadzam się z uwagą co do importowania domyślnych. Należy dbać o czytelność kodu, a unikanie niepotrzebnych komplikacji jest na to świetnym sposobem. Z tego samego powodu warto unikać anonimowych eksportów domyślnych.

      Pozdrawiam również! 😉

  • Czesław Wapno

    Należy uważać z importowaniem całych modułów, bo wtedy rozmiar aplikacji rośnie w oczach.

    • Co prawda, to prawda.

      W początkowych fazach tworzenia projektu możemy sobie jeszcze na to pozwolić. Ciężko przewidzieć, jakie funkcje z biblioteki, przydadzą nam się podczas kodowania. Jednak przed wypuszczeniem programu na produkcję, wypada dokładnie przeanalizować listę importów. Na pewno pojawi się dużo okazji na pozbycie się zbędnych kilobajtów.