Moduli ECMA Script o CommonJS?
Principali differenze tra i moduli ECMA Script e CommonJS in Node.js
In Node.js v14, è possibile creare due tipologie di moduli: ci sono moduli “vecchio stile” che utilizzano la specifica CommonJS (CJS) e gli script “nuovo stile” ESM che utilizzano la specifica ECMA Script. Gli script CJS usano require()
e module.exports
, mentre quelli ESM utilizzano import
ed export
. In questo articolo daremo uno sguardo al funzionamento di queste due specifiche descrivendone le principali differenze.
CJS !== ESM
Una cosa importante da chiarire prima di iniziare ad addentrarci nella discussione è che la specifica CommonJS è completamente diversa da quella ECMA Script. Possono risultare molto simili ma le loro implementazioni sono molto diverse.
Per comprendere ancora meglio quanto detto guardate l’immagine sottostante. In un primo momento i due animali raffigurati possono sembrare lo stesso ma in realtà il primo è un leone marino ed il secondo è una foca. Allo stesso modo non confondete i moduli CJS con quelli ESM. Si somigliano ma sono due specifiche completamente diverse!
Perché i moduli?
Prima dell’avvento dei moduli ECMA Script, in Node.js l’unico modo di collegare i moduli fra di loro era mediante la funzione require()
, un approccio semplice ma potente. Come probabilmente già saprete, Node.js utilizza il concetto di modulo come unità di misura per strutturare il codice di un’applicazione e soprattutto come meccanismo principale per l’occultamento delle informazioni, mantenendo private tutte le variabili che non sono esplicitamente esportate.
Uno dei maggiori problemi con JavaScript era rappresentato dall’assenza dello spazio dei nomi. Programmi che vengono eseguiti nell’ambito globale inquinandolo con dati e che provengono sia dal codice dell’applicazione interna che dalle dipendenze. Una tecnica popolare per risolvere questo problema, che probabilmente molti di voi conosceranno, è la seguente:
var $, jQuery
$,jQuery=(()=>{
return { /* ... */ }
})()
Questo pattern prende il nome di IIFE (Immediately Invoked Function Expression) -conosciuta anche come Funzione Anonima ad Esecuzione Automatica- e contiene due parti principali:
- La prima è la funzione anonima con ambito lessicale racchiusa all’interno dell’operatore di raggruppamento (). Questa funzione impedisce l’accesso alle variabili all’interno dell’idioma IIFE e l’inquinamento dell’ambito globale;
- La seconda parte crea la funzione immediatamente richiamata attraverso la quale il motore JavaScript interpreterà direttamente la funzione.
Vediamo ora un altro esempio:
const module = (() => {
const privateFoo = () => { ... }
const privateBar = []
return {
publicFoo: () => { ... },
publicBar: () => { ... }
}
})()
Anche in questo caso sfruttiamo una funzione di richiamo automatico per creare un ambito privato, esportando solo le parti che dovrebbero essere pubbliche. Nel codice precedente, la variabile del modulo contiene solo l’API esportata, mentre il resto del contenuto del modulo è praticamente inaccessibile dall’esterno. Come vedremo tra poco, l’idea alla base di questo pattern viene utilizzata come base per il sistema di moduli Node.js.
La specifica CommonJS
CommonJS (CJS) è uno standard per strutturare e organizzare il codice JavaScript. CJS assiste nello sviluppo di app lato server e il suo formato ha fortemente influenzato la gestione dei moduli di NodeJS.
CommonJS avvolge ogni modulo in una funzione chiamata require
e include un oggetto chiamato module.exports
, che esporta il codice per la disponibilità richiesta da altri moduli. Tutto quello che bisogna fare è aggiungere tutto ciò che desideri accessibile ad altri file nell’oggetto exports
e richiedere il modulo nel file dipendente. Prima che il codice di un modulo venga eseguito, Node.js lo avvolgerà con una funzione wrapper simile alla seguente:
(function(exports, require, module, __filename, __dirname) {
// Codice del nostro modulo
})
In questo modo, Node.js ottiene le stesse cose che si propone di fare una IIFE:
- Mantiene le variabili di primo livello (definite con var, const o let) nell’ambito del modulo anziché nell’oggetto globale;
- Aiuta a fornire alcune variabili dall’aspetto globale che sono effettivamente specifiche del modulo, come ad esempio gli oggetti
module
edexports
che il programmatore può utilizzare per esportare i valori di un modulo, o le variabilifilename
edirname
che contengono rispettivamente il path assoluto del modulo e il percorso della directory che lo contiene.
Un piccolo esempio
Facciamo un esempio. Creiamo un modulo geometry.js
, utilizzando la specifica CJS, che esporta alcune semplici formule:
const { PI, pow } = Mathexports.circleArea = r => PI * pow(r, 2)
exports.circleCircumference = r => 2 * PI * r
exports.squarePerimeter = l => l * 4
exports.squareArea = l => pow(l, 2) * 6
Il modulo geometry.js
esporta le funzioni circleArea
, circleCircumference
, squarePerimeter
e squareArea
. Le funzioni e gli oggetti vengono aggiunti alla radice di un modulo, specificando proprietà aggiuntive sull’oggetto exports
.
Le variabili locali al modulo saranno private, perché il modulo è racchiuso dalla funzione wrapper. In questo esempio, le variabili PI
e pow
saranno private. Alla proprietà module.exports
può essere assegnato anche un valore o una classe. Per esempio:
const { PI, pow } = Mathexports.circleArea = r => PI * pow(r, 2)
exports.circleCircumference = r => 2 * PI * r
exports.squarePerimeter = l => l * 4
exports.squareArea = l => pow(l, 2) * 6
exports.Parallelogram = class {
constructor (b, h) {
this.b = b
this.h = h
} perimeter () {
return (this.b * 2) + (this.h * 2)
} area () {
return this.b * this.h
}
}
Per poter utilizzare questo modulo creiamo il file calculator.js
, che utilizza il modulo geometry
:
const {
Parallelogram,
circleArea,
squareArea,
circleCircumference
} = require('./geometry')// Creiamo un istanza della classe Parallelogram
const p = new Parallelogram(5, 4)
console.log(`Parallelogram area: ${p.area()}`)
console.log(`Parallelogram perimeter: ${p.perimeter()}`)// Calcolo dell'area di un quadrato
console.log(`Square area: ${squareArea(4)}`)// Calcolo della circonferenza e dell'area di un cerchio
console.log(`Circle circumference: ${circleCircumference(9)}`)
console.log(`Circle area: ${circleArea(9)}`)
Eseguendo lo script il risultato che otterremo sarà il seguente:
Parallelogram area: 20
Parallelogram perimeter: 18
Square area: 96
Circle circumference: 56.548667764616276
Circle area: 254.46900494077323
Scope di un modulo
In precedenza abbiamo detto che la specifica CJS avvolge ogni nostro modulo in un wrapper che ha la seguente forma:
(function(exports, require, module, __filename, __dirname) {
// Codice del nostro modulo
})
Questa funzione crea uno scope per il nostro modulo, fornendogli alcune utility. Diamo ora uno sguardo a cosa rappresentano le diverse variabili messe a disposizione.
dirname e filename
Sono due variabili stringa il cui contenuto rappresenta:
- __dirname: il nome della directory del modulo corrente;
- __filename: il nome del file del modulo corrente. Questo è il percorso assoluto del file del modulo corrente con i collegamenti simbolici risolti. Per un programma principale questo non è necessariamente lo stesso del nome del file utilizzato nella riga di comando.
Ecco un piccolo script di esempio:
// Nel mio caso stamperà "Dirname is: /Users/davidedantonio/Desktop"
console.log(`Dirname is: ${__dirname}`)// Nel mio caso stamperà "Filename is: /Users/davidedantonio/Desktop/filename_dirname.js"
console.log(`Filename is: ${__filename}`)
module.exports
In ogni modulo, la variabile module
è un riferimento all’oggetto che rappresenta il modulo corrente. Per comodità, module.exports
è accessibile anche tramite exports module-global
. module
non è in realtà un globale ma piuttosto locale per ogni modulo.
La variabile module contiene le seguenti informazioni:
- module.children: gli oggetti modulo richiesti per la prima volta da questo;
- module.exports: rappresenta l’istruzione che dice a Node. js quali bit di codice (funzioni, oggetti, stringhe, ecc.) da “esportare” da un determinato file in modo che altri file possano accedere al codice esportato;
- module.filename: contiene il path assoluto del modulo. Equivale a
__filename
; - module.id: l’identificatore del modulo. In genere questo è il nome del file completamente risolto;
- module.isPreloading: è
true
se il modulo è in esecuzione durante la fase di pre caricamento di Node.js; - module.loaded: indica se il modulo ha terminato il caricamento o è in fase di caricamento;
- module.paths: I percorsi di ricerca per i moduli.
exports
Questo è solo zucchero sintattico. Altro non è che un riferimento a module.exports
che è più breve da digitare.
require
La funzione require()
ci consente di includere moduli nella nostra applicazione. Puoi aggiungere moduli core di Node.js, moduli userland (node_modules
) o moduli locali. È il modo più semplice per includere moduli che esistono in file separati. La funzionalità di base di require
è che legge un file JavaScript, esegue il file e quindi procede alla restituzione dell’oggetto exports
.
Il modulo principale
Quando un file viene eseguito direttamente da Node.js, require.main
sarà uguale a module
. Ciò significa che è possibile determinare se un file è stato eseguito direttamente testando require.main === module
. Per un file main.js
, questo sarà vero se eseguito dalla console con il comando node main.js
, ma falso se eseguito da require('./main')
.
// file main.js
console.log(require.main === module)
Se proviamo ad eseguire il seguente script:
$ node main.js
true
Se ora creiamo un altro file che include main.js
:
// file main2.js
require('./main')
Eseguendo questo script il risultato che otterremo sarà:
$ node main2.js
false
Poiché module fornisce una proprietà filename (normalmente equivalente a __filename
), il punto di ingresso dell’applicazione corrente può essere ottenuto controllando require.main.filename
.
La specifica ECMAScript
I moduli ECMAScript rappresentano lo standard ufficiale per impacchettare il codice JavaScript per il riutilizzo. I moduli vengono definiti utilizzando una varietà di istruzioni di importazione ed esportazione. Node.js tratta il codice JavaScript come moduli CommonJS per impostazione predefinita. Ma possiamo dire a Node.js di trattare il codice JavaScript come moduli ECMAScript tramite l’estensione del file .mjs
anziché js
, o la proprietà "type"
nel file package.json
.
Esportare funzionalità
La prima cosa da fare per accedere alle funzionalità del modulo è esportarle. Questo viene fatto utilizzando export
. Il modo più semplice per usarlo è posizionarlo davanti a tutti gli elementi che desideri esportare fuori dal modulo, ad esempio:
export const foo = 'foo'
export function baz () { ... }
export const draw = () => { ... }
export class Draw { ... }
Come potete vedere è possibile esportare variabili, funzioni e classi esattamente come la specifica CJS. Un modo più conveniente per esportare tutti gli elementi che si desidera esportare consiste nell’utilizzare una singola istruzione export
alla fine del modulo, seguita da un elenco separato da virgole delle funzionalità che si desidera esportare racchiuse tra parentesi graffe. Per esempio:
const foo = 'foo'
function baz () { ... }
const draw = () => { ... }
class Draw { ... }export { foo, baz, draw, Draw }
Questa tipologia di esportazione viene definita come esportazione nominale: ogni elemento (che si tratti di una funzione, const, ecc.) è stato indicato con il suo nome al momento dell’esportazione e quel nome, come vedremo di seguito, verrà utilizzato nel momento in cui effettueremo l’importazione.
Esiste anche un tipo di esportazione chiamato esportazione predefinita: è progettato per semplificare l’utilizzo di una funzione predefinita fornita da un modulo e aiuta anche i moduli JavaScript a interagire con i sistemi di moduli CommonJS. Torniamo alla classe Parallelogram
vista in precedenza e vediamo come esportarla utilizzando questa tecnica
class Parallelogram { ... }export default Parallelogram
oppure
export default class Parallelogram { ... }
Importare moduli
Ora che sappiamo come esportare funzionalità da un modulo, è necessario importarle nello script per poterle utilizzare. Il modo più semplice per farlo è il seguente:
import { foo, baz, draw, Draw } from './path/module.js
Un altro modo per importare tutte le funzionalità di un modulo è quella di utilizzare l’operatore * per poi accedere alle singole funzionalità con il punto (.):
import * as myModule from './path/module.js'myModule.foo
myModule.baz
const d = new myModule.Draw()
....
Proviamo ora ad importare il modulo Parallelogram
visto in precedenza. In questo caso è stato utilizzata un’esportazione predefinita, quindi importare il modulo diventa ancora più semplice:
import Parallelogram from './path/parallelogramModule.js'
Maggiori informazioni riguardo i moduli ECMAScript potete trovarle sulla documentazione ufficiale.
Ricreiamo il modulo geometry
Ora ricreiamo i moduli geometry
e calculator
utilizzando questo standard. Ecco il file geometry.mjs
// geometry module
const { PI, pow } = Mathexport const circleArea = r => PI * pow(r, 2)
export const circleCircumference = r => 2 * PI * r
export const squarePerimeter = l => l * 4
export const squareArea = l => pow(l, 2) * 6
export class Parallelogram {
constructor (b, h) {
this.b = b
this.h = h
} perimeter () {
return (this.b * 2) + (this.h * 2)
} area () {
return this.b * this.h
}
}
Mentre calculator.mjs
:
import {
Parallelogram,
circleArea,
squareArea,
circleCircumference
} from './geometry.mjs'// Creiamo un istanza della classe Parallelogram
const p = new Parallelogram(5, 4)
console.log(`Parallelogram area: ${p.area()}`)
console.log(`Parallelogram perimeter: ${p.perimeter()}`)// Calcolo dell'area di un quadrato
console.log(`Square area: ${squareArea(4)}`)// Calcolo della circonferenza e dell'area di un cerchio
console.log(`Circle circumference: ${circleCircumference(9)}`)
console.log(`Circle area: ${circleArea(9)}`)
Differenze tra ESM e CJS
La specifica ECMA Script ha cambiato moltissime cose in JavaScript. Per esempio i moduli ECMA Script utilizzano la modalità rigorosa per impostazione predefinita ('use strict'
), questo non si riferisce all’oggetto globale, l’ambito funziona in modo diverso, ecc. Ci sono anche altre differenze sostanziali tra i moduli ESM e CJS in Node.js. Le principali, a mio avviso, sono le seguenti:
- require, exports o module.exports: Nella maggior parte dei casi, l’importazione del modulo ES può essere utilizzata per caricare i moduli CommonJS. Se necessario, è possibile costruire una funzione require all’interno di un modulo ES utilizzando
module.createRequire()
. - __filename e __dirname: Queste due variabili non saranno disponibili nei modulie ES.
La lista completa delle differenze tra queste due specifiche potete trovarla sulla documentazione ufficiale di Node.js.
Sincrono o asincrono 🤔
Nella specifica CommonJS, require()
è sincrona; non restituisce una promessa o una callback. require()
legge dal disco (o dalla rete) ed esegue immediatamente lo script, che può eseguire a sua volta I/O o altro, e restituisce i valori esportati da module.exports
.
Nella specifica ECMA Script invece, il loader attraversa alcune fasi asincrone. Nella prima fase, analizza lo script per rilevare le funzioni importate ed esportate senza però eseguirne il codice. Nella fase di analisi, il loader ESM può rilevare immediatamente un errore di battitura nelle importazioni denominate e generare un’eccezione senza mai eseguire effettivamente il codice di dipendenza.
Successivamente il loader del modulo ESM scarica e analizza in modo asincrono tutti gli script importati finché alla fine non trova uno script che non importa nulla. Man mano che carica i moduli costruisce un “grafico delle dipendenze”. Tutti gli script “fratelli” nel grafico del modulo ES vengono scaricati in parallelo, ma vengono eseguiti in ordine, garantito dalle specifiche del caricatore.
Creare moduli che supportano CJS e ESM
Se gestite un modulo che deve supportare CJS ed ESM, fate un favore ai vostri utenti e seguite queste linee guida per creare un “modulo doppio” che funzioni alla grande in CJS ed ESM.
Per concludere
In questo articolo è stata data una panoramica generale sia ai moduli CJS che ESM ed alle principali differenze tra i due. Voi quale usate quando scrivete le vostre applicazioni Node.js? Perché? Sarebbe bello sapere cosa ne pensate! Nel frattempo:
HAPPY, CODING!