JavaScript Module Systems Showdown: CommonJS vs AMD vs ES2015

När JavaScript-utvecklingen blir allt vanligare blir namnrymder och beroenden mycket svårare att hantera. Olika lösningar utvecklades för att hantera detta problem i form av modulsystem. I det här inlägget kommer vi att utforska de olika lösningar som för närvarande används av utvecklare och de problem som de försöker lösa. Läs vidare!

Introduktion: Varför behövs JavaScript-moduler?

Om du är bekant med andra utvecklingsplattformar har du förmodligen en viss uppfattning om begreppen inkapsling och beroende. Olika programvaror utvecklas vanligen isolerat tills något krav måste uppfyllas av en tidigare existerande programvara. I det ögonblick då den andra programvaran tas med i projektet skapas ett beroende mellan den och den nya delen av koden. Eftersom dessa delar av programvaran måste fungera tillsammans är det viktigt att inga konflikter uppstår mellan dem. Detta kan låta trivialt, men utan någon form av inkapsling är det en tidsfråga innan två moduler hamnar i konflikt med varandra. Detta är en av anledningarna till att element i C-bibliotek vanligtvis har ett prefix:

#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

Inkapsling är viktigt för att förhindra konflikter och underlätta utvecklingen.

När det gäller beroenden är de implicita i traditionell klientsidig JavaScript-utveckling. Med andra ord är det utvecklarens uppgift att se till att beroendena är uppfyllda när ett kodblock exekveras. Utvecklare måste också se till att beroenden uppfylls i rätt ordning (ett krav för vissa bibliotek).

Följande exempel är en del av Backbone.js exempel. Skript laddas manuellt i rätt ordning:

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

När JavaScript-utvecklingen blir alltmer komplex kan hanteringen av beroenden bli besvärlig. Refactoring är också försämrat: var ska nyare beroenden placeras för att upprätthålla rätt ordning i laddningskedjan?

JavaScript-modulsystem försöker hantera dessa och andra problem. De föddes av nödvändighet för att tillgodose det ständigt växande JavaScript-landskapet. Låt oss se vad de olika lösningarna ger.

En ad hoc-lösning: The Revealing Module Pattern

De flesta modulsystem är relativt nya. Innan de fanns tillgängliga började ett visst programmeringsmönster användas i mer och mer JavaScript-kod: det avslöjande modulmönstret.

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

Det här exemplet är hämtat från Addy Osmanis bok JavaScript Design Patterns.

JavaScript-scopes (åtminstone fram till uppkomsten av let i ES2015) fungerar på funktionsnivå. Med andra ord kan inte den bindning som deklareras inuti en funktion undkomma dess räckvidd. Det är av denna anledning som det avslöjande modulmönstret förlitar sig på funktioner för att kapsla in privat innehåll (som många andra JavaScript-mönster).

I exemplet ovan exponeras offentliga symboler i det returnerade lexikonet. Alla andra deklarationer skyddas av det funktionsomfång som omsluter dem. Det är inte nödvändigt att använda var och ett omedelbart anrop till den funktion som omsluter det privata scope; en namngiven funktion kan också användas för moduler.

Det här mönstret har använts ganska länge i JavaScript-projekt och hanterar inkapslingsfrågan ganska bra. Det gör inte mycket åt frågan om beroenden. Ordentliga modulsystem försöker hantera även detta problem. En annan begränsning ligger i det faktum att det inte går att inkludera andra moduler i samma källkod (om man inte använder eval).

Pros

  • Simpelt nog för att kunna implementeras var som helst (inga bibliotek, inget språkstöd krävs).
  • Flera moduler kan definieras i en enda fil.

Kons

  • Inget sätt att programmeringsmässigt importera moduler (utom genom att använda eval).
  • Avhängigheter måste hanteras manuellt.
  • Asynkron laddning av moduler är inte möjlig.
  • Cirkulära beroenden kan vara besvärliga.
  • Svårt att analysera för statiska kodanalysatorer.

CommonJS

CommonJS är ett projekt som syftar till att definiera en rad specifikationer för att underlätta utvecklingen av JavaScript-program på serversidan. Ett av de områden som CommonJS-teamet försöker ta itu med är moduler. Node.js-utvecklarna hade ursprungligen för avsikt att följa CommonJS-specifikationen men beslutade senare att inte göra det. När det gäller moduler är Node.js implementering mycket påverkad av den:

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

En kväll på Joyent, när jag nämnde att jag var lite frustrerad över någon löjlig begäran om en funktion som jag visste var en fruktansvärd idé, sa han till mig: ”Glöm CommonJS. Det är dött. Vi är server side JavaScript.” – NPM-skapare Isaac Z. Schlueter citerar Node.js-skapare Ryan Dahl

Det finns abstraktioner ovanpå Node.js modulsystem i form av bibliotek som överbryggar gapet mellan Node.js moduler och CommonJS. I det här inlägget kommer vi bara att visa de grundläggande funktionerna som i stort sett är desamma.

I både Node’s och CommonJS’s moduler finns det i huvudsak två element för att interagera med modulsystemet: require och exports. require är en funktion som kan användas för att importera symboler från en annan modul till det aktuella tillämpningsområdet. Parametern som skickas till require är modulens id. I Node-implementationen är det namnet på modulen i katalogen node_modules (eller, om den inte finns i den katalogen, sökvägen till den). exports är ett speciellt objekt: allt som läggs in i det kommer att exporteras som ett offentligt element. Namnen på fälten bevaras. En märklig skillnad mellan Node och CommonJS uppstår i form av module.exports-objektet. I Node är module.exports det verkliga specialobjektet som exporteras, medan exports bara är en variabel som som standard binds till module.exports. CommonJS har å andra sidan inget module.exports-objekt. Den praktiska konsekvensen är att det i Node inte är möjligt att exportera ett helt förkonstruerat objekt utan att gå igenom 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 };}

CommonJS-moduler utformades med serverutveckling i åtanke. Naturligtvis är API:et synkront. Med andra ord laddas modulerna i det ögonblick och i den ordning de behövs inne i en källfil.

Pros

  • Enkel: en utvecklare kan förstå konceptet utan att titta på dokumentationen.
  • Hantering av beroenden är integrerad: Moduler kräver andra moduler och laddas i den ordning som behövs.
  • require kan anropas var som helst: Moduler kan laddas programmatiskt.
  • Cirkulära beroenden stöds.

Konsekvenser

  • Synkront API gör att det inte lämpar sig för vissa användningsområden (klientsidan).
  • En fil per modul.
  • Browsers kräver ett loaderbibliotek eller transpiling.
  • Ingen konstruktorfunktion för moduler (Node stöder dock detta).
  • Svårt att analysera för statiska kodanalysatorer.

Implementationer

Vi har redan talat om en implementering (i partiell form): Node.js.

Node.js JavaScript Modules

För klienten finns det för närvarande två populära alternativ: webpack och browserify. Browserify utvecklades uttryckligen för att analysera Node-liknande moduldefinitioner (många Node-paket fungerar out-of-the-box med den!) och bunta din kod plus koden från dessa moduler i en enda fil som bär alla beroenden. Webpack, å andra sidan, utvecklades för att hantera skapandet av komplexa pipelines av källkodstransformationer före publicering. Detta inkluderar buntning av CommonJS-moduler.

Asynchronous Module Definition (AMD)

AMD föddes ur en grupp utvecklare som var missnöjda med den riktning som antagits av CommonJS. AMD delades faktiskt upp från CommonJS tidigt under dess utveckling. Den största skillnaden mellan AMD och CommonJS ligger i dess stöd för asynkron modulladdning.

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

Asynkron laddning möjliggörs genom att använda JavaScript:s traditionella closure-idiom: en funktion anropas när de begärda modulerna är färdiga med att laddas. Moduldefinitioner och import av en modul utförs av samma funktion: när en modul definieras görs dess beroenden explicita. Därför kan en AMD-läsare ha en fullständig bild av beroendegrafen för ett visst projekt vid körning. Bibliotek som inte är beroende av varandra för laddning kan således laddas samtidigt. Detta är särskilt viktigt för webbläsare, där starttiderna är avgörande för en bra användarupplevelse.

Pros

  • Asynkron laddning (bättre starttider).
  • Cirkulära beroenden stöds.
  • Kompatibilitet för require och exports.
  • Hantering av beroenden är helt integrerad.
  • Moduler kan delas upp i flera filer vid behov.
  • Konstruktorfunktioner stöds.
  • Stöd för insticksmoduler (anpassade laddningssteg).

Konsekvenser

  • Lätt mer komplexa syntaktiskt.
  • Laddningsbibliotek krävs om de inte transpileras.
  • Svårt att analysera för statiska kodanalysatorer.

Implementationer

För tillfället är de populäraste implementeringarna av AMD require.js och Dojo.

Require.js för JavaScript-moduler

Att använda require.js är ganska enkelt: inkludera biblioteket i din HTML-fil och använd attributet data-main för att tala om för require.js vilken modul som ska laddas först. Dojo har ett liknande upplägg.

ES2015 Modules

Tyvärr har ECMA-teamet bakom standardiseringen av JavaScript beslutat att ta itu med frågan om moduler. Resultatet kan ses i den senaste versionen av JavaScript-standarden: ECMAScript 2015 (tidigare känd som ECMAScript 6). Resultatet är syntaktiskt tilltalande och kompatibelt med både synkrona och asynkrona arbetssätt.

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

Exempel hämtat från Axel Rauschmayers blogg

Direktivet import kan användas för att föra in moduler i namnområdet. Detta direktiv är till skillnad från require och define inte dynamiskt (dvs. det kan inte anropas på något ställe). Direktivet export kan å andra sidan användas för att uttryckligen göra element offentliga.

Den statiska karaktären hos direktiven import och export gör det möjligt för statiska analysatorer att bygga upp ett fullständigt träd av beroenden utan att köra kod. ES2015 stöder dynamisk laddning av moduler:

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

I själva verket anger ES2015 endast syntaxen för dynamiska och statiska modulladdare. I praktiken behöver ES2015-implementationer inte göra något efter att ha analyserat dessa direktiv. Modulladdare som System.js krävs fortfarande tills nästa ECMAScript-specifikation släpps.

Denna lösning, i kraft av att den är integrerad med språket, låter körtiderna välja den bästa laddningsstrategin för moduler. Med andra ord, när asynkron laddning ger fördelar kan den användas av körtiden.

Pros

  • Synkron och asynkron laddning stöds.
  • Syntaktiskt enkelt.
  • Stöd för statiska analysverktyg.
  • Integrerat med språket (stöds så småningom överallt, inget behov av bibliotek).
  • Stöd för cirkulära beroenden.

Konsekvenser

  • Stöds fortfarande inte överallt.

Implementationer

Olyckligtvis har ingen av de större JavaScript-körningstiderna stöd för ES2015-moduler i sina nuvarande stabila grenar. Detta innebär att det inte finns något stöd för Firefox, Chrome eller Node.js. Lyckligtvis har många transpilers stöd för moduler och en polyfill finns också tillgänglig. För närvarande kan ES2015-förinställningen för Babel hantera moduler utan problem.

Babel för JavaScript-moduler

Den heltäckande lösningen: System.js

Du kanske försöker att komma bort från äldre kod genom att använda ett modulsystem. Eller så kanske du vill försäkra dig om att oavsett vad som händer kommer den lösning du valt fortfarande att fungera. Kom in i System.js: en universell modulladdare med stöd för CommonJS, AMD och ES2015-moduler. Den kan fungera tillsammans med transpilers som Babel eller Traceur och har stöd för Node- och IE8+-miljöer. Att använda den är en fråga om att ladda System.js i din kod och sedan peka på din bas-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>

Då System.js gör allt jobb i farten bör användningen av ES2015-moduler i allmänhet överlåtas till en transpiler under byggsteget i produktionsläge. När du inte är i produktionsläge kan System.js anropa transpilern åt dig, vilket ger en sömlös övergång mellan produktions- och felsökningsmiljöer.

Aside: Vad vi använder på Auth0

På Auth0 använder vi JavaScript i stor utsträckning. För vår serverbaserade kod använder vi Node.js-moduler i CommonJS-stil. För viss kod på klientsidan föredrar vi AMD. För vårt React-baserade Passwordless Lock-bibliotek har vi valt ES2015-moduler.

Gillar du vad du ser? Registrera dig och börja använda Auth0 i dina projekt idag.

Är du en utvecklare och gillar vår kod? Om så är fallet kan du ansöka om en ingenjörstjänst nu. Vi har ett fantastiskt team!

Slutsats

Att bygga moduler och hantera beroenden var besvärligt tidigare. Nyare lösningar, i form av bibliotek eller ES2015-moduler, har tagit bort det mesta av smärtan. Om du funderar på att starta en ny modul eller ett nytt projekt är ES2015 rätt väg att gå. Den kommer alltid att få stöd och det nuvarande stödet med hjälp av transpilers och polyfills är utmärkt. Om du å andra sidan föredrar att hålla dig till vanlig ES5-kod är den vanliga uppdelningen mellan AMD för klienten och CommonJS/Node för servern fortfarande det vanliga valet. Glöm inte att lämna dina tankar till oss i kommentarsfältet nedan. Hack on!

Lämna en kommentar