Tejer el grafo a mano
Construir un grafo reactivo con suscripción explícita, sin tracking automático, para ver exactamente qué trabajo asume el motor cuando lo automatiza y qué se pierde al hacerlo.
Antes de que el motor descubra las dependencias solo, conviene declararlas a mano una vez. El ejercicio es incómodo a propósito: al terminarlo tendrás muy claro qué trabajo exacto te está ahorrando el tracking automático, qué garantías extra compra, y —esto es lo que sorprende— qué cosas hace mejor la versión manual.
- Implementar un grafo reactivo con dependencias declaradas explícitamente.
- Comparar el resultado con la versión de tracking automático.
- Identificar las tres cosas que la declaración manual hace mejor.
- Entender por qué casi todos los motores eligen igualmente el automático.
Un motor con dependencias declaradas
En la versión manual, crear una derivación exige pasar la lista de sus fuentes. El motor no observa nada: simplemente conecta lo que le dices.
function fuente(valor) {
return { tipo: 'fuente', valor, observadores: new Set() };
}
function derivada(fuentes, calcular) {
const nodo = { tipo: 'derivada', valor: undefined, fuentes, observadores: new Set(), sucio: true, calcular };
for (const f of fuentes) f.observadores.add(nodo); // aristas fijas, tejidas una vez
return nodo;
}
function leer(nodo) {
if (nodo.tipo === 'fuente') return nodo.valor;
if (nodo.sucio) {
nodo.valor = nodo.calcular(...nodo.fuentes.map(leer));
nodo.sucio = false;
}
return nodo.valor;
}
function escribir(nodo, valor) {
if (Object.is(nodo.valor, valor)) return;
nodo.valor = valor;
ensuciar(nodo);
}
function ensuciar(nodo) {
for (const o of nodo.observadores) {
if (o.sucio) continue; // ya marcado, sus descendientes tambien
o.sucio = true;
ensuciar(o);
}
}
Cuarenta líneas y ya tienes propagación correcta con evaluación perezosa. Úsalo así.
const precio = fuente(100);
const cantidad = fuente(3);
const subtotal = derivada([precio, cantidad], (p, c) => p * c);
const total = derivada([subtotal], (s) => s * 1.21);
leer(total); // 363
escribir(cantidad, 5);
leer(total); // 605, recalculado bajo demanda
Fíjate en lo que no hace falta aquí: no hay variable de observador actual, no hay desatado antes de reejecutar, no hay estructura de índices cruzados. Las aristas se tejen una vez en la construcción y no cambian jamás. Es notablemente más simple.
Lo que la versión manual hace mejor
El grafo es estático y por tanto analizable. Como las aristas se conocen en el momento de la construcción y no cambian, se puede calcular el orden topológico una sola vez y reutilizarlo en cada propagación. Con tracking automático el grafo cambia en cada ejecución, así que el orden hay que recalcularlo o aproximarlo continuamente. Este es el motivo por el que los sistemas de flujo de datos con topología fija —motores de audio, grafos de compilación, hojas de cálculo con dependencias declaradas— pueden ser más eficientes que un motor de interfaz.
Cero contabilidad en la ejecución. No hay que desatar ni volver a tejer. Una reejecución solo cuesta el cómputo, sin las dos operaciones por dependencia que paga el automático. En cómputos muy baratos y muy frecuentes eso es la mitad del coste.
Las dependencias son documentación. Al leer derivada([precio, cantidad], ...) sabes de qué depende sin ejecutar nada. Con tracking automático, saberlo exige ejecutar el cuerpo, y en un cuerpo con condicionales la respuesta depende del estado. Para razonar sobre un sistema grande, esa diferencia importa.
La declaración explícita sigue muy viva. La lista de dependencias de los hooks de React es exactamente esto. El primer argumento de watch en Vue es exactamente esto. El helper on de Solid convierte una computación de tracking automático en una de dependencias declaradas, y existe precisamente porque a veces quieres ese control. No es un modelo primitivo superado: es un punto distinto del mismo eje, con otras ventajas.
Lo que pierde
Tres cosas, y las tres son decisivas en interfaces.
Las dependencias hay que mantenerlas sincronizadas con el cuerpo a mano. Si añades una lectura al cuerpo y olvidas añadirla a la lista, el nodo no se actualiza cuando debería. Es un bug silencioso, y es exactamente la clase de error que las reglas de lint sobre listas de dependencias intentan atrapar. Que exista toda una industria de reglas de lint para este problema dice bastante sobre su frecuencia.
No hay dependencias condicionales. Si el cuerpo lee a cuando una bandera es cierta y b cuando es falsa, la lista tiene que incluir las dos, así que el nodo se reejecuta ante cambios en b incluso mientras la bandera es cierta. En un grafo con muchas condicionales, ese trabajo sobrante es enorme. El tracking automático lo elimina por construcción, porque solo teje las aristas que la ejecución de verdad recorrió.
No compone. Si calcularTotal() es una función que lee tres fuentes y quieres llamarla desde una derivación, tienes que replicar sus dependencias en la lista de quien la llama, y actualizarlas cuando la función cambie. La abstracción se rompe: las dependencias atraviesan las fronteras de función y el sistema de declaración no. Con tracking automático, una función que lee fuentes se puede llamar desde cualquier sitio y las aristas se tejen solas.
flowchart TB M[declaracion manual] --> M1[grafo estatico y analizable] M --> M2[cero contabilidad en ejecucion] M --> M3[dependencias visibles en el codigo] M --> M4[hay que sincronizarlas a mano] M --> M5[sin dependencias condicionales] M --> M6[no compone entre funciones] style M fill:#89b4fa,color:#11111b style M1 fill:#a6e3a1,color:#11111b style M2 fill:#a6e3a1,color:#11111b style M3 fill:#a6e3a1,color:#11111b style M4 fill:#f38ba8,color:#11111b style M5 fill:#f38ba8,color:#11111b style M6 fill:#f38ba8,color:#11111b
La justificación que se da del tracking automático suele ser la comodidad: no tener que escribir la lista. Es cierto y es lo de menos. La razón de peso es la tercera de la lista anterior, y es de naturaleza distinta: con dependencias declaradas, el conjunto de dependencias no es composicional. Si f depende de a y b, y g llama a f, entonces g depende de a y b, pero el sistema no lo sabe salvo que alguien lo escriba, y hay que reescribirlo cada vez que cambie el cuerpo de f. Eso significa que la lista de dependencias de una función forma parte de su interfaz pública, y por tanto refactorizar el interior de una función es un cambio incompatible para sus llamadores. Es una violación de encapsulación en toda regla, y es la razón por la que las listas de dependencias declaradas escalan tan mal en bases de código grandes: no fallan por olvidos, fallan porque impiden abstraer. El tracking automático resuelve eso moviendo el descubrimiento de dependencias del tiempo de escritura al tiempo de ejecución, con lo que atraviesa cualquier número de fronteras de función sin que nadie declare nada. A cambio acepta que el grafo sea dinámico e impredecible, con todas las consecuencias que veremos en el nivel 3. Ese es el intercambio real, y una vez lo ves entiendes tanto por qué React Compiler existe —automatizar lo que el programador declaraba— como por qué los cinco motores de señales convergieron en el automático sin ponerse de acuerdo.
El puente al nivel siguiente
Todo lo que separa el motor de esta lección del de un framework real es una sola cosa: sustituir la lista de dependencias declarada por un mecanismo que las descubra durante la ejecución. Ese mecanismo es una variable de módulo y tres líneas de código, y es el nivel 3 entero.
Cuando lo veas, vuelve a este motor manual y comprueba cuántas de sus cuarenta líneas sobreviven. Casi todas: el marcado, la lectura perezosa, la comparación de igualdad. Lo que se añade es el descubrimiento y su inseparable compañero, el desatado.
- Parte del motor de esta lección y añade una variable de módulo para el observador actual.
- Haz que
leerteje la arista cuando esa variable no sea nula, y elimina el parámetro de dependencias dederivada. - Añade el desatado antes de recalcular y comprueba con una condicional que las aristas cambian entre ejecuciones.
- Cuenta cuántas líneas has añadido y cuántas has borrado, y anota qué garantía has perdido en el camino.