Moduły w ES6

Zacznikmy od trochę teorii.

Rozważmy przykład:


//script1.js ---
const a = 2;
const b = 3;
document.querySelector('.btn').addEventListener('click', function() {
    console.log(a * b);
});

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?


//script1.js ---
const a = 2;
const b = 3;
document.querySelector('.btn').addEventListener('click', function() {
    console.log(a * b);
});

//plugin-slider.js ---
a = "slider-name";

Inny programista nadpisał nam zmienną a, przez co po kliknięciu na button dostajemy równanie "slider-name" * 3, co w wyniku daje nam NaN (Non a Number). Spowodowane jest to tym, ze wszystkie skrypty działają tak jakby były pisane w 1 pliku. I to jest problem.
W normalnych językach programowania jest tak, że każdy oddzielny plik to jakby zamknięte środowisko, z którego nic nie wychodzi na zewnątrz. Znaczy wychodzi - ale tylko to co MY chcemy.

No dobra - ale jak rozwiązać powyższy problem?


const a = 2;
function myFunct() {
    const a = 3;
}

Wiesz, że zmienne lokalne NIE nadpisują zmiennych globalnych. Można więc powiedzieć, że taka funkcja jest jakby zamkniętym pudelkiem, które sprawia, że nasze lokalne (te w funkcji) zmienne są bezpieczne przed światem zewnętrznym.
Ok. Więc by zabezpieczyć nasze całe skrypty, wystarczy otoczyć je funkcjami:


//script1.js ---
function init1() {
    const a = 2;
    const b = 3;
    document.querySelector('.btn').addEventListener('click', function() {
        console.log(a * b);
    });
}
init1();

//plugin-slider.js ---
function init2() {
    const a = "slider-name";
}
init2();

I powyższe rozwiązanie jest prawie dobrym rozwiązaniem.
Widzisz jednak, że w powyższym skrypcie musiałem zastosować 2 oddzielne nazwy funkcji, dodatkowo je wywoływać. Nie jest to doskonałe, bo nie wiem czy przypadkiem autor plugina nie zastosował by takiej samej nazwy funkcji. No i to ręczne wywoływanie - bleh...
Aż prosi się by zamienić to na IIFE (czyli Immediately-invoked function expression).


//script1.js ---
(function() {
    const a = 2;
    const b = 3;
    document.querySelector('.btn').addEventListener('click', function() {
        console.log(a * b)
    });
})();

//plugin-slider.js ---
(function() {
    const a = "slider-name";
}
})();

Powyższa metoda jest prostym przykładem. Problem z nią jest taki, że nie wystawiliśmy na zewnątrz rzeczy które chcemy

Wszystko jest zamknięte w funkcjach i nic nie wychodzi poza nie. A może być sytuacja, że chcemy coś wystawić:


//script1.js ---
(function() {
    const calculateSlides = function() {...}
    const importantFunction = function() {...}
    const initEngine = function() {...}
    const doNotTouch = function() {...}

    //chcemy na zewnatrz wystawic tylko funkcje,
    //ktora uruchomi ten nasz caly skomplikowany biznes
    const initSlider = function() {
        const a = null;
        importantFunction();
        initEngine();
    }
})()

I na to są sposoby. Wystarczy, ze nasza IIFE będzie zwracać takie rzeczy:


//script1.js ---
const slideModule = (function() {
    const calculateSlides = function() {...}
    const importantFunction = function() {...}
    const initEngine = function() {...}
    const doNotTouch = function() {...}

    //to chcemy wystawic na zewnatrz
    const initSlider = function() {
        const a = null;
        importantFunction();
        initEngine();
    }

    return {
        init : initSlider
    }
})()

//script2.js ---
slideModule.init();

W powyższym rozważaniu chciałem pokazać Ci, że ogólnie w czystym JS jest ciut kombinowania by zrobić nawet prosty moduł.

Javascript domyślnie nie ma czegoś takiego jak moduły. Przez wiele lat jednak środowisko programistów stworzyło świetne rozwiązania, które zastępują ten niedobór. Najważniejsze z nich to:

  • CommonJS Modules - najczęściej stosowane w Node.js. Charakteryzują się krótkim kodem i są przeznaczone do wczytywania synchronicznego. Głównie są stosowane po stronie serwerów. Znasz je chociażby z Gulpa:
    
            const gulp = requre('gulp');
            const gulpSourcemaps = require('gulp-sourcemaps');
            const myOtherScript = require('./my-other-script');
            ...
            
  • Asynchronous Module Definition (AMD) - najpopularniejszą implementacją tego rozwiazania 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 (dynamiczne wczytywanie kiedy potrzeba). Głównie są stosowane po stronie przeglądarek. To podejście możesz spotkać np w Require.js
    
            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 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 tmatych typów na uniwersalny

Jeżeli bardziej interesują cię te tematy, poczytaj sobie jeden z wielu wpisów na ten temat np ten:
https://www.davidbcalhoun.com/2014/what-is-amd-commonjs-and-umd/ lub https://addyosmani.com/writing-modular-js/

Moduły w ES6

Celem powstania modułów w ES6 było stworzenie prostego, a zarazem łączącego powyższe wzory rozwiązania.

Ogólna zasada polega na tym, że każdy plik to zamknięty moduł. Wszystko w nim nie wpływa na inne pliki. Możemy więc pisać dowolne nazwy zmiennych, używać globalnych zmiennych itp nie przejmując się tym, że zostanie to nadpisane przez inne skrypty. Jeżeli chcemy coś wystawić na zewnątrz stosujemy instrukcję export. Jeżeli chcemy coś pobrać co zostało wyeksportowane w innym pliku, stosujemy import:


//plik script1.js ------

const text = "To jest wewnętrzna zmienna";

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

export { mix } //wystawiamy na zewnątrz funkcję mix

//plik app.js ------
import { mix } from './script1.js'; //importujemy funkcję mix

console.log( mix("ALA MA KOTA") ); //AlA Ma kOtA

console.log(text); //blad! - tekstu nie eksportowaliśmy

Zauważ ze powyżej przy imporcie i exporcie zastosowaliśmy zapis {...}. Między te klamry możemy wpisywać co dokładnie chcemy importować i eksportować.

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

Default

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


//plik script1.js ------
function myFunct(txt) {
    console.log(txt, txt.log)
}

export default myFunct;

//plik app.js ------
import myFunct from './script1.js';
myFunct("lorem ipsum");

Default wraz z klamrami można łączyć:


//plik script1.js ------
function myFunct(txt) {
    console.log(txt, txt.log)
}

function printF(txt) {
    console.log('%c' + txt, 'color: blue; font-size:20px');
}

export default myFunct;
export { printF }

//plik app.js ------
import myF from './script1.js';
import { printF } from './script1.js'

myF("lorem ipsum");
printF("lorem ipsum");

Zmiana importowanej/eksportowanej nazwy

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


//plik script1.js ------
function myFunctionWithVeryLongNameBecauseIAmHardcore(txt) {
    console.log(txt)
}

export { myFunctionWithVeryLongNameBecauseIAmHardcore }

//plik app.js ------
import { myFunctionWithVeryLongNameBecauseIAmHardcore as myFunct } from './script1.js';

myFunct("ala ma kota");

Co ciekawe to samo słowo as możesz zastosować przy eksporcie:


//plik script1.js ------
function myFunctionWithVeryLongNameBecauseIAmHardcore(txt) {
    console.log(txt)
}

export { myFunctionWithVeryLongNameBecauseIAmHardcore as print }

//plik app.js ------
import { print } from './script1.js';

print("ala ma kota");

//plik script1.js ------
function myFunctionWithVeryLongNameBecauseIAmHardcore(txt) {
    console.log(txt.toLowerCase());
}

function nazwaFunkcji(txt) {
    console.log(txt.toUpperCase());
}

export { myFunctionWithVeryLongNameBecauseIAmHardcore as default, nazwaFunkcji }



//plik app.js ------
import lorem from './script1.js';
import { nazwaFunkcji as m } from './script1.js';

lorem("ala ma kota");
m("ala ma psa")

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


//plik script1.js ------
export default function superFunkcja(txt) {
    console.log(txt.toLowerCase());
}

function multiply(a, b) {
    return a * b;
}

export { otherFunct }



//plik script1.js ------
import {default as myFunct, otherFunct } from './script1.js'
myFunct("ala ma kota");
multiply(2, 3);

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.