Due sistemi di moduli, un solo linguaggio
In origine JavaScript non aveva alcun sistema di moduli. Node ha colmato il vuoto nel 2009 con CommonJS (require, module.exports), e per anni il codice Node è stato così. Poi nel 2015 il linguaggio stesso ha introdotto un sistema di moduli standard, gli ES Modules (import, export), che oggi supportano sia i browser sia Node.
Quindi in giro li vedrai entrambi. Ecco lo stesso piccolo modulo scritto nei due modi:
Stessa funzione, due involucri diversi. Il resto della pagina spiega quando l'involucro conta e quale scegliere.
Le differenze di sintassi
Le differenze di tutti i giorni stanno su una cartolina:
// CommonJS
const fs = require("fs");
const { readFile } = require("fs/promises");
module.exports = something;
module.exports.name = value;
exports.name = value;
// ES Modules
import fs from "fs";
import { readFile } from "fs/promises";
export default something;
export const name = value;
export { name };
require è una normale chiamata di funzione. import è un'istruzione: può comparire solo al livello principale di un modulo e il percorso deve essere una stringa letterale. Questa restrizione non è arbitraria: è ciò che permette a ESM di fare cose che CommonJS non può fare.
La vera differenza: statico e dinamico
CommonJS valuta require() quando viene eseguita la riga. Puoi metterlo dentro un if, calcolare il percorso a runtime, caricare un modulo in modo condizionale:
Gli ES Modules sono statici. Il motore analizza tutte le istruzioni import prima di eseguire qualsiasi codice, costruisce un grafo delle dipendenze e risolve tutto in anticipo. Ecco perché il percorso deve essere una stringa letterale e perché import compare solo al livello principale.
Il vantaggio: gli strumenti possono vedere l'intero grafo dei moduli senza eseguire nulla. È così che i bundler fanno il tree-shaking (eliminando gli export non usati), che gli editor ti danno un completamento automatico preciso e che il browser può scaricare i moduli in parallelo.
Quando ti serve davvero un caricamento dinamico in ESM, usa import(), un'espressione simile a una funzione che restituisce una Promise:
Come Node decide quale sistema usa un file
Un singolo progetto Node può contenere entrambi i tipi di file. Node capisce quale sistema usa ciascun file guardando due cose:
- L'estensione del file:
.mjsè sempre ESM,.cjsè sempre CommonJS. - Il campo
"type"delpackage.jsonpiù vicino:"module"significa che i file.jssono ESM,"commonjs"(il valore predefinito se manca) significa che sono CJS.
// package.json
{
"name": "my-app",
"type": "module"
}
Con "type": "module", un semplice hello.js nello stesso pacchetto usa import/export. Metti un hello.cjs accanto e quel singolo file usa require. È così che un progetto può migrare gradualmente, o che una libreria può distribuire entrambe le versioni fianco a fianco.
Tranello per chi inizia: in un file ESM, require e module.exports semplicemente non esistono. Se li usi per abitudine otterrai un ReferenceError.
Interoperabilità: mescolare i due sistemi
Capiterà spesso che un file ESM debba usare un pacchetto CommonJS, o viceversa. Le regole non sono simmetriche.
ESM che importa CommonJS funziona direttamente. L'oggetto module.exports di CJS diventa l'export predefinito:
// app.mjs
import greet from "./greet.cjs";
console.log(greet("Rosa"));
Gli import con nome da CommonJS a volte funzionano (Node prova a rilevare gli export con nome in modo statico), ma per andare sul sicuro prendi l'export predefinito e destrutturalo:
import pkg from "./utils.cjs";
const { parse, stringify } = pkg;
CommonJS che importa ESM è la direzione dolorosa. Non puoi fare require() di un ES module: genera l'errore ERR_REQUIRE_ESM. La via d'uscita è l'import() dinamico, che funziona anche in CJS e restituisce una Promise:
Il Node moderno (22+) ha aggiunto un require() sincrono per ESM in certe condizioni, ma l'import() dinamico resta la risposta portabile.
Altre differenze di comportamento da conoscere
Oltre alla sintassi, i due sistemi non concordano su alcuni dettagli che ogni tanto fanno male:
Qualche altra:
thisal livello principale. In CJS,thisèmodule.exports. In ESM èundefined. ESM è sempre in strict mode.__dirnamee__filename. CJS te li dà gratis. In ESM li ricavi daimport.meta.url:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
- Estensioni dei file negli import. ESM richiede l'estensione (
"./utils.js", non"./utils") per i percorsi relativi. CJS è più tollerante. - Binding vivi o copie. Gli import ESM sono riferimenti vivi alle variabili del modulo che le esporta. CJS ti dà una copia di ciò che era assegnato a
module.exportsal momento del caricamento. La maggior parte del codice non se ne accorge mai, ma conta con le dipendenze circolari.
Quale dei due usare?
Per un nuovo progetto: ES Modules. Imposta "type": "module" in package.json e non voltarti indietro. ESM è lo standard del linguaggio, funziona allo stesso modo nei browser e in Node, supporta il top-level await e gli strumenti sono costruiti pensando a lui.
Resta su CommonJS quando:
- Mantieni una codebase CJS esistente e la migrazione non vale ancora la pena.
- Pubblichi una libreria che deve supportare versioni di Node molto vecchie o utenti che non possono usare ESM.
- Una dipendenza fondamentale distribuisce solo CJS e la sua interoperabilità è complicata. (Oggi è raro, ma succede ancora.)
Anche in quei casi leggerai continuamente codice ESM: tutto ciò che è stato pubblicato su npm negli ultimi anni va in quella direzione. Saperli leggere entrambi non è facoltativo; conoscere bene gli idiomi di quello che stai davvero scrivendo, sì, è la parte su cui concentrarti.
Una checklist mentale veloce
Quando apri un nuovo file, chiediti:
- Questo file usa
import/exportoppurerequire/module.exports? Non mescolarli. - Cosa dice il
package.jsonpiù vicino riguardo a"type"? - Se importi un pacchetto, controlla il suo
package.json: distribuisce ESM, CJS o entrambi? - Se incontri
ERR_REQUIRE_ESM, sei in CJS e stai cercando di caricare ESM. Passa all'import()dinamico o sposta il chiamante su ESM.
Il novanta per cento della confusione sui moduli in Node rientra in uno di questi quattro casi.
Prossimo passo: le basi di npm
I moduli servono a suddividere il tuo codice in più file. Il passo successivo è usare codice scritto da altri: è a questo che serve npm. Vedremo come installare i pacchetti, gli intervalli semver e le parti del flusso di lavoro di npm che usi davvero ogni giorno.
Domande frequenti
Che differenza c'è tra require e import in JavaScript?
require è il modo di CommonJS per caricare i moduli: è sincrono, viene eseguito nel punto in cui lo chiami e restituisce ciò che il modulo ha assegnato a module.exports. import è la sintassi degli ES Modules: è statico, viene portato in cima al file e analizzato prima che il codice venga eseguito. I due sistemi non concordano nemmeno su cosa sia this, su come si risolvono le dipendenze circolari e sul fatto che sia ammesso il top-level await.
In un nuovo progetto Node conviene usare CommonJS o ES Modules?
Usa gli ES Modules. Imposta "type": "module" in package.json e scrivi import/export. ESM è lo standard ufficiale, funziona nei browser e in Node e supporta il top-level await. CommonJS compare ancora nei pacchetti e negli strumenti più vecchi, quindi lo leggerai anche se non lo scrivi.
Posso mescolare require e import nello stesso progetto?
Sì, con delle regole. Un file .mjs o un pacchetto con "type": "module" usa ESM; un .cjs o "type": "commonjs" usa CJS. ESM può fare import di un modulo CommonJS (il module.exports diventa l'export predefinito). CommonJS invece non può fare require() di un modulo ESM direttamente: devi usare l'import() dinamico, che restituisce una Promise.