El híbrido: marcar en push, evaluar en pull
El modelo que usan todos los motores de producción: la escritura empuja una marca barata hacia adelante, la lectura tira del cálculo hacia atrás. Implementado entero, con la justificación de cada mitad.
Push descubre a los afectados pero calcula de más y desordenado. Pull calcula lo justo y en orden pero no descubre a nadie. La combinación no es un compromiso entre dos opciones mediocres: es tomar de cada una exactamente aquello en lo que es óptima. Empuja información barata hacia adelante, tira del cálculo caro hacia atrás. Todos los motores de esta familia lo hacen así, y esta lección lo implementa entero.
- Implementar el modelo push-pull completo con sus dos fases.
- Justificar por qué lo que se empuja es una marca y no un valor.
- Ver cómo la fase de pull impone el orden topológico sin calcularlo.
- Trazar una propagación completa paso a paso.
Las dos fases
La escritura hace solo esto: marcar hacia adelante.
const LIMPIO = 0, QUIZA = 1, SUCIO = 2;
function escribir(fuente, valor) {
if (fuente.iguales(fuente.valor, valor)) return;
fuente.valor = valor;
marcar(fuente, SUCIO); // empuja marcas, no calculos
}
function marcar(nodo, estado) {
for (const obs of nodo.observadores) {
if (obs.estado >= estado) continue; // ya esta al menos asi de sucio
const antes = obs.estado;
obs.estado = estado;
if (obs.efecto) planificar(obs); // los efectos hay que ejecutarlos
if (antes === LIMPIO) marcar(obs, QUIZA); // los nietos, solo quiza
}
}
Los observadores directos quedan SUCIO: su entrada cambió de verdad, hay que recalcularlos. Los descendientes indirectos quedan QUIZA: alguna de sus entradas podría haber cambiado, pero eso depende de si el recálculo de la de arriba produce un valor distinto. La distinción entre los dos estados es lo que hace funcionar el modelo, y es el tema de la lección siguiente.
La lectura hace la otra mitad: resolver hacia atrás.
function leerDerivada(nodo) {
if (nodo.estado !== LIMPIO) actualizar(nodo);
seguir(nodo);
return nodo.valor;
}
function actualizar(nodo) {
if (nodo.estado === QUIZA) {
for (const f of nodo.fuentes) {
if (f.fn) actualizar(f); // resuelve las derivaciones de arriba
if (nodo.estado === SUCIO) break; // una de ellas cambio de verdad
}
if (nodo.estado === QUIZA) { // ninguna cambio
nodo.estado = LIMPIO;
return; // no se recalcula nada
}
}
if (nodo.estado !== SUCIO) return;
ejecutar(nodo);
}
El bloque del QUIZA es la joya del modelo. Un nodo marcado como quizá sucio pregunta hacia arriba antes de decidir. Si al resolver sus fuentes ninguna cambió de valor, pasa a limpio sin ejecutar su cuerpo. Eso es un recálculo entero ahorrado, y en una cadena larga se ahorran todos los de abajo.
Cómo un nodo pasa de quizá a sucio
Falta la pieza que conecta las dos fases: cuando una derivación se recalcula y su valor cambia, tiene que ascender a sus observadores de QUIZA a SUCIO.
function ejecutar(nodo) {
desatar(nodo);
const anterior = Observador;
Observador = nodo;
try {
const v = nodo.fn();
if (nodo.efecto) { nodo.valor = v; return; }
if (!nodo.iguales(nodo.valor, v)) { // el valor cambio de verdad
nodo.valor = v;
for (const o of nodo.observadores) {
if (o.estado >= SUCIO) continue;
o.estado = SUCIO; // ahora si, seguro
if (o.efecto) planificar(o);
}
}
} finally {
Observador = anterior;
nodo.estado = LIMPIO;
}
}
Solo se asciende a los observadores directos. Los indirectos ya están en QUIZA desde la fase de marcado, y cuando les toque preguntar hacia arriba descubrirán la verdad ellos mismos. Esa economía —no repropagar el QUIZA que ya está puesto— es lo que mantiene la fase de push en coste lineal.
flowchart TB
W[escritura en la fuente] --> M1[observadores directos a SUCIO]
M1 --> M2[descendientes a QUIZA]
M2 --> Q[los efectos marcados entran en la cola]
Q --> R[al ejecutar el efecto se lee hacia arriba]
R --> D{alguna fuente cambio de valor}
D -->|si| E[recalcular y ascender a SUCIO]
D -->|no| L[pasar a LIMPIO sin ejecutar]
style W fill:#89b4fa,color:#11111b
style M1 fill:#f38ba8,color:#11111b
style M2 fill:#f9e2af,color:#11111b
style Q fill:#fab387,color:#11111b
style R fill:#cba6f7,color:#11111b
style D fill:#f9e2af,color:#11111b
style E fill:#a6e3a1,color:#11111b
style L fill:#94e2d5,color:#11111bUna propagación paso a paso
Vale la pena seguir un caso completo, porque hasta que no lo ves con números el modelo no encaja.
const a = senal(1);
const par = memo(() => a() % 2 === 0); // false
const etiqueta = memo(() => par() ? 'par' : 'impar'); // 'impar'
efecto(() => pintar(etiqueta()));
Estado inicial: los tres nodos limpios, el efecto ya ejecutado.
a.set(3). Fase de push: par es observador directo, pasa a SUCIO. Como estaba limpio, se propaga QUIZA hacia abajo: etiqueta a QUIZA, y desde ahí el efecto a QUIZA; el efecto entra en la cola. La fase de push ha tocado tres nodos y no ha calculado nada.
Fase de pull, al vaciar la cola: se actualiza el efecto, que está en QUIZA, así que pregunta a sus fuentes. Su fuente es etiqueta, también en QUIZA, que pregunta a la suya. par está en SUCIO, así que se ejecuta: 3 % 2 === 0 da false, el mismo valor de antes. No asciende a nadie y pasa a limpio. Vuelve el control a etiqueta, que sigue en QUIZA: no hay más fuentes, así que pasa a limpio sin ejecutarse. Vuelve al efecto, que sigue en QUIZA y también pasa a limpio sin ejecutarse.
Resultado: se ejecutó un cuerpo de los tres, el mínimo posible. Push puro habría ejecutado los tres. Pull puro habría necesitado que alguien preguntara.
Fíjate en el patrón: en cuanto un nodo se recalcula y devuelve el mismo valor, todo lo que cuelga de él se queda en QUIZA y acaba pasando a limpio sin ejecutarse. Es un cortocircuito que puede ahorrar subárboles enteros, y es la razón por la que colocar un memo en el punto correcto del grafo tiene un impacto desproporcionado. El nivel 6 lo desarrolla.
Qué aporta cada mitad
Merece la pena separar los méritos, porque explica por qué las dos son necesarias.
De push viene el descubrimiento. Sin él nadie sabría que hay que hacer algo, y los efectos no se dispararían. Push es barato porque lo que empuja es una asignación de entero por nodo, no un cálculo.
De pull viene el orden y la pereza. El orden, porque la resolución recursiva impone la topología sin necesidad de ordenar nada. La pereza, porque un nodo cuyo valor nadie pide se queda marcado y no se evalúa; si más tarde se vuelve a escribir su entrada, sigue sin evaluarse. Un panel oculto puede acumular cien cambios y pagar un solo recálculo cuando se abra.
Y de la combinación viene la garantía glitch-free: como todo cálculo ocurre en la fase de pull, y la fase de pull resuelve dependencias antes que dependientes, es imposible evaluar un nodo con entradas de épocas distintas.
La regla que hay debajo trasciende la reactividad y merece un nombre propio: empuja información barata en la dirección en que descubres, tira de trabajo caro en la dirección en que calculas. La verás una y otra vez en sistemas distribuidos y en bases de datos. Una invalidación de caché empuja una clave —barato— y el siguiente lector tira del valor —caro—. Un índice de base de datos empuja una marca de sucio y el planificador decide después si merece la pena reconstruir. Un sistema de notificaciones empuja un identificador y el cliente tira del contenido. En los tres casos, el motivo es idéntico al de aquí: la dirección del descubrimiento y la dirección de la dependencia son opuestas, y quien intenta hacer las dos cosas en la misma dirección paga trabajo especulativo o latencia de sondeo. Si terminas este track y lo único que retienes es esta frase, ya habrá valido la pena, porque es aplicable muchísimo más allá de las interfaces. Y de paso explica por qué la propuesta de Signals de TC39 puede ser tan pequeña: una vez fijas este esquema, la API mínima que hace falta para exponerlo son dos clases y un vigilante.
- Implementa el modelo de esta lección y añade contadores separados para nodos marcados y cuerpos ejecutados.
- Construye la cadena de tres derivaciones del ejemplo y escribe veinte valores que no cambien el primer resultado.
- Comprueba que los nodos marcados crecen linealmente y los cuerpos ejecutados no.
- Sustituye el primer memo por uno cuyo valor sí cambie siempre y compara las dos cifras.