El conocimiento es el nuevo dinero.
Aprender es la nueva manera en la que inviertes
Acceso Cursos

¿Quieres hacer tus componentes más fáciles de entender? Usa el Diseño de SolidJS: Componentes.

Diseño de SolidJS: Componentes - Crea aplicaciones más escalables y mantenibles.

· 10 min de lectura
¿Quieres hacer tus componentes más fáciles de entender? Usa el Diseño de SolidJS: Componentes.

SolidJS es una biblioteca de interfaz de usuario JavaScript de alto rendimiento. Esta serie de artículos profundiza en la tecnología y las decisiones que se tomaron para diseñar la biblioteca. No necesita entender este contenido para utilizar Solid. El artículo de hoy se enfoca en el Sistema de Componentes de Solid.

Costo de los componentes


A veces siento que paso tanto tiempo en benchmarks y micro-optimizaciones que podría estar perdiendo contacto con la experiencia real de desarrollo. Quiero decir, ¿cuándo fue la última vez que viste un benchmark dividir la solución en Componentes cuando no era una librería Virtual DOM como React? Diferentes librerías conllevan diferentes costes a la hora de enfocar la modularización del código. Sin embargo, la modularización es una verdad innegable cuando se trata de escalar cualquier tipo de solución que está más involucrado que un simple punto de referencia. Sin embargo, rara vez nos fijamos en el coste.

La verdad es que el coste de los componentes no es blanco o negro. Las librerías virtuales DOM como React requieren Componentes para manejar una gestión de cambios eficiente y, por lo tanto, están construidas de tal forma que dividir el código en Componentes tiene un coste mínimo. De hecho, hacerlo a menudo mejora el rendimiento. El renderizado inicial ya es un montón de document.createElements debido a las llamadas HyperScript separadas. Y más Componentes sólo permiten memoización de grano más fino.

Mientras que una biblioteca reactiva como Svelte tiene que resolver su reactividad dentro de un ámbito local en su mayor parte, lo que conduce a una sobrecarga adicional cada vez que se separan las cosas. No estoy sugiriendo que haciendo esto el rendimiento de estas librerías caiga en un lugar terrible, pero significa que en la práctica su rendimiento no va a estar tan por delante como algunos benchmarks te harían creer.

Renderizado ideal


Dejemos a un lado nuestro framework favorito y cerremos los ojos para imaginar cuál sería el enfoque ideal para renderizar el DOM a partir de lo que hemos aprendido de los benchmarks de rendimiento.

En primer lugar, veamos el renderizado inicial. Para obtener el máximo rendimiento, deberíamos clonar nodos en masa mediante node.cloneNode(true). Queremos nuevas instancias cada vez que presentemos nuevos datos, ya que reutilizar/agrupar nodos puede tener efectos secundarios peligrosos. Los trozos continuos más grandes posibles (plantilla) nos darán el mayor rendimiento, así que los puntos de ruptura naturales están donde la vista es estructuralmente dinámica.

Con esto quiero decir que la estructura puede cambiar dependiendo de los datos, como los condicionales o los bucles dentro de la vista. Cada punto de montaje condicional y cada iteración del bucle marcan una nueva plantilla.

Al actualizar querríamos casi el paradigma opuesto. Nos gustaría evitar la sobrecarga de la difusión de todo el árbol y tienen actualizaciones se aplican en el nivel de grano más fino. Si un nombre se actualiza, sólo deberían actualizarse los lugares específicos en los que se utiliza. Todo esto suena muy bien y es lo que prometen las bibliotecas reactivas.

La verdad del asunto es que crear este nivel de granularidad tiene un coste significativo en el renderizado inicial cuando hay muchos de estos nodos. Crear nodos reactivos no es tan caro como el DOM pero es mucho más costoso que la mayoría del código JavaScript.

En realidad, hacer diferencias simples localizadas no es tan caro en absoluto. El trabajo desperdiciado sólo entra realmente en juego cuando esos límites incluyen partes estructuralmente dinámicas ya que ahora toda la condición o lista necesita ser resuelta de nuevo sólo para llegar quizás a un punto de datos por debajo. Los problemas de creación y eliminación de nodos entran ahora en la ecuación y la complejidad ya no se escala sin esfuerzo.

Así que, tal vez como era de esperar, yo diría que el renderizado más eficaz también haría las actualizaciones en el ámbito de plantilla estática más grande. Y en las pruebas de rendimiento, vemos esto todo el tiempo. Muchas de las implementaciones más rápidas tratan de evitar romper la vista tanto como sea posible. Pero la vida real no es un benchmark.

Reevaluar los límites


Ya hemos establecido que los Componentes son la forma de facto de dividir tu código. En una librería reactiva, la ejecución está definida por una serie de manejadores de eventos. Estos manejadores forman un gráfico por derecho propio. El tacto más fácil sería alinear la granularidad con Componentes. Este es el enfoque que MobX utiliza con React, y cómo funciona Vue esencialmente. Ambas bibliotecas se alimentan de motores virtuales DOM diff donde los componentes sirven como nodos.

Svelte también funciona de forma similar. Compila Componentes a módulos optimizados localmente.

Pero por otro lado, desde el punto de vista del rendimiento, los Componentes no son necesariamente los límites correctos. Los componentes pueden subdividir plantillas estáticas, y pueden contener secciones estructuralmente dinámicas.

La creación de las primeras y la actualización de las segundas pueden afectar al rendimiento de la aplicación. Así que para Solid, sabía que el manejo de Componentes iba a ser crítico tanto para el punto de vista de la experiencia del desarrollador como para definir el rendimiento en el mundo real.

Así que lo primero que hice con Solid fue intentar que los componentes fueran poco más que una llamada a una función. Básicamente son fábricas de gráficos DOM/reactivos. Crean nodos DOM y computaciones reactivas que crean cierres sobre los puntos de datos reactivos y esos nodos DOM.

Una vez creados, los componentes no tienen ningún propósito, ya que el sistema funciona por sí solo. Parece bastante simple. Sin embargo, usar este patrón efectivamente con un sistema reactivo que no tiene límites en el Componente no siempre es una buena experiencia de desarrollo o intuitiva. Considere usar uno de los proxies de estado de Solid:

<MyComponent name={state.user.name} />

Y se podría definir este Componente como:

const MyComponent = props => <h2>Hi, {props.name}</h2>

Ahora bien, si esto fuera sólo una llamada a una función sería de esperar que el compilador de salida algo como esto:

MyComponent({name: state.user.name});

Sin embargo, esto plantea un problema. Estamos resolviendo el estado a un simple valor en el momento de la llamada, momento en el que ya no es reactivo. Peor aún, puede ser rastreado por su propio contexto causando que todo el contexto propietario reevalúe el cambio de valor.

Como era de esperar, la respuesta es envolverlo en una función. Pero esto conlleva sus propios problemas. ¿Realmente quieres acceder a name como props.name()? Y más aún, ¿qué pasa si quieres que tu Componente acepte valores simples?

<MyComponent name="Jack Smith" />

No. Quería evitar que el escritor de componentes tuviera que comprobar si está recibiendo una función o no. Una táctica que he visto comúnmente en el pasado en este tipo de bibliotecas es envolver cada vinculación en una computación que escribe en un único observable props o en un objeto proxy props.

Pero eso es pesado. Pero no hacer nada no es aceptable. Donde aterricé fue en usar el compilador para detectar expresiones dinámicas y envolverlas en funciones dejando los valores simples como una asignación directa.

Luego tomar esas expresiones envueltas y establecerlas como un getter en el objeto props. Esto es manejado por un compilador interno generado por el método createComponent, donde el 3er argumento son las claves que deben ser envueltas como un getter.

createComponent(MyComponent, {name: () => state.user.name}, ["name"]);

Lo que esto significa es que state.user.name no se evalúa hasta que se accede a props.name para que sea rastreado por el contexto en el que se está utilizando props.name. No es hasta que los nodos internos del Componente son creados y vinculados que el valor es accedido.

Esto asegura que no se hagan dependencias hasta el último momento. MobX tiene esta consideración similar de no acceder a props hasta donde están siendo vinculados, pero con React no hay una solución simple aquí. Sin embargo, esta simple envoltura de función presentada consistentemente detrás de un Object getter, nos permite empaquetar las cosas sin crear ningún nodo reactivo adicional.

Este patrón de evaluación diferida también es útil para manejar props.children ya que para cosas como condicionales o plantillas (render props) el Componente puede evaluar los hijos a su discreción. Esto da una increíble cantidad de poder a los Componentes.

Hay un segundo problema con el uso de funciones. Considera un condicional:

<div>{state.show && <MyComponent />}</div>

Esto se reduciría aproximadamente a:

const el = document.createElement('div');
createEffect(() => el.appendChild(state.show && MyComponent({}));

El problema aquí es que ahora  se está ejecutando dentro de un contexto de seguimiento. Cualquier tipo de acceso incidental a una propiedad reactiva desencadenaría todo el condicional de nuevo .createComponent también asegura que el Componente se crea en un contexto no rastreado.

Con esto a nuestra disposición básicamente podemos permitir que el componente desaparezca después de la ejecución con la mínima sobrecarga de una llamada a una función y la configuración de unos cuantos getters en un objeto props. No se crean nuevos nodos reactivos, que es donde está el gasto real. Sin embargo, aunque hayamos separado el ciclo de vida reactivo de los componentes, esto no nos ayuda a optimizar a ese nivel ideal de plantilla estática. Todavía estamos dividiendo nuestro código.

Compilación


Por supuesto, este es el primer lugar al que acudí. Si has estado siguiendo a lo largo de la compilación ha resuelto una serie de problemas para nosotros, y casi es la única razón reactiva biblioteca como esta puede ser tan performant. Así que lógicamente, este parecía el mejor lugar para empezar.

La gente ha estado usando compiladores de lenguajes durante años para hacer grandes optimizaciones como el inlining de funciones, así que pensé ¿qué pasa con el inlining de componentes? ¿Y si permitiéramos al compilador reconstruir la plantilla estática más grande compilando los componentes? Es más fácil decirlo que hacerlo.

Incluso si se da por sentado que, debido a la división del código, no seríamos capaces de inlinear todo, incluso módulos separados es problemático ya que los bundlers generalmente aplican transformaciones a nivel de módulo antes de hacer Tree Shaking y unirlo.

El problema, por supuesto, es que no podemos aplazar la transpilación JSX sin alienar el resto de la cadena de herramientas JS y sin transpilación cosas como Tree Shaking no funcionarán correctamente. Intenté algunas cosas que acabaron con mi código no incluido en el bundle final antes incluso de tener la oportunidad de transformarlo allí. Y seamos sinceros, cuanto más detallada sea la división del código, menos beneficios obtendremos.

Así que puse esto en un segundo plano y continuó en mi lista de cosas por hacer. Lo siguiente ocurrió con el Server Side Rendering. Esto terminó siendo un problema particularmente desagradable para Solid cuando se trataba de la hidratación del lado del cliente.

Sin los puntos de anclaje de los componentes y con tanto estado transferible la solución no podía parecerse al enfoque que adoptaba cualquier otra librería. Este tema merece un artículo propio. Pero mientras trabajaba en ello un colega autor de la librería me señaló: "Esto sería mucho más fácil si pudieras hacerlo todo en 2 pasadas como una librería Virtual DOM".

Obviamente, yo no quería cambiar fundamentalmente cómo Solid trabajó en este caso, así que estaba perplejo aquí también. Pero entonces se me ocurrió algo. En este caso ya hago 2 pasadas. Una en el servidor y otra en el cliente. Si no podía resolverlo en tiempo de compilación qué tal si lo resolvía en tiempo de ejecución como una librería Virtual DOM.

Terminé aprovechando el runtime en el servidor para marcar nodos y el runtime en el cliente para buscarlos resolviendo el problema de hidratación y permitiendo SSR en Solid.

Aprovechar el tiempo de ejecución


A menudo siento que tengo muy pocas ideas originales, y simplemente tengo un don para tomar lo que otros han creado y encontrar maneras de aprovecharlas de nuevas maneras. En este caso, la API de contexto de React. La API de Contexto de React utiliza el Contexto de Componente para crear un árbol jerárquico donde un hijo puede hacer una búsqueda para extraer el estado de un padre distante.

En el caso de Solid, se identificó que el contexto debería estar vinculado al contexto reactivo en lugar de a los componentes. Siempre y cuando backlink contextos reactivos de la computación a la computación de los padres podemos hacer búsquedas similares. Desde ahí podemos crear un contexto reactivo específico a través de un Componente Proveedor y cualquier hijo puede acceder al Proveedor más cercano de ese tipo.

Por cierto, este mecanismo de tiempo de ejecución no basado en Componentes en realidad da a Solid la capacidad de hacer un montón de cosas. Más allá de proporcionar una API de Contexto, he utilizado este método para implementar Suspense en una librería reactiva, y de interés aquí, la capacidad de asociar un nodo de computación hijo específico que puede ser compartido en el contexto.

He sido capaz de hacer una API que vincula múltiples manejadores a una sola computación en tiempo de ejecución. Mientras cada manejador no escriba a un valor que otro manejador escucha no hay problema, y hemos ahorrado la sobrecarga de crear múltiples computaciones. En el caso de la vinculación a los atributos por lo general todo se lee por lo que esto funciona bien. Así que ahora puedes imaginar si tuvieras una estructura como esta:

<>
  <Button text={props.confirmButtonName} />
  <Button text={props.cancelButtonName} />
  <Button text={props.infoButtonName} icon={props.infoButtonIcon}/>
</>

Y cada Componente Botón se vincula a sus propios nodos DOM internos. Si sus componentes internos llaman a esta API, todos ellos pueden estar vinculados al mismo cálculo aunque el código esté dividido, ya que todos están en el mismo contexto.

De este modo, podemos tomar distintos componentes y plantillas y volver a unir todos los enlaces estructurales estáticos con el compilador generando estas llamadas de cálculo por lotes y añadiendo una simple comprobación de igualdad en tiempo de ejecución. Esto proporciona una simple diferencia que se comparte a través de todas las plantillas estáticas sin tener en cuenta los componentes desde un punto de vista reactivo. Este es el mayor problema de escalabilidad con las bibliotecas reactivas.

Ahora básicamente tenemos la menor sobrecarga en tiempo de ejecución de una librería Virtual DOM en la creación inicial sin pagar ninguna de las complejas sobrecargas de reconciliación.

Bueno, no es exactamente cierto. Ese es el peor caso. Nuestras plantillas DOM están siendo divididas. Es definitivamente más caro hacer más operaciones DOM separadas. Cada clon extra y appendChild conlleva un coste. Las bibliotecas virtuales DOM no están clonando en masa y usando document.createElement por lo que no están obteniendo beneficios de todos modos.

Hay costes ocultos con las plantillas en que caminar por los nodos, para decir aplicar bindings tiene un coste real. Así que cuando los componentes son pequeños hay muy poca diferencia aquí.

Hasta la fecha no he encontrado la manera de volver a unir las plantillas. Cada intento que he utilizado en el uso de fábricas de plantillas (haciendo plantillas de múltiples copias de la misma plantilla) no ha logrado mejorar el rendimiento de una manera significativa, ya que el coste de recorrer el hijo es casi la mitad del coste de la creación.

Incluso clonar un gran fragmento de documento y anexar todos los nodos a la vez fue sólo marginalmente más rápido que hacer 1000 cloneNodes por separado. He montado JSPerf para demostrarlo.

Todas las pruebas son equivalentes excepto la prueba del div más grande que en lugar de aplicar sólo la plantilla, mueve todo el lote bajo un div que adjunta. Este no es un escenario práctico del mundo real, pero muestra lo costoso que es recorrer los nodos hijos.

Conclusión


Aún queda trabajo por hacer aquí, pero puedes ver cómo Solid puede utilizar Componentes de una forma muy familiar pero sin pagar el coste de rendimiento. Ha habido varios desafíos en el camino, pero he disfrutado el reto de tratar de pensar en diferentes maneras de abordar lo que ahora se consideran problemas en su mayoría resueltos.

Las características en torno a los Componentes son en realidad las de las características más modernas que se encuentran en las librerías, por lo que creo que su manejo es una pieza crítica para actualizar las librerías reactivas con las tendencias en el Desarrollo Web de los últimos años.

Fuente

Plataforma de cursos gratis sobre programación

Artículos Relacionados

Usando Pipes para transformar datos
· 5 min de lectura
Llama3 sacale el máximo provecho
· 4 min de lectura
Angular Signals: Mejores practicas
· 5 min de lectura