Anatomía mínima: las cinco piezas
Fuente, computación, observador actual, planificador y ámbito de vida. Las cinco piezas que aparecen en todos los motores de grano fino, con la firma que tiene cada una y el nombre que recibe en cada implementación real.
Si abres el código de Solid, de Vue, de Angular o de Preact Signals encontrarás las mismas cinco piezas con nombres distintos. Conocerlas de antemano convierte la lectura de cualquiera de esos códigos fuente en un ejercicio de traducción en vez de en una exploración a ciegas. Esta lección es el diccionario: qué hace cada pieza, qué firma tiene, y cómo se llama en cada casa.
- Nombrar las cinco piezas de un motor de grano fino y su responsabilidad.
- Traducir el vocabulario entre Solid, Vue, Angular y Preact Signals.
- Escribir la firma mínima de cada pieza en JavaScript.
- Situar cada pieza en el nivel del track que la desarrolla.
Las dos estructuras de datos
Pieza 1: la fuente
Una fuente es un nodo que guarda un valor y una lista de quién lo observa. No tiene entradas: su valor solo cambia porque alguien lo escribe desde fuera. Es la raíz del grafo.
const fuente = {
valor: 0,
observadores: new Set(), // quien depende de mi
iguales: Object.is, // como decido si un valor nuevo cuenta como cambio
};
Los tres campos son los mismos en todas partes. El tercero, la función de igualdad, parece un detalle y no lo es: define la frontera entre un cambio que propaga y uno que no, y por tanto controla directamente cuánto trabajo hace el sistema. Volveremos a él en el nivel 6.
En Solid la fuente se crea con createSignal y devuelve una pareja de funciones. En Vue es ref, con acceso por la propiedad .value. En Angular es signal(), con lectura por llamada y escritura por .set() o .update(). En Preact Signals es signal(), con acceso por .value. En la propuesta de TC39, Signal.State, con .get() y .set(). Cinco sintaxis, una estructura.
Pieza 2: la computación
Una computación es un nodo con una función que reejecutar y una lista de las fuentes que leyó la última vez. Se subdivide en dos sabores según si su valor importa.
Una derivación —memo, computed— produce un valor que otros leen. Es a la vez computación y fuente: tiene fuentes porque lee y observadores porque la leen. Ocupa el centro del grafo.
Un efecto no produce valor útil: existe por lo que hace fuera del sistema. Es una hoja: tiene fuentes pero nadie lo observa.
const computacion = {
fn, // el cuerpo a reejecutar
fuentes: new Set(), // de quien leo
observadores: new Set(), // quien me lee, vacio si soy un efecto
estado: 2, // limpio, quiza sucio, sucio
valor: undefined,
};
En Solid, createMemo y createEffect, ambos sobre la misma clase Computation interna con un campo pure que los distingue. En Vue, computed y watchEffect, sobre ReactiveEffect. En Angular, computed() y effect(). En Preact Signals, computed y effect. En TC39, Signal.Computed y, para los efectos, Signal.subtle.Watcher, que es deliberadamente de más bajo nivel.
Las tres variables de módulo
Pieza 3: el observador actual
Esta es la pieza que sorprende: no es una estructura, es una variable de módulo. Un único hueco global que contiene la computación que se está ejecutando en este instante, o null si no hay ninguna.
let Observador = null; // la computacion en ejecucion, o null
Su función es hacer de canal lateral entre la lectura de una fuente y la computación que la está leyendo, sin que la fuente tenga que recibir nada por parámetro. Cuando una fuente se lee, mira Observador; si hay alguien, teje la arista.
Esa variable es el mecanismo que hace que el tracking sea automático, y también la raíz de todas sus limitaciones: si la lectura ocurre después de un await, Observador ya vale otra cosa y la arista no se teje. El nivel 3 va entero sobre esta variable y sus consecuencias.
Se llama Listener en Solid, activeSub en el Vue actual, activeConsumer en Angular, evalContext en Preact Signals, y computing o equivalentes en implementaciones más pequeñas.
Pieza 4: el planificador
El planificador decide cuándo corren los efectos. Es la pieza donde los motores divergen más, porque encierra una decisión de producto: si los efectos corren de forma síncrona con la escritura, el programador razona con más facilidad pero paga cascadas; si corren diferidos a una cola, hay una ventana de inconsistencia observable a cambio de agrupar trabajo.
let Lote = null; // la cola de efectos pendientes, o null si no hay lote
function lote(fn) {
if (Lote) return fn(); // ya hay uno abierto, nos sumamos
const cola = Lote = new Set();
try { return fn(); }
finally {
for (const e of cola) { cola.delete(e); ejecutar(e); }
Lote = null;
}
}
En Solid, batch más dos colas internas separadas para computaciones puras y efectos. En Vue, la cola de jobs con nextTick y la opción flush de los watchers, que admite pre, post y sync. En Angular, el planificador de detección de cambios, que desde la estabilización del modo zoneless se apoya directamente en las notificaciones del grafo de señales. En Preact Signals, batch. En TC39, deliberadamente nada: el proposal deja el scheduling entero fuera de la especificación, y esa ausencia es una decisión de diseño, no un hueco por rellenar.
Pieza 5: el ámbito de vida
La quinta pieza responde a una pregunta que las otras cuatro ignoran: cuando una parte de la aplicación desaparece, ¿quién desata sus aristas y quién ejecuta sus limpiezas?
let Duenno = null; // el ambito que posee lo que se cree ahora
const ambito = {
hijos: [], // computaciones y ambitos creados dentro de mi
limpiezas: [], // callbacks a ejecutar cuando me desechen
};
Sin esta pieza, cada suscripción tiene que darse de baja a mano, y olvidarse de una es una fuga silenciosa. Con ella, desechar un ámbito desecha en cascada todo lo que nació dentro. Es el mecanismo que distingue a la familia de Solid y es el tema del nivel 7.
Solid lo llama Owner, con createRoot, onCleanup, getOwner y runWithOwner. Vue lo llama effectScope, con getCurrentScope y onScopeDispose. Angular lo ata al inyector de dependencias: un effect() creado en un contexto de inyección se destruye con él, y también admite un DestroyRef explícito. Preact Signals no tiene árbol de dueños: effect devuelve una función de baja y la responsabilidad es tuya.
flowchart TB O[observador actual] -.teje aristas.-> G[grafo] F[fuentes] --> G C[computaciones] --> G G --> P[planificador] P --> E[efectos ejecutados] D[duenno] -.posee.-> C D -.posee.-> D2[subambitos] style O fill:#f9e2af,color:#11111b style F fill:#89b4fa,color:#11111b style C fill:#cba6f7,color:#11111b style G fill:#94e2d5,color:#11111b style P fill:#fab387,color:#11111b style E fill:#a6e3a1,color:#11111b style D fill:#f9e2af,color:#11111b style D2 fill:#f9e2af,color:#11111b
Vuelve a mirar las piezas 3 y 5. Ambas son una sola variable mutable de módulo: la computación actual y el dueño actual. Todo el resto —el grafo, la propagación, la limpieza en cascada— se apoya en que esas dos variables tengan el valor correcto en el instante correcto, y se mantienen correctas con un patrón único que verás cientos de veces: guardar el valor previo, asignar el nuevo, ejecutar, restaurar en un finally. Es una pila implícita construida sobre la pila de llamadas de JavaScript. Esto tiene tres consecuencias que explican casi todos los comportamientos raros que sufrirás. Primera: el tracking es dinámico y sincrónico, así que cualquier cosa que rompa la sincronía —un await, un setTimeout, un callback de addEventListener— rompe la asociación, y por eso todos los motores tienen la misma advertencia sobre async. Segunda: como es una pila, los ámbitos anidan de forma natural y gratis, que es justo lo que necesitas para componer componentes. Tercera: como es global al módulo, dos copias del mismo motor en el bundle son dos variables distintas y las aristas no se tejen entre ellas; es la causa real de esa clase de bugs en los que todo funciona en desarrollo y nada reacciona en producción tras una mala deduplicación de dependencias.
Dónde se desarrolla cada pieza
Cada pieza tiene su nivel. Las fuentes y las computaciones se estudian como estructura de datos en el nivel 2. El observador actual, en el 3. El algoritmo de propagación, en el 4 y el 5. La memoización, que es una propiedad de las derivaciones, en el 6. El árbol de dueños, en el 7. El planificador, en el 8.
Y en el nivel 12 se ensamblan las cinco en un motor funcional de unas ciento cuarenta líneas que puedes ejecutar. Si en algún momento del camino pierdes de vista para qué servía algo, vuelve a esta lista de cinco.
- Abre el código fuente de la parte reactiva de un framework que uses, y no leas la documentación.
- Encuentra las cinco piezas y anota el nombre exacto que recibe cada una.
- Localiza el punto donde se guarda y restaura el observador actual, y comprueba si usa
tryyfinally. - Determina si ese motor tiene la pieza 5 y, si no la tiene, quién asume la responsabilidad de dar de baja.