Qué puede y qué no puede inferir un compilador
El límite teórico de la reactividad compilada: qué información está disponible en el texto del programa, qué exige ejecutarlo, y por qué ningún compilador puede sustituir el descubrimiento de dependencias en el caso general.
Antes de mirar qué hace cada compilador conviene establecer qué es posible. Hay información sobre un programa reactivo que está en su texto y hay información que solo aparece al ejecutarlo, y la frontera entre ambas no es una limitación de las herramientas actuales sino un resultado que se puede argumentar. Esta lección delimita el terreno para que las tres siguientes se lean como lo que son: exploraciones de un espacio con bordes conocidos.
- Distinguir la información estática de la que requiere ejecución.
- Argumentar por qué el conjunto exacto de dependencias es indecidible.
- Enumerar las cuatro cosas que un compilador sí puede hacer.
- Reconocer el coste de la aproximación conservadora.
Lo que está en el texto
Un compilador ve el código fuente y puede deducir bastante.
Qué identificadores son reactivos. Si una declaración usa una marca reconocible —una rune, una llamada a una función conocida, una anotación de tipo—, el compilador sabe qué variables son fuentes.
Dónde se leen y dónde se escriben. Un análisis de flujo localiza todos los puntos donde un identificador reactivo aparece. Esto permite insertar las llamadas de lectura y de escritura, que es exactamente lo que hacen las runes.
Qué expresiones de una plantilla son estáticas. Si un fragmento no contiene ninguna referencia reactiva, no hace falta crear un efecto para él. Esta es la optimización más rentable de todas las que hace un compilador de plantillas.
Qué se puede precomputar. Cadenas constantes, estructuras de plantilla, formas de objeto. Todo lo que no dependa del estado se puede emitir ya resuelto.
Lo que exige ejecutar
Y aquí está el límite. El conjunto exacto de dependencias de una computación no es deducible del texto en el caso general, y el argumento es breve.
Considera este cuerpo.
const vista = derivar(() => {
if (condicionCompleja(entrada())) return a();
return b();
});
Para saber si vista depende de a o de b, hay que saber qué devuelve condicionCompleja. Para saber eso hay que ejecutarla. Y condicionCompleja puede ser código arbitrario: un bucle que puede no terminar, una llamada a otra función, una lectura de otro estado. Decidir estáticamente qué rama se toma es equivalente a decidir si un programa arbitrario produce un valor determinado, que es indecidible.
Un compilador solo tiene dos salidas ante esto. Ser conservador: suponer que depende de las dos, con lo que el nodo se reejecuta ante cambios en b incluso mientras la condición selecciona a. O dejar que el runtime lo descubra: insertar las llamadas de lectura y que el motor teja las aristas al ejecutar.
Todos los compiladores reales eligen la segunda, y es importante entender por qué: la aproximación conservadora destruye la ventaja principal del modelo. Las dependencias condicionales del nivel 3 son una de las mayores fuentes de ahorro, y un análisis que las descarte produce un grafo con muchas más aristas de las necesarias.
flowchart TB C[compilador] --> S[informacion estatica] C --> D[informacion dinamica] S --> S1[que identificadores son reactivos] S --> S2[donde se leen y se escriben] S --> S3[que partes de la plantilla son estaticas] D --> D1[que rama toma una condicional] D --> D2[cuantas veces itera un bucle] D --> D3[el conjunto exacto de dependencias] D1 --> R[lo resuelve el runtime] D2 --> R D3 --> R style C fill:#fab387,color:#11111b style S fill:#a6e3a1,color:#11111b style D fill:#f9e2af,color:#11111b style S1 fill:#a6e3a1,color:#11111b style S2 fill:#a6e3a1,color:#11111b style S3 fill:#a6e3a1,color:#11111b style D1 fill:#f9e2af,color:#11111b style D2 fill:#f9e2af,color:#11111b style D3 fill:#f9e2af,color:#11111b style R fill:#cba6f7,color:#11111b
Las cuatro cosas que sí puede hacer
Delimitado el terreno, queda un espacio considerable y muy rentable.
Eliminar la sintaxis de acceso. Convertir contador en leer(nodoContador) y contador++ en la escritura correspondiente. No cambia nada del modelo: solo quita ruido. Es lo que hacen las runes de Svelte.
Generar plantillas en lugar de árboles descriptivos. En vez de emitir código que construye un árbol de objetos, emitir una cadena HTML que se clona y un conjunto de instrucciones que rellenan los huecos. Ahorra la construcción de la descripción y la comparación. Es lo que hace el compilador de JSX de Solid.
Distinguir lo estático de lo dinámico. Si una expresión de plantilla no contiene lecturas reactivas, emitirla como texto sin crear ningún efecto. Es de donde sale buena parte de la ventaja de los motores compilados: no crean nodos reactivos para lo que no cambia.
Insertar memoización. Analizar qué valores se recalculan innecesariamente y envolver esos cálculos en cachés. Es lo que hace React Compiler, y es distinto de lo anterior porque no elimina la maquinaria: la genera.
De las cuatro, la que más rendimiento aporta en la práctica no es la que suena más ambiciosa sino la tercera: no crear nodos reactivos para las partes estáticas. En una plantilla típica, la mayoría del marcado es estructura fija y solo unos pocos huecos varían. Un motor que crea un efecto por expresión sin distinguir paga cientos de nodos para nada; uno que distingue paga los que de verdad cambian. Esa diferencia es mayor que cualquier mejora en el algoritmo de propagación.
El coste de la aproximación conservadora
Vale la pena cuantificar por qué nadie elige el análisis estático de dependencias, con un caso concreto.
const panel = derivar(() => {
if (!visible()) return null;
return { filas: datos().map(formatear), total: total(), meta: metadatos() };
});
Con descubrimiento en tiempo de ejecución y visible a falso, el nodo tiene una arista. Con análisis conservador tendría cuatro, y se reejecutaría ante cualquier cambio en datos, total o metadatos aunque el panel lleve una hora cerrado.
Escalado a una aplicación con cincuenta paneles de los que se ven tres, la diferencia entre las dos estrategias es de un orden de magnitud en número de aristas y en reejecuciones. Ningún ahorro de sintaxis compensa eso.
La conclusión que ordena el nivel entero, y que conviene tener clara antes de mirar las herramientas concretas, es que hay una división del trabajo entre compilación y ejecución que no es provisional. El compilador se ocupa de todo lo que está determinado por el texto: qué es reactivo, dónde se accede, qué partes de la salida son fijas, qué llamadas se pueden alinear o eliminar. El runtime se ocupa de todo lo que depende de los valores: qué rama se toma, cuántos elementos hay, qué aristas existen ahora mismo. Esa frontera no se va a mover con compiladores mejores, porque no está donde está por falta de sofisticación sino porque la información no existe en tiempo de compilación. Cuando alguien anuncia un framework que elimina el runtime reactivo por completo, hay exactamente dos posibilidades y merece la pena identificar cuál: o ha adoptado la aproximación conservadora y ha perdido las dependencias condicionales, lo que se detecta escribiendo un memo con una guarda y midiendo si el subgrafo se desconecta; o sigue teniendo runtime y lo que ha eliminado es la sintaxis, que es valioso pero es otra cosa. Saber distinguir esas dos situaciones te ahorrará bastante tiempo evaluando herramientas nuevas, y es una pregunta que se responde con un experimento de diez líneas.
- Escribe un memo con una condicional cuya rama dependa de un cálculo no trivial.
- Compílalo con un framework que use runes y examina la salida: comprueba si las dependencias están resueltas o si se descubren al ejecutar.
- Escribe la guarda barata del nivel 3 y mide si el subgrafo caro se desconecta de verdad.
- Escribe el mismo caso con dependencias declaradas a mano y compara el número de reejecuciones.