Comment résoudre les problèmes de dépendance circulaires désagréables une fois pour toutes en JavaScript et en TypeScript

  1. index.js nécessite AbstractNode.js
  2. 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) dans Leaf
  3. Afin que le chargeur de module commence à charger le fichier Leaf.js. Ce qui, à son tour, commence par exiger Abstractnode.js.
  4. 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 de Leaf), les instructions introduisant la classe AbstractNode n’ont pas encore été exécutées !
  5. Ainsi, la classe Leaf essaie de s’étendre à partir de la valeur undefined, 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:

  1. Chargez d’abord la classe AbstractNode
  2. Chargez ensuite les classes Node et Leaf.

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:

  1. Le module internal.js importe et exporte tout à partir de chaque module local du projet
  2. Tous les autres modules du projet importent uniquement à partir du fichier internal.js, et jamais directement à partir d’autres fichiers du projet.
  3. 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 depuis internal.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!

  1. Tout d’abord, nous avons résolu notre problème! Comme démontré ici, notre application fonctionne à nouveau avec plaisir.
  2. 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)
  3. 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.
  4. 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.
  5. 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.

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée.