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

Las runes: reescribir el acceso

Qué transformación aplica exactamente el compilador de Svelte, por qué una rune no puede ser una función, y qué garantías extra le da al compilador saber qué variables son reactivas.

⏱ 17 min

Las runes son el ejemplo más claro de la primera de las cuatro cosas que un compilador puede hacer: eliminar la sintaxis de acceso sin cambiar nada del modelo. Esta lección mira la transformación de cerca, explica por qué una rune tiene que ser un marcador y no una función, y examina las optimizaciones adicionales que se vuelven posibles cuando el compilador sabe qué variables son reactivas.

🎯 Al terminar esta lección sabrás
  • Trazar la transformación de una declaración de estado a llamadas de runtime.
  • Explicar por qué una rune no puede ser una función normal.
  • Identificar las optimizaciones que habilita conocer las variables reactivas.
  • Reconocer los casos que el compilador no puede transformar.

La transformación

La forma general es esta: cada declaración marcada se convierte en la creación de un nodo, cada lectura de esa variable en una llamada de lectura, y cada escritura en una llamada de escritura.

// Lo que escribes
let contador = $state(0);
let doble = $derived(contador * 2);

function incrementar() { contador += 1; }
$effect(() => { console.log(doble); });

// La forma de lo que se ejecuta, esquematizada
const contador = fuente(0);
const doble = derivada(() => leer(contador) * 2);

function incrementar() { escribir(contador, leer(contador) + 1); }
efecto(() => { console.log(leer(doble)); });

Lo notable es lo poco que cambia el modelo: es exactamente el motor de los niveles anteriores, con las llamadas puestas por una máquina en vez de por ti. No hay ningún mecanismo nuevo, ninguna optimización que un motor sin compilador no pudiera hacer. El compilador quita ruido.

Fíjate en $derived(contador * 2): recibe una expresión, no una función. Eso es lo que hace imposible que una rune sea una función normal.

Por qué una rune no puede ser una función

Si $derived fuera una función, su argumento se evaluaría antes de llamarla, y lo que recibiría sería el número 0, no la expresión. Para que funcione hay que capturar la expresión sin evaluarla, y en JavaScript solo hay dos formas: envolverla en una función —que es lo que hacen todos los demás motores— o transformar el código, que es lo que hace el compilador.

De ahí salen las tres propiedades de una rune que sorprenden al principio.

No se importa. No hay ningún módulo del que venga. Es un símbolo que el compilador reconoce.

No se puede pasar como valor. No es un objeto, así que no se puede asignar, pasar como argumento ni almacenar.

Solo funciona donde el compilador llega. En un fichero que la herramienta de compilación no procesa, la rune no significa nada.

ℹ️
Son sintaxis, no biblioteca

La mejor manera de entenderlas es pensar que las runes son una extensión de la sintaxis del lenguaje, no una API. Igual que await no es una función y no se puede pasar como valor, una rune tampoco. Y como cualquier extensión sintáctica, el precio es que tu fichero deja de ser JavaScript válido: necesita un procesador que lo entienda.

Lo que habilita saber qué es reactivo

Aquí es donde el compilador aporta más allá de la sintaxis. Al saber qué identificadores son reactivos, puede analizar cada expresión de la plantilla y clasificarla.

Expresiones sin ninguna variable reactiva. Se emiten como estructura fija dentro de la plantilla clonable. No se crea ningún nodo reactivo para ellas. En una plantilla típica, esto es la mayoría del marcado.

Expresiones con una sola variable reactiva. Se pueden emitir con una actualización directa, sin necesidad de una derivación intermedia.

Expresiones con varias. Necesitan un nodo que las combine.

// De estas tres expresiones de plantilla, solo dos generan nodos reactivos
// <h1>Panel de control</h1>            -> estructura fija, cero nodos
// <span>{nombre}</span>                -> una actualizacion directa
// <b>{total} de {maximo}</b>           -> un nodo que combina dos fuentes

Esta clasificación es la optimización más rentable de todas, como decíamos en la lección anterior. Sin ella, un motor de grano fino crea un efecto por cada expresión de la plantilla, incluidas las que nunca cambian, y paga su memoria y su contabilidad para siempre.

flowchart TB
P[expresion de plantilla] --> C{contiene variables reactivas}
C -->|ninguna| E[emitir como estructura fija]
C -->|una| D[actualizacion directa]
C -->|varias| N[nodo que combina]
E --> R1[cero coste en ejecucion]
D --> R2[un efecto minimo]
N --> R3[un nodo con varias aristas]
style P fill:#89b4fa,color:#11111b
style C fill:#f9e2af,color:#11111b
style E fill:#a6e3a1,color:#11111b
style D fill:#94e2d5,color:#11111b
style N fill:#cba6f7,color:#11111b
style R1 fill:#a6e3a1,color:#11111b
style R2 fill:#94e2d5,color:#11111b
style R3 fill:#cba6f7,color:#11111b

Lo que el compilador no puede transformar

Tres fronteras, y las tres se manifiestan como pérdida silenciosa de reactividad.

Los valores que salen del ámbito compilado. Si pasas una variable de estado a una función definida en otro fichero que el compilador no transforma, pasas su valor actual. La reactividad se queda dentro. La solución, como en todos los motores, es pasar un getter.

Las estructuras dinámicas. Un objeto cuyas claves se determinan en tiempo de ejecución no puede transformarse propiedad a propiedad. Ahí entra el proxy, que es un mecanismo de runtime y no de compilación. Por eso Svelte usa proxies para objetos y arrays en $state: el compilador no llega y el runtime sí.

Las clases nativas. Map, Set y compañía siguen sin ser observables, por las razones del nivel 9. De ahí las versiones instrumentadas del paquete de reactividad.

Lo interesante de las tres es que son exactamente las mismas fronteras que en cualquier otro motor. El compilador cambia la sintaxis dentro de su alcance y no cambia nada de la semántica fuera de él.

El compilador mueve la frontera, no la elimina

Aquí está el resultado que conviene extraer de esta lección y que se aplicará también a las dos siguientes. En cualquier motor reactivo existe una frontera entre lo que es reactivo y lo que no, y esa frontera hay que respetarla al escribir código: no puedes sacar un valor de su contenedor y esperar que siga conectado. Lo que hace un compilador es mover esa frontera para que caiga en menos sitios: dentro de un componente de Svelte, la reactividad es transparente y no hay que pensar en ella. Lo que no hace, y no puede hacer, es eliminarla; solo desplazarla hacia los bordes del código que compila. Y esto produce un fenómeno con el que hay que contar: cuanto más transparente es la reactividad dentro, más brusca resulta la frontera cuando la cruzas. En Solid, pasar usuario en vez de usuario() es una distinción visible que se aprende una vez y ya no se olvida. En Svelte, pasar usuario a una función y descubrir que ha dejado de ser reactivo es sorprendente precisamente porque dentro nada lo era. Es el mismo intercambio del nivel 9 con los proxies, expresado en tiempo de compilación en lugar de en tiempo de ejecución. La conclusión práctica es que con un motor compilado hay que prestar especial atención a las fronteras de módulo y de función, porque son donde vive toda la complejidad que el resto del código ya no tiene.

⚔️ Traza la frontera de tu compilador
  1. Escribe un componente con estado y pásalo a una función definida en otro fichero.
  2. Comprueba que la reactividad se pierde y examina la salida compilada para ver por qué.
  3. Cámbialo por un getter y verifica que se conserva.
  4. Escribe una plantilla con diez expresiones, tres de ellas reactivas, y cuenta cuántos nodos reactivos genera el compilador.