Da JavaScript-udvikling bliver mere og mere almindelig, bliver namespaces og afhængigheder meget vanskeligere at håndtere. Der blev udviklet forskellige løsninger til at håndtere dette problem i form af modulsystemer. I dette indlæg vil vi undersøge de forskellige løsninger, der i øjeblikket anvendes af udviklere, og de problemer, som de forsøger at løse. Læs videre!
Introduktion: Hvorfor er der brug for JavaScript-moduler?
Hvis du er bekendt med andre udviklingsplatforme, har du sikkert en vis forestilling om begreberne indkapsling og afhængighed. Forskellige stykker software udvikles normalt isoleret, indtil et eller andet krav skal opfyldes af et tidligere eksisterende stykke software. I det øjeblik, det andet stykke software kommer ind i projektet, skabes der en afhængighed mellem det og det nye stykke kode. Da disse dele af softwaren skal fungere sammen, er det vigtigt, at der ikke opstår konflikter mellem dem. Dette lyder måske banalt, men uden en form for indkapsling er det et spørgsmål om tid, før to moduler kommer i konflikt med hinanden. Dette er en af grundene til, at elementer i C-biblioteker normalt bærer et præfiks:
#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
Indkapsling er afgørende for at undgå konflikter og lette udviklingen.
Når det gælder afhængigheder, er de i traditionel klientside JavaScript-udvikling implicitte. Med andre ord er det udviklerens opgave at sørge for, at afhængigheder er opfyldt på det tidspunkt, hvor en kodeblok udføres. Udviklerne skal også sørge for, at afhængighederne er opfyldt i den rigtige rækkefølge (et krav fra visse biblioteker).
Det følgende eksempel er en del af Backbone.js’s eksempler. Skripter indlæses manuelt i den rigtige rækkefølge:
<!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>
Da JavaScript-udviklingen bliver mere og mere kompleks, kan afhængighedsstyring blive besværlig. Refactoring er også vanskeliggjort: Hvor skal nyere afhængigheder placeres for at opretholde den korrekte rækkefølge i indlæsningskæden?
JavaScript-modulsystemer forsøger at håndtere disse og andre problemer. De blev født af nødvendighed for at imødekomme det stadigt voksende JavaScript-landskab. Lad os se, hvad de forskellige løsninger bringer til bordet.
En ad hoc-løsning: The Revealing Module Pattern
De fleste modulsystemer er relativt nye. Før de blev tilgængelige, begyndte et bestemt programmeringsmønster at blive brugt i mere og mere JavaScript-kode: det afslørende modulmønster.
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" );
Dette eksempel er hentet fra Addy Osmanis bog om JavaScript Design Patterns.
JavaScript-scopes (i hvert fald indtil fremkomsten af let
i ES2015) fungerer på funktionsniveau. Med andre ord kan en binding, uanset hvilken binding der er erklæret inde i en funktion, ikke undslippe dens scope. Det er af denne grund, at det afslørende modulmønster er afhængig af funktioner til at indkapsle privat indhold (som mange andre JavaScript-mønstre).
I eksemplet ovenfor er offentlige symboler eksponeret i den returnerede ordbog. Alle andre deklarationer er beskyttet af det funktionsområde, der omslutter dem. Det er ikke nødvendigt at bruge var
og et øjeblikkeligt kald til den funktion, der omslutter det private scope; en navngiven funktion kan også bruges til moduler.
Dette mønster har været anvendt i et stykke tid i JavaScript-projekter og håndterer spørgsmålet om indkapsling ret pænt. Det gør ikke meget ved spørgsmålet om afhængigheder. Egentlige modulsystemer forsøger også at håndtere dette problem. En anden begrænsning ligger i det faktum, at det ikke er muligt at inkludere andre moduler i samme kildekode (medmindre man bruger eval
).
Pros
- Enkel nok til at kunne implementeres hvor som helst (ingen biblioteker, ingen sprogunderstøttelse påkrævet).
- Flere moduler kan defineres i en enkelt fil.
Cons
- Ingen mulighed for at importere moduler programmatisk (undtagen ved at bruge
eval
). - Afhængigheder skal håndteres manuelt.
- Asynkron indlæsning af moduler er ikke mulig.
- Cirkulære afhængigheder kan være besværlige.
- Svært at analysere for statiske kodeanalysatorer.
CommonJS
CommonJS er et projekt, der har til formål at definere en række specifikationer, der skal hjælpe med udviklingen af server-side JavaScript-applikationer. Et af de områder, som CommonJS-holdet forsøger at tage fat på, er moduler. Node.js-udviklerne havde oprindeligt til hensigt at følge CommonJS-specifikationen, men besluttede sig senere imod det. Når det gælder moduler, er Node.js’ implementering meget påvirket af 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 aften hos Joyent, da jeg nævnte, at jeg var lidt frustreret over en eller anden latterlig anmodning om en funktion, som jeg vidste var en forfærdelig idé, sagde han til mig: “Glem CommonJS. Det er dødt. Vi er server side JavaScript.” – NPM-skaber Isaac Z. Schlueter citerer Node.js-skaber Ryan Dahl
Der er abstraktioner oven på Node.js’ modulsystem i form af biblioteker, der bygger bro mellem Node.js’ moduler og CommonJS. I forbindelse med dette indlæg vil vi kun vise de grundlæggende funktioner, som for det meste er de samme.
I både Node’s og CommonJS’s moduler er der grundlæggende to elementer til at interagere med modulsystemet: require
og exports
. require
er en funktion, der kan bruges til at importere symboler fra et andet modul til det aktuelle omfang. Den parameter, der overgives til require
, er modulets id. I Node’s implementering er det navnet på modulet i mappen node_modules
(eller, hvis det ikke er i denne mappe, stien til den). exports
er et særligt objekt: alt, hvad der sættes i det, vil blive eksporteret som et offentligt element. Navne til felter bevares. En ejendommelig forskel mellem Node og CommonJS opstår i form af module.exports
-objektet. I Node er module.exports
det rigtige specielle objekt, der bliver eksporteret, mens exports
blot er en variabel, der som standard bliver bundet til module.exports
. CommonJS har på den anden side ikke noget module.exports
-objekt. Den praktiske konsekvens er, at det i Node ikke er muligt at eksportere et fuldt præ-konstrueret objekt uden at gå gennem 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 blev designet med serverudvikling for øje. Naturligvis er API’et synkront. Det vil sige, at modulerne indlæses i det øjeblik og i den rækkefølge, de er nødvendige inde i en kildefil.
Pros
- Enkel: En udvikler kan forstå konceptet uden at kigge i dokumentationen.
- Afhængighedsstyring er integreret: Moduler kræver andre moduler og bliver indlæst i den nødvendige rækkefølge.
-
require
kan kaldes hvor som helst: Moduler kan indlæses programmatisk. - Cirkulære afhængigheder understøttes.
Konsekvenser
- Synkron API gør den uegnet til visse anvendelser (klientside).
- En fil pr. modul.
- Browsere kræver et loaderbibliotek eller transpiling.
- Ingen konstruktørfunktion for moduler (Node understøtter dog dette).
- Svært at analysere for statiske kodeanalysatorer.
Implementeringer
Vi har allerede talt om én implementering (i delvis form): Node.js.
For klienten er der i øjeblikket to populære muligheder: webpack og browserify. Browserify blev eksplicit udviklet til at analysere Node-lignende moduldefinitioner (mange Node-pakker fungerer out-of-the-box med den!) og bundle din kode plus koden fra disse moduler i en enkelt fil, der bærer alle afhængigheder. Webpack blev på den anden side udviklet til at håndtere oprettelsen af komplekse pipelines af kildetransformationer før udgivelse. Dette omfatter bundling af CommonJS-moduler.
Asynkronous Module Definition (AMD)
AMD blev født ud af en gruppe udviklere, der var utilfredse med den retning, som CommonJS havde valgt. AMD blev faktisk adskilt fra CommonJS tidligt i dens udvikling. Den væsentligste forskel mellem AMD og CommonJS ligger i understøttelsen af asynkron modulindlæsning.
//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 indlæsning er muliggjort ved at bruge JavaScript’s traditionelle closure-idiom: en funktion kaldes, når de ønskede moduler er færdige med at blive indlæst. Moduldefinitioner og import af et modul udføres af den samme funktion: når et modul defineres, gøres dets afhængigheder eksplicitte. Derfor kan en AMD-loader have et fuldstændigt billede af afhængighedsgrafen for et givet projekt på køretid. Biblioteker, der ikke er afhængige af hinanden ved indlæsning, kan således indlæses på samme tid. Dette er især vigtigt for browsere, hvor opstartstider er afgørende for en god brugeroplevelse.
Pros
- Asynkron indlæsning (bedre opstartstider).
- Cirkulære afhængigheder understøttes.
- Kompatibilitet for
require
ogexports
. - Afhængighedsstyring er fuldt integreret.
- Moduler kan om nødvendigt opdeles i flere filer.
- Konstruktorfunktioner understøttes.
- Plugin-understøttelse (brugerdefinerede indlæsningstrin).
Konsekvenser
- Lidt mere kompleks syntaktisk.
- Laderbiblioteker er påkrævet, medmindre de transpileres.
- Svært at analysere for statiske kodeanalysatorer.
Implementeringer
På nuværende tidspunkt er de mest populære implementeringer af AMD kræver.js og Dojo.
Brug af require.js er ret ligetil: inkludér biblioteket i din HTML-fil, og brug attributten data-main
til at fortælle require.js, hvilket modul der skal indlæses først. Dojo har en lignende opsætning.
ES2015 Modules
Det ECMA-team, der står bag standardiseringen af JavaScript, har heldigvis besluttet at tage fat på spørgsmålet om moduler. Resultatet kan ses i den seneste udgave af JavaScript-standarden: ECMAScript 2015 (tidligere kendt som ECMAScript 6). Resultatet er syntaktisk tiltalende og kompatibelt med både synkrone og asynkrone funktionsmåder.
//------ 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
Eksempel taget fra Axel Rauschmayer blog
Direktivet import
kan bruges til at bringe moduler ind i namespace. Dette direktiv er i modsætning til require
og define
ikke dynamisk (dvs. det kan ikke kaldes et hvilket som helst sted). export
-direktivet kan derimod bruges til eksplicit at gøre elementer offentlige.
Den statiske karakter af import
– og export
-direktivet gør det muligt for statiske analysatorer at opbygge et komplet træ af afhængigheder uden at køre kode. ES2015 understøtter dynamisk indlæsning af moduler:
System.import('some_module') .then(some_module => { // Use some_module }) .catch(error => { // ... });
I virkeligheden angiver ES2015 kun syntaksen for de dynamiske og statiske modulindlæsere. I praksis er ES2015-implementeringer ikke forpligtet til at gøre noget efter parsing af disse direktiver. Modulindlæsere som System.js er stadig påkrævet, indtil den næste ECMAScript-specifikation udgives.
Denne løsning lader i kraft af at være integreret med sproget runtimes vælge den bedste indlæsningsstrategi for moduler. Med andre ord, når asynkron indlæsning giver fordele, kan den bruges af runtime.
Pros
- Synkron og asynkron indlæsning understøttes.
- Syntaktisk enkel.
- Understøttelse af statiske analyseværktøjer.
- Integreret med sproget (understøttes efterhånden overalt, intet behov for biblioteker).
- Understøttelse af cirkulære afhængigheder.
Konsekvenser
- Er stadig ikke understøttet overalt.
Implementeringer
Der er desværre ingen af de store JavaScript-køretider, der understøtter ES2015-moduler i deres nuværende stabile grene. Det betyder ingen understøttelse af Firefox, Chrome eller Node.js. Heldigvis understøtter mange transpilere dog moduler, og der findes også en polyfill. I øjeblikket kan ES2015-forindstillingen til Babel håndtere moduler uden problemer.
Den alt-i-en-løsning: System.js
Du kan opleve, at du forsøger at bevæge dig væk fra ældre kode ved hjælp af ét modulsystem. Eller du vil måske sikre dig, at uanset hvad der sker, vil den løsning, du har valgt, stadig fungere. Kom ind i System.js: en universel modullæsser, der understøtter CommonJS, AMD og ES2015-moduler. Den kan fungere sammen med transpilere som Babel eller Traceur og kan understøtte Node- og IE8+-miljøer. Brug af den er et spørgsmål om at indlæse System.js i din kode og derefter pege den til din basis-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>
Da System.js gør alt arbejdet on-the-fly, bør brugen af ES2015-moduler generelt overlades til en transpiler under build-trinnet i produktionstilstand. Når du ikke er i produktionstilstand, kan System.js kalde transpileren for dig, hvilket giver en problemfri overgang mellem produktions- og fejlfindingsmiljøer.
Afhængig: Hvad vi bruger hos Auth0
Hos Auth0 bruger vi JavaScript i stor udstrækning. Til vores server-side kode bruger vi CommonJS-stil Node.js-moduler. Til visse klientsidekoder foretrækker vi AMD. Til vores React-baserede Passwordless Lock-bibliotek har vi valgt ES2015-moduler.
Kan du lide det, du ser? Tilmeld dig, og begynd at bruge Auth0 i dine projekter i dag.
Er du udvikler, og kan du lide vores kode? Hvis ja, så ansøg om en ingeniørstilling nu. Vi har et fantastisk team!
Konklusion
Bygning af moduler og håndtering af afhængigheder var besværligt tidligere. Nyere løsninger, i form af biblioteker eller ES2015-moduler, har fjernet det meste af smerten. Hvis du overvejer at starte et nyt modul eller projekt, er ES2015 den rigtige vej at gå. Det vil altid blive understøttet, og den nuværende understøttelse ved hjælp af transpilere og polyfills er fremragende. Hvis du på den anden side foretrækker at holde dig til almindelig ES5-kode, er den sædvanlige opdeling mellem AMD til klienten og CommonJS/Node til serveren fortsat det sædvanlige valg. Glem ikke at efterlade os dine tanker i kommentarfeltet nedenfor. Hack on!