Wykaz systemów modułów JavaScriptu: CommonJS vs AMD vs ES2015

As JavaScript development gets more and more common, namespaces and dependencies get much more difficult to handle. Aby poradzić sobie z tym problemem opracowano różne rozwiązania w postaci systemów modułowych. W tym poście poznamy różne rozwiązania stosowane obecnie przez programistów i problemy, które próbują rozwiązać. Czytaj dalej!

Wprowadzenie: Why Are JavaScript Modules Needed?

Jeśli jesteś zaznajomiony z innymi platformami programistycznymi, prawdopodobnie masz jakieś pojęcie o pojęciach enkapsulacji i zależności. Różne części oprogramowania są zazwyczaj rozwijane w izolacji do momentu, gdy jakiś wymóg musi zostać spełniony przez wcześniej istniejącą część oprogramowania. W momencie, gdy ten inny fragment oprogramowania jest wprowadzany do projektu, tworzona jest zależność pomiędzy nim a nowym fragmentem kodu. Ponieważ te fragmenty oprogramowania muszą ze sobą współpracować, ważne jest, aby nie powstawały między nimi żadne konflikty. Może to brzmieć banalnie, ale bez pewnego rodzaju enkapsulacji, kwestią czasu jest, kiedy dwa moduły wejdą ze sobą w konflikt. Jest to jeden z powodów, dla których elementy w bibliotekach C zazwyczaj posiadają przedrostek:

#ifndef MYLIB_INIT_H#define MYLIB_INIT_Henum mylib_init_code { mylib_init_code_success, mylib_init_code_error};enum mylib_init_code mylib_init(void);// (...)#endif //MYLIB_INIT_H

Enkapsulacja jest niezbędna, aby zapobiec konfliktom i ułatwić rozwój.

Jeśli chodzi o zależności, w tradycyjnym rozwoju JavaScript po stronie klienta, są one ukryte. Innymi słowy, zadaniem programisty jest upewnienie się, że zależności są spełnione w momencie, gdy jakikolwiek blok kodu jest wykonywany. Programiści muszą również upewnić się, że zależności są spełnione we właściwej kolejności (wymóg niektórych bibliotek).

Poniższy przykład jest częścią przykładów Backbone.js. Skrypty są ręcznie ładowane w odpowiedniej kolejności:

<!DOCTYPE html><html lang="en"> <head> <meta charset="utf-8"> <title>Backbone.js Todos</title> <link rel="stylesheet" href="todos.css"/> </head> <body> <script src="../../test/vendor/json2.js"></script> <script src="../../test/vendor/jquery.js"></script> <script src="../../test/vendor/underscore.js"></script> <script src="../../backbone.js"></script> <script src="../backbone.localStorage.js"></script> <script src="todos.js"></script> </body> <!-- (...) --></html>

As JavaScript development gets more and more complex, dependency management can get cumbersome. Refaktoryzacja jest również utrudniona: gdzie powinny być umieszczone nowsze zależności, aby zachować właściwą kolejność łańcucha ładowania?

Systemy modułów JavaScript próbują poradzić sobie z tymi problemami i innymi. Narodziły się z konieczności, aby dostosować się do ciągle rosnącego krajobrazu JavaScript. Zobaczmy, co różne rozwiązania wnoszą do stołu.

An Ad Hoc Solution: The Revealing Module Pattern

Większość systemów modułowych jest stosunkowo nowa. Zanim były dostępne, pewien szczególny wzorzec programowania zaczął być używany w coraz większej ilości kodu JavaScript: wzorzec modułu ujawniającego.

var myRevealingModule = (function () { var privateVar = "Ben Cherry", publicVar = "Hey there!"; function privateFunction() { console.log( "Name:" + privateVar ); } function publicSetName( strName ) { privateVar = strName; } function publicGetName() { privateFunction(); } // Reveal public pointers to // private functions and properties return { setName: publicSetName, greeting: publicVar, getName: publicGetName };})();myRevealingModule.setName( "Paul Kinlan" );

Ten przykład został zaczerpnięty z książki Addy Osmani’s JavaScript Design Patterns.

Zakresy JavaScriptu (przynajmniej do czasu pojawienia się let w ES2015) działają na poziomie funkcji. Innymi słowy, jakiekolwiek wiązanie jest zadeklarowane wewnątrz funkcji nie może uciec z jej zakresu. Z tego powodu wzorzec modułu ujawniającego opiera się na funkcjach w celu enkapsulacji prywatnych treści (podobnie jak wiele innych wzorców JavaScript).

W powyższym przykładzie symbole publiczne są odsłonięte w zwróconym słowniku. Wszystkie inne deklaracje są chronione przez zakres funkcji, które je zamykają. Nie jest konieczne używanie var i natychmiastowego wywołania funkcji zamykającej zakres prywatny; nazwana funkcja może być używana również dla modułów.

Ten wzorzec jest używany od dłuższego czasu w projektach JavaScript i dość ładnie radzi sobie z kwestią enkapsulacji. Nie robi zbyt wiele z kwestią zależności. Właściwe systemy modułów próbują poradzić sobie również z tym problemem. Innym ograniczeniem jest fakt, że dołączanie innych modułów nie może być wykonane w tym samym źródle (chyba że przy użyciu eval).

Pros

  • Wystarczająco proste, aby być zaimplementowane gdziekolwiek (bez bibliotek, bez wymaganego wsparcia językowego).
  • Multiple modules can be defined in a single file.

Cons

  • No way to programmatically import modules (except by using eval).
  • Zależności muszą być obsługiwane ręcznie.
  • Asynchroniczne ładowanie modułów nie jest możliwe.
  • Krągłe zależności mogą być kłopotliwe.
  • Trudne do przeanalizowania dla statycznych analizatorów kodu.

CommonJS

CommonJS jest projektem, który ma na celu zdefiniowanie serii specyfikacji, aby pomóc w rozwoju aplikacji JavaScript po stronie serwera. Jednym z obszarów, do których zespół CommonJS próbuje się odnieść są moduły. Twórcy Node.js pierwotnie zamierzali podążać za specyfikacją CommonJS, ale później się na to nie zdecydowali. Jeśli chodzi o moduły, implementacja Node.js jest pod jej dużym wpływem:

// In circle.jsconst PI = Math.PI;exports.area = (r) => PI * r * r;exports.circumference = (r) => 2 * PI * r;// In some fileconst circle = require('./circle.js');console.log( `The area of a circle of radius 4 is ${circle.area(4)}`);

Jednego wieczoru w Joyent, kiedy wspomniałem, że jestem trochę sfrustrowany jakimś niedorzecznym żądaniem funkcji, o której wiedziałem, że jest okropnym pomysłem, powiedział mi: „Zapomnij o CommonJS. To jest martwe. Jesteśmy JavaScriptem po stronie serwera”. – Twórca NPM Isaac Z. Schlueter cytujący twórcę Node.js Ryana Dahla

Na szczycie systemu modułów Node.js znajdują się abstrakcje w postaci bibliotek, które wypełniają lukę między modułami Node.js a CommonJS. Dla celów tego postu pokażemy tylko podstawowe funkcje, które są w większości takie same.

W obu modułach Node’a i CommonJS istnieją zasadniczo dwa elementy do interakcji z systemem modułów: require i exports. require jest funkcją, która może być użyta do importowania symboli z innego modułu do bieżącego zakresu. Parametrem przekazywanym do require jest id modułu. W implementacji Node’a jest to nazwa modułu w katalogu node_modules (lub, jeśli nie ma go w tym katalogu, ścieżka do niego). exports jest specjalnym obiektem: wszystko, co zostanie w nim umieszczone, zostanie wyeksportowane jako element publiczny. Nazwy pól są zachowane. Osobliwa różnica między Node i CommonJS pojawia się w formie obiektu module.exports. W Node, module.exports jest prawdziwym obiektem specjalnym, który jest eksportowany, podczas gdy exports jest tylko zmienną, która jest domyślnie związana z module.exports. CommonJS, z drugiej strony, nie ma obiektu module.exports. Praktyczną implikacją jest to, że w Node nie jest możliwe wyeksportowanie w pełni prekonstruowanego obiektu bez przechodzenia przez module.exports:

// This won't work, replacing exports entirely breaks the binding to// modules.exports.exports = (width) => { return { area: () => width * width };}// This works as expected.module.exports = (width) => { return { area: () => width * width };}

Moduły CommonJS zostały zaprojektowane z myślą o rozwoju serwera. Naturalnie, API jest synchroniczne. Innymi słowy, moduły są ładowane w momencie i w kolejności, w jakiej są wymagane wewnątrz pliku źródłowego.

Pros

  • Proste: programista może uchwycić koncepcję bez zaglądania do docs.
  • Zarządzanie zależnościami jest zintegrowane: moduły wymagają innych modułów i są ładowane w wymaganej kolejności.
  • requiremożna wywołać w dowolnym miejscu: moduły można ładować programowo.
  • Obsługiwane są zależności kołowe.

Konsekwencje

  • Synchroniczne API sprawia, że nie nadaje się do pewnych zastosowań (client-side).
  • Jeden plik na moduł.
  • Przeglądarki wymagają biblioteki ładującej lub transpilacji.
  • Brak funkcji konstruktora dla modułów (Node jednak to obsługuje).
  • Trudny do przeanalizowania dla statycznych analizatorów kodu.

Implementacje

Mówiliśmy już o jednej implementacji (w formie częściowej): Node.js.

Moduły JavaScript Node.js

Dla klienta istnieją obecnie dwie popularne opcje: webpack i browserify. Browserify został stworzony specjalnie do parsowania definicji modułów podobnych do Node (wiele pakietów Node działa z nim out-of-the-box!) i łączenia twojego kodu oraz kodu z tych modułów w pojedynczy plik, który przenosi wszystkie zależności. Webpack, z drugiej strony, został stworzony do obsługi tworzenia złożonych potoków transformacji źródła przed publikacją. Obejmuje to łączenie razem modułów CommonJS.

Asynchronous Module Definition (AMD)

AMD narodziło się z grupy programistów, którzy byli niezadowoleni z kierunku przyjętego przez CommonJS. W rzeczywistości, AMD zostało wyodrębnione z CommonJS na wczesnym etapie jego rozwoju. Główna różnica między AMD a CommonJS leży w jego wsparciu dla asynchronicznego ładowania modułów.

//Calling define with a dependency array and a factory functiondefine(, function (dep1, dep2) { //Define the module value by returning a value. return function () {};});// Or:define(function (require) { var dep1 = require('dep1'), dep2 = require('dep2'); return function () {};});

Asynchroniczne ładowanie jest możliwe dzięki użyciu tradycyjnego idiomu zamknięcia JavaScript: funkcja jest wywoływana, gdy żądane moduły są zakończone ładowaniem. Definicje modułów i importowanie modułu jest wykonywane przez tę samą funkcję: kiedy moduł jest definiowany, jego zależności są jawne. Dlatego też, program ładujący AMD może mieć pełny obraz grafu zależności dla danego projektu w czasie uruchamiania. Biblioteki, które nie zależą od siebie podczas ładowania, mogą więc być ładowane w tym samym czasie. Jest to szczególnie ważne w przypadku przeglądarek, gdzie czasy uruchamiania są kluczowe dla dobrego doświadczenia użytkownika.

Pros

  • Asynchroniczne ładowanie (lepsze czasy uruchamiania).
  • Obsługiwane są zależności kołowe.
  • Kompatybilność dla require i exports.
  • Zarządzanie zależnościami w pełni zintegrowane.
  • Moduły mogą być dzielone na wiele plików w razie potrzeby.
  • Obsługiwane są funkcje konstruktora.
  • Obsługa wtyczek (niestandardowe kroki ładowania).

Konsekwencje

  • Nieco bardziej złożone składniowo.
  • Biblioteki ładujące są wymagane, chyba że są transpilowane.
  • Trudne do przeanalizowania dla statycznych analizatorów kodu.

Implementacje

Obecnie najpopularniejszymi implementacjami AMD są require.js i Dojo.

Require.js dla modułów JavaScript

Używanie require.js jest całkiem proste: dołącz bibliotekę do swojego pliku HTML i użyj atrybutu data-main, aby powiedzieć require.js, który moduł powinien być załadowany jako pierwszy. Dojo ma podobną konfigurację.

ES2015 Moduły

Na szczęście, zespół ECMA stojący za standaryzacją JavaScriptu postanowił zająć się kwestią modułów. Efekt można zobaczyć w najnowszym wydaniu standardu JavaScript: ECMAScript 2015 (wcześniej znany jako ECMAScript 6). Rezultat jest przyjemny składniowo i kompatybilny zarówno z synchronicznymi, jak i asynchronicznymi trybami pracy.

//------ lib.js ------export const sqrt = Math.sqrt;export function square(x) { return x * x;}export function diag(x, y) { return sqrt(square(x) + square(y));}//------ main.js ------import { square, diag } from 'lib';console.log(square(11)); // 121console.log(diag(4, 3)); // 5

Przykład zaczerpnięty z bloga Axela Rauschmayera

Dyrektywa import może być użyta do wprowadzenia modułów do przestrzeni nazw. Dyrektywa ta, w przeciwieństwie do require i define nie jest dynamiczna (tzn. nie można jej wywołać w dowolnym miejscu). Z kolei dyrektywa export może być użyta do jawnego upublicznienia elementów.

Statyczna natura dyrektywy import i export pozwala analizatorom statycznym na zbudowanie pełnego drzewa zależności bez uruchamiania kodu. ES2015 obsługuje dynamiczne ładowanie modułów:

System.import('some_module') .then(some_module => { // Use some_module }) .catch(error => { // ... });

Prawdę mówiąc, ES2015 określa tylko składnię dla dynamicznych i statycznych ładowarek modułów. W praktyce implementacje ES2015 nie są zobowiązane do robienia czegokolwiek po parsowaniu tych dyrektyw. Ładowacze modułów, takie jak System.js, są nadal wymagane do czasu wydania kolejnej specyfikacji ECMAScript.

To rozwiązanie, dzięki temu, że jest zintegrowane z językiem, pozwala runtimes wybrać najlepszą strategię ładowania modułów. Innymi słowy, gdy asynchroniczne ładowanie daje korzyści, może być używane przez runtime.

Pros

  • Synchroniczne i asynchroniczne ładowanie obsługiwane.
  • Syntaktycznie proste.
  • Wsparcie dla narzędzi do analizy statycznej.
  • Zintegrowane z językiem (docelowo obsługiwane wszędzie, bez potrzeby stosowania bibliotek).
  • Wspierane zależności kołowe.

Konsekwencje

  • Nadal nie wspierane wszędzie.

Implementacje

Niestety, żaden z głównych runtimów JavaScript nie wspiera modułów ES2015 w swoich obecnych stabilnych gałęziach. Oznacza to brak wsparcia dla Firefox, Chrome, czy Node.js. Na szczęście, wiele transpilerów wspiera moduły i polyfill jest również dostępny. Obecnie, preset ES2015 dla Babel radzi sobie z modułami bez problemów.

Babel for JavaScript Modules

The All-in-One Solution: System.js

Może się okazać, że próbujesz odejść od starszego kodu używając jednego systemu modułów. Możesz też chcieć mieć pewność, że cokolwiek się stanie, rozwiązanie, które wybrałeś, będzie nadal działać. Wprowadź System.js: uniwersalny moduł ładujący, który obsługuje moduły CommonJS, AMD i ES2015. Może pracować w tandemie z transpilerami takimi jak Babel czy Traceur i może obsługiwać środowiska Node oraz IE8+. Używanie go jest kwestią załadowania System.js w twoim kodzie, a następnie wskazania go na twój bazowy URL:

 <script src="system.js"></script> <script> // set our baseURL reference path System.config({ baseURL: '/app', // or 'traceur' or 'typescript' transpiler: 'babel', // or traceurOptions or typescriptOptions babelOptions: { } }); // loads /app/main.js System.import('main.js'); </script>

Jako że System.js wykonuje całą pracę w locie, używanie modułów ES2015 powinno być generalnie pozostawione transpilerowi podczas kroku budowania w trybie produkcyjnym. Kiedy nie jest w trybie produkcyjnym, System.js może wywołać transpiler dla ciebie, zapewniając płynne przejście między środowiskiem produkcyjnym a debugowaniem.

Poza tym: What We Use At Auth0

W Auth0 intensywnie korzystamy z JavaScriptu. Do naszego kodu po stronie serwera używamy modułów Node.js w stylu CommonJS. W przypadku niektórych kodów po stronie klienta, preferujemy AMD. W przypadku naszej biblioteki Passwordless Lock opartej na React, zdecydowaliśmy się na moduły ES2015.

Lubisz to, co widzisz? Zarejestruj się i zacznij używać Auth0 w swoich projektach już dziś.

Jesteś programistą i podoba Ci się nasz kod? Jeśli tak, zgłoś się teraz na stanowisko inżyniera. Mamy świetny zespół!

Podsumowanie

Budowanie modułów i obsługa zależności były uciążliwe w przeszłości. Nowsze rozwiązania, w postaci bibliotek lub modułów ES2015, zdjęły z nas większość bólu. Jeśli rozważasz rozpoczęcie nowego modułu lub projektu, ES2015 jest właściwą drogą. Zawsze będzie wspierany, a obecne wsparcie za pomocą transpilerów i polyfill jest doskonałe. Z drugiej strony, jeśli wolisz trzymać się czystego kodu ES5, to zwykły podział między AMD dla klienta i CommonJS/Node dla serwera pozostaje zwykłym wyborem. Nie zapomnij zostawić nam swoich przemyśleń w sekcji komentarzy poniżej. Hack on!

Dodaj komentarz