wandres.dev
NIVEL DIOS · Síntesis de la reactividad fina

Parte 3: el dueño y la limpieza en cascada

La segunda variable de módulo y el árbol de propiedad: registro de hijos, callbacks de limpieza, desecho recursivo y raíces desacopladas. Veinticinco líneas que hacen el motor usable de verdad.

⏱ 17 min

Con las partes 1 y 2 el motor propaga correctamente y no hace trabajo de más. Lo que todavía no puede hacer es olvidar. Un efecto creado vive para siempre, sus aristas mantienen vivo su cierre, y cualquier recurso que adquiera —un temporizador, un escuchador— queda sin cancelar. Esta parte añade la segunda variable de módulo y el árbol de propiedad, y con ella el motor pasa de ser una curiosidad a ser utilizable.

🎯 Al terminar esta lección sabrás
  • Añadir el dueño actual y el registro de hijos.
  • Implementar el desecho recursivo con su orden correcto.
  • Integrar la limpieza en el ciclo de reejecución.
  • Escribir la raíz desacoplada y verificar que no rastrea.

La segunda variable

Idéntico patrón al del observador, con otra pregunta que responder.

let Duenno = null;   // el ambito que posee lo que se cree ahora

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
}

La política de alLimpiar sin dueño admite tres opciones: ejecutar, ignorar o avisar. Ejecutar hace la primitiva siempre segura de llamar, que es lo mejor para un motor pequeño. Un motor de producción suele avisar en desarrollo, porque casi siempre indica que el programador esperaba estar dentro de un ámbito.

El desecho recursivo

function limpiar(nodo) {
  for (const h of nodo.hijos) desechar(h);                 // 1. los hijos primero
  nodo.hijos.length = 0;
  for (const fn of nodo.limpiezas) {
    try { fn(); } catch (e) { console.error(e); }          // 2. luego mis limpiezas
  }
  nodo.limpiezas.length = 0;
}

function desechar(nodo) {
  limpiar(nodo);
  if (nodo.fuentes) desatar(nodo);                          // 3. fuera del grafo
  nodo.estado = LIMPIO;
}

Los hijos antes que las limpiezas, porque un hijo puede depender de un recurso que la limpieza del padre va a liberar. Es el orden inverso a la construcción, la regla universal de la destrucción de recursos.

El try alrededor de cada limpieza importa: si una falla, las demás tienen que ejecutarse igualmente. Sin él, un fallo deja el árbol a medio destruir y el resto de los recursos sin liberar.

Y la línea de desatar es la que más se olvida al implementar esto: sin ella, el árbol de propiedad se destruye y las aristas del grafo se quedan, con lo que la fuga que el mecanismo pretendía evitar sigue exactamente igual.

Integrarlo en el ciclo

Solo hay que tocar dos sitios, y que sean solo dos es lo que hace el mecanismo fiable.

function crearComputacion(fn, esEfecto, iguales) {
  const nodo = {
    fn, valor: undefined, estado: SUCIO, efecto: esEfecto,
    fuentes: new Set(), observadores: new Set(),
    hijos: [], limpiezas: [],                    // <- campos nuevos
    iguales: iguales ?? Object.is,
  };
  registrar(nodo);                                // <- punto 1: me posee el duenno actual
  return nodo;
}

Y dentro de actualizar, antes de reejecutar el cuerpo.

  if (nodo.estado !== SUCIO) return;
  limpiar(nodo);                                  // <- punto 2: destruir lo de la pasada anterior
  desatar(nodo);
  const oA = Observador, dA = Duenno;
  Observador = nodo; Duenno = nodo;               // soy observador y duenno a la vez
  try {
    // ... igual que en la parte 2
  } finally {
    Observador = oA; Duenno = dA;
    nodo.estado = LIMPIO;
  }

Fíjate en la simetría con el desatado: todo lo que la ejecución anterior creó se destruye en el mismo punto, aristas y recursos por igual. Son la misma operación aplicada a dos clases de cosa.

Y el efecto ahora devuelve su función de baja en lugar del nodo.

function efecto(fn) {
  const nodo = crearComputacion(fn, true);
  actualizar(nodo);
  return () => desechar(nodo);
}

La raíz y las pruebas

La raíz

Un ámbito sin padre, cuya vida controla quien lo crea.

function raiz(fn) {
  const nodo = { hijos: [], limpiezas: [], fuentes: null };
  const dA = Duenno, oA = Observador;
  Duenno = nodo;
  Observador = null;                              // una raiz no rastrea lo de fuera
  try { return fn(() => desechar(nodo)); }
  finally { Duenno = dA; Observador = oA; }
}

Los dos detalles importantes: se pasa la función de desecho al cuerpo, para que quien crea la raíz controle su vida; y se pone Observador a null, para que crear una raíz dentro de un efecto no haga que el efecto se suscriba a todo lo que la raíz lea.

flowchart TB
R[raiz] --> E1[efecto A]
R --> E2[efecto B]
E1 --> M[memo creado dentro de A]
E1 --> L1[limpieza de A]
M --> L2[limpieza del memo]
D[desechar la raiz] -.destruye en cascada.-> R
style R fill:#cba6f7,color:#11111b
style E1 fill:#cba6f7,color:#11111b
style E2 fill:#cba6f7,color:#11111b
style M fill:#cba6f7,color:#11111b
style L1 fill:#94e2d5,color:#11111b
style L2 fill:#94e2d5,color:#11111b
style D fill:#f38ba8,color:#11111b

Pruébalo

// 1. Cascada: los hijos se desechan antes que el padre
const orden = [];
const tirar = raiz((desecha) => {
  alLimpiar(() => orden.push('padre'));
  efecto(() => { alLimpiar(() => orden.push('hijo')); });
  return desecha;
});
tirar();
console.assert(orden.join() === 'hijo,padre');

// 2. Limpieza entre reejecuciones
const a = senal(0);
const 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 b = senal(0);
let n = 0;
efecto(() => { n++; raiz(() => b()); });
b.set(1);
console.assert(n === 1);

La segunda es la que más se usa en la práctica y la que hace el motor utilizable: cada reejecución libera lo que la anterior adquirió, sin que nadie lleve la cuenta.

Veinticinco lineas para reintroducir la propiedad determinista en un lenguaje que no la tiene

Vale la pena valorar la desproporción entre lo que cuesta esta parte y lo que aporta. Son veinticinco líneas, no tienen ningún algoritmo interesante, y sin embargo son la diferencia entre un motor de juguete y uno que se puede usar en un programa real. La razón es la que argumentaba el nivel 7: JavaScript resuelve la memoria con recolección de basura y no resuelve los recursos. Un temporizador, un escuchador, una conexión o una petición en vuelo no se liberan porque nadie los referencie; hay que liberarlos explícitamente. En un lenguaje con destructores deterministas eso lo hace el ámbito; aquí no hay ámbito que lo haga, y el árbol de dueños es exactamente la reintroducción de esa propiedad. Y es un motor reactivo el que más la necesita, porque es el que más recursos crea implícitamente: escribes un efecto y sin decirlo has creado una suscripción que hay que dar de baja. Si esa creación implícita no tuviera una destrucción implícita a juego, el modelo sería inutilizable a escala, y volverías a la contabilidad manual de suscripciones que hacía tan penosa la programación con observables. La simetría entre creación y destrucción implícitas es lo que hace viable el grano fino, y cuesta veinticinco líneas.

⚔️ Verifica el arbol
  1. Escribe la parte 3 e integra los dos puntos de enganche en actualizar.
  2. Ejecuta las tres pruebas y comprueba que pasan.
  3. Elimina la línea de desatar en desechar y averigua cuál de las pruebas deja de detectar la fuga.
  4. Invierte el orden dentro de limpiar y escribe un caso donde eso produzca un error observable.