Come risolvere i problemi di dipendenza circolare una volta per tutte in JavaScript e TypeScript
-
index.js
richiedeAbstractNode.js
- Il caricatore del modulo inizia a caricare
AbstractNode.js
e ad eseguire il codice del modulo. La cosa che incontra per la prima volta è un’istruzione require (import) suLeaf
- Quindi il caricatore del modulo inizia a caricare il file
Leaf.js
. Che, a sua volta, inizia richiedendoAbstractnode.js
. -
AbstractNode.js
è già in fase di caricamento e viene immediatamente restituito dalla cache del modulo. Tuttavia, poiché quel modulo non è ancora stato eseguito oltre la prima riga (il require diLeaf
), le istruzioni che introducono la classeAbstractNode
non sono ancora state eseguite! - Quindi, la classe
Leaf
tenta di estendersi dal valoreundefined
, piuttosto che da una classe valida. Che genera l’eccezione di runtime mostrata sopra. BUM!
Fix tentativo 1
Quindi, si scopre che la nostra dipendenza circolare causa un brutto problema. Tuttavia, se guardiamo da vicino è abbastanza facile determinare quale dovrebbe essere l’ordine di caricamento:
- Carica la classe
AbstractNode
prima - Carica la classe
Node
eLeaf
dopo.
In altre parole, definiamo prima la classe AbstractNode
, quindi richiediamo Leaf
e Node
. Dovrebbe funzionare, perché Leaf
e Node
non devono ancora essere noti quando si definisce la classe AbstractNode
. Finché sono definiti prima che AbstractNode.from
venga chiamato per la prima volta, dovremmo andare bene. Quindi proviamo il seguente cambiamento:
Risulta, ci sono alcuni problemi con questa soluzione:
Innanzitutto, questo è brutto e non scala. In una grande base di codice,ciò comporterà lo spostamento delle importazioni in modo casuale fino a quando le cose non funzioneranno. Che è spesso solo temporaneo, poiché un piccolo refactoring o modifica delle istruzioni di importazione in futuro può regolare sottilmente l’ordine di caricamento del modulo, reintroducendo il problema.
In secondo luogo, se funziona dipende fortemente dal bundler del modulo. Ad esempio, in codesandbox, quando si raggruppa la nostra app con Parcel (o Webpack o Rollup), questa soluzione non funziona. Tuttavia, quando si esegue questo localmente con il Nodo.moduli js e commonJS questa soluzione potrebbe funzionare bene.
Evitando il problema
Quindi, a quanto pare, questo problema non può essere risolto facilmente. Poteva essere evitato? La risposta è sì, ci sono diversi modi per evitare il problema. Prima di tutto, avremmo potuto mantenere il codice in un unico file. Come mostrato nel nostro esempio iniziale, in questo modo possiamo risolvere il problema in quanto fornisce il pieno controllo sull’ordine in cui viene eseguito il codice di inizializzazione del modulo.
In secondo luogo, alcune persone useranno il problema sopra come argomento per fare affermazioni come “Non si dovrebbero usare le classi” o “Non usare l’ereditarietà”. Ma questa è una semplificazione eccessiva del problema. Anche se sono d’accordo sul fatto che i programmatori ricorrono spesso all’ereditarietà troppo rapidamente, per alcuni problemi è semplicemente perfetto e potrebbe produrre grandi benefici in termini di struttura del codice, riutilizzo o prestazioni. Ma soprattutto, questo problema non è limitato all’ereditarietà della classe. Esattamente lo stesso problema può essere introdotto quando si hanno dipendenze circolari tra le variabili del modulo e le funzioni che vengono eseguite durante l’inizializzazione del modulo!
Potremmo riorganizzare il nostro codice in modo tale da suddividere la classe AbstractNode
in pezzi più piccoli, in modo che AbstractNode
non abbia dipendenze da Node
o Leaf
. In questa sandbox il metodo from
è stato estratto dalla classe AbstractNode
e inserito in un file separato. Questo risolve il problema, ma ora il nostro progetto e l’API sono strutturati in modo diverso. Nei grandi progetti potrebbe essere molto difficile determinare come tirare fuori questo trucco, o addirittura impossibile! Immagina ad esempio cosa accadrebbe se il metodo print
dipendesse da Node
o Leaf
nella prossima iterazione della nostra app Bonus
Bonus: un brutto trucco aggiuntivo che ho usato prima: restituisce classi base dalle funzioni e leva la funzione di sollevamento per caricare le cose nel giusto ordine. Non sono nemmeno sicuro di come spiegarlo correttamente.
Il modello di modulo interno per il salvataggio!
Ho combattuto con questo problema in più occasioni in molti progetti Alcuni esempi includono il mio lavoro a Mendix, MobX, MobX-state-tree e diversi progetti personali. Ad un certo punto, alcuni anni fa ho persino scritto uno script per concatenare tutti i file sorgente e cancellare tutte le istruzioni di importazione. Un bundler di moduli poveri solo per ottenere una presa sull’ordine di caricamento del modulo.
Tuttavia, dopo aver risolto questo problema alcune volte, è apparso un modello. Uno che dà il pieno controllo sull’ordine di caricamento del modulo, senza dover ristrutturare il progetto o tirare strani hack! Questo modello funziona perfettamente con tutte le catene di strumenti su cui l’ho provato (Rollup, Webpack, Parcel, Node).
Il punto cruciale di questo modello è introdurre un file index.js
e internal.js
. Le regole del gioco sono le seguenti:
- Il modulo
internal.js
importa ed esporta tutto da ogni modulo locale nel progetto - Ogni altro modulo nel progetto importa solo dal file
internal.js
e mai direttamente da altri file nel progetto. - Il file
index.js
è il punto di ingresso principale e importa ed esporta tutto dainternal.js
che si desidera esporre al mondo esterno. Si noti che questo passaggio è rilevante solo se si sta pubblicando una libreria utilizzata da altri. Quindi abbiamo saltato questo passaggio nel nostro esempio.
Si noti che le regole di cui sopra si applicano solo alle nostre dipendenze locali. Le importazioni di moduli esterni vengono lasciate così come sono. Non sono coinvolti nei nostri problemi di dipendenza circolare, dopo tutto. Se applichiamo questa strategia alla nostra applicazione demo, il nostro codice sarà simile a questo:
Quando si applica questo modello per la prima volta, potrebbe sembrare molto artificioso. Ma ha alcuni vantaggi molto importanti!
- Prima di tutto, abbiamo risolto il nostro problema! Come dimostrato qui la nostra applicazione è felicemente in esecuzione di nuovo.
- La ragione per cui questo risolve il nostro problema è: ora abbiamo il pieno controllo sull’ordine di caricamento del modulo. Qualunque sia l’ordine di importazione in
internal.js
, sarà il nostro ordine di caricamento del modulo. (Potresti voler controllare l’immagine qui sotto, o rileggere la spiegazione dell’ordine del modulo sopra per vedere perché questo è il caso) - Non abbiamo bisogno di applicare refactoring che non vogliamo. Né siamo costretti a usare brutti trucchi, come spostare le istruzioni in fondo al file. Non dobbiamo compromettere l’architettura, l’API o la struttura semantica della nostra base di codice.
- Bonus: le istruzioni di importazione diventeranno molto più piccole, poiché importeremo materiale da meno file. Ad esempio
AbstractNode.js
ha solo l’istruzione import ora, dove ne aveva due prima. - Bonus: con
index.js
, abbiamo un’unica fonte di verità, dando un controllo a grana fine su ciò che esponiamo al mondo esterno.