Cómo solucionar problemas de dependencia circulares desagradables de una vez por todas en JavaScript y TypeScript
-
index.js
requiereAbstractNode.js
- El cargador de módulos comienza a cargar
AbstractNode.js
y a ejecutar el código del módulo. Lo primero que encuentra es una instrucción require (import) aLeaf
- Para que el cargador de módulos comience a cargar el archivo
Leaf.js
. Que, a su vez, comienza por requerirAbstractnode.js
. -
AbstractNode.js
ya se está cargando y se devuelve inmediatamente desde la caché del módulo. Sin embargo, dado que ese módulo no se ejecutó más allá de la primera línea todavía (el requisito deLeaf
), ¡las instrucciones que introducen la claseAbstractNode
aún no se han ejecutado! - Por lo tanto, la clase
Leaf
intenta extenderse desde el valorundefined
, en lugar de una clase válida. Que lanza la excepción de tiempo de ejecución que se muestra arriba. BOOM!
Intento de corrección 1
Por lo tanto, resulta que nuestra dependencia circular causa un problema desagradable. Sin embargo, si miramos de cerca, es bastante fácil determinar cuál debe ser el orden de carga:
- Cargue la clase
AbstractNode
primero - Cargue la clase
Node
yLeaf
después de eso.
En otras palabras, definamos primero la clase AbstractNode
y luego hagamos que requiera Leaf
y Node
. Eso debería funcionar, porque Leaf
y Node
no tienen que conocerse aún al definir la clase AbstractNode
. Siempre y cuando se definan antes de que AbstractNode.from
se llame por primera vez, deberíamos estar bien. Así que probemos el siguiente cambio:
Resulta que hay algunos problemas con esta solución:
Primero, esto es feo y no escala. En una base de código grande, esto dará lugar a que las importaciones se muevan aleatoriamente hasta que las cosas simplemente funcionen. Lo que a menudo es solo temporal, ya que una pequeña refactorización o cambio en las instrucciones de importación en el futuro puede ajustar sutilmente el orden de carga del módulo, reintroduciendo el problema.
En segundo lugar, si esto funciona depende en gran medida del paquete de módulos. Por ejemplo, en codesandbox, al agrupar nuestra aplicación con Paquete (o Paquete Web o Paquete acumulativo), esta solución no funciona. Sin embargo, al ejecutar esto localmente con Node.módulos js y CommonJS esta solución podría funcionar bien.
Evitar el problema
Por lo que, aparentemente, este problema no se puede solucionar fácilmente. ¿Podría haberse evitado? La respuesta es sí, hay varias maneras de evitar el problema. En primer lugar, podríamos haber guardado el código en un solo archivo. Como se muestra en nuestro ejemplo inicial, de esa manera podemos resolver el problema, ya que da control total sobre el orden en que se ejecuta el código de inicialización del módulo.
En segundo lugar, algunas personas usarán el problema anterior como argumento para hacer declaraciones como “Uno no debe usar clases”, o “No usar herencia”. Pero eso es una simplificación excesiva del problema. Aunque estoy de acuerdo en que los programadores a menudo recurren a la herencia demasiado rápido, para algunos problemas es perfecto y podría producir grandes beneficios en términos de estructura de código, reutilización o rendimiento. Pero lo más importante, este problema no se limita a la herencia de clase. ¡Exactamente el mismo problema se puede introducir al tener dependencias circulares entre variables de módulo y funciones que se ejecutan durante la inicialización del módulo!
Podríamos reorganizar nuestro código de tal manera que dividamos la clase AbstractNode
en piezas más pequeñas, de modo que AbstractNode
no tenga dependencias en Node
o Leaf
. En esta caja de arena, el método from
se ha extraído de la clase AbstractNode
y se ha puesto en un archivo separado. Esto resuelve el problema, pero ahora nuestro proyecto y API están estructurados de manera diferente. En grandes proyectos, puede ser muy difícil determinar cómo lograr este truco, ¡o incluso imposible! Imagine, por ejemplo, lo que sucedería si el método print
dependiera de Node
o Leaf
en la siguiente iteración de nuestra aplicación Bonus
Bono: un truco feo adicional que utilicé antes: devolver clases base de funciones y aprovechar la función de elevación para cargar las cosas en el orden correcto. Ni siquiera sé cómo explicarlo correctamente.
El patrón de módulo interno al rescate!
He luchado con este problema en múltiples ocasiones en muchos proyectos, algunos ejemplos incluyen mi trabajo en Mendix, MobX, MobX-state-tree y varios proyectos personales. En algún momento, hace unos años, incluso escribí un script para concatenar todos los archivos de origen y borrar todas las instrucciones de importación. Un paquete de módulos para pobres solo para controlar el orden de carga del módulo.
Sin embargo, después de resolver este problema algunas veces, apareció un patrón. Uno que da control total sobre el orden de carga del módulo, sin necesidad de reestructurar el proyecto o tirar de hacks extraños! Este patrón funciona perfectamente con todas las cadenas de herramientas con las que lo he probado (Rollup, Webpack, Parcel, Node).
El quid de este patrón es introducir un archivo index.js
y internal.js
. Las reglas del juego son las siguientes:
- El módulo
internal.js
importa y exporta todo desde cada módulo local del proyecto - Cada otro módulo del proyecto solo importa desde el archivo
internal.js
, y nunca directamente desde otros archivos del proyecto. - El archivo
index.js
es el punto de entrada principal e importa y exporta todo desdeinternal.js
que desea exponer al mundo exterior. Tenga en cuenta que este paso solo es relevante si está publicando una biblioteca que es consumida por otros. Así que nos saltamos este paso en nuestro ejemplo.
Tenga en cuenta que las reglas anteriores solo se aplican a nuestras dependencias locales. Las importaciones de módulos externos se dejan como están. Después de todo, no están involucrados en nuestros problemas de dependencia circular. Si aplicamos esta estrategia a nuestra aplicación de demostración, nuestro código se verá así:
Al aplicar este patrón por primera vez, puede sentirse muy artificial. ¡Pero tiene algunos beneficios muy importantes!
- En primer lugar, ¡resolvimos nuestro problema! Como se muestra aquí, nuestra aplicación se está ejecutando felizmente de nuevo.
- La razón por la que esto resuelve nuestro problema es que ahora tenemos control total sobre el orden de carga del módulo. Sea cual sea el pedido de importación en
internal.js
, será nuestro pedido de carga de módulos. (Es posible que desee revisar la imagen a continuación o volver a leer la explicación del pedido del módulo anterior para ver por qué este es el caso) - No necesitamos aplicar refactorizaciones que no queremos. Tampoco estamos obligados a usar trucos feos, como mover las declaraciones require al final del archivo. No tenemos que comprometer la arquitectura, API o estructura semántica de nuestra base de código.
- Bono: las declaraciones de importación se volverán mucho más pequeñas, ya que importaremos cosas de menos archivos. Por ejemplo,
AbstractNode.js
solo tiene la instrucción on import ahora, donde tenía dos antes.Bono - : con
index.js
, tenemos una única fuente de verdad, que nos da un control preciso de lo que exponemos al mundo exterior.