Moduły

O co chodzi z tymi modułami?

Wyobraź sobie, że właśnie dołączyłeś do swojej strony poniższy skrypt:


//app.js
let a = 2;

const button = document.querySelector(".btn");
button.addEventListener("click", e => {
    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?


<body>
    ...
    <script src="script.js"></script>
    <script src="plugin-slider.js"></script>
</body>

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

Po odpaleniu naszej strony dostaniemy w konsoli błąd. Wynika to z faktu, że dołączając pliki ze skryptami (tak jak powyżej) są one traktowane jak jeden duży skrypt. Oznacza to, że bez problemu możemy z jednego pliku odwołać się do zmiennych z innego pliku, ale też powoduje to, że nie możemy ponownie zadeklarować takiej samej zmiennej (chyba, że wrzucimy ją w jakiś blok lub funkcję).

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 poprzednich wersjach Javascript jednym ze sposobów na rozwiązanie tego problemu było zastosowanie wzorca modułu.


    const app = (function() {
        const slideCount = 10;
        let current = 0;

        function calculateSlides() {...}
        function importantFunction() {...}
        function initEngine() {...}
        function doNotTouch() {...}

        return {
            importantFunction,
            initEngine
        }
    })()

    app.initEngine(); //ok
    app.slideCount = 20; //błąd - nie mamy dostępu, bo nie wystawiliśmy na zewnątrz
    

Wraz z rozwojem JavaScript pojawiały się różne rozwiązania dla modułów, wśród których mamy takie jak AMD (Asynchronous Module Definition), UMD (Universal Module Definition), CommonJS czy moduły wprowadzone w ES6.

Moduły typu AMD były najstarszą implementacją modularyzacji, pierwotnie wprowadzoną przez popularną kiedyś bibliotekę require.js. Ich głównymi cechami była asynchroniczność, oraz to, że były przystosowane do działania po stronie przeglądarki.

Kolejny typ modułów - CommonJS - był i jest nastawiony na działanie po stronie serwera, czyli w środowisku Node.js.

Moduły typu UMD były natomiast próbą połączenia obydwu typów.

W poniższym tekście skupimy się na najbardziej popularnych typach czyli CommonJS oraz ES6. Jeżeli chcesz poczytać o dodatkowych rozwiązaniach, przeczytaj np. ten artykuł.

CommonJS

Common JavaScript Modules to typ modułów stosowanych po stronie serwera czyli w Node.js. W chwili pisania tego tekstu są one najczęściej używanymi modułami w tym środowisku. W przyszłości mają ustąpić miejsca modułom ES6 (którymi zajmiemy się poniżej), które też tam możemy używać, przy czym na chwilę obecną wymaga to użycia dodatkowych flag.

Jeżeli używałeś Gulpa, Webpacka, czy dowolnej innej paczki z Node.js, nie powinieneś być zaskoczony:


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

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

W tym systemie modułów korzystamy głównie z 2 instrukcji:
require - do dołączania rzeczy z innych plików
exports - gdy chcemy coś wystawić na zewnątrz danego pliku


//plik1.js -----
function print(txt) {
    console.log(`Tekst ${txt} ma ${txt.length} liter`);
}
module.exports.print = print;

//możemy też wystawiać bezpośrednio
module.exports.big = function(txt) {
    console.log(txt.toUpperCase();
}

//plik2.js -----
//importujemy z pliku 1
const { print } = require("./plik1");
print("Ala");

big(); //błąd bo nie zaimportowaliśmy

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 slider.js w tym samym katalogu
const myPlugin = require("../plugin/plugin"); //dołączam kod z pliku plugin.js 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.


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

//wystawiamy na zewnątrz

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

//lub

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

//lub

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

Na zewnątrz pliku zawsze wystawiany jest domyślnie pusty obiekt module.exports. Zmienna exports jest tak naprawdę referencją o skróconej nazwie.


module.exports = {}; //ten obiekt jest automatycznie wystawiany na zewnątrz
exports = module.exports; //referencja

Użycie skróconego zapisu spotkamy w wielu skryptach. Przy stosowaniu jego warto trochę uważać, ponieważ można nieumyślnie popsuć eksportowanie:


//skoro exports wskazuje na module.exports, to poniższy kod 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 nowe właściwości do module.exports

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

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

Powyższą sytuację można przyrównać do poniższej sytuacji:


let ex = module.exports;
ex = {}; //ex wskazuje już na inny pusty obiekt

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

Dla nas najważniejsze jest co i jak importujemy i eksportujemy.


//plik1.js -----
module.exports.testA = function() { ... }
module.exports.testB = function() { ... }
module.exports.nr = 102;

//plik2.js -----

//jeżeli podstawimy pod zmienną wynik require, zostanie pod nią podstawiony obiekt module.exports wraz
//ze wszystkimi wyeksportowanymi rzeczami
const fn = require("./plik1");
console.log(fn); //{testA : fn, testB: fn, nr : 102}

Podczas takiego importowania możemy zastosować destrukturyzację, wyciągając z danego pliku tylko rzeczy, które nas interesują:


//plik2.js -----

const { testA, nr } = require("./plik1");
testA();
console.log(nr); //102

Jeżeli chcesz się więcej dowiedzieć o tym typie modułów, polecam obejrzeć poniższy film.

Moduły w ES6

Celem powstania modułów w ES6 było stworzenie łączącego wszystkie typy modułów rozwiązania. Miało być więc asynchroniczne, miało być proste, miało też działać po stronie serwera i przeglądarki.

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 functions.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 "./functions"; //wybieramy rzeczy do eksportu

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 przejrzymy 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 "./functions";

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

//lub

import { smallText } from "./functions";
import { bigText } from "./functions";
import { mixText } from "./functions.js"; //rozszerzenie js jest domyślnym, więc nie musimy go podawać - chyba że danego kodu używamy bezpośrednio w przeglądarce (patrz poniżej)

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


//plik app.js

import * as fn from "./functions";

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

Default

By ułatwić robotę osobom, które później będą używać rzeczy z naszego pliku, możemy zastosować także instrukcję default. Instrukcją tą oznaczamy rzecz, która będzie domyślnie exportowana z danego pliku. Dzięki temu przy imporcie nie będziemy musieli podawać dokładnej nazwy. 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 fn from "./functions";
fn("lorem ipsum");

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


//plik functions.js

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

export default function() { ... }

//plik app.js
import fn from "./functions";
import { printM, printB } from "./functions"

fn();
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";

myFn("ala ma kota");

To samo słowo as możesz zastosować przy eksporcie, tak by innym ułatwić używanie naszego kodu:


//plik functions.js

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

export { myFunctionWithVeryLongName as log }

//plik app.js

import { log } from "./functions";

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

myFn();
big()

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.

Na stronie https://www.samanthaming.com/tidbits/79-module-cheatsheet znajdziesz inne ciekawe źródło na temat powyższych informacji.

Moduły w przeglądarce

Powyższe tematy to dość "nowe" rzeczy w Javascripcie, stąd zadziałają tylko w nowszych przeglądarkach.

Do używania ich w praktyce możemy podejść na dwa sposoby.

Po pierwsze nasz modułowy kod możemy bundlować do jednego wynikowego pliku za pomocą dowolnego bundlera - np. webpacka, czy parcela. Plusem takiego podejścia będzie to, że dostajemy jeden plik, zamiast wielu małych. Mniej requestów, czyli szybciej powinna się wczytywać nasza strona.
Dla nas najcenniejsze jest jednak to, że podczas takiego bundlowania nasz kod możemy dodatkowo przetwarzać (np. optymalizować, zamieniać na starszą wersję), ale też dzięki bundlerom możemy używać różnych typów modułów (czyli import/export z es6 i require/module.exports z CommonJS), ponieważ kod zostanie odpowiednio przetworzony - tak by działał.

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 do użycia, ponieważ nie musimy konfigurować oddzielnych narzędzi.

Aby użyć modułów bezpośrednio w kodzie wystarczy do dołączanego pliku 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>
</head>
<body>

    <!-- nasz główny plik aplikacji powinien mieć type module -->
    <script src="app.js" type="module"></script>
</body>
</html>

Ten sam atrybut możemy także używać dla skryptów inline:


<body>
    ...
    <script type="module">
    import { myFn } from "./functions.js";
    myFn();
    </script>
</body>
Użycie atrybutu type="module" powoduje z automatu, że dołączane w ten sposób skrypty zachowują się podobnie do tych dołączanych z atrybutem defer (1).

Importy i eksporty wyglądać tu będą tak samo jak przedstawiłem powyżej. Różnica w porównaniu do użycia bundlera będzie tutaj w podawaniu ścieżek do plików. W przypadku stosowania bundlerów jeżeli dana paczka jest zainstalowana w node_modules, wystarczy, że podamy nazwę danej paczki, a resztą zajmie się sam bundler. W przypadku stosowania modułów na czysto w przeglądarce musimy podawać całą ścieżkę do plików wraz z rozszerzeniem .js:


//gdy używamy bundlera
import { slider } from "slider";

//gdy używamy modułów bezpośrednio w przeglądarce
import { slider } from "../node_modules/slider/slider.js";

//gdy używamy bundlera
import { myFn, myOtherFn } from "./functions"

//gdy używamy modułów bezpośrednio w przeglądarce
import { myFn, myOtherFn } from "./functions.js"

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.

Używane w ten sposób moduły będą doczytywane, gdy przeglądarka natrafi na odpowiedni import w danym pliku. Jeżeli jakieś moduły są kluczowe do działania naszego kodu, powinniśmy wczytać je jak najszybciej. Jednym ze sposobów jest użycie linków z atrybutem rel="modulepreload":


<head>
  <link rel="modulepreload" href="other.mjs">
  <link rel="modulepreload" href="texts.mjs">
  <link rel="modulepreload" href="functions.mjs">
</head>
<body>

    <-- app.js odnosi się do powyższych modułów -->
    <script src="app.js" type="module"></script>
</body>

Więcej na ten temat dowiesz się na tej stronie.

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>
</head>
<body>

    <script src="app.js" type="module"></script>
    <script src="legacy-code.js" nomodule></script>
</body>
</html>

Ze względu na średnie wsparcie, a także mniejszą optymalizację (1, 2, 3, 4), zalecam mimo wszystko używanie odpowiednich narzędzi bundlujących.

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, a takie importowanie musi odbywać się z głównego zasięgu.


import { big } from "./functions.js"; //ok

if (...) {
    import { small } from "./functions.js"; //błąd, nie możemy statycznie importować we wnętrzu warunku
}

Jakiś czas temu została zaproponowana nowa funkcja import(), która pozwala importować moduły dynamicznie. Funkcja ta działa już 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.

Ważne przy tym jest, że funkcja import() zwraca obietnicę:


//functions.js
export const smallText(txt) = () => {
    console.log(txt.toUpperCase());
}

export const bigText(txt) = () => {
    console.log(txt.toUpperCase());
}

export default () => {
    console.log("Domyślna funkcja");
}

export { smallText, bigText }

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

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


async function load() {
    const obj = await import("./functions.js");
    obj.smallText();
    obj.bigText();
}

const button = document.querySelector("button");
button.addEventListener("click", async () => {
    const obj = await import("./functions.js");
    obj.smallText();
    obj.bigText();
});

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

Dodatkowe materiały

Wszelkie prawa zastrzeżone. Jeżeli chcesz używać jakiejś części tego kursu, skontaktuj się z autorem.
Aha - i ta strona korzysta z ciasteczek.

Menu