Introduzione
Quando un progetto JavaScript cresce, è normale suddividere il codice in più moduli. Ogni file può esportare funzioni, classi o costanti e importare ciò che gli serve dagli altri file. Questa organizzazione migliora la leggibilità e rende il codice più riutilizzabile.
Esiste però un problema poco evidente: la dipendenza circolare. Si verifica quando un modulo importa un secondo modulo e, direttamente o indirettamente, il secondo modulo importa nuovamente il primo. Per esempio, utente.js importa una funzione da ordine.js, mentre ordine.js importa dati da utente.js.
I moduli ES, chiamati anche ES Modules o ESM, riescono spesso a gestire questo scenario grazie ai live binding, cioè collegamenti dinamici alle variabili esportate. Tuttavia, se un valore viene utilizzato prima di essere inizializzato, può verificarsi un errore come ReferenceError: Cannot access ... before initialization.
In questo tutorial vedremo come riconoscere le dipendenze circolari, come progettare i moduli per evitarle e come estrarre la logica condivisa in un modulo indipendente.
Codice completo
Immaginiamo una piccola applicazione che gestisce utenti e ordini. Una progettazione problematica potrebbe essere la seguente:
// utente.js
import { calcolaTotaleOrdini } from ´./ordine.js´;
export const utenteCorrente = {
id: 1,
nome: ´Giulia´
};
export function mostraRiepilogoUtente() {
const totale = calcolaTotaleOrdini(utenteCorrente.id);
return `${utenteCorrente.nome} ha speso ${totale} euro`;
}
// ordine.js
import { utenteCorrente } from ´./utente.js´;
const ordini = [
{ id: 101, utenteId: 1, totale: 35 },
{ id: 102, utenteId: 1, totale: 20 },
{ id: 103, utenteId: 2, totale: 50 }
];
export function calcolaTotaleOrdini(utenteId = utenteCorrente.id) {
return ordini
.filter((ordine) => ordine.utenteId === utenteId)
.reduce((somma, ordine) => somma + ordine.totale, 0);
}
// app.js
import { mostraRiepilogoUtente } from ´./utente.js´;
console.log(mostraRiepilogoUtente());
// Giulia ha speso 55 euro
Il problema può essere risolto eliminando l’importazione di utente.js da ordine.js. Il modulo degli ordini non dovrebbe conoscere l’utente corrente: è sufficiente ricevere l’identificativo come parametro.
// ordine.js
const ordini = [
{ id: 101, utenteId: 1, totale: 35 },
{ id: 102, utenteId: 1, totale: 20 },
{ id: 103, utenteId: 2, totale: 50 }
];
// Il modulo dipende solo dal parametro ricevuto.
export function calcolaTotaleOrdini(utenteId) {
return ordini
.filter((ordine) => ordine.utenteId === utenteId)
.reduce((somma, ordine) => somma + ordine.totale, 0);
}
// utente.js
import { calcolaTotaleOrdini } from ´./ordine.js´;
export const utenteCorrente = {
id: 1,
nome: ´Giulia´
};
export function mostraRiepilogoUtente() {
const totale = calcolaTotaleOrdini(utenteCorrente.id);
return `${utenteCorrente.nome} ha speso ${totale} euro`;
}
// app.js
import { mostraRiepilogoUtente } from ´./utente.js´;
console.log(mostraRiepilogoUtente());
// Giulia ha speso 55 euro
Spiegazione
Come nasce una dipendenza circolare
Nel primo esempio, utente.js importa calcolaTotaleOrdini da ordine.js. Allo stesso tempo, ordine.js importa utenteCorrente da utente.js. La sequenza forma un ciclo:
app.jsimportautente.js;utente.jsimportaordine.js;ordine.jsimporta nuovamenteutente.js.
Gli import ES vengono risolti prima dell’esecuzione del codice. I moduli vengono caricati e inizializzati secondo una precisa relazione di dipendenze. Se una funzione viene chiamata soltanto dopo l’inizializzazione di tutti i moduli, il ciclo potrebbe sembrare funzionare. Tuttavia, basta accedere troppo presto a una costante o a una classe per ottenere un errore.
La soluzione: dipendenze esplicite
La funzione calcolaTotaleOrdini ha bisogno soltanto dell’identificativo dell’utente. È quindi più corretto ricevere questo dato tramite parametro, invece di importarlo da un altro modulo.
Questa tecnica segue il principio delle dipendenze esplicite: ogni funzione dichiara chiaramente ciò di cui ha bisogno. Il risultato è un modulo più semplice da testare, più riutilizzabile e meno collegato al resto dell’applicazione.
Quando usare un modulo condiviso
Se due moduli devono davvero utilizzare gli stessi dati o le stesse funzioni, è preferibile creare un terzo modulo indipendente. Per esempio, un file modello.js può contenere le strutture comuni senza importare né utenti né ordini.
// modello.js
export function creaOrdine(utenteId, totale) {
if (!Number.isInteger(utenteId)) {
throw new TypeError(´utenteId deve essere un numero intero´);
}
if (totale < 0) {
throw new RangeError(´Il totale non può essere negativo´);
}
return {
utenteId,
totale
};
}
// ordine.js
import { creaOrdine } from ´./modello.js´;
const ordini = [
creaOrdine(1, 35),
creaOrdine(1, 20),
creaOrdine(2, 50)
];
export function calcolaTotaleOrdini(utenteId) {
return ordini
.filter((ordine) => ordine.utenteId === utenteId)
.reduce((somma, ordine) => somma + ordine.totale, 0);
}
In questo modo, modello.js non dipende da nessun altro modulo applicativo. La struttura delle dipendenze diventa più semplice e lineare.
Best practice
- Passa i dati tramite parametri: evita che una funzione legga direttamente lo stato interno di un altro modulo quando può riceverlo esplicitamente.
- Mantieni i moduli focalizzati: un modulo dovrebbe avere una responsabilità principale, come gestire ordini, validare dati o formattare testi.
- Separa il dominio dallo stato: le funzioni di calcolo dovrebbero preferibilmente ricevere dati e restituire risultati, senza dipendere da variabili globali o dallo stato corrente dell’interfaccia.
- Crea moduli condivisi neutrali: tipi, validatori e funzioni pure possono essere spostati in file che non importano i moduli applicativi.
- Evita chiamate durante l’inizializzazione: eseguire subito una funzione importata può esporre più facilmente problemi di ordine di inizializzazione.
- Controlla la direzione delle dipendenze: i moduli di basso livello non dovrebbero importare quelli che rappresentano livelli superiori dell’applicazione.
- Usa strumenti di analisi: strumenti come ESLint e i bundler possono segnalare cicli tra moduli prima che arrivino in produzione.
Riepilogo
Le dipendenze circolari si verificano quando due o più moduli si importano reciprocamente. Anche se gli ES Modules possono gestire alcuni cicli, il codice rimane fragile e può fallire a causa dell’accesso a valori non ancora inizializzati.
La strategia più efficace consiste nel rendere le dipendenze esplicite, passare i dati tramite parametri e spostare la logica condivisa in moduli indipendenti. Una struttura con dipendenze che procedono in una sola direzione è più facile da comprendere, testare e mantenere.
