Parte 5: el motor completo y hacia dónde va TC39
Las ciento treinta y ocho líneas ensambladas y probadas, el banco de pruebas que verifica cada garantía, y el estado real de la propuesta de Signals en el comité de estandarización de JavaScript.
Aquí está todo junto. Ciento treinta y ocho líneas de JavaScript sin dependencias que implementan tracking automático, propagación push-pull sin glitches, memoización perezosa con corte por igualdad, árbol de dueños con limpieza en cascada y lotes reentrantes. Cópialo, ejecútalo y modifícalo. Después, el estado real de la propuesta que pretende poner esta misma máquina dentro del lenguaje.
- Ensamblar el motor completo y verificarlo con el banco de pruebas.
- Localizar cada una de las cinco piezas del nivel 0 en el código final.
- Conocer el estado real de la propuesta de Signals en TC39.
- Decidir qué añadirías a este motor y con qué criterio.
El motor
// ---------- estado del motor ----------
let Observador = null;
let Duenno = null;
let Lote = null;
const LIMPIO = 0, QUIZA = 1, SUCIO = 2;
// ---------- aristas ----------
function seguir(fuente) {
if (!Observador) return;
Observador.fuentes.add(fuente);
fuente.observadores.add(Observador);
}
function desatar(nodo) {
for (const f of nodo.fuentes) f.observadores.delete(nodo);
nodo.fuentes.clear();
}
// ---------- duennos ----------
function registrar(nodo) { if (Duenno) Duenno.hijos.push(nodo); }
function alLimpiar(fn) { if (Duenno) Duenno.limpiezas.push(fn); else fn(); }
function limpiar(nodo) {
for (const h of nodo.hijos) desechar(h);
nodo.hijos.length = 0;
for (const fn of nodo.limpiezas) { try { fn(); } catch (e) { console.error(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 };
const dA = Duenno, oA = Observador;
Duenno = nodo; Observador = null;
try { return fn(() => desechar(nodo)); }
finally { Duenno = dA; Observador = oA; }
}
// ---------- lote ----------
function lote(fn) {
if (Lote) return fn();
const cola = Lote = new Set();
try { return fn(); }
finally {
for (const e of cola) { cola.delete(e); if (e.estado !== LIMPIO) actualizar(e); }
Lote = null;
}
}
function planificar(efecto) {
if (Lote) Lote.add(efecto);
else lote(() => Lote.add(efecto));
}
// ---------- marcado en dos fases ----------
function marcar(fuente, estado) {
for (const obs of fuente.observadores) {
if (obs.estado >= estado) continue;
const antes = obs.estado;
obs.estado = estado;
if (obs.efecto) planificar(obs);
if (antes === LIMPIO) marcar(obs, QUIZA);
}
}
// ---------- senal ----------
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;
nodo.valor = v;
lote(() => marcar(nodo, SUCIO));
return v;
};
return leer;
}
// ---------- computaciones ----------
function crearComputacion(fn, esEfecto, iguales) {
const nodo = {
fn, valor: undefined, estado: SUCIO, efecto: esEfecto,
fuentes: new Set(), observadores: new Set(),
hijos: [], limpiezas: [], iguales: iguales ?? Object.is,
};
registrar(nodo);
return nodo;
}
function actualizar(nodo) {
if (nodo.estado === QUIZA) {
for (const f of nodo.fuentes) {
if (f.fn) actualizar(f);
if (nodo.estado === SUCIO) break;
}
if (nodo.estado === QUIZA) { nodo.estado = LIMPIO; return; }
}
if (nodo.estado !== SUCIO) return;
limpiar(nodo);
desatar(nodo);
const oA = Observador, dA = Duenno;
Observador = nodo; Duenno = nodo;
try {
const v = nodo.fn(nodo.valor);
if (nodo.efecto) { nodo.valor = v; return; }
if (!nodo.iguales(nodo.valor, v)) {
nodo.valor = v;
for (const o of nodo.observadores) {
if (o.estado >= SUCIO) continue;
o.estado = SUCIO;
if (o.efecto) planificar(o);
}
}
} finally {
Observador = oA; Duenno = dA;
nodo.estado = LIMPIO;
}
}
function memo(fn, opciones = {}) {
const nodo = crearComputacion(fn, false, opciones.iguales);
return () => {
if (nodo.estado !== LIMPIO) actualizar(nodo);
seguir(nodo);
return nodo.valor;
};
}
function efecto(fn) {
const nodo = crearComputacion(fn, true);
actualizar(nodo);
return () => desechar(nodo);
}
function sinSeguir(fn) {
const a = Observador; Observador = null;
try { return fn(); } finally { Observador = a; }
}
export { senal, memo, efecto, lote, raiz, alLimpiar, sinSeguir };
Las cinco piezas, localizadas
Vuelve al inventario del nivel 0 y encuéntralas en el código.
La fuente es el objeto de senal: valor, observadores, función de igualdad.
La computación es lo que devuelve crearComputacion, con la bandera efecto distinguiendo los dos sabores.
El observador actual es Observador, gestionado en actualizar y en sinSeguir con el patrón de guardar y restaurar.
El planificador es Lote, lote y planificar.
El ámbito de vida es Duenno, con registrar, alLimpiar, limpiar, desechar y raiz.
Todo el track cabe en esas cinco piezas, y todas las decisiones de diseño que hemos estudiado están tomadas en una línea concreta que puedes señalar.
El banco de pruebas
Estas comprobaciones verifican cada garantía por separado. Si modificas el motor, ejecútalas.
let fallos = 0;
const eq = (a, b, m) => {
const ok = JSON.stringify(a) === JSON.stringify(b);
if (!ok) { fallos++; console.log('FALLA', m, a, b); } else console.log('ok', m);
};
// Propagacion y corte por igualdad
{ const a = senal(1); const d = memo(() => a() * 2); const log = [];
efecto(() => log.push(d())); a.set(2); a.set(2); a.set(3);
eq(log, [2, 4, 6], 'propagacion con dedupe'); }
// Consistencia glitch-free en el rombo
{ const a = senal(1); const b = memo(() => a() + 1); const c = memo(() => a() * 10);
const log = []; efecto(() => log.push(b() + c())); a.set(2);
eq(log, [12, 23], 'rombo sin glitch'); }
// El memo corta la cascada
{ const a = senal(1); let n = 0; const par = memo(() => a() % 2 === 0);
efecto(() => { par(); n++; });
a.set(3); a.set(5); a.set(7); eq(n, 1, 'memo corta');
a.set(2); eq(n, 2, 'memo repropaga si cambia'); }
// Dependencias condicionales
{ const cond = senal(true), x = senal('x'), y = senal('y'); const log = [];
efecto(() => log.push(cond() ? x() : y()));
y.set('Y2'); eq(log, ['x'], 'rama muerta no dispara');
cond.set(false); eq(log, ['x', 'Y2'], 'cambia de rama');
x.set('X3'); eq(log, ['x', 'Y2'], 'la rama vieja se desato'); }
// Lote
{ const a = senal(1), b = senal(2); let n = 0;
efecto(() => { a(); b(); n++; });
lote(() => { a.set(10); b.set(20); }); eq(n, 2, 'lote agrupa'); }
// Limpieza en cascada
{ const orden = [];
const tirar = raiz((d) => { alLimpiar(() => orden.push('raiz'));
efecto(() => { alLimpiar(() => orden.push('efecto')); }); return d; });
tirar(); eq(orden, ['efecto', 'raiz'], 'cascada de limpieza'); }
// 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);
eq(log, ['corre 0', 'limpio 0', 'corre 1', 'limpio 1', 'corre 2'], 'limpieza por pasada'); }
// Pereza del memo
{ const a = senal(1); let c = 0; const m = memo(() => { c++; return a() * 2; });
eq(c, 0, 'perezoso'); m(); a.set(2); eq(c, 1, 'escribir no recalcula');
eq(m(), 4, 'valor correcto'); eq(c, 2, 'recalcula al leer'); }
// El estado intermedio corta una cadena entera
{ const a = senal(1); let c1 = 0, c2 = 0, n = 0;
const m1 = memo(() => { c1++; return a() > 0; });
const m2 = memo(() => { c2++; return m1() ? 'si' : 'no'; });
efecto(() => { m2(); n++; }); a.set(2); a.set(3);
eq([c1, c2, n], [3, 1, 1], 'QUIZA corta la cadena'); }
// Efecto que escribe: cascada dentro del mismo vaciado
{ const a = senal(0), b = senal(0); const log = [];
efecto(() => { log.push('a=' + a()); if (a() < 3) b.set(a()); });
efecto(() => log.push('b=' + b())); a.set(1);
eq(log, ['a=0', 'b=0', 'a=1', 'b=1'], 'cascada'); }
// Leer sin rastrear
{ const a = senal(1), b = senal(1); let n = 0;
efecto(() => { a(); sinSeguir(() => b()); n++; });
b.set(5); eq(n, 1, 'sinSeguir no teje arista');
a.set(5); eq(n, 2, 'la lectura seguida si'); }
console.log(fallos === 0 ? 'TODO OK' : fallos + ' FALLOS');
flowchart TB P1[parte 1 nucleo] --> P2[parte 2 estados memo y dos fases] P2 --> P3[parte 3 duenno y limpieza] P3 --> P4[parte 4 lote y politica] P4 --> M[motor completo de 138 lineas] M --> G1[sin glitches] M --> G2[sin trabajo inutil] M --> G3[sin fugas] M --> G4[transacciones atomicas] style P1 fill:#89b4fa,color:#11111b style P2 fill:#cba6f7,color:#11111b style P3 fill:#f9e2af,color:#11111b style P4 fill:#fab387,color:#11111b style M fill:#94e2d5,color:#11111b style G1 fill:#a6e3a1,color:#11111b style G2 fill:#a6e3a1,color:#11111b style G3 fill:#a6e3a1,color:#11111b style G4 fill:#a6e3a1,color:#11111b
Qué falta aquí y qué falta en el lenguaje
Qué le falta y con qué criterio añadirlo
Este motor es completo en garantías y austero en comodidades. Lo que le falta, por orden de rentabilidad.
Detección de ciclos. Un cuarto estado para nodos en evaluación, que permita lanzar un error claro en lugar de desbordar la pila. Barato y muy útil en desarrollo.
Protección contra cascadas infinitas. El contador de la parte 4, que no incluimos en el código final para no alargarlo.
Contexto heredado. Un campo padre en cada nodo y dos funciones de veinte líneas, que aprovechan el árbol de dueños ya construido.
Representación más eficiente de las aristas. Sustituir los conjuntos por arrays con índices cruzados o por listas enlazadas con reutilización. Es mecánico y no toca la lógica de propagación, que es precisamente el argumento de la lección 3 del nivel 2 para empezar por lo legible.
Estado profundo por proxy. Todo el nivel 9, construido encima sin tocar nada de esto.
El criterio para decidir qué añadir es el mismo de todo el track: mide primero. Instrumenta cuerpos ejecutados y aristas tejidas, encuentra el nodo con más descendientes, y añade solo lo que ese diagnóstico pida.
La propuesta de Signals en TC39
La misma máquina que acabas de escribir es la que un grupo de trabajo del comité de estandarización de JavaScript quiere llevar al lenguaje. Conviene ser preciso sobre su estado, porque se exagera con frecuencia.
Está en Stage 1. Eso significa que el comité ha aceptado que el problema merece explorarse. No hay compromiso sobre la forma final de la API, no hay implementaciones en motores de navegador, y no hay fecha. El plan declarado del grupo es hacer prototipado temprano sustancial, incluida la integración en varios frameworks, antes de intentar avanzar de etapa.
La API propuesta es deliberadamente austera. Signal.State para el estado escribible, con .get() y .set(). Signal.Computed para la derivación cacheada y perezosa, con solo .get(). Las dos aceptan una opción equals, que es la función de igualdad del nivel 6. Y Signal.subtle.Watcher, una primitiva de bajo nivel que notifica cuando algo vigilado se ensucia.
import { Signal } from 'signal-polyfill';
const contador = new Signal.State(0);
const doble = new Signal.Computed(() => contador.get() * 2);
contador.set(5);
doble.get(); // 10, calculado perezosamente
No hay efectos en la propuesta. Es la decisión más interesante y la más malinterpretada. En lugar de un effect, hay un vigilante que notifica una vez y de forma síncrona, y deja que el framework decida cuándo vaciar. Es decir: la propuesta estandariza el mecanismo y deja fuera la política, exactamente la separación que encontramos al terminar la parte 4.
El grupo incluye a autores de los motores que hemos comparado, además de gente de Ember, Qwik y MobX. Esa amplitud es la que da credibilidad al intento: si los cinco convergieron en la misma arquitectura por su cuenta, estandarizar la parte compartida tiene sentido.
Muchas propuestas se quedan en Stage 1 durante años y algunas no avanzan nunca. Que exista un polyfill publicado y que varios frameworks estén experimentando con él es una buena señal, y no es lo mismo que estar en camino de aterrizar. La postura sensata hoy es conocerla, experimentar con el polyfill si te interesa, y no construir sobre ella nada que no estés dispuesto a reescribir.
Cierro el track con la razón por la que merecía la pena escribir estas ciento treinta y ocho líneas en lugar de solo leer sobre ellas. Hay una diferencia cualitativa entre saber que un motor reactivo mantiene un grafo y haber escrito la línea que teje una arista; entre saber que existe la consistencia glitch-free y haber visto tu propio rombo imprimir el valor imposible antes de arreglarlo. A partir de ahora, cuando una interfaz no se actualice, tu primera pregunta ya no será cuál de las reglas del framework has incumplido, sino qué arista falta y por qué no se tejió —y esa pregunta tiene respuesta, siempre, en un sitio concreto. Cuando algo se ejecute de más, no buscarás una memoización que añadir sino el nodo que asciende a sucio cuando no debería. Cuando leas la documentación de un motor que no conoces, no aprenderás una API: reconocerás las ocho decisiones y sabrás cuál tomó en cada una con un par de experimentos. Y cuando aparezca el siguiente framework prometiendo una reactividad revolucionaria, tendrás la única pregunta que separa lo nuevo de lo renombrado: qué guarda en memoria entre dos cambios, quién lo recorre y cuándo. Esa capacidad no caduca con las versiones, no depende del framework que uses en el trabajo y no se obtiene leyendo. Se obtiene escribiendo el motor, y ya lo has escrito.
- Copia el motor completo, ejecuta el banco de pruebas y comprueba que pasa entero.
- Añade la detección de dependencias circulares con un cuarto estado y una prueba que la verifique.
- Sustituye los conjuntos por arrays con índices cruzados sin tocar
marcarniactualizar, y comprueba que las pruebas siguen pasando. - Instala el polyfill de la propuesta de TC39, reimplementa dos de las pruebas sobre él, y anota qué te ha faltado y cómo lo has suplido.