Menu

ES Modules in JavaScript: import, export e caricamento dinamico

Come funzionano gli ES Modules in JavaScript: export con nome e default, sintassi di import, import() dinamico e le regole che distinguono i moduli dagli script classici.

Questa pagina include editor eseguibili: modifica, esegui e vedi subito l'output.

Un modulo è un file con il suo scope

Prima che esistessero gli ES Modules, ogni tag <script> riversava le sue variabili nel namespace globale, e l'ordine di caricamento decideva chi vedeva cosa. Gli ES Modules risolvono il problema dando a ogni file il proprio scope. Niente esce se non fai esplicitamente export. Niente entra se non fai esplicitamente import.

Due file, uno che esporta e uno che importa:

add e multiply vivono in math.js. Diventano visibili in main.js solo grazie all'import. Nient'altro in math.js, che siano helper, costanti o qualsiasi altra cosa, è raggiungibile dall'esterno.

Da qui derivano due regole che conviene assimilare presto:

  • I moduli girano automaticamente in strict mode. Non serve 'use strict'.
  • Il this di primo livello è undefined, non l'oggetto globale.

Export con nome: esporta man mano

È la forma più comune. Metti export davanti a qualsiasi function, class, const o let e diventa parte dell'interfaccia pubblica del modulo:

I nomi tra graffe devono corrispondere esattamente ai nomi esportati: import { circlearea } fallirebbe. Se un nome è in conflitto con qualcosa che hai già, rinominalo in fase di import con as:

Puoi anche elencare gli export in fondo al file invece che in linea, cosa che alcuni preferiscono per avere una sezione "API pubblica" ben visibile:

Entrambi gli stili danno lo stesso risultato. Scegline uno e resta coerente all'interno di un progetto.

Export default: uno per modulo

Un modulo può anche avere un singolo export default. I default sono pensati per i file che hanno davvero una cosa principale: un componente, una classe, un oggetto di configurazione:

Tre cose da notare:

  • Niente graffe attorno a log nell'import.
  • Il nome importato è quello che vuoi tu. import shout from './logger.js' funzionerebbe allo stesso modo.
  • Un solo export default per file. Se provi ad aggiungerne un secondo, il file non verrà interpretato.

Export con nome e default possono convivere:

Prima il default, poi quelli con nome tra graffe. L'ordine è fisso.

Quale usare? Gli export con nome sono più facili da rifattorizzare: rinominare in tutta la codebase è un semplice trova e sostituisci, perché ogni import usa lo stesso nome. I default sono flessibili, ma lasciano a ogni chiamante la libertà di scegliere un nome diverso, e questo rende più difficili le ricerche con grep. La maggior parte delle guide di stile moderne preferisce gli export con nome e riserva il default ai moduli con un unico scopo reale.

Importare tutto, riesportare, effetti collaterali

Altre forme di import che incontrerai.

Raccogliere tutti gli export con nome in un unico oggetto namespace:

Riesportare da un altro modulo senza importare nello scope corrente:

Con questo pattern i punti di ingresso delle librerie raccolgono i pezzi dai file interni in un'unica interfaccia pubblica.

E infine, un import senza alcun binding, per i moduli il cui unico compito sono gli effetti collaterali (polyfill, CSS-in-JS, registrazione di handler):

Il file viene eseguito una volta; non viene importato niente per nome.

Gli import sono statici e live

Due proprietà di import che ogni tanto sorprendono.

Statici. Le dichiarazioni import vengono risolte prima che venga eseguita qualsiasi parte del tuo codice. Non puoi metterne una dentro un if, una funzione o un try. Il percorso deve essere una stringa letterale, non una variabile. È questo che permette agli strumenti di analizzare gli import senza eseguire il codice: bundler, type checker e tree-shaker si basano tutti su questo.

// Not allowed - SyntaxError.
if (userWantsFancy) {
  import { fancy } from './fancy.js';
}

Se ti serve un caricamento condizionale, usa import() (lo vediamo tra poco).

Live. Un binding importato è un riferimento in sola lettura all'export, non una copia istantanea. Se il modulo che esporta riassegna il valore, chi importa vede il nuovo valore:

Inoltre non puoi riassegnare un import dal lato di chi lo usa: count = 5 in main.js lancerebbe un errore. Gli import sono viste in sola lettura.

import() dinamico per caricare su richiesta

Quando devi decidere a runtime se caricare un modulo (funzionalità pesanti, code splitting per route, polyfill condizionali), usa import() come funzione. Restituisce una promise che si risolve con gli export del modulo:

Visto che è una normale chiamata di funzione, puoi:

  • Fare await dentro una funzione async.
  • Passare una variabile come percorso.
  • Usarla dentro un if o un try/catch.

Il destructuring dell'oggetto risolto funziona come con un import statico:

Quando fai destructuring, default è la chiave dell'export default. Rinominala come preferisci.

I casi d'uso pratici sono il code-splitting (scaricare la libreria dei grafici solo quando l'utente clicca "mostra grafico"), i polyfill basati sul feature detection e i plugin scoperti a runtime.

Eseguire gli ES Modules: browser e Node

La sintassi è la stessa ovunque; quello che cambia è come il runtime trova e carica il file.

Nel browser, marca lo script di ingresso come modulo:

<script type="module" src="./main.js"></script>

Con type="module", il browser rispetta import/export, esegue il codice in strict mode e rimanda l'esecuzione a dopo il parsing dell'HTML. I percorsi devono essere relativi (./, ../) o URL assoluti: gli specificatori nudi come import 'lodash' non funzionano senza una import map o un bundler.

In Node, ci sono due modi per attivarli:

  • Dare al file l'estensione .mjs, oppure
  • Impostare "type": "module" nel package.json più vicino, così ogni file .js diventa un modulo.

Node richiede anche percorsi completi con estensione: import './utils.js', non import './utils'.

// package.json
{
  "type": "module",
  "main": "./index.js"
}

Entrambi gli ambienti richiedono estensioni esplicite nell'ESM nativo. I bundler (Vite, webpack, esbuild) risolvono per te i percorsi senza estensione durante lo sviluppo: comodo, ma affidarti a questo significa che il tuo sorgente non girerà senza la fase di build.

Errori comuni

Alcune cose che mettono in difficoltà:

  • Dimenticare type="module" nel browser. Senza, <script> viene eseguito come script classico e import è un errore di sintassi.
  • Estensioni mancanti in Node. import './utils' fallisce; import './utils.js' funziona. I bundler lo nascondono, i runtime nativi no.
  • Aspettarsi __dirname o require in un ES Module. Esistono solo in CommonJS. In ESM, usa import.meta.url e convertilo quando ti serve un percorso.
  • Import circolari che toccano valori non ancora pronti. Due moduli che si importano a vicenda sono permessi, ma leggere un export non ancora assegnato ti restituisce undefined. Struttura il codice in modo che il ciclo non venga attraversato durante l'inizializzazione, oppure spezzalo.
  • Provare a fare import in modo condizionale. L'istruzione statica import non lo consente. Usa l'import() dinamico per tutto ciò che dipende dal runtime.

Prossimo passo: CommonJS vs ESM

Gli ES Modules sono lo standard, ma molto codice Node in circolazione usa ancora CommonJS: require, module.exports e un insieme diverso di regole su quando viene eseguito il codice. Conoscerli entrambi, e sapere come interagiscono, è il tema della prossima pagina.

Domande frequenti

Come si usano gli ES Modules in JavaScript?

Esporta i valori da un file con export o export default, poi importali in un altro file con import. Nel browser, carica il file di ingresso con <script type="module" src="main.js"></script>. In Node, usa l'estensione .mjs oppure imposta "type": "module" nel package.json.

Che differenza c'è tra export default ed export con nome?

Un modulo può avere tanti export con nome (export function foo() {}) ma un solo export default (export default ...). Gli export con nome vanno importati con lo stesso identico nome tra graffe: import { foo } from './x.js'. Un export default si può importare con qualsiasi nome: import whatever from './x.js'.

Cos'è l'import() dinamico in JavaScript?

import() chiamato come funzione restituisce una promise che si risolve con gli export del modulo. A differenza dell'istruzione statica import, viene eseguito al momento della chiamata, quindi puoi caricare codice in modo condizionale o su richiesta. È così che si implementano il code-splitting e il lazy loading.

Serve l'estensione del file nei percorsi di import?

Negli ES Modules nativi, cioè nei browser e nel loader ESM di Node, sì. Devi scrivere ./utils.js, non ./utils. Bundler come Vite e webpack sono più permissivi e risolvono per te i percorsi senza estensione, ma affidarti a questo rende il tuo codice non portabile.

Illustrazione dei linguaggi di programmazione di Coddy

Impara a programmare con Coddy

INIZIA