Wie man fiese zirkuläre Abhängigkeitsprobleme ein für alle Mal in JavaScript & TypeScript behebt
-
index.js
benötigtAbstractNode.js
- Der Modullader beginnt mit dem Laden von
AbstractNode.js
und dem Ausführen des Modulcodes. Das, worauf es zuerst stößt, ist eine require (import) Anweisung anLeaf
- , sodass der Modullader beginnt, die
Leaf.js
Datei zu laden. Was wiederum mit der Anforderung vonAbstractnode.js
beginnt. -
AbstractNode.js
wird bereits geladen und sofort aus dem Modulcache zurückgegeben. Da dieses Modul jedoch noch nicht über die erste Zeile hinausgelaufen ist (die Anforderung vonLeaf
), wurden die Anweisungen zur Einführung der KlasseAbstractNode
noch nicht ausgeführt! - Die Klasse
Leaf
versucht also, vom Wertundefined
zu erweitern, anstatt von einer gültigen Klasse. Was die oben gezeigte Laufzeitausnahme auslöst. BUMM!
Fix Versuch 1
Es stellt sich also heraus, dass unsere zirkuläre Abhängigkeit ein unangenehmes Problem verursacht. Wenn wir jedoch genau hinschauen, ist es ziemlich einfach zu bestimmen, wie die Ladereihenfolge aussehen soll:
- Laden Sie zuerst die
AbstractNode
Klasse - Laden Sie danach die
Node
undLeaf
Klasse.
Mit anderen Worten, definieren wir zuerst die AbstractNode
-Klasse und lassen Sie sie dann Leaf
und Node
benötigen. Das sollte funktionieren, da Leaf
und Node
bei der Definition der Klasse AbstractNode
noch nicht bekannt sein müssen. Solange sie definiert sind, bevor AbstractNode.from
zum ersten Mal aufgerufen wird, sollten wir in Ordnung sein. Versuchen wir also die folgende Änderung:
Es stellt sich heraus, dass es einige Probleme mit dieser Lösung gibt:
Erstens ist dies hässlich und skaliert nicht. In einer großen Codebasis führt dies dazu, dass Importe zufällig verschoben werden, bis Dinge einfach funktionieren. Was oft nur vorübergehend ist, da ein kleines Refactoring oder eine Änderung der Importanweisungen in der Zukunft die Reihenfolge des Modulladens subtil anpassen und das Problem wieder einführen kann.
Zweitens hängt es stark vom Modulbündler ab, ob dies funktioniert. In Codesandbox funktioniert diese Lösung beispielsweise nicht, wenn Sie unsere App mit Parcel (oder Webpack oder Rollup) bündeln. Wenn Sie dies jedoch lokal mit Node ausführen.js- und CommonJS-Module Diese Problemumgehung funktioniert möglicherweise einwandfrei.
Vermeidung des Problems
Daher kann dieses Problem anscheinend nicht einfach behoben werden. Hätte es vermieden werden können? Die Antwort ist ja, es gibt mehrere Möglichkeiten, das Problem zu vermeiden. Zunächst hätten wir den Code in einer einzigen Datei aufbewahren können. Wie in unserem ersten Beispiel gezeigt, können wir das Problem auf diese Weise lösen, da es die vollständige Kontrolle über die Reihenfolge gibt, in der der Modulinitialisierungscode ausgeführt wird.
Zweitens werden einige Leute das obige Problem als Argument verwenden, um Aussagen wie “Man sollte keine Klassen verwenden” oder “Keine Vererbung verwenden” zu machen. Aber das ist eine übermäßige Vereinfachung des Problems. Obwohl ich zustimme, dass Programmierer oft zu schnell auf Vererbung zurückgreifen, ist es für einige Probleme einfach perfekt und kann große Vorteile in Bezug auf Codestruktur, Wiederverwendung oder Leistung bringen. Am wichtigsten ist jedoch, dass dieses Problem nicht auf die Klassenvererbung beschränkt ist. Genau das gleiche Problem kann auftreten, wenn zirkuläre Abhängigkeiten zwischen Modulvariablen und Funktionen auftreten, die während der Modulinitialisierung ausgeführt werden!
Wir könnten unseren Code so neu organisieren, dass wir die AbstractNode
-Klasse in kleinere Teile aufteilen, so dass AbstractNode
keine Abhängigkeiten von Node
oder Leaf
hat. In dieser Sandbox wurde die from
-Methode aus der AbstractNode
-Klasse gezogen und in eine separate Datei eingefügt. Dies löst das Problem, aber jetzt sind unser Projekt und unsere API anders strukturiert. In großen Projekten kann es sehr schwierig sein zu bestimmen, wie dieser Trick ausgeführt werden soll, oder sogar unmöglich! Stellen Sie sich zum Beispiel vor, was passieren würde, wenn die print
-Methode in der nächsten Iteration unserer App von Node
oder Leaf
abhängen würde …
Bonus: Ein zusätzlicher hässlicher Trick, den ich zuvor verwendet habe: Basisklassen von Funktionen zurückgeben und Funktionshebevorgänge nutzen, um die Dinge in der richtigen Reihenfolge zu laden. Ich bin mir nicht einmal sicher, wie ich es richtig erklären soll.
Das interne Modulmuster zur Rettung!
Ich habe mehrfach mit diesem Problem in vielen Projekten gekämpft, einige Beispiele sind meine Arbeit bei Mendix, MobX, MobX-state-tree und mehrere persönliche Projekte. Irgendwann habe ich vor ein paar Jahren sogar ein Skript geschrieben, um alle Quelldateien zu verketten und alle Importanweisungen zu löschen. Ein schlechter Modulbündler, nur um die Modulladereihenfolge in den Griff zu bekommen.
Nachdem dieses Problem einige Male gelöst wurde, trat jedoch ein Muster auf. Eine, die die volle Kontrolle über die Modulladereihenfolge gibt, ohne das Projekt umstrukturieren oder seltsame Hacks ziehen zu müssen! Dieses Muster funktioniert perfekt mit allen Toolketten, an denen ich es ausprobiert habe (Rollup, Webpack, Parcel, Node).
Der Kern dieses Musters besteht darin, eine index.js
und internal.js
Datei einzuführen. Die Spielregeln lauten wie folgt:
- Das
internal.js
-Modul importiert und exportiert alles aus jedem lokalen Modul im Projekt - Jedes andere Modul im Projekt importiert nur aus der
internal.js
-Datei und niemals direkt aus anderen Dateien im Projekt. - Die
index.js
-Datei ist der Haupteinstiegspunkt und importiert und exportiert alles voninternal.js
, was Sie der Außenwelt zugänglich machen möchten. Beachten Sie, dass dieser Schritt nur relevant ist, wenn Sie eine Bibliothek veröffentlichen, die von anderen verwendet wird. Also haben wir diesen Schritt in unserem Beispiel übersprungen.
Beachten Sie, dass die obigen Regeln nur für unsere lokalen Abhängigkeiten gelten. Externe Modulimporte bleiben unverändert. Sie sind schließlich nicht an unseren zirkulären Abhängigkeitsproblemen beteiligt. Wenn wir diese Strategie auf unsere Demo-Anwendung anwenden, sieht unser Code folgendermaßen aus:
Wenn Sie dieses Muster zum ersten Mal anwenden, kann es sich sehr künstlich anfühlen. Aber es hat ein paar sehr wichtige Vorteile!
- Zunächst haben wir unser Problem gelöst! Wie hier gezeigt, läuft unsere App wieder glücklich.
- Der Grund, warum dies unser Problem löst, ist: Wir haben jetzt die volle Kontrolle über die Reihenfolge des Modulladens. Was auch immer die Importreihenfolge in
internal.js
ist, wird unsere Modulladereihenfolge sein. (Vielleicht möchten Sie das Bild unten überprüfen oder die obige Erklärung zur Modulreihenfolge erneut lesen, um zu sehen, warum dies der Fall ist.) - Wir müssen keine Refactorings anwenden, die wir nicht möchten. Wir sind auch nicht gezwungen, hässliche Tricks anzuwenden, wie das Verschieben von require-Anweisungen an den unteren Rand der Datei. Wir müssen keine Kompromisse bei der Architektur, API oder semantischen Struktur unserer Codebasis eingehen.
- Bonus: Importanweisungen werden viel kleiner, da wir Dinge aus less-Dateien importieren. Zum Beispiel hat
AbstractNode.js
jetzt nur noch eine Importanweisung, wo es zuvor zwei hatte. - Prämie: mit
index.js
haben wir eine einzige Quelle der Wahrheit, die eine feinkörnige Kontrolle darüber gibt, was wir der Außenwelt aussetzen.