Come lo sviluppo JavaScript diventa sempre più comune, i namespace e le dipendenze diventano molto più difficili da gestire. Sono state sviluppate diverse soluzioni per affrontare questo problema sotto forma di sistemi di moduli. In questo post, esploreremo le diverse soluzioni attualmente impiegate dagli sviluppatori e i problemi che cercano di risolvere. Continuate a leggere!
Introduzione: Perché sono necessari i moduli JavaScript?
Se avete familiarità con altre piattaforme di sviluppo, probabilmente avete qualche nozione dei concetti di incapsulamento e dipendenza. Diversi pezzi di software sono di solito sviluppati in isolamento fino a quando qualche requisito deve essere soddisfatto da un pezzo di software già esistente. Nel momento in cui quell’altro pezzo di software viene portato nel progetto, si crea una dipendenza tra esso e il nuovo pezzo di codice. Poiché questi pezzi di software devono lavorare insieme, è importante che non sorgano conflitti tra loro. Questo può sembrare banale, ma senza una sorta di incapsulamento, è una questione di tempo prima che due moduli entrino in conflitto tra loro. Questa è una delle ragioni per cui gli elementi nelle librerie C di solito portano un prefisso:
#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
L’incapsulamento è essenziale per prevenire i conflitti e facilitare lo sviluppo.
Quando si tratta di dipendenze, nel tradizionale sviluppo JavaScript lato client, esse sono implicite. In altre parole, è compito dello sviluppatore assicurarsi che le dipendenze siano soddisfatte nel momento in cui qualsiasi blocco di codice viene eseguito. Gli sviluppatori devono anche assicurarsi che le dipendenze siano soddisfatte nel giusto ordine (un requisito di alcune librerie).
Il seguente esempio fa parte degli esempi di Backbone.js. Gli script sono caricati manualmente nell’ordine corretto:
<!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>
Come lo sviluppo di JavaScript diventa sempre più complesso, la gestione delle dipendenze può diventare ingombrante. Anche il refactoring è compromesso: dove dovrebbero essere messe le nuove dipendenze per mantenere il corretto ordine della catena di caricamento?
I sistemi di moduli JavaScript cercano di affrontare questi e altri problemi. Sono nati per necessità per accomodare il panorama JavaScript in continua crescita. Vediamo cosa portano al tavolo le diverse soluzioni.
Una soluzione ad hoc: The Revealing Module Pattern
La maggior parte dei sistemi di moduli sono relativamente recenti. Prima che fossero disponibili, un particolare pattern di programmazione ha iniziato ad essere usato in sempre più codice JavaScript: il pattern del modulo rivelatore.
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" );
Questo esempio è stato preso dal libro JavaScript Design Patterns di Addy Osmani.
Gli scope JavaScript (almeno fino alla comparsa di let
in ES2015) lavorano a livello di funzione. In altre parole, qualsiasi binding sia dichiarato all’interno di una funzione non può sfuggire al suo scope. È per questa ragione che il pattern del modulo rivelatore si basa sulle funzioni per incapsulare i contenuti privati (come molti altri pattern JavaScript).
Nell’esempio sopra, i simboli pubblici sono esposti nel dizionario restituito. Tutte le altre dichiarazioni sono protette dall’ambito della funzione che le racchiude. Non è necessario usare var
e una chiamata immediata alla funzione che racchiude l’ambito privato; una funzione con nome può essere usata anche per i moduli.
Questo schema è in uso da un po’ di tempo nei progetti JavaScript e tratta abbastanza bene la questione dell’incapsulamento. Non fa molto per la questione delle dipendenze. Sistemi di moduli adeguati tentano di affrontare anche questo problema. Un’altra limitazione sta nel fatto che l’inclusione di altri moduli non può essere fatta nello stesso sorgente (a meno che non si usi eval
).
Pros
- Semplice abbastanza da essere implementato ovunque (nessuna libreria, nessun supporto linguistico richiesto).
- Moduli multipli possono essere definiti in un singolo file.
Cons
- Nessun modo di importare programmaticamente i moduli (tranne usando
eval
). - Le dipendenze devono essere gestite manualmente.
- Il caricamento asincrono dei moduli non è possibile.
- Le dipendenze circolari possono essere fastidiose.
- Difficile da analizzare per analizzatori di codice statico.
CommonJS
CommonJS è un progetto che mira a definire una serie di specifiche per aiutare nello sviluppo di applicazioni JavaScript lato server. Una delle aree che il team di CommonJS cerca di affrontare sono i moduli. Gli sviluppatori di Node.js originariamente intendevano seguire le specifiche di CommonJS, ma poi hanno deciso di non farlo. Quando si tratta di moduli, l’implementazione di Node.js ne è molto influenzata:
// 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)}`);
Una sera al Joyent, quando ho menzionato di essere un po’ frustrato da una richiesta ridicola per una caratteristica che sapevo essere un’idea terribile, mi ha detto, “Dimentica CommonJS. È morto. Noi siamo il JavaScript lato server”. – Isaac Z. Schlueter, creatore di NPM, cita Ryan Dahl, creatore di Node.js
Ci sono astrazioni sopra il sistema di moduli di Node.js sotto forma di librerie che colmano il divario tra i moduli di Node.js e CommonJS. Per gli scopi di questo post, mostreremo solo le caratteristiche di base che sono per lo più le stesse.
In entrambi i moduli di Node e CommonJS ci sono essenzialmente due elementi per interagire con il sistema dei moduli: require
e exports
. require
è una funzione che può essere usata per importare simboli da un altro modulo nello scope corrente. Il parametro passato a require
è l’id del modulo. Nell’implementazione di Node, è il nome del modulo all’interno della directory node_modules
(o, se non è all’interno di tale directory, il percorso per raggiungerlo). exports
è un oggetto speciale: qualsiasi cosa vi si metta dentro verrà esportata come elemento pubblico. I nomi dei campi sono conservati. Una differenza peculiare tra Node e CommonJS si presenta nella forma dell’oggetto module.exports
. In Node, module.exports
è il vero oggetto speciale che viene esportato, mentre exports
è solo una variabile che viene legata di default a module.exports
. CommonJS, d’altra parte, non ha un oggetto module.exports
. L’implicazione pratica è che in Node non è possibile esportare un oggetto completamente precostruito senza passare attraverso 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 };}
I moduli CommonJS sono stati progettati con lo sviluppo del server in mente. Naturalmente, l’API è sincrona. In altre parole, i moduli sono caricati al momento e nell’ordine in cui sono richiesti all’interno di un file sorgente.
Pros
- Semplice: uno sviluppatore può afferrare il concetto senza guardare i documenti.
- La gestione delle dipendenze è integrata: i moduli richiedono altri moduli e vengono caricati nell’ordine necessario.
-
require
possono essere chiamati ovunque: i moduli possono essere caricati programmaticamente. - Le dipendenze circolari sono supportate.
Cons
- L’API sincrona lo rende non adatto a certi usi (lato client).
- Un file per modulo.
- I browser richiedono una libreria di caricamento o la transpilazione.
- Nessuna funzione costruttrice per i moduli (Node però lo supporta).
- Difficile da analizzare per gli analizzatori statici di codice.
Implementazioni
Abbiamo già parlato di una implementazione (in forma parziale): Node.js.
Per il client, ci sono attualmente due opzioni popolari: webpack e browserify. Browserify è stato esplicitamente sviluppato per analizzare le definizioni dei moduli in stile Node (molti pacchetti Node funzionano out-of-the-box con esso!) e raggruppare il tuo codice più il codice di quei moduli in un singolo file che porta tutte le dipendenze. Webpack, d’altra parte, è stato sviluppato per gestire la creazione di complesse pipeline di trasformazioni dei sorgenti prima della pubblicazione. Questo include il raggruppamento dei moduli CommonJS.
Asynchronous Module Definition (AMD)
AMD è nato da un gruppo di sviluppatori che erano scontenti della direzione adottata da CommonJS. Infatti, AMD è stato separato da CommonJS all’inizio del suo sviluppo. La differenza principale tra AMD e CommonJS risiede nel suo supporto per il caricamento asincrono dei moduli.
//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 () {};});
Il caricamento asincrono è reso possibile utilizzando il tradizionale idioma di chiusura di JavaScript: una funzione viene chiamata quando i moduli richiesti hanno finito il caricamento. Le definizioni dei moduli e l’importazione di un modulo sono effettuate dalla stessa funzione: quando un modulo è definito le sue dipendenze sono rese esplicite. Pertanto, un caricatore AMD può avere un quadro completo del grafico delle dipendenze per un dato progetto in fase di esecuzione. Le librerie che non dipendono l’una dall’altra per il caricamento possono quindi essere caricate allo stesso tempo. Questo è particolarmente importante per i browser, dove i tempi di avvio sono essenziali per una buona esperienza utente.
Pros
- Carico asincrono (migliori tempi di avvio).
- Sono supportate le dipendenze circolari.
- Compatibilità per
require
eexports
. - Gestione delle dipendenze completamente integrata.
- I moduli possono essere divisi in più file se necessario.
- Sono supportate le funzioni costruttore.
- Supporto per i plugin (fasi di caricamento personalizzate).
Cons
- Leggermente più complesso sintatticamente.
- Le librerie di caricamento sono richieste a meno che non siano transpilate.
- Difficile da analizzare per gli analizzatori statici di codice.
Implementazioni
Al momento, le implementazioni più popolari di AMD sono require.js e Dojo.
Utilizzare require.js è abbastanza semplice: includi la libreria nel tuo file HTML e usa l’attributo data-main
per dire a require.js quale modulo deve essere caricato per primo. Dojo ha una configurazione simile.
ES2015 Modules
Fortunatamente, il team ECMA dietro la standardizzazione di JavaScript ha deciso di affrontare la questione dei moduli. Il risultato può essere visto nell’ultima versione dello standard JavaScript: ECMAScript 2015 (precedentemente noto come ECMAScript 6). Il risultato è sintatticamente piacevole e compatibile con entrambe le modalità di funzionamento sincrono e asincrono.
//------ 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
Esempio tratto dal blog di Axel Rauschmayer
La direttiva import
può essere usata per portare i moduli nel namespace. Questa direttiva, al contrario di require
e define
non è dinamica (cioè non può essere chiamata in nessun posto). La direttiva export
, d’altra parte, può essere usata per rendere esplicitamente pubblici gli elementi.
La natura statica delle direttive import
e export
permette agli analizzatori statici di costruire un albero completo delle dipendenze senza eseguire il codice. ES2015 supporta il caricamento dinamico dei moduli:
System.import('some_module') .then(some_module => { // Use some_module }) .catch(error => { // ... });
In verità, ES2015 specifica solo la sintassi per i caricatori di moduli dinamici e statici. In pratica, le implementazioni ES2015 non sono tenute a fare nulla dopo aver analizzato queste direttive. I caricatori di moduli come System.js sono ancora richiesti fino al rilascio della prossima specifica ECMAScript.
Questa soluzione, in virtù di essere integrata nel linguaggio, permette ai runtime di scegliere la migliore strategia di caricamento per i moduli. In altre parole, quando il caricamento asincrono dà dei benefici, può essere usato dal runtime.
Pros
- Caricamento sincrono e asincrono supportato.
- Sintatticamente semplice.
- Supporto per strumenti di analisi statica.
- Integrato nel linguaggio (eventualmente supportato ovunque, senza bisogno di librerie).
- Dipendenze circolari supportate.
Cons
- Ancora non supportato ovunque.
Implementazioni
Purtroppo, nessuno dei principali runtime JavaScript supporta i moduli ES2015 nei loro attuali rami stabili. Questo significa nessun supporto per Firefox, Chrome o Node.js. Fortunatamente, molti transpilers supportano i moduli ed è disponibile anche un polyfill. Attualmente, il preset ES2015 per Babel può gestire i moduli senza problemi.
La soluzione completa: System.js
Potresti trovarti a cercare di allontanarti dal codice legacy usando un sistema di moduli. O potresti voler essere sicuro che qualsiasi cosa accada, la soluzione che hai scelto continuerà a funzionare. Inserisci System.js: un caricatore di moduli universale che supporta CommonJS, AMD e i moduli ES2015. Può lavorare in tandem con transpilers come Babel o Traceur e può supportare Node e gli ambienti IE8+. Usarlo è una questione di caricare System.js nel vostro codice e poi puntarlo al vostro URL di base:
<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>
Poiché System.js fa tutto il lavoro al volo, l’uso dei moduli ES2015 dovrebbe generalmente essere lasciato a un transpiler durante la fase di compilazione in modalità di produzione. Quando non è in modalità di produzione, System.js può chiamare il transpiler per voi, fornendo una transizione senza soluzione di continuità tra gli ambienti di produzione e di debug.
A parte: Cosa usiamo ad Auth0
Ad Auth0, usiamo JavaScript pesantemente. Per il nostro codice lato server, usiamo moduli Node.js in stile CommonJS. Per alcuni codici lato client, preferiamo AMD. Per la nostra libreria Passwordless Lock basata su React, abbiamo optato per i moduli ES2015.
Ti piace quello che vedi? Iscriviti e inizia a usare Auth0 nei tuoi progetti oggi stesso.
Sei uno sviluppatore e ti piace il nostro codice? Se sì, fai domanda per una posizione da ingegnere ora. Abbiamo un team fantastico!
Conclusione
Costruire moduli e gestire le dipendenze era complicato in passato. Le soluzioni più recenti, sotto forma di librerie o moduli ES2015, hanno eliminato la maggior parte del dolore. Se state cercando di iniziare un nuovo modulo o progetto, ES2015 è la strada giusta da percorrere. Sarà sempre supportato e l’attuale supporto tramite transpilers e polyfills è eccellente. D’altra parte, se preferite attenervi al semplice codice ES5, la solita divisione tra AMD per il client e CommonJS/Node per il server rimane la scelta abituale. Non dimenticate di lasciarci i vostri pensieri nella sezione commenti qui sotto. Hack on!