BazarJS: Module loaders e package managers

Seconda puntata di BazarJS sul magico mondo delle single-page applications... oggi si inizia a fare sul serio con module loaders, bundlers e package managers, tre degli argomenti più "confusi", controversi e peculiari del mondo Javascript.

Come sempre, dopo una panoramica delle soluzioni disponibili, cercheremo di analizzare vantaggi e svantaggi per arrivare alla nostra personale scelta.

Tempo di lettura: 16 minuti

Come ben sappiamo, Javascript nativamente non possiede alcun tipo di meccanismo atto a gestire dipendenze tra file — per capirci, l'equivalente della require Ruby, o dell’@import Sass. Per anni siamo stati abituati a convivere con questa mancanza sfruttando semplicemente un mix di funzioni anonime e namespace globale:

// calculator.js
(function(root) {
  var calculator = {
    sum: function(a, b) { return a + b; }
  };
  root.Calculator = calculator;
})(this);

// app.js
console.log(calculator.sum(1, 2)); // => 3

Purtroppo, come ben sappiamo, si tratta di una soluzione “a metà”: non venendo generata in alcun modo un'alberatura esplicita delle dipendenze, si delega il compito di specificare l'ordinamento delle inclusioni dei differenti moduli allo sviluppatore — regalando i classici errori del tipo TypeError: undefined is not a function quando non si azzecca l'ordinamento corretto.

CommonJS

Node.js ha sopperito a questa mancanza implementando il pattern specificato dal comitato spontaneo denominato CommonJS; si tratta di una soluzione elegante, comoda da utilizzare e simile a quella presente in altri linguaggi di programmazione, in grado di garantire il necessario incapsulamento dei moduli:

// calculator.js
module.exports = {
  sum: function(a, b) { return a + b; }
};

// app.js
var assert = require('assert');
var calculator = require('./calculator');
assert.equal(3, calculator.sum(1, 2));

La funzione require() si occupa di leggere il contenuto del file locale specificato, valutarlo, e ritornare al richiedente il contenuto dell'oggetto module.exports. Tutto ciò che nel modulo non viene esposto su questo oggetto, rimane a tutti gli effetti “privato” e non accessibile dall'esterno.

Considerando che la chiamata verso la require() è sincrona, si tratta purtroppo di una soluzione che male si adatta al contesto di un browser, in cui il caricamento dinamico di file Javascript dev'essere necessariamente asincrono.

AMD

Proprio a seguito di questa valutazione, CommonJS ha definito una variante asincrona per i caricamento dei moduli, che prende il nome di AMD (Asynchronous Module Definition), e dunque sfruttabile molto più facilmente all'interno di un browser:

// calculator.js
define("calculator", function() {
  return {
    sum: function(a, b) { return a + b; }
  };
});

// app.js
define("app", ["calculator"], function(calculator) {
  console.log(calculator.sum(1, 2)); // => 3
});

I moduli vengono definiti tramite la funzione define(), attraverso la quale si specifica anche il nome del modulo, ed eventuali dipendenze che necessita. Il pattern permette di caricare queste ultime in asincrono, per poi venire passate alla callback sotto forma di parametri, conservando l'ordinamento specificato tramite l'array.

Meno elegante dell'equivalente in Node.js, certo, ma d'altra parte è l'unica via percorribile su un browser (almeno apparentemente… torneremo più avanti su questo punto).

La luce in fondo al tunnel: ECMAScript 6

ECMAScript 6 — ovvero la prossima versione di Javascript, la cui standardizzazione è terminata nel 2014 e che lentamente verrà implementata dai browser — ha finalmente proposto una soluzione “ufficiale” al problema, con una sintassi simile a quanto presente ad esempio in Python:

// calculator.js
export function sum(a, b) { return a + b; }

// app.js
import * as calculator from 'calculator';

console.log(calculator.sum(1, 2)); // => 3

È da notare come l'impostazione della keyword import sia di tipo sincrono: significa forse che potrà essere sfruttata solo in Node.js e similari? Fortunatamente no: i browser, per implementare questo meccanismo, dovranno occuparsi di effettuare un'analisi statica del codice prima di valutare effettivamente il corpo del file Javascript, per ricercare eventuali import da caricare a monte della valutazione del codice stesso.

E lo sviluppatore? Paga.

Considerata la frammentazione attuale, ed in attesa di un supporto globale alla nuova sintassi “universale” ECMAScript 6, lo sviluppatore Javascript che intende rilasciare una libreria Javascript è oggi costretto a rendere il proprio modulo compatibile con tutte e tre le possibilità di caricamento descritte:

  • Attraverso globals
  • CommonJS
  • AMD

Sebbene possa apparire come un effort notevole, all'atto pratico non è particolarmente complesso riuscirci; è sufficiente wrappare la propria libreria intorno ad uno scheletro di questo tipo:

// calculator.js
(function (name, context, definition) {
  if (typeof module != 'undefined' && module.exports)
    module.exports = definition();
  else if (typeof define == 'function' && define.amd)
    define(name, definition);
  else
    context[name] = definition();
}('calculator', this, function () {
  // your module here!
  return {
    sum: function(a, b) { return a + b; }
  };
});

Una libreria in grado supportare tutti gli attuali “standard” di caricamento si dice che supporti l’UMD (Universal Module Definition).

Fortunatamente, la maggior parte delle librerie sviluppate negli ultimi anni supporta l'UMD, ed è dunque in grado di poter venire sfruttata in ogni ambiente runtime, sia client-side che server-side.

Ma veniamo a noi :)

Alla luce di questa doverosa introduzione “teorica”, sorge una domanda: con quale meccanismo di module-loading dovremmo scrivere la nostra applicazione client-side? Verrebbe da pensare ad AMD come unico possibile candidato, visti i limiti descritti… è proprio così? E in seconda battuta: a quale database di moduli attingere?

Esploriamo insieme le più popolari soluzioni che la community Javascript ci ha messo a disposizione.

Soluzione 1: RequireJS + Bower

RequireJS è la più popolare implementazione del pattern AMD in circolazione, mentre Bower è il package manager di riferimento per pacchetti front-end (dunque non solo JS, ma anche CSS, Sass, etc).

Diamo un occhio ai numeri di questi due progetti:

RequireJS
Bower
  • Homepage: http://bower.io
  • Data di creazione: Settembre 2012
  • Numero di moduli a disposizione: 21.564
  • Github stars: ★ 11.471

Con RequireJS, il caricamento delle dipendenze nella pagina avviene importando un unico script: RequireJS, appunto.

<script data-main="scripts/main" src="scripts/require.js"></script>

L'attributo data-main sul tag specifica l'entry point dell'applicazione, nel quale vengono specificate le eventuali configurazioni di RequireJS, necessarie a permettergli lo scaricamento dei file, e che inizializza la catena di import asincroni AMD già visti:

// main.js
requirejs.config({ baseUrl: '/scripts' });
requirejs(['app']);

Considerando che RequireJS si occupa esclusivamente del caricamento dei moduli, diventa evidente la necessità affiancarlo ad un package manager come Bower, in grado di permetterci di accedere ad un enorme database di moduli/librerie front-end di terze parti (+20.000), gestendo per noi l'analisi delle dipendenze, eventuali conflitti di versioning, e lo scaricamento stesso dei moduli in locale.

L'equivalente Bower del Gemfile si chiama bower.json, ed è un qualcosa di questo tipo:

{
  "name": "my-project",
  "private": true,
  "dependencies": {
    "rsvp": "~3.0.16"
  }
}

Il comando bower install, leggendo questo file, è in grado di scaricare le dipendenze specificate all'interno della directory locale ./bower_components.

Ça va sans dire, attraverso il metodo requirejs.config() è possibile configurare RequireJS in modo tale da recuperare i moduli Bower all'interno di questa cartella.

La dura realtà

Il caricamento asincrono di AMD (e dunque RequireJS) sembra a prima vista un'ottima idea, in grado di permetterci di scaricare progressivamente solo i file strettamente necessari all'esecuzione della nostra app.

Nella pratica si tratta di un'idea praticamente irrealizzabile in un contesto come quello del browser, dove l'overhead HTTP sul download asincrono dei singoli file Javascript risulta essere drammatico, al punto da distruggere le performance dell'applicativo su progetti mediamente complessi.

RequireJS consiglia dunque di procedere con un operazione bundling dei vari moduli attraverso il loro tool da riga di comando r.js:

node r.js -o name=main out=bundle.js baseUrl=.

Il processo si occupa di effettuare il parsing dei file a partire da un entry-point (nel nostro caso main.js) per ricostruire l'albero delle dipendenze sulla base delle chiamate define() che trova nel suo percorso. A questo punto è in grado di ordinare e concatenare tutti i moduli necessari in un unico file, bundle.js che includeremo nel nostro HTML al posto del file main.js visto in precedenza:

<script data-main="scripts/bundle" src="scripts/require.js"></script>

Il file di bundle non è altro che una concatenazione dei file Javascript necessari a runtime:

// bundle.js
define("calculator", [],function() {
  return {
    sum: function(a, b) { return a + b; }
  };
});

define("app", ["calculator"], function(calculator) {
  console.log(calculator.sum(1, 2)); // => 3
});

requirejs(['app']);

L'ordine con cui i file vengono concatenati dal tool permette a RequireJS di effettuare a runtime un'operazione di caching del corpo dei moduli prima di arrivare alla chiamata inizializzatrice requirejs(), e scongiurando quindi l'avvio di ulteriori richieste di scaricamento.

Soluzione 2: Browserify + Npm

Al contrario di RequireJS, Browserify permette di scrivere la propria applicazione client-side sfruttando il loading sincrono CommonJS (alà Node.js, per capirci). Come può essere possibile? Vedremo nel dettaglio il come, nel frattempo un occhio ad alcune statistiche di utilizzo non fa mai male:

Browserify
Npm
  • Homepage: http://npmjs.org/
  • Data di creazione: Settembre 2009
  • Numero di pacchetti a disposizione: 115.973 (!!!)
  • Github stars: ★ 5.332

Similmente a RequireJS, Browserify mette a disposizione un tool da riga di comando che effettua il parsing dei moduli a partire da un entry point (in questo caso, app.js) alla ricerca dell'albero delle occorrenze della chiamata require():

browserify app.js --outfile bundle.js

Il risultato è simile al seguente: 1

// bundle.js
debundle({
  entryPoint: "./app",
  modules: {
    "./app": function(require, module) {
      var calculator = require('./calculator');
      console.log(calculator.sum(1, 2));
    },
    "./calculator": function(require, module) {
      module.exports = {
        sum: function(a, b) { return a + b; }
      };
    }
  }
});

function debundle(data) {
  var cache = {};
  var require = function(name) {
    if (cache[name]) { return cache[name]; }
    var module = cache[name] = { exports: {} };
    data.modules[name](require, module);
    return module.exports;
  };
  return require(data.entryPoint);
}

You see what we did there? :) L'entry-point di partenza, così come il contenuto di ogni modulo che potrebbe venire richiesto a runtime, vengono passati ad una particolare funzione debundle() la quale implementa, client-side, il banale meccanismo module.exports/require CommonJS.

Il trucco è tutto qua: effettuando un bundle di tutti i moduli a monte, il processo di require può tranquillamente seguire una logica sincrona.

Browserify, non contento, si spinge oltre: per completare la simulazione di un ambiente Node.js, permette non solo di effettuare require() dei propri file applicativi locali, ma anche

  • di eventuali pacchetti npm presenti nella directory ./node_modules;
  • di alcuni dei moduli core Node.js (url, path, stream, events, http).

Questi ultimi, non essendo ovviamente stati pensati per un utilizzo su browser, sono stati riscritti interamente dal team Browserify mantenendo la medesima API, e nel processo di bundle verranno inclusi al posto degli originali.

Ultima, importante, considerazione: il meccanismo di parsing Browserify può essere aumentato attraverso l'utilizzo di cosiddetti transform, logiche di terze parti in grado di modificare/pre-processare i file sorgenti prima di venire inclusi nel bundle:

browserify app.js         \
  --transform coffeeify   \
  --transform uglifify    \
  --outfile bundle.js

Un comando di questo genere ci permette ad esempio di scrivere la nostra app in Coffeescript, e ottenere un bundle.js compilato e compresso. Non male.

Analisi

Innanzitutto, riprendendo la prima parte di questa serie, entrambe le soluzioni si integrano bene con i principali task runner (es. gulp-browserify, gulp-requirejs), dunque 1 a 1 palla al centro.

Browserify prende coscienza del fatto che l'asincronicità di RequireJS non è altro che una forma di wishful thinking irrealizzabile nel pratico 2 e decide quindi di sfruttare il pattern CommonJS sincrono, più comodo e meno verboso. Punto a favore.

Browserify, d'altro canto, richiede l'utilizzo di pacchetti npm, quando il package-manager più idoneo ad un utilizzo front-end in realtà sarebbe Bower. Si tratta in realtà di una considerazione più filosofica che pratica, considerato che:

  • la stragrande maggioranza dei moduli front-end vengono rilasciati anche su npm;
  • transform Browserify come debowerify permettono, se necessario, di includere pacchetti Bower alla stregua di quelli npm;

È comunque necessario prestare attenzione ai pacchetti npm che si decide di utilizzare, dato che non è detto che siano funzionanti all'interno di un browser. Proprio a questo scopo Toby Ho ha rilasciato Browserify Search, uno strumento che è in grado di darci questa informazione attraverso analisi piuttosto sofisticate sui pacchetti npm. Fortunatamente, ad oggi, circa la metà dei pacchetti npm (circa 60.000) passano i check.

Browserify ha attualmente la community più numerosa ed attiva tra i suoi competitor. A titolo puramente esemplificativo della vitalità che circonda questo progetto citiamo:

  • Watchify, un watcher per Browserify in grado di effettuare un nuovo bundle all'update di una qualsiasi delle dipendenze del progetto e di ridurre di un ordine di grandezza i tempi di build consecutivi al primo mediante meccanismi di cache;

  • Disc, uno strumento in grado di analizzare il bundle Browserify per mostrare visivamente uno spaccato navigabile delle dipendenze più pesanti nel pacchetto;

  • partition-bundle, un plugin in grado di partizionare i moduli dell'applicativo su più bundle, in modo da permettere uno scaricamento iniziale più rapido, ed un caricamento progressivo della logica client-side.

Sia RequireJS che Browserify hanno il supporto per le sourcemaps, fondamentali per riuscire a debuggare l'applicazione in produzione.

La scelta

Browserify. La scelta è ricaduta su questa soluzione per il già citato supporto della community e per la sua maggiore comodità di scrittura, ma non solo.

La scelta di scrivere una applicazione Node.js-compatibile si porta dietro di sé due ulteriori, enormi vantaggi:

  • la possibilità di realizzare applicativi isomorfi. In altre parole, applicazioni Javascript che, attraverso semplici astrazioni implementate a livello di routing e renderizzazione delle viste, sono in grado di venire eseguite in egual modo sia lato browser che lato server 3. Un approccio di questo tipo permette di ottenere l'ottimo tra i due mondi:

    • estrema rapidità di visualizzazione della prima pagina caricata dall'utente, grazie ad una pre-renderizzazione effettuata server-side senza dover attendere il download e caricamento di tutto il codice Javascript lato browser;
    • aggiornamenti immediati client-side dal primo caricamento in poi, limitando al minimo le chiamate verso il server.
  • la possibilità di eseguire gli unit-test del proprio codice front-end senza la necessità di lanciare un browser, ma semplicemente eseguendoli in un ambiente Node.js. Sappiamo bene quanto fondamentale sia mantenere i tempi di esecuzione dei test più bassi possibili, e mettere un browser in mezzo a questo processo costringe ad un paio di secondi di attesa per lancio.

Nuovi competitor sono già alle porte…

La nostra scelta in questo ambito, in realtà, è tutt'altro che definitiva. Una nuova ondata di soluzioni alternative sta già bussando alla porta, ed inizia a riscuotere successo sugli sviluppatori più reazionari (forse meglio dire hipster?) della comunità.

Webpack

Uno dei tool più quotati in questo senso sembrerebbe essere Webpack. Senza discostarsi troppo da quelli che sono i concetti fondamentali di Browserify, Webpack introduce una serie di divergenze su alcune tematiche, ben espresse dallo stesso autore di Browserify in questo articolo, che ha suscitato notevole interesse e reazioni contrastanti nella comunità Javascript. Da tenere d'occhio.

jspm

Ben più audace è il progetto jspm, che sta ricevendo in queste settimane grande attenzione e i cui tratti distintivi sono quelli di:

  • permettere di installare dipendenze sia Node.js, che Bower, che Github;
  • supportare ogni libreria, implementando tutti i possibili meccanismi di module loading disponibili (globals, AMD, CommonJS);
  • permettere la scrittura della propria applicazione in ECMAScript 6, con tanto di direttive di import;
  • ultimo tratto, questo veramente innovativo se sommato ai precedenti: non richiedere alcuna operazione di bundle.

Come è possibile tutto questo? Il pre-processamento dei file ECMAScript 6, il parsing delle direttive di import, così come lo scaricamento delle dipendenze avviene totalmente a runtime, client-side: una mossa che, sebbene appesantisca le performance dell'ambiente di sviluppo, sicuramente semplifica, e di tanto, il processo di boostrap di una nuova applicazione, uno dei punti più dolorosi per un neofita Javascript.

Utilizzando jspm, in produzione si può ricadere nuovamente nella classica generazione di un bundle, oppure — secondo tratto fortemente innovativo del progetto — nell’utilizzo di una CDN HTTP/2 che, mediante un meccanismo chiamato di dependency cache, permette di annullare i tempi di latenza e gli overhead di uno scaricamento progressivo. A breve cercheremo di parlare più approfonditamente di jspm: ne vale la pena, anche solo a livello formativo :)

Coming next: Preprocessori CSS

Questo post è stato duro :) Nel prossimo ci prenderemo una breve pausa digestiva dal mondo Javascript, per affrontare invece l'altra metà del mondo front-end, i fogli di stile. Forti della nostra esperienza su Sass, daremo un occhiata oggettiva alle principali alternative Javascript disponibili (Less.js e Stylus). Vale la pena cambiare preprocessore?

Seguici su Twitter o tramite feed RSS per rimanere aggiornato sulla prossima uscita!


  1. La funzione debundle() in questo articolo è stata semplificata al fine di renderla più comprensibile: ecco la versione effettivamente utilizzata da Browserify

  2. O almeno, lo sarà fino all'arrivo di HTTP/2, in grado di ridurre drammaticamente latenza e overhead per singola richiesta. 

  3. Spike Brehm ha realizzato lo scorso anno un semplice progetto su Github che mostra il funzionamento pratico di una applicazione isomorfa. Dateci un occhio. 

Hai trovato questo post interessante?Scopri chi siamo

Made with Middleman and DatoCMS, our CMS for static websites