Moduły w ES6

Rozważmy przykład skryptu na naszej stronie:


//app.js
let a = 2;
document.querySelector('.btn').addEventListener('click', function() {
    console.log(a * a);
});

Po kliknięciu na .btn wypisujemy wynik prostego równania.
Co się stanie, gdy do strony dołączymy inny skrypt - np. jakiś plugin slidera, w którym inny programista używa takich samych nazw zmiennych?


//app.js
let a = 2;
document.querySelector('.btn').addEventListener('click', function() {
    console.log(a * a);
});

//plugin-slider.js ---
a = "prev slide";
b = "next slide";

Domyślnie wszystkie skrypty, jakie dołączymy do naszej strony są traktowane jak jeden wielki skrypt, co oznacza, że każdy plik ma dostęp do danych z innych plików.

Inny programista nadpisał nam zmienną a, przez co po kliknięciu na button dostajemy NaN. Spowodowane jest to tym, ze po klasycznym dołączeniu skryptów do strony, wszystkie działają tak jakby były pisane w 1 pliku.

Aby zapobiec wrzucaniu wszystkiego do jednego wora, w wielu językach programowania wprowadzono mechanizm modułów, który sprawia, że każdy oddzielny plik staje się swoistym zamkniętym środowiskiem, z którego nic samo z siebie nie wychodzi na zewnątrz. W takim pliku możemy więc śmiało pisać dowolne nazwy zmiennych, używać globalnych zmiennych nie przejmując się tym, że zostanie to nadpisane przez inne skrypty.
Dodatkowo w każdym momencie możemy wskazać rzeczy, które chcemy by były dostępne poza tym plikiem.

W naszym powyższym przypadku także możemy pokusić się o ochronę naszego kodu okrywając go funkcją:


//w przypadku let/const można też same klamry
(function() {
    let a = 2;
    let b = 3;
    document.querySelector('.btn').addEventListener('click', function() {
        console.log(a * b);
    });
})();

Problem z powyższym kodem jest taki, że obejmując wszystko funkcją nie dajemy dostępu do niczego wewnątrz. I na to są sposoby. Wystarczy, ze nasza funkcja będzie zwracać takie rzeczy:


const ourModule = (function() {
    let slideCount = ...;
    const calculateSlides = function() {...}
    const importantFunction = function() {...}
    const initEngine = function() {...}
    const doNotTouch = function() {...}

    return {
        importantFunction : importantFunction,
        initEngine : initEngine
    }
})()

ourModule.initEngine(); //ok
ourModule.slideCount = 20; //błąd - nie mamy dostępu

Powyższe rozważanie to tylko prosta próba stworzenia prawdziwego modułu. Wcale nie najlepsza, bo dla przykładu nie niweluje problemu z nazwą samego modułu, która także może być już zarezerwowana.

Wraz z rozwojem Javascript pojawiło sie kilka innych rozwiązań modułów. Omówimy je poniżej.

CommonJS

Common JavaScript Modules to typ modułów stosowanych po stronie serwera czyli w Node.js. Charakteryzuje się krótkim kodem i jest przeznaczony do wczytywania synchronicznego. Znasz je chociażby z Gulpa czy Webpacka:

const gulp = require("gulp");
const sourcemaps = require("gulp-sourcemaps");
...

exports.css = css;
exports.default = ...

W tym typie modułów korzystamy głównie z 2 funkcji:
require - do importowania rzeczy z innych plików
exports - gdy chcemy coś eksportować do innych plików


//plik1.js -----
function print() {
    ...
}

exports.print = print;

//plik2.js -----
const print = require("./plik1");
print();

Funkcja require() służy do importowania kodu z innych plików. Jako jej parametr podajemy ścieżkę do pliku js. Jeżeli ścieżkę rozpoczniemy od kropki (lub np. dwóch), wtedy jest to relatywna ścieżka względem danego pliku w którym piszemy (najczęściej stosuje się to przy imporcie naszych plików). Jeżeli ścieżkę taką zaczniemy nie od kropki, dany moduł domyślnie będzie szukany w katalogu node_modules.


const $ = require("jquery"); //dołączam jquery z katalogu node_modules
const mySlider = require("./slider"); //dołączam slider z pliku w tym katalogu
const myPlugin = require("../plugin/plugin"); //dołączam plik z katalogu plugin leżącego w katalogu powyżej

Do wystawiania rzeczy na zewnątrz pliku możemy używać dwóch zapisów:
module.exports oraz exports.


const text = function() { ... }
const text2 = function() { ... }
const nr = 200;

//wystawiamy na zewnątrz

module.exports = {
    text : text,
    text2 : text2,
    nr : nr
}

//lub

exports.text = text;
exports.text2 = text2;
exports.nr = nr;
module exports

Zmienna exports jest jest referencją do eksportowanego na zewnątrz obiektu module.exports:


module.exports = {}; //to jest wystawiane na zewnątrz
exports = module.exports; //referencja

Użycie skróconego zapisu spotkamy w wielu skryptach. Należy jednak trochę uważać, ponieważ można nieumyślnie popsuć eksportowanie:


//skoro exports wskazuje na module.exports, to poniższy kdo nie zadziała,
//ponieważ po jego wykonaniu exports będzie wskazywało na nowy - inny obiekt!
exports = {
    nr : 10,
    age : 200,
    print : function() { ... }
}

//poniższe zadziała, bo nie popsuliśmy referencji, a tylko dodaliśmy właściwości
exports.nr = 10;
exports.age = 200;
exports.print : function() { ... }

exports.myData = {
    nr : 10,
    age : 200,
    print : function() { ... }
}

Coś jak:


let ex = module.exports;
ex = {}; //to już jest coś innego

let ex = module.exports;
ex.data = {} //wszystko ok

Asynchronous Module Definition (AMD)

Asynchronous Module Definition (AMD) - najpopularniejszą implementacją tego rozwiązania jest biblioteka Require.js. Rozwiązanie to charakteryzuje się trochę bardziej skomplikowanym kodem implementacji, oraz tym, że moduły takie przeznaczone są do asynchronicznego wczytywania. Głównie są stosowane po stronie przeglądarek.


define(['jquery'], function ($) {
    //metody
    function myFunc(){};

    //metody wystawione na zewnątrz
    return myFunc;
});

UMD: Universal Module Definition

Oba powyższe podejścia nie są ze sobą kompatybilne. Wzór UMD tworzy swego rodzaju wrapper,który stara się połączyć oba powyższe rozwiązania:


(function (root, factory) {
    if (typeof define === "function" && define.amd) {
        define(["jquery", "underscore"], factory);
    } else if (typeof exports === "object") {
        module.exports = factory(require("jquery"), require("underscore"));
    } else {
        root.Requester = factory(root.$, root._);
    }
}(this, function ($, _) {
    var Requester = { ... };

    return Requester;
}));

Kod nie jest może za łatwy, dlatego istnieją serwisy takie jak http://urequire.org/, które służą do konwersji tamtych typów na uniwersalny

Jeżeli bardziej interesują cię te tematy, poczytaj sobie jeden z wielu wpisów na ten temat np. ten:

My tymczasem przejdziemy do gwoździa programu, czyli modułów w EcmaScript 2015.

Moduły w ES6

Celem powstania modułów w ES6 było stworzenie prostego, działającego po stronie przeglądarki rozwiązania.

W podejściu tym stosujemy dwa słowa kluczowe:
Jeżeli chcemy coś wystawić na zewnątrz stosujemy instrukcję export.
Jeżeli chcemy coś zaimportować, stosujemy import:


//plik textfn.js ------
function smallText(txt) {
    return txt.toLowerCase();
}

function bigText(txt) {
    return txt.toUpperCase();
}

function mixText(txt) {
    return [...txt].map((el, i) => i%2 === 0 ? el.toLowerCase() : el.toUpperCase());
}

export { smallText, bigText, mixText }

//plik app.js ------
import { smallText, bigText } from './textfn'; //nie musimy wszystkiego importować

console.log( smallText("Ala ma kota") ); //ala ma kota
console.log( bigText("Ala ma kota") ); //ALA MA KOTA

Takich zapisów importu/exportu jest kilka wariantów. Poniżej inne rozwiązania.

Użyteczny trik

Zanim jednak przejdziemy dalej, mini uwaga co do pracy.

W wielu dobrych edytorach takich jak Visual Studio Code czy Webstorm odpowiednie zapisy importu pojawią się same na górze pliku gdy chociaż raz użyjemy nazwy danej funkcji:


import { smallText } from './textfn';

smallText("Lubie placki"); //to wpisałem, powyższy import dodał się sam

Innym rozwiązaniem jest też odpowiednia kolejność pisania importu.

Na początku wpisujemy puste klamry i od razu podajemy odpowiedni plik, a następnie wracając kursorem między klamry naciskamy klawisze Ctrl + spację by wybrać odpowiednią metodę do zaimportowania.

import / export

Importowanie i eksportowanie wielu rzeczy

W powyższym kodzie eksportowaliśmy kilka rzeczy za pomocą zapisu z klamrami. Możemy też eksportować pojedyncze rzeczy indywidualnie:


//functions.js

function smallText() { }
function bigText() { }
function mixText() { }

export { smallText, bigText, mixText }

//lub

export function smallText() { }
export function bigText() { }
export function mixText() { }

//plik app.js

import { smallText, bigText, mixText } from './functions.js';

//lub

import { smallText } from './functions.js';
import { bigText } from './functions.js';
import { mixText } from './functions.js';

Dodatkowo jeżeli chcemy wiele rzeczy importować pod wspólną nazwą, możemy zastosować gwizdkę i as:


//plik app.js

import * as fn from './functions.js';

fn.smallText();
fn.bigText();
fn.mixText();

Default

By ułatwić robotę osobom, które później będą importować rzeczy z naszego pliku, możemy zastosować także instrukcję default. Dzięki temu przy imporcie nie będziemy musieli podawać dokładnej nazwy importowanej rzeczy. Ważne jest jednak to, że default może być tylko jeden na plik:


//plik functions.js

function myFunct(txt) { }
export default myFunct;

//lub

export default function() { }

Jeżeli teraz chcemy taką domyślną rzecz zaimportować, przy imporcie pomijamy klamry podając dla niej nową nazwę:


//plik app.js

import test from './functions.js';
test("lorem ipsum");

Default można także łączyć z eksportem konkretnych rzeczy:


//plik functions.js

function printM() { ... }
function printB() { ... }

export default function() { ... }
export { printM, printB }

//plik app.js
import myF from './functions.js';
import { printM, printB } from './functions.js'

myF();
printM();
printB();

Zmiana importowanej/eksportowanej nazwy

Czasami przy imporcie nie będziesz chciał stosować domyślnej nazwy funkcji. Wtedy stosujemy as przy imporcie:


//plik functions.js

function myFunctionWithVeryLongName(txt) {
    console.log(txt)
}

export { myFunctionWithVeryLongName }

//plik app.js

import { myFunctionWithVeryLongName as myFn } from './functions.js';

myFn("ala ma kota");

To samo słowo as możesz zastosować przy eksporcie:


//plik functions.js

function myFunctionWithVeryLongName(txt) {
    console.log(txt)
}

export { myFunctionWithVeryLongName as log }

//plik app.js

import { log } from './functions.js';

log("ala ma kota");

//plik functions.js

function myFunctionWithVeryLongName() { }
function bigText() { }

export { myFunctionWithVeryLongName as default, bigText }

//plik app.js

import myFn from './functions.js';
import { bigText as big } from './functions.js';

myFn();
big()

Dodatkowo możesz importować defaultowe rzeczy pod jakaś nazwą:


//plik functions.js

function multiply() {
}

export default function() {
}

export { multiply }

//plik app.js

import { default as myFn, multiply } from './functions.js'

myFn();
multiply();

Jak widzisz są różne sposoby zapisuje praktycznie tego samego. W praktyce spokojnie możesz zostać przy default i {} bez żadnego kombinowania. Warto czasami jednak użyć tego as by sobie skrócić jakąś importowaną nazwę funkcji.

Moduły w przeglądarce

Jeżeli mówimy o modułach ES6, to w zasadzie na chwilę obecną mówimy o 2 podejściach. Jednym z nich będzie użycie Webpacka czy podobnych narzędzi do bundlowania importowanych/eksportowanych plików w jeden wynikowy plik.

Plusem tutaj będzie to, że dostajemy jeden połączony plik, zamiast wielu małych. Mniej requestów, czyli szybciej powinno się wczytywać.
Innym plusem jest to, że w przypadku niektórych bundlerów (np. webpack) możemy używać różnych typów modułów (commonjs/es6) i nasz kod i tak zadziała, ponieważ webpack odpowiednio go dla nas zmodyfikuje.

Drugim sposobem jest zastosowanie modułów bezpośrednio w przeglądarce. Metoda ta mimo, że ma nieco mniejsze wsparcie od bundlowania, przy różnego rodzaju testach czy projektach nastawionych tylko na nowe przeglądarki może okazać się zwyczajnie szybsza, ponieważ nie musimy konfigurować oddzielnych narzędzi.

Aby użyć modułów bezpośrednio w kodzie wystarczy do plików dodać atrybut type="module":


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <script src="app.js" type="module" defer></script>
    <script src="functions.js" type="module" defer></script>
    <script src="other.js" type="module" defer></script>
</head>
<body>

</body>
</html>
Atrybut type="module" możemy także używać dla skryptów inline:

    <script type="module">
    console.log("lubie koty");
    </script>
    

Importy i eksporty wyglądać tu będą tak samo jak przedstawiłem powyżej. Różnica będzie tutaj w podawaniu ścieżek do paczek zainstalowanych w node_modules. Nie wystarczy tutaj nie zaczynać ścieżki od kropki. Musimy tutaj zawsze używać ścieżki relatywnej i odpowiednio przejść do właściwego katalogu w node_modules.


//gdy używamy webpacka
import { map, tail } from 'lodash';

//gdy używamy modułów bezpośrednio w przeglądarce
import { map, tail } from "../node_modules/lodash";

Dodatkowo moduły w kodzie ze względu na bezpieczeństwo nie zadziałają, jeżeli odpalamy stronę bezpośrednio z dysku. Do ich użycia powinniśmy stronę odpalać z jakiegoś serwera. Dobrze sprawdzi się tutaj browserSync (np. wraz z Gulpem), lub np. Http-Server. Osobiście mam go zainstalowanego globalnie poleceniem npm i http-server -g i jeżeli zachodzi taka potrzeba, odpalam go poleceniem: http-server -c-1

Poza type="module" dla dołączanych plików mamy także atrybut nomodule, którym możemy oznaczyć plik ze skryptami dla przeglądarek, które domyślnie nie wspierają modułów. Przeglądarki, które wspierają moduły, domyślnie powinny pomijać pliki z tym atrybutem:


<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Document</title>
    <script src="app.js" type="module" defer></script>
    <script src="functions.js" type="module" defer></script>
    <script src="other.js" type="module" defer></script>
</head>
<body>

    <script src="bundle.min.js" nomodule defer></script>
</body>
</html>

Ze względu na średnie wsparcie, a także mniejszą optymalizację*, autor zaleca mimo wszystko używanie odpowiednich narzędzi bundlujących. Webpack nie jest wcale taki zły jak niektórzy go opisują, a dzięki niemu nie tylko zyskamy możliwość używania obydwu składni, ale i przy okazji możemy skonwertować nasz nowoczesny modułowy kod na taki, który zrozumieją i starsze przeglądarki.

Ogarniając kod bundlerem (takim jak Webpack), nie tylko minimalizujemy wykonywany kod, redukujemy liczbę requestów, ale także możemy zastosować kilka innych mechanizmów takich jak tree shaking. Przy czym taka optymalizacja na siłę może stać się bronią obosieczną.

Dynamiczne importowanie

Powyżej omówione sposoby importu i eksportu są statyczne, co oznacza, że dane moduły nie mogą być importowane dynamicznie w zależności od potrzeb.


if (...) {
    import { small } from './functions.js'; //błąd, nie możemy statycznie importować w warunku
}

Jakiś czas temu została zaproponowana nowa składnia import(), która pozwala importować moduły dynamicznie. Składania ta trafiła już do finalnej i działa w niektórych najnowszych przeglądarkach.
Dzięki niej import może odbywać się na żądanie - np. w warunku if, czy dla przykładu po kliknięciu na przycisk.

Funkcja import() zwraca promise:


//functions.js
function smallText(txt) { }
function bigText(txt) { }
function mixText(txt) { }

export default function() { }
export { smallText, bigText, mixText }

import("./functions.js")
    .then(obj => {
        obj.smallText();
        obj.bigText();
        obj.default();
    })
    .catch(err => { ... })

Możemy tutaj też zastosować async/await:


async function load() {
    let obj = await import('./functions.js');
    obj.smallText();
    obj.bigText();
    obj.default();
}

button.addEventListener("click", function() {
    load();
})

Co ciekawe, importowanie w ten sposób zasobów nie wymaga od nas wrzucania do html skryptów jako type="module"