Implementar el árbol de dueños completo
Las veinticinco líneas que añaden propiedad, limpieza en cascada, raíces y contexto heredado a un motor reactivo, con las pruebas que verifican cada garantía.
Cerramos el nivel juntando todas las piezas en una implementación que funciona y que se puede probar. El árbol de dueños completo —registro, cascada, raíces, captura de dueño y contexto heredado— cabe en unas veinticinco líneas, y cada una de sus garantías se verifica con un test de cinco. Este código es literalmente el que aparecerá en el motor del nivel 12.
- Ensamblar el árbol de dueños completo en un módulo coherente.
- Añadir contexto heredado sobre la misma estructura.
- Escribir las pruebas que verifican cada garantía.
- Integrar el árbol con el ciclo de reejecución de computaciones.
El módulo completo
let Duenno = null;
function registrar(nodo) {
if (Duenno) Duenno.hijos.push(nodo);
}
function alLimpiar(fn) {
if (Duenno) Duenno.limpiezas.push(fn);
else fn(); // sin duenno, limpiar de inmediato es lo menos malo
}
function limpiar(nodo) {
for (const hijo of nodo.hijos) desechar(hijo);
nodo.hijos.length = 0;
for (const fn of nodo.limpiezas) {
try { fn(); } catch (e) { console.error('fallo en limpieza', e); }
}
nodo.limpiezas.length = 0;
}
function desechar(nodo) {
limpiar(nodo);
if (nodo.fuentes) desatar(nodo);
nodo.estado = LIMPIO;
}
function raiz(fn) {
const nodo = { hijos: [], limpiezas: [], fuentes: null, contexto: Duenno?.contexto ?? null };
const dA = Duenno, oA = Observador;
Duenno = nodo; Observador = null;
try { return fn(() => desechar(nodo)); }
finally { Duenno = dA; Observador = oA; }
}
function duennoActual() { return Duenno; }
function conDuenno(duenno, fn) {
const dA = Duenno, oA = Observador;
Duenno = duenno; Observador = null;
try { return fn(); } finally { Duenno = dA; Observador = oA; }
}
Veinticinco líneas. El detalle de alLimpiar sin dueño merece un comentario: hay tres políticas posibles —ejecutar de inmediato, ignorar en silencio, o avisar— y la mejor para un motor didáctico es ejecutar, porque hace que la primitiva sea siempre segura de llamar. Solid, por ejemplo, avisa en desarrollo cuando se registra una limpieza fuera de un ámbito, que es la opción más informativa.
La integración con la reejecución
El árbol se engancha al ciclo de vida de las computaciones en dos puntos, y solo dos.
function crearComputacion(fn, esEfecto) {
const nodo = {
fn, valor: undefined, estado: SUCIO, efecto: esEfecto,
fuentes: new Set(), observadores: new Set(),
hijos: [], limpiezas: [],
};
registrar(nodo); // punto 1: al crearse, me posee el duenno actual
return nodo;
}
function actualizar(nodo) {
// ... resolucion del estado
limpiar(nodo); // punto 2: al reejecutar, destruyo lo de la pasada anterior
desatar(nodo);
const oA = Observador, dA = Duenno;
Observador = nodo; Duenno = nodo; // soy observador y duenno a la vez
try { nodo.valor = nodo.fn(nodo.valor); }
finally { Observador = oA; Duenno = dA; nodo.estado = LIMPIO; }
}
Que sean solo dos puntos es lo que hace el mecanismo fiable. Cuantas menos funciones toquen el árbol, menos sitios hay donde romper sus invariantes.
Contexto heredado sobre la misma estructura
Como el árbol de dueños ya modela el anidamiento léxico, sirve tal cual para pasar valores hacia abajo sin parámetros.
function proveer(clave, valor, fn) {
if (!Duenno) throw new Error('proveer necesita un duenno');
Duenno.contexto = { ...(Duenno.contexto ?? {}), [clave]: valor };
return fn();
}
function consumir(clave, porDefecto) {
let d = Duenno;
while (d) {
if (d.contexto && clave in d.contexto) return d.contexto[clave];
d = d.padre;
}
return porDefecto;
}
Para que consumir funcione hace falta un campo padre en cada nodo, que se asigna en registrar. Es una línea más y merece la pena porque abre esta funcionalidad entera.
function registrar(nodo) {
nodo.padre = Duenno;
if (Duenno) Duenno.hijos.push(nodo);
}
Con eso, el mismo árbol responde a las tres preguntas: quién me destruye, qué destruyo yo, y de quién heredo configuración. Es exactamente lo que hace el contexto en cualquier framework de esta familia.
flowchart TB R[raiz] --> A[ambito de la pagina] A --> B[ambito del panel] B --> C[efecto que consume el tema] A -.provee tema.-> A C -.sube buscando el tema.-> A style R fill:#cba6f7,color:#11111b style A fill:#cba6f7,color:#11111b style B fill:#cba6f7,color:#11111b style C fill:#a6e3a1,color:#11111b
Las pruebas y el cierre del nivel
Las pruebas
Cada garantía tiene su test, y son cortos.
// 1. Cascada: los hijos se desechan antes que el padre
{
const orden = [];
const tirar = raiz((desechar) => {
alLimpiar(() => orden.push('padre'));
efecto(() => { alLimpiar(() => orden.push('hijo')); });
return desechar;
});
tirar();
console.assert(orden.join() === 'hijo,padre');
}
// 2. Limpieza entre reejecuciones
{
const a = senal(0), log = [];
raiz(() => efecto(() => { const v = a(); alLimpiar(() => log.push('limpio ' + v)); log.push('corre ' + v); }));
a.set(1); a.set(2);
console.assert(log.join() === 'corre 0,limpio 0,corre 1,limpio 1,corre 2');
}
// 3. Una raiz no rastrea lo de fuera
{
const a = senal(0); let n = 0;
efecto(() => { n++; raiz(() => a()); }); // la lectura dentro de la raiz no suscribe
a.set(1);
console.assert(n === 1);
}
// 4. Desechar desata las aristas
{
const a = senal(0);
const tirar = raiz((d) => { efecto(() => a()); return d; });
console.assert(a.nodo.observadores.size === 1);
tirar();
console.assert(a.nodo.observadores.size === 0);
}
La cuarta es la más importante y la que más se olvida al implementar: desechar tiene que desatar. Sin esa línea, el árbol destruye la estructura de propiedad y deja las aristas del grafo intactas, con lo que la fuga que el mecanismo pretendía evitar sigue exactamente igual.
Después de implementarlo, merece la pena valorar lo que este mecanismo aporta al conjunto. Sin él tienes un sistema de propagación correcto que no se puede usar en una aplicación real, porque cada efecto que crees es una fuga a menos que lleves la cuenta a mano, y llevar la cuenta a mano es el problema que demostramos que no escala en la lección anterior. Con él, la creación de efectos se vuelve una operación que no requiere pensar en el ciclo de vida, y eso cambia por completo el estilo de código que la gente escribe: puedes crear efectos dentro de bucles, dentro de condicionales, dentro de funciones auxiliares, con la tranquilidad de que morirán con su ámbito. Esa tranquilidad es la que permite la reactividad de grano fino de verdad, con miles de efectos pequeños en lugar de unos pocos grandes; sin ella, cada efecto tendría un coste administrativo que empujaría a agruparlos, y agruparlos es exactamente lo que destruye la granularidad que hacía valioso el modelo. De modo que hay una cadena de implicaciones que conviene tener presente y que muy poca gente hace explícita: el árbol de dueños es lo que hace viable el grano fino. Sin propiedad automática, el coste de gestionar mil efectos supera al beneficio de tenerlos, y acabas escribiendo un motor de grano fino con un grafo de grano grueso. Por eso este nivel está donde está y no como un apéndice: no es una comodidad ergonómica, es un requisito arquitectónico.
Cierre del nivel
Dos variables de módulo, dos estructuras. El observador construye el grafo de dependencias y responde a qué recalcular. El dueño construye el árbol de propiedad y responde a qué destruir. Las dos se gestionan con el mismo patrón de guardar y restaurar, las dos se pierden al cruzar una frontera asíncrona, y las dos tienen su primitiva de captura y restauración.
Lo que queda por decidir es cuándo corre todo esto: si los efectos se ejecutan en el momento de la escritura o se agrupan y difieren. Ese es el nivel 8.
- Implementa el módulo completo de esta lección sobre tu motor.
- Ejecuta las cuatro pruebas y comprueba que pasan.
- Elimina la línea de desatado en
desechary comprueba cuál de las pruebas falla. - Añade el campo
padrey el contexto heredado, y escribe una prueba que verifique que un valor provisto arriba llega a un efecto tres niveles abajo.