Desatar antes de reejecutar
La operación menos vistosa y más importante del tracking automático: borrar todas las aristas antes de recalcular. Qué falla exactamente si se omite y qué optimizaciones existen para no pagarla entera.
De las cuatro líneas de una reejecución, la que nadie recuerda es la primera: borrar todas las aristas existentes antes de volver a ejecutar. Es contraintuitiva —parece un desperdicio destruir información que probablemente vas a recrear igual— y es obligatoria. Esta lección explica por qué no se puede omitir, qué se rompe cuando se omite, y las dos técnicas que usan los motores reales para no pagarla completa.
- Justificar por qué el desatado precede a la reejecución y no la sigue.
- Reproducir el fallo concreto que aparece si se omite.
- Implementar el desatado en las tres representaciones de aristas.
- Explicar la reutilización de aristas como optimización de esta operación.
Por qué antes y no después
El desatado tiene que ir antes de la ejecución, y la razón es de secuencia. Si se hiciera después, habría que distinguir las aristas tejidas en esta pasada de las de la anterior, lo que exigiría marcarlas con un número de pasada y recorrerlas todas para separar. Borrando antes, todo lo que aparezca durante la ejecución es por definición actual.
function ejecutar(nodo) {
desatar(nodo); // el grafo queda sin aristas entrantes para este nodo
Observador = nodo;
try { nodo.valor = nodo.fn(); } // todo lo tejido aqui es de esta pasada
finally { Observador = null; }
}
function desatar(nodo) {
for (const f of nodo.fuentes) f.observadores.delete(nodo);
nodo.fuentes.clear();
}
Fíjate en que desatar toca las dos direcciones, como exige la invariante de simetría del nivel 2. Borrar solo nodo.fuentes dejaría al nodo en las listas de observadores de sus antiguas fuentes, que es exactamente el fallo que veremos ahora.
El fallo cuando se omite
Vamos a reproducirlo, porque es la mejor manera de entender la operación.
const rama = senal('a');
const a = senal(1), b = senal(2);
let ejecuciones = 0;
efecto(() => {
ejecuciones++;
console.log(rama() === 'a' ? a() : b());
});
// aristas: rama, a ejecuciones: 1
rama.set('b');
// CON desatado -> aristas: rama, b ejecuciones: 2
// SIN desatado -> aristas: rama, a, b ejecuciones: 2
a.set(99);
// CON desatado -> no pasa nada, no hay arista con a
// SIN desatado -> el efecto se reejecuta, imprime b, ejecuciones: 3
El fallo tiene dos caras. La visible es el trabajo inútil: el efecto corre por un cambio en una fuente que su código actual ni siquiera lee. Es molesto, medible y va empeorando conforme el programa recorre más ramas, porque las aristas solo se acumulan.
La cara invisible es peor. Sin desatado, un nodo nunca puede salir del grafo. Si el efecto pertenece a un componente que se destruye, sigue en las listas de observadores de todas las fuentes que leyó alguna vez, y esas listas lo mantienen vivo junto con su cierre, sus variables capturadas y cualquier nodo del DOM que retuviera. Es la fuga clásica de un sistema reactivo, y la única defensa es que exista una operación de baja: la misma que usa el desatado.
Un motor sin desatado tiene una propiedad fatal: el conjunto de aristas es monótono creciente. Cada rama nueva que el programa recorra añade dependencias que ya no se quitan. Una aplicación de larga vida acaba con cada nodo suscrito a casi todo, y el rendimiento se degrada de forma continua sin ninguna causa localizable en un perfil. Es el modo de fallo que hace que reiniciar la pestaña arregle las cosas.
Desatar en las tres representaciones
Con conjuntos hash es trivial: un delete por arista y un clear final. Coste lineal en el número de dependencias, con la constante de un borrado en tabla hash.
Con arrays e índices cruzados es el intercambio con el último elemento que vimos en el nivel 2, más la reparación del índice del elemento movido. También lineal, con constante muy baja, pero con la complicación de mantener los índices coherentes.
Con listas doblemente enlazadas es desenlazar cada arista tocando sus dos vecinos. Lineal y sin trucos.
Las tres son lineales en el número de dependencias del nodo. Como ese número suele ser pequeño —dos o tres en la mayoría de los efectos de plantilla—, el coste absoluto es bajo. Lo que pesa es la frecuencia: se paga en cada reejecución de cada nodo.
Optimizarlo y colocarlo
Las dos optimizaciones
Reutilización de aristas. La observación de partida es que la inmensa mayoría de las reejecuciones leen exactamente las mismas fuentes en el mismo orden. En vez de destruir y recrear, se recorre la lista existente en paralelo a la ejecución: si la fuente leída coincide con la arista que toca, se reutiliza; si no coincide, ahí empieza la divergencia y a partir de ese punto se desenlaza y se teje.
// Esquema de la reutilizacion, con la lista de aristas existentes
function seguirConReutilizacion(fuente) {
const actual = Observador.aristaActual;
if (actual && actual.fuente === fuente) {
Observador.aristaActual = actual.siguiente; // coincide, cero asignaciones
return;
}
desenlazarDesde(Observador.aristaActual); // divergencia, a partir de aqui se rehace
enlazarNueva(Observador, fuente);
}
En el caso común no se asigna ni un objeto y no se toca ninguna tabla hash: solo se avanza un puntero. Es la técnica de Preact Signals, y la que Vue heredó al reescribir su reactividad sobre alien-signals. La ganancia es real y grande en aplicaciones con muchas reejecuciones de dependencias estables.
Numeración de pasadas. Una alternativa más sencilla: en vez de borrar, marcar cada arista con el número de la pasada en que se usó, y al final de la ejecución barrer las que tengan un número antiguo. Cambia dos recorridos por uno más una comparación por arista. Es más simple de implementar y menos eficaz que la reutilización, y aparece en motores pequeños.
flowchart TB
A[reejecutar] --> B{estrategia}
B --> C[desatar todo y volver a tejer]
B --> D[recorrer en paralelo y reutilizar]
B --> E[marcar con numero de pasada y barrer]
C --> C1[simple, dos operaciones por arista]
D --> D1[cero asignaciones si nada cambio]
E --> E1[una pasada extra al final]
style A fill:#89b4fa,color:#11111b
style B fill:#f9e2af,color:#11111b
style C fill:#cba6f7,color:#11111b
style D fill:#a6e3a1,color:#11111b
style E fill:#94e2d5,color:#11111b
style C1 fill:#94e2d5,color:#11111b
style D1 fill:#a6e3a1,color:#11111b
style E1 fill:#94e2d5,color:#11111bLa razón profunda del desatado no es la eficiencia sino la corrección semántica. Sin él, el grafo acumula la unión de todas las dependencias que el nodo tuvo alguna vez, es decir, se convierte en un historial. Con él, el grafo contiene exactamente las dependencias de la última ejecución: una fotografía del presente. Y esa diferencia importa porque toda la garantía del modelo —que un nodo se reejecuta si y solo si algo que de verdad lee ha cambiado— depende de que el grafo sea presente y no historia. Aquí hay además una simetría que conviene apreciar, porque explica por qué esta operación aparece en todos los motores sin excepción: el tracking automático es una operación que construye estado, y toda operación que construye estado necesita su inversa para que el sistema sea reversible. Tejer sin desatar es como asignar sin liberar. Es exactamente la misma estructura de problema que la gestión de memoria manual, y por eso su solución acaba tomando la misma forma: en el nivel 7 verás que el árbol de dueños no es más que la generalización de esta idea a todo lo que una computación crea, no solo a sus aristas. Desatar aristas y desechar hijos son la misma operación aplicada a dos clases de recurso.
Dónde encaja en el motor final
En el motor del nivel 12, el desatado son tres líneas y se llama desde un solo sitio: justo antes de reejecutar el cuerpo de una computación, en la misma función que también desecha los hijos y ejecuta las limpiezas. Esa colocación no es casual: todo lo que la ejecución anterior creó se destruye en el mismo punto, aristas incluidas.
- Escribe un efecto que lea veinte fuentes y reejecútalo diez mil veces con dependencias estables.
- Instrumenta el número de operaciones de alta y baja de aristas.
- Implementa la numeración de pasadas y vuelve a medir.
- Implementa la reutilización con recorrido en paralelo y compara las tres, explicando de dónde viene la diferencia.