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

Parte 1: el núcleo, señal y efecto

Las primeras treinta líneas del motor: el observador actual, el tejido y desatado de aristas, la fuente con su comparación de igualdad y el efecto que se reejecuta. Funciona, y ya se puede probar.

⏱ 18 min

Empezamos a construir. En cinco lecciones vamos a escribir un motor de reactividad completo y funcional en unas ciento cuarenta líneas de JavaScript llano, sin dependencias y sin tocar el DOM ni una vez. Esta primera parte son treinta líneas y ya hace algo útil: tracking automático de dependencias con reejecución. También tiene dos defectos concretos que las partes siguientes arreglan, y conviene verlos.

🎯 Al terminar esta lección sabrás
  • Escribir el observador actual y las dos operaciones sobre aristas.
  • Implementar la fuente con su comparación de igualdad.
  • Implementar el efecto con desatado y reejecución.
  • Reconocer los dos defectos de esta versión y qué los arregla.

Estado del motor y aristas

Todo empieza con una variable de módulo y dos funciones.

let Observador = null;   // la computacion en ejecucion, o null

function seguir(fuente) {
  if (!Observador) return;                      // lectura fuera de ambito reactivo
  Observador.fuentes.add(fuente);               // arista hacia atras
  fuente.observadores.add(Observador);          // arista hacia adelante
}

function desatar(nodo) {
  for (const f of nodo.fuentes) f.observadores.delete(nodo);
  nodo.fuentes.clear();
}

Usamos conjuntos en las dos direcciones. Deduplican solos, dan alta y baja en tiempo constante y son lo más legible; el nivel 2 discutía las alternativas y cuándo compensan.

seguir y desatar son las únicas dos funciones de todo el motor que modifican aristas. Esa escasez es deliberada: mantener la invariante de simetría del nivel 2 es fácil si solo hay dos sitios donde se puede romper.

La señal

Una fuente con su valor, su lista de observadores y su función de igualdad.

function senal(inicial, opciones = {}) {
  const nodo = {
    valor: inicial,
    observadores: new Set(),
    iguales: opciones.iguales ?? Object.is,
  };
  const leer = () => { seguir(nodo); return nodo.valor; };
  leer.set = (sig) => {
    const v = typeof sig === 'function' ? sig(nodo.valor) : sig;
    if (nodo.iguales(nodo.valor, v)) return v;      // frontera de propagacion
    nodo.valor = v;
    for (const o of [...nodo.observadores]) correr(o);
    return v;
  };
  return leer;
}

Tres decisiones de diseño que merece la pena justificar.

El getter es una función y el setter una propiedad suya. Podríamos devolver una pareja como hace Solid, o un objeto con .value como hace Vue. Colgar set del getter permite pasar leer sin conceder escritura, y a la vez tener las dos operaciones a mano cuando hace falta.

El setter acepta un valor o una función. contador.set(n => n + 1) evita tener que leer antes de escribir, lo que fuera de un ámbito reactivo sería incómodo y dentro tejería una arista no deseada.

La comparación corta antes de tocar nada. Si el valor es igual, no se asigna y no se propaga. Es la frontera de propagación del nivel 6, y ponerla aquí significa que ninguna escritura redundante llega al grafo.

El efecto

Un nodo con un cuerpo y una lista de fuentes. La función que lo ejecuta es el patrón de guardar y restaurar del nivel 3, con el desatado del nivel 3 delante.

function correr(nodo) {
  desatar(nodo);                    // borrar las aristas de la pasada anterior
  const anterior = Observador;
  Observador = nodo;                // abrir el ambito de recoleccion
  try { nodo.fn(); }
  finally { Observador = anterior; }
}

function efecto(fn) {
  const nodo = { fn, fuentes: new Set() };
  correr(nodo);                     // los efectos son ansiosos: corren al crearse
  return nodo;
}

function sinSeguir(fn) {
  const a = Observador;
  Observador = null;
  try { return fn(); } finally { Observador = a; }
}

El finally no es opcional: si el cuerpo lanza y no se restaura la variable, todas las lecturas posteriores del programa se suscriben a un nodo muerto y el grafo queda corrupto.

El [...nodo.observadores] del setter tampoco es cosmético: correr llama a desatar, que modifica la lista de observadores que estamos recorriendo. Copiar antes de iterar evita el comportamiento indefinido.

Pruébalo y mira sus defectos

Pruébalo

Ya funciona. Estos tres casos pasan con lo escrito hasta aquí.

// 1. Reactividad basica y deduplicacion por igualdad
const log = [];
const a = senal(1);
efecto(() => log.push(a()));
a.set(2); a.set(2); a.set(3);
console.assert(JSON.stringify(log) === '[1,2,3]');

// 2. Dependencias condicionales: el grafo cambia en cada ejecucion
const cond = senal(true), x = senal('x'), y = senal('y');
const l2 = [];
efecto(() => l2.push(cond() ? x() : y()));
y.set('Y');            // no dispara: no hay arista con y
cond.set(false);       // dispara y cambia de rama
x.set('X');            // ya no dispara: la arista con x se desato
console.assert(JSON.stringify(l2) === '["x","Y"]');

// 3. Leer sin rastrear
const b = senal(1), c = senal(1);
let n = 0;
efecto(() => { b(); sinSeguir(() => c()); n++; });
c.set(9);
console.assert(n === 1);

Treinta líneas y ya tienes tracking automático, dependencias condicionales y desatado correcto. Es más de lo que parece.

flowchart LR
S[senal] -->|observadores| E[efecto]
E -->|fuentes| S
W[escritura] --> S
S --> C[correr el efecto]
C --> D[desatar aristas viejas]
D --> T[tejer las nuevas al ejecutar]
style S fill:#89b4fa,color:#11111b
style E fill:#a6e3a1,color:#11111b
style W fill:#f9e2af,color:#11111b
style C fill:#cba6f7,color:#11111b
style D fill:#cba6f7,color:#11111b
style T fill:#cba6f7,color:#11111b

Los dos defectos

Esta versión es push puro, con los dos problemas del nivel 4.

Trabajo inútil. No hay derivaciones, así que no hay nada perezoso: cada escritura ejecuta todos los efectos alcanzables, aunque su resultado no cambie nada.

Glitches. Prueba el rombo y verás el valor imposible.

const a = senal(1);
const b = { fn: null };                     // aun no tenemos memos
// con solo efectos, dos efectos que leen a y se combinan producen
// estados intermedios visibles en cuanto haya dos caminos

Los dos defectos tienen una sola causa: la escritura ejecuta cuerpos en lugar de marcar. La parte 2 introduce los tres estados, la propagación en dos fases y el memo, y con eso los dos desaparecen a la vez.

Estas treinta lineas ya contienen la decision que define a la familia

Antes de seguir, detente en lo que ya has escrito, porque la pieza más importante ya está puesta. seguir es el tracking automático completo; y con él has elegido, sin poder deshacerlo después, que el grafo sea dinámico. De esa elección se deriva absolutamente todo lo que viene. Como el grafo cambia en cada ejecución, hace falta desatar, que es la inversa de seguir. Como hace falta desatar, hacen falta las aristas hacia atrás, que es la mitad de la memoria del sistema. Como el grafo cambia, no se puede precalcular un orden topológico y hay que descubrirlo al recorrer, que es el marcado en dos fases de la parte 2. Y como cada ejecución crea recursos nuevos, hace falta un mecanismo que destruya los de la pasada anterior, que es el árbol de dueños de la parte 3. Cuatro consecuencias arquitectónicas de una sola línea de cuatro palabras. Si en lugar de seguir hubieras escrito una API que recibiera las dependencias declaradas, el motor entero sería otro: más pequeño, más rápido en el caso plano, incapaz de dependencias condicionales y de componer entre funciones. Esa es la decisión, ya está tomada, y todo lo demás la sirve.

⚔️ Rompe el nucleo a proposito
  1. Escribe las treinta líneas y ejecuta las tres pruebas.
  2. Elimina el finally de correr, haz que un cuerpo lance, y comprueba qué le pasa al grafo después.
  3. Elimina desatar y verifica con el caso condicional que las aristas se acumulan.
  4. Quita la copia del conjunto en el setter y encuentra un caso donde el comportamiento cambie.