JavaScript Module Systems Showdown: CommonJS vs AMD vs ES2015

Naarmate JavaScript ontwikkeling meer en meer gemeengoed wordt, worden namespaces en afhankelijkheden veel moeilijker te hanteren. Verschillende oplossingen zijn ontwikkeld om met dit probleem om te gaan in de vorm van module systemen. In dit bericht zullen we de verschillende oplossingen verkennen die momenteel door ontwikkelaars worden gebruikt en de problemen die ze proberen op te lossen. Lees verder!

Inleiding: Why Are JavaScript Modules Needed?

Als je bekend bent met andere ontwikkelplatforms, heb je waarschijnlijk wel enige notie van de concepten inkapseling en afhankelijkheid. Verschillende stukken software worden gewoonlijk geïsoleerd ontwikkeld totdat aan een bepaalde eis moet worden voldaan door een eerder bestaand stuk software. Op het moment dat dat andere stuk software in het project wordt gebracht, wordt er een afhankelijkheid gecreëerd tussen dat andere stuk software en het nieuwe stuk code. Aangezien deze stukken software moeten samenwerken, is het van belang dat er geen conflicten tussen hen ontstaan. Dit klinkt misschien triviaal, maar zonder enige vorm van inkapseling is het een kwestie van tijd voordat twee modules met elkaar conflicteren. Dit is een van de redenen waarom elementen in C bibliotheken meestal een prefix dragen:

#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

Inkapseling is essentieel om conflicten te voorkomen en ontwikkeling te vergemakkelijken.

Wanneer het aankomt op afhankelijkheden, in traditionele client-side JavaScript ontwikkeling, zijn ze impliciet. Met andere woorden, het is de taak van de ontwikkelaar om ervoor te zorgen dat aan afhankelijkheden wordt voldaan op het moment dat een blok code wordt uitgevoerd. Ontwikkelaars moeten er ook voor zorgen dat aan afhankelijkheden wordt voldaan in de juiste volgorde (een vereiste van bepaalde bibliotheken).

Het volgende voorbeeld maakt deel uit van Backbone.js’s voorbeelden. Scripts worden handmatig in de juiste volgorde geladen:

<!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>

Als JavaScript-ontwikkeling steeds complexer wordt, kan afhankelijkhedenbeheer omslachtig worden. Refactoring wordt ook bemoeilijkt: waar moeten nieuwere afhankelijkheden worden geplaatst om de juiste volgorde van de laadketen te handhaven?

JavaScript-modulesystemen proberen met deze en andere problemen om te gaan. Ze zijn geboren uit noodzaak om het steeds groeiende JavaScript landschap tegemoet te komen. Laten we eens kijken wat de verschillende oplossingen inhouden.

Een ad hoc oplossing: The Revealing Module Pattern

De meeste modulesystemen zijn relatief recent. Voordat ze beschikbaar waren, begon een bepaald programmeerpatroon in steeds meer JavaScript-code te worden gebruikt: het onthullende modulepatroon.

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

Dit voorbeeld is afkomstig uit Addy Osmani’s JavaScript Design Patterns boek.

JavaScript scopes (althans tot het verschijnen van let in ES2015) werken op het functieniveau. Met andere woorden, welke binding ook wordt gedeclareerd binnen een functie, deze kan niet ontsnappen aan het bereik. Het is om deze reden dat het onthullende module patroon vertrouwt op functies om private inhoud in te kapselen (net als veel andere JavaScript-patronen).

In het bovenstaande voorbeeld worden publieke symbolen blootgesteld in de geretourneerde dictionary. Alle andere declaraties worden beschermd door het functiebereik dat ze omsluit. Het is niet nodig om var te gebruiken en een onmiddellijke aanroep naar de functie die de private scope omsluit; een named function kan ook voor modules worden gebruikt.

Dit patroon wordt al geruime tijd gebruikt in JavaScript-projecten en gaat vrij aardig om met de inkapselingkwestie. Het doet niet veel aan het afhankelijkheidsvraagstuk. Goede module systemen proberen ook met dit probleem om te gaan. Een andere beperking is dat andere modules niet in dezelfde broncode kunnen worden opgenomen (tenzij eval wordt gebruikt).

Pros

  • Simpel genoeg om overal te kunnen worden geïmplementeerd (geen bibliotheken, geen taalondersteuning nodig).
  • Meerdere modules kunnen in een enkel bestand worden gedefinieerd.

Cons

  • Geen manier om modules programmatisch te importeren (behalve door eval te gebruiken).
  • Dependenties moeten handmatig worden afgehandeld.
  • Synchroon laden van modules is niet mogelijk.
  • Circulaire afhankelijkheden kunnen lastig zijn.
  • Hard om te analyseren voor statische code-analysers.

CommonJS

CommonJS is een project dat tot doel heeft een reeks specificaties te definiëren om te helpen bij de ontwikkeling van server-side JavaScript-toepassingen. Een van de gebieden die het CommonJS team probeert aan te pakken zijn modules. Node.js ontwikkelaars waren oorspronkelijk van plan om de CommonJS specificatie te volgen, maar besloten later om dit niet te doen. Wat modules betreft, is de implementatie van Node.js er sterk door beïnvloed:

// 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)}`);

Op een avond bij Joyent, toen ik zei een beetje gefrustreerd te zijn door een of ander belachelijk verzoek om een functie waarvan ik wist dat het een vreselijk idee was, zei hij tegen me: “Vergeet CommonJS. Het is dood. Wij zijn server side JavaScript.” – NPM-bedenker Isaac Z. Schlueter citeert Node.js-bedenker Ryan Dahl

Er zijn abstracties bovenop het module-systeem van Node.js in de vorm van bibliotheken die de kloof tussen de modules van Node.js en CommonJS overbruggen. Voor de doeleinden van deze post, zullen we alleen de basisfuncties laten zien, die grotendeels hetzelfde zijn.

In zowel Node’s als CommonJS’s modules zijn er in wezen twee elementen om te interageren met het module-systeem: require en exports. require is een functie die kan worden gebruikt om symbolen uit een andere module te importeren in de huidige scope. De parameter die aan require wordt doorgegeven is de id van de module. In Node’s implementatie is dat de naam van de module in de node_modules directory (of, als hij niet in die directory staat, het pad erheen). exports is een speciaal object: alles wat er in wordt gezet zal worden geëxporteerd als een publiek element. Namen voor velden blijven behouden. Een eigenaardig verschil tussen Node en CommonJS ontstaat in de vorm van het module.exports object. In Node is module.exports het echte speciale object dat wordt geëxporteerd, terwijl exports slechts een variabele is die standaard wordt gebonden aan module.exports. CommonJS, aan de andere kant, heeft geen module.exports object. De praktische implicatie is dat het in Node niet mogelijk is om een volledig voorgeconstrueerd object te exporteren zonder module.exports te doorlopen:

// 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 };}

CommonJS modules zijn ontworpen met server ontwikkeling in gedachten. Natuurlijk, de API is synchroon. Met andere woorden, modules worden geladen op het moment en in de volgorde waarin ze nodig zijn binnen een bronbestand.

Pros

  • Simpel: een ontwikkelaar kan het concept begrijpen zonder naar de docs te kijken.
  • Dependentiebeheer is geïntegreerd: modules vereisen andere modules en worden in de vereiste volgorde geladen.
  • require kan overal worden aangeroepen: modules kunnen programmatisch worden geladen.
  • Circulaire afhankelijkheden worden ondersteund.

Cons

  • Synchrone API maakt het niet geschikt voor bepaalde toepassingen (client-side).
  • Een bestand per module.
  • Browsers vereisen een loader bibliotheek of transpiling.
  • Geen constructor-functie voor modules (Node ondersteunt dit wel).
  • Hard te analyseren voor statische code-analysers.

Implementaties

We hebben het al over één implementatie gehad (in gedeeltelijke vorm): Node.js.

Node.js JavaScript Modules

Voor de client zijn er momenteel twee populaire opties: webpack en browserify. Browserify is expliciet ontwikkeld om Node-achtige moduledefinities te ontleden (veel Node-pakketten werken er out-of-the-box mee!) en uw code plus de code van die modules in één enkel bestand te bundelen dat alle afhankelijkheden bevat. Webpack, aan de andere kant, werd ontwikkeld om complexe pijplijnen van brontransformaties te maken alvorens te publiceren. Dit omvat ook het bundelen van CommonJS-modules.

Asynchronous Module Definition (AMD)

AMD is ontstaan uit een groep ontwikkelaars die ontevreden waren over de richting die CommonJS was ingeslagen. In feite werd AMD al vroeg in de ontwikkeling afgesplitst van CommonJS. Het belangrijkste verschil tussen AMD en CommonJS ligt in de ondersteuning voor het asynchroon laden van modules.

//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 () {};});

Asynchroon laden wordt mogelijk gemaakt door gebruik te maken van JavaScript’s traditionele closure idioom: een functie wordt aangeroepen als de gevraagde modules klaar zijn met laden. Module-definities en het importeren van een module worden door dezelfde functie uitgevoerd: wanneer een module wordt gedefinieerd, worden de afhankelijkheden expliciet gemaakt. Daarom kan een AMD loader een volledig beeld krijgen van de afhankelijkheidsgrafiek voor een bepaald project tijdens runtime. Bibliotheken die niet van elkaar afhankelijk zijn voor het laden, kunnen dus tegelijkertijd worden geladen. Dit is vooral belangrijk voor browsers, waar opstarttijden essentieel zijn voor een goede gebruikerservaring.

Pros

  • Asynchroon laden (betere opstarttijden).
  • Circulaire afhankelijkheden worden ondersteund.
  • Compatibiliteit voor require en exports.
  • Dependency management volledig geïntegreerd.
  • Modules kunnen worden opgesplitst in meerdere bestanden indien nodig.
  • Constructor functies worden ondersteund.
  • Plugin ondersteuning (aangepaste laadstappen).

Cons

  • Syntactisch iets complexer.
  • Loaderbibliotheken zijn vereist, tenzij getranspiled.
  • Hard te analyseren voor statische code analyzers.

Implementaties

De populairste implementaties van AMD zijn momenteel require.js en Dojo.

Require.js voor JavaScript-modules

Het gebruik van require.js is vrij eenvoudig: neem de bibliotheek op in je HTML-bestand en gebruik het data-main-attribuut om require.js te vertellen welke module eerst moet worden geladen. Dojo heeft een soortgelijke opzet.

ES2015 Modules

Helaas heeft het ECMA-team achter de standaardisatie van JavaScript besloten om het probleem van de modules aan te pakken. Het resultaat is te zien in de nieuwste versie van de JavaScript-standaard: ECMAScript 2015 (voorheen bekend als ECMAScript 6). Het resultaat is syntactisch bevredigend en compatibel met zowel synchrone als asynchrone werkwijzen.

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

Voorbeeld overgenomen van Axel Rauschmayer blog

De import directive kan worden gebruikt om modules in de namespace te brengen. Deze directive is, in tegenstelling tot require en define, niet dynamisch (d.w.z. dat hij niet op een willekeurige plaats kan worden aangeroepen). De export directive daarentegen kan gebruikt worden om elementen expliciet public te maken.

Het statische karakter van de import en export directive stelt statische analyzers in staat om een volledige boom van afhankelijkheden op te bouwen zonder code uit te voeren. ES2015 ondersteunt wel het dynamisch laden van modules:

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

In werkelijkheid specificeert ES2015 alleen de syntaxis voor de dynamische en statische module-laders. In de praktijk hoeven ES2015-implementaties niets te doen na het parsen van deze directieven. Module loaders zoals System.js zijn nog steeds vereist tot de volgende ECMAScript spec wordt vrijgegeven.

Doordat deze oplossing in de taal is geïntegreerd, kunnen runtimes de beste laadstrategie voor modules kiezen. Met andere woorden, wanneer asynchroon laden voordelen biedt, kan het door de runtime worden gebruikt.

Pros

  • Synchroon en asynchroon laden ondersteund.
  • Syntactisch eenvoudig.
  • Ondersteuning voor statische analyse tools.
  • Integreerd met de taal (uiteindelijk overal ondersteund, geen bibliotheken nodig).
  • Ondersteunt cirkelvormige afhankelijkheden.

Cons

  • Nog niet overal ondersteund.

Implementaties

Gelukkig genoeg ondersteunt geen van de grote JavaScript-runtimes ES2015-modules in hun huidige stabiele takken. Dit betekent geen ondersteuning voor Firefox, Chrome of Node.js. Gelukkig ondersteunen veel transpilers modules wel en is er ook een polyfill beschikbaar. Momenteel kan de ES2015-voorinstelling voor Babel zonder problemen overweg met modules.

Babel voor JavaScript-modules

De alles-in-één-oplossing: System.js

U probeert misschien uw verouderde code te vervangen door één modulesysteem. Of u wilt er zeker van zijn dat wat er ook gebeurt, de gekozen oplossing nog steeds werkt. Enter System.js: een universele module loader die CommonJS, AMD en ES2015 modules ondersteunt. Het kan werken in combinatie met transpilers zoals Babel of Traceur en ondersteunt Node en IE8+ omgevingen. Het gebruik ervan is een kwestie van System.js in uw code laden en vervolgens naar uw basis-URL wijzen:

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

Zoals System.js al het werk on-the-fly doet, moet het gebruik van ES2015-modules over het algemeen worden overgelaten aan een transpiler tijdens de build-stap in productiemodus. Wanneer System.js niet in productiemodus is, kan het de transpiler voor u aanroepen, waardoor een naadloze overgang tussen productie- en debug-omgevingen ontstaat.

Niet van toepassing: Wat we gebruiken bij Auth0

Bij Auth0 maken we veel gebruik van JavaScript. Voor onze server-side code, gebruiken we CommonJS-stijl Node.js modules. Voor bepaalde client-side code, geven we de voorkeur aan AMD. Voor onze React-gebaseerde wachtwoordloze slotbibliotheek hebben we gekozen voor ES2015-modules.

Vindt u het leuk wat u ziet? Meld u aan en begin vandaag nog met het gebruik van Auth0 in uw projecten.

Ben je een ontwikkelaar en hou je van onze code? Zo ja, solliciteer dan nu voor een engineering positie. We hebben een geweldig team!

Conclusie

Het bouwen van modules en het afhandelen van afhankelijkheden was in het verleden omslachtig. Nieuwere oplossingen, in de vorm van bibliotheken of ES2015 modules, hebben het meeste leed weggenomen. Als u een nieuwe module of project wil starten, is ES2015 de juiste keuze. Het zal altijd ondersteund worden en de huidige ondersteuning via transpilers en polyfills is uitstekend. Aan de andere kant, als je het liever bij gewone ES5 code houdt, blijft de gebruikelijke verdeling tussen AMD voor de client en CommonJS/Node voor de server de gebruikelijke keuze. Vergeet niet om je gedachten achter te laten in de commentaar sectie hieronder. Hack on!

Plaats een reactie