Comment résoudre les problèmes de dépendance circulaires désagréables une fois pour toutes en JavaScript et en TypeScript
-
index.js
nécessiteAbstractNode.js
- Le chargeur de module commence à charger
AbstractNode.js
et à exécuter le code du module. La première chose qu’il rencontre est une instruction require (import) dansLeaf
- Afin que le chargeur de module commence à charger le fichier
Leaf.js
. Ce qui, à son tour, commence par exigerAbstractnode.js
. -
AbstractNode.js
est déjà en cours de chargement et est immédiatement renvoyé du cache du module. Cependant, comme ce module ne s’est pas encore exécuté au-delà de la première ligne (l’exigence deLeaf
), les instructions introduisant la classeAbstractNode
n’ont pas encore été exécutées ! - Ainsi, la classe
Leaf
essaie de s’étendre à partir de la valeurundefined
, plutôt qu’une classe valide. Ce qui lève l’exception d’exécution indiquée ci-dessus. BOUM !
Tentative de correction 1
Il s’avère donc que notre dépendance circulaire cause un problème désagréable. Cependant, si nous regardons de près, il est assez facile de déterminer quel devrait être l’ordre de chargement:
- Chargez d’abord la classe
AbstractNode
- Chargez ensuite les classes
Node
etLeaf
.
En d’autres termes, définissons d’abord la classe AbstractNode
, puis demandons-lui Leaf
et Node
. Cela devrait fonctionner, car Leaf
et Node
ne doivent pas encore être connus lors de la définition de la classe AbstractNode
. Tant qu’ils sont définis avant que AbstractNode.from
ne soit appelé pour la première fois, cela devrait aller. Essayons donc le changement suivant:
Il s’avère qu’il y a quelques problèmes avec cette solution:
Tout d’abord, c’est moche et n’évolue pas. Dans une grande base de code, cela entraînera le déplacement aléatoire des importations jusqu’à ce que les choses fonctionnent. Ce qui n’est souvent que temporaire, car un petit refactoring ou un changement dans les instructions d’importation à l’avenir peut ajuster subtilement l’ordre de chargement du module, réintroduisant le problème.
Deuxièmement, le fait que cela fonctionne dépend fortement du bundler de modules. Par exemple, dans codesandbox, lorsque vous regroupez notre application avec Parcel (ou Webpack ou Rollup), cette solution ne fonctionne pas. Cependant, lors de l’exécution locale de ce nœud.modules js et CommonJS cette solution de contournement peut très bien fonctionner.
Éviter le problème
Donc, apparemment, ce problème ne peut pas être résolu facilement. Cela aurait-il pu être évité? La réponse est oui, il existe plusieurs façons d’éviter le problème. Tout d’abord, nous aurions pu conserver le code dans un seul fichier. Comme indiqué dans notre exemple initial, de cette façon, nous pouvons résoudre le problème car il donne un contrôle total sur l’ordre dans lequel le code d’initialisation du module s’exécute.
Deuxièmement, certaines personnes utiliseront le problème ci-dessus comme argument pour faire des déclarations comme “On ne devrait pas utiliser de classes” ou “N’utilisez pas d’héritage”. Mais c’est une simplification excessive du problème. Bien que je convienne que les programmeurs ont souvent recours à l’héritage trop rapidement, pour certains problèmes, il est tout simplement parfait et pourrait générer de grands avantages en termes de structure de code, de réutilisation ou de performances. Mais le plus important, ce problème ne se limite pas à l’héritage de classe. Exactement le même problème peut être introduit lorsque des dépendances circulaires entre les variables de module et les fonctions qui s’exécutent lors de l’initialisation du module!
Nous pourrions réorganiser notre code de telle sorte que nous divisions la classe AbstractNode
en petits morceaux, de sorte que AbstractNode
n’ait aucune dépendance sur Node
ou Leaf
. Dans ce bac à sable, la méthode from
a été extraite de la classe AbstractNode
et placée dans un fichier séparé. Cela résout le problème, mais maintenant notre projet et notre API sont structurés différemment. Dans les grands projets, il peut être très difficile de déterminer comment réussir cette astuce, voire impossible! Imaginez par exemple ce qui se passerait si la méthode print
dépendait de Node
ou Leaf
lors de la prochaine itération de notre applicationBonus
Bonus: une astuce laide supplémentaire que j’ai utilisée auparavant: renvoyer des classes de base à partir de fonctions et tirer parti du levage de fonctions pour charger les choses dans le bon ordre. Je ne sais même pas comment l’expliquer correctement.
Le modèle de module interne à la rescousse!
Je me suis battu avec ce problème à plusieurs reprises sur de nombreux projets.Quelques exemples incluent mon travail chez Mendix, MobX, MobX-state-tree et plusieurs projets personnels. À un moment donné, il y a quelques années, j’ai même écrit un script pour concaténer tous les fichiers source et effacer toutes les instructions d’importation. Un bundler de modules pauvres juste pour avoir une prise sur l’ordre de chargement du module.
Cependant, après avoir résolu ce problème à quelques reprises, un motif est apparu. Celui qui donne un contrôle total sur l’ordre de chargement du module, sans avoir besoin de restructurer le projet ou de tirer des hacks bizarres! Ce modèle fonctionne parfaitement avec toutes les chaînes d’outils sur lesquelles je l’ai essayé (Rollup, Webpack, Parcel, Node).
Le nœud de ce modèle est d’introduire un fichier index.js
et internal.js
. Les règles du jeu sont les suivantes:
- Le module
internal.js
importe et exporte tout à partir de chaque module local du projet - Tous les autres modules du projet importent uniquement à partir du fichier
internal.js
, et jamais directement à partir d’autres fichiers du projet. - Le fichier
index.js
est le point d’entrée principal et importe et exporte tout ce que vous souhaitez exposer au monde extérieur depuisinternal.js
. Notez que cette étape n’est pertinente que si vous publiez une bibliothèque consommée par d’autres. Nous avons donc sauté cette étape dans notre exemple.
Notez que les règles ci-dessus ne s’appliquent qu’à nos dépendances locales. Les importations de modules externes sont laissées telles quelles. Ils ne sont pas impliqués dans nos problèmes de dépendance circulaire après tout. Si nous appliquons cette stratégie à notre application de démonstration, notre code ressemblera à ceci:
Lorsque vous appliquez ce modèle pour la première fois, cela peut sembler très artificiel. Mais il a quelques avantages très importants!
- Tout d’abord, nous avons résolu notre problème! Comme démontré ici, notre application fonctionne à nouveau avec plaisir.
- La raison pour laquelle cela résout notre problème est: nous avons maintenant un contrôle total sur l’ordre de chargement du module. Quel que soit l’ordre d’importation dans
internal.js
, sera notre ordre de chargement de module. (Vous voudrez peut-être vérifier l’image ci-dessous ou relire l’explication de l’ordre des modules ci-dessus pour voir pourquoi c’est le cas) - Nous n’avons pas besoin d’appliquer des refactorisations que nous ne voulons pas. Nous ne sommes pas non plus obligés d’utiliser des astuces laides, comme déplacer des instructions require au bas du fichier. Nous n’avons pas à compromettre l’architecture, l’API ou la structure sémantique de notre base de code.
- Bonus: les instructions d’importation deviendront beaucoup plus petites, car nous importerons des éléments à partir de moins de fichiers. Par exemple,
AbstractNode.js
n’a que l’instruction on import maintenant, où il en avait deux auparavant. - Bonus: avec
index.js
, nous avons une source unique de vérité, donnant un contrôle précis sur ce que nous exposons au monde extérieur.