wandres.dev
COMPILAR LA REACTIVIDAD · Cuando el compilador hace el trabajo

El compilador JSX de Solid

La segunda cosa que puede hacer un compilador: convertir una plantilla en HTML clonable más un conjunto de efectos que rellenan solo los huecos variables, sin árbol descriptivo intermedio.

⏱ 17 min

El mismo JSX que en un motor de reconciliación se compila a llamadas que construyen un árbol de objetos, en Solid se compila a algo completamente distinto: una plantilla HTML que se clona y unas cuantas instrucciones que enganchan efectos a los puntos que varían. La comparación de las dos salidas para el mismo fuente es la demostración más limpia de la diferencia entre las dos familias del nivel 1.

🎯 Al terminar esta lección sabrás
  • Comparar las dos compilaciones posibles del mismo JSX.
  • Entender por qué clonar una plantilla es más rápido que construir nodos.
  • Ver cómo se enganchan los efectos a los huecos variables.
  • Reconocer qué expresiones el compilador decide envolver en un efecto.

El mismo fuente, dos salidas

Partimos de un fragmento cualquiera.

function Fila(props) {
  return (
    <li class="fila">
      <span class="etiqueta">Nombre</span>
      <span class="valor">{props.nombre}</span>
    </li>
  );
}

Compilado para reconciliación, la salida es una llamada por elemento que construye objetos descriptivos, cada vez que el componente se ejecuta.

function Fila(props) {
  return crearElemento('li', { class: 'fila' },
    crearElemento('span', { class: 'etiqueta' }, 'Nombre'),
    crearElemento('span', { class: 'valor' }, props.nombre));
}

Tres objetos por invocación, más la comparación con los de la invocación anterior.

Compilado para grano fino, la salida es una plantilla que se clona más una instrucción para el único hueco variable.

const plantilla = crearPlantilla(
  `<li class="fila"><span class="etiqueta">Nombre</span><span class="valor"></span></li>`
);

function Fila(props) {
  const raiz = plantilla();                 // clona el fragmento entero de una vez
  const valor = raiz.firstChild.nextSibling;
  efectoDeRender(() => { valor.textContent = props.nombre; });
  return raiz;
}

Fíjate en lo que no está: no hay ningún objeto descriptivo, no hay comparación, y la parte estática —el li, las clases, el texto Nombre— no genera ningún nodo reactivo. Solo el hueco variable tiene un efecto.

Por qué clonar es más rápido

La plantilla se crea una sola vez, típicamente asignando su HTML a un elemento de plantilla del documento. A partir de ahí, cada instancia se produce clonando ese fragmento.

Clonar un subárbol del DOM es una operación implementada en el motor del navegador que copia una estructura ya parseada, sin volver a analizar HTML y sin cruzar la frontera entre JavaScript y el motor de renderizado una vez por nodo. Construir el mismo subárbol con llamadas individuales cruza esa frontera una vez por elemento, por atributo y por texto.

La diferencia es notable en subárboles medianos y grandes, y es la razón por la que los frameworks compilados ganan tanto en creación de listas: no es su motor reactivo, es que están clonando en vez de construyendo.

💡
Esta optimizacion es independiente del modelo reactivo

Merece la pena señalarlo para no atribuir méritos donde no toca: la generación de plantillas clonables no tiene nada que ver con la reactividad. Un motor de reconciliación podría hacerlo igual, y algunos lo hacen para las partes estáticas. Cuando compares el rendimiento de creación entre dos frameworks, buena parte de la diferencia puede venir de aquí y no del algoritmo de propagación.

Qué envuelve el compilador en un efecto

La decisión clave es cuáles de las expresiones interpoladas necesitan un efecto. El criterio es sintáctico y conservador de una forma interesante.

Una expresión que es claramente estática —un literal, una constante conocida— se emite directamente en la plantilla.

Una expresión que contiene una llamada a función se envuelve en un efecto, porque el compilador no puede saber si esa llamada lee una señal. Es una decisión conservadora, y en este caso es la correcta: un efecto de más cuesta poco, y no crearlo cuando hacía falta produce una interfaz que no se actualiza.

Un acceso a una propiedad de las propiedades del componente también se envuelve, porque el compilador convierte las propiedades en un objeto con getters y no sabe si el getter es reactivo.

<div>
  <span>Total</span>                 {/* estatico, sin efecto */}
  <span>{lista().length}</span>      {/* llamada, se envuelve */}
  <span>{props.titulo}</span>        {/* propiedad, se envuelve */}
  <span>{CONSTANTE}</span>           {/* identificador simple, no se envuelve */}
</div>

El último caso es el que produce sorpresas: un identificador simple no se envuelve, así que si contiene un valor que luego cambia, no se actualiza. La regla práctica es que lo reactivo tiene que ser visiblemente una llamada o un acceso a propiedad para que el compilador lo reconozca.

flowchart TB
J[JSX] --> C[compilador]
C --> P[plantilla HTML clonable]
C --> I[instrucciones para los huecos]
P --> N[clonar por instancia]
I --> E[un efecto por hueco variable]
E --> D[escritura directa en el nodo]
style J fill:#89b4fa,color:#11111b
style C fill:#fab387,color:#11111b
style P fill:#94e2d5,color:#11111b
style I fill:#cba6f7,color:#11111b
style N fill:#a6e3a1,color:#11111b
style E fill:#cba6f7,color:#11111b
style D fill:#a6e3a1,color:#11111b

Las propiedades como objeto con getters

Un detalle de la transformación que resuelve un problema real del nivel 9. Como el componente corre una sola vez, si las propiedades fueran un objeto plano sus valores quedarían congelados en esa única ejecución.

El compilador las convierte en un objeto donde cada propiedad es un getter que evalúa la expresión original en el momento del acceso.

// Lo que escribes
<Fila nombre={usuario().nombre} />

// La forma de lo que se genera
Fila({ get nombre() { return usuario().nombre; } });

Así, leer props.nombre dentro de un efecto ejecuta la expresión en ese momento y teje la arista correcta. Y por eso desestructurar las propiedades rompe la reactividad: al desestructurar se invocan los getters una vez y se guardan los valores. Es exactamente la trampa del nivel 9, y aquí queda claro por qué existe.

El compilador de plantillas hace mas por el rendimiento que el motor reactivo

Una conclusión que incomoda a las dos partes del debate y que conviene decir con claridad. En la mayoría de las comparativas entre un framework compilado de grano fino y uno de reconciliación, la parte de la diferencia que viene del compilador de plantillas es mayor que la que viene del modelo reactivo. Clonar en vez de construir, no crear nodos para lo estático, y escribir directamente en el nodo del DOM en vez de emitir un parche: esas tres cosas son de la capa de renderizado, no del grafo de dependencias. El modelo reactivo aporta lo suyo en las actualizaciones, y ahí su ventaja es real y grande. Pero en la creación, que es lo que domina la primera carga y el reemplazo masivo, la ventaja viene casi entera del compilador. Reconocer esto tiene dos consecuencias útiles. Primera, para elegir: si tu carga de trabajo es de creación —listas que se reemplazan, navegación entre pantallas— evalúa el compilador de plantillas y no el motor reactivo, porque es lo que va a decidir. Segunda, para diseñar: si construyes un framework, la generación de plantillas clonables es la optimización con mejor relación entre esfuerzo y ganancia de todas las que puedes acometer, y es completamente independiente de qué modelo reactivo elijas. Puedes tenerla con reconciliación, con señales o con lo que sea.

⚔️ Compara las dos salidas
  1. Escribe un componente con marcado estático y dos huecos variables.
  2. Compílalo con un compilador de reconciliación y con uno de grano fino, y compara las dos salidas.
  3. Cuenta cuántos nodos reactivos crea cada uno y cuántas operaciones sobre el DOM hace al crear mil instancias.
  4. Desestructura las propiedades en el segundo y comprueba que la reactividad se pierde, señalando el getter que dejó de llamarse.