wandres.dev
GRANO FINO Y GRANO GRUESO · La distinción fundamental

El motor de grano fino por dentro

La alternativa mecánica: en lugar de comparar descripciones, cada expresión de la plantilla es un efecto con una arista directa al nodo del DOM que actualiza. Implementado sin framework para que se vea el salto directo.

⏱ 18 min

El modelo de grano fino invierte la apuesta. En vez de reconstruir una descripción para averiguar qué cambió, mantiene una arista permanente entre cada valor y el punto exacto del mundo que ese valor controla. No hay descripción intermedia, no hay comparación, no hay búsqueda: hay una lista de suscriptores y un salto directo. Esta lección lo implementa entero, sin framework, para que veas de dónde sale la ventaja y qué factura la acompaña.

🎯 Al terminar esta lección sabrás
  • Implementar un motor de grano fino mínimo con fuentes y efectos.
  • Construir una plantilla donde cada expresión sea un efecto independiente.
  • Ver que la actualización salta del valor al nodo del DOM sin recorrido.
  • Cuantificar la memoria y la contabilidad que se pagan a cambio.

Veinte líneas de motor

Todo lo que hace falta para el modelo fino cabe en muy poco código. Fuentes con lista de observadores, un observador actual, y efectos que se reejecutan.

let Observador = null;

function senal(inicial) {
  let valor = inicial;
  const observadores = new Set();
  const leer = () => {
    if (Observador) { observadores.add(Observador); Observador.fuentes.add(observadores); }
    return valor;
  };
  leer.set = (v) => {
    if (Object.is(valor, v)) return;
    valor = v;
    for (const o of [...observadores]) o.correr();
  };
  return leer;
}

function efecto(fn) {
  const nodo = {
    fuentes: new Set(),
    correr() {
      for (const s of nodo.fuentes) s.delete(nodo);   // desatar las aristas viejas
      nodo.fuentes.clear();
      const anterior = Observador;
      Observador = nodo;
      try { fn(); } finally { Observador = anterior; }
    },
  };
  nodo.correr();
  return nodo;
}

Está deliberadamente simplificado: sin memos, sin lotes, sin garantías de orden. Todo eso llega en los niveles siguientes. Lo que ya se ve aquí es lo esencial: una escritura recorre una lista de observadores y los ejecuta, y nada más.

La plantilla es un conjunto de efectos

Ahora la parte que hace la diferencia. En vez de una función que devuelve una descripción del árbol, construimos el DOM una sola vez y colocamos un efecto en cada punto que puede cambiar.

function listaDeTareas(tareas, filtro) {
  const ul = document.createElement('ul');
  const contador = document.createElement('p');

  // Un efecto por cada punto variable del DOM
  efecto(() => { contador.textContent = `${tareas().length} tareas`; });

  efecto(() => {
    const visibles = tareas().filter(t => filtro() === 'todas' || !t.hecha);
    ul.replaceChildren(...visibles.map(fila));
  });

  return [contador, ul];
}

function fila(tarea) {
  const li = document.createElement('li');
  li.textContent = tarea.texto;
  efecto(() => { li.className = tarea.hecha() ? 'hecha' : ''; });
  return li;
}

Fíjate en fila: tarea.hecha es una señal propia de esa tarea, y el efecto que lee esa señal escribe directamente en ese li. Cuando se marca una tarea como hecha, la escritura recorre una lista de un único observador y ejecuta una asignación a className. La lista de mil filas no se recorre. El ul no se toca. La función listaDeTareas no vuelve a ejecutarse jamás.

flowchart LR
S[senal hecha de la tarea 437] --> E[efecto de clase]
E --> N[el li numero 437]
style S fill:#89b4fa,color:#11111b
style E fill:#cba6f7,color:#11111b
style N fill:#a6e3a1,color:#11111b

Tres nodos. Ese es el grafo completo que participa en la actualización, con independencia de que la aplicación tenga diez mil elementos más. Esa es la propiedad que define el modelo: el coste de una actualización es independiente del tamaño de la aplicación.

El componente corre una vez

La consecuencia que más cuesta interiorizar al venir del otro modelo es que la función del componente es un constructor, no un renderizador. Corre una vez, crea los nodos y los efectos, y termina para siempre.

function contador() {
  const n = senal(0);
  console.log('esto se imprime UNA vez en toda la vida del componente');
  const boton = document.createElement('button');
  efecto(() => { boton.textContent = `Pulsado ${n()} veces`; });
  boton.onclick = () => n.set(n() + 1);
  return boton;
}

De aquí salen, en cadena, casi todas las diferencias de ergonomía entre las dos familias. No hace falta una lista de dependencias en los efectos, porque el efecto no se recrea. No existe el problema de la identidad de las funciones entre renders, porque no hay renders. No hace falta memoizar para evitar reconstrucciones, porque no hay reconstrucciones. Y, a cambio, desestructurar props rompe la reactividad, porque desestructurar lee el valor una vez en el único momento en que la función corre; ese es el asunto del nivel 9.

⚠️
La contrapartida es que ya no hay reinicio

Como el componente no vuelve a ejecutarse, todo lo que crea vive hasta que alguien lo destruye explícitamente. Cada efecto es una suscripción viva; cada suscripción viva es una fuga potencial. El modelo de reconciliación no tiene este problema porque no tiene suscripciones. El de grano fino lo tiene, y lo resuelve con el árbol de dueños del nivel 7. Que ese mecanismo exista no es un lujo: es el precio obligado de haber quitado el reinicio.

La factura

Contemos lo que cuesta el modelo, sin adornos.

Memoria por arista. Cada dependencia ocupa espacio en dos estructuras: la lista de observadores de la fuente y la lista de fuentes del observador. Una aplicación con cien mil aristas paga esa memoria de forma permanente, mientras que el modelo de reconciliación no paga nada entre ciclos, salvo el árbol anterior.

Contabilidad por ejecución. Cada vez que un efecto se reejecuta, desata todas sus aristas viejas y teje las nuevas. Para un efecto que lee veinte fuentes, son cuarenta operaciones sobre estructuras de datos por ejecución, además del trabajo real. Cuando el trabajo real es una asignación a textContent, la contabilidad puede ser la mitad del coste total.

Indirección en cada lectura. Leer una señal no es leer una variable: es llamar a una función que consulta el observador actual, posiblemente inserta en dos colecciones, y devuelve. En un bucle apretado eso importa, y es la razón por la que todos los motores ofrecen alguna forma de leer sin rastrear.

Coste de arranque. Crear el grafo cuesta. Una pantalla con cinco mil nodos reactivos paga cinco mil creaciones de objeto, cinco mil primeras ejecuciones y el tejido inicial de todas sus aristas antes de mostrar nada. La reconciliación paga un recorrido y unas asignaciones, sin estructuras persistentes. En arranque en frío, el grano fino no gana automáticamente.

Grano fino cambia el coste de tiempo a espacio, y de actualizacion a arranque

El resumen honesto del modelo no es que sea más rápido, sino que mueve el coste de sitio. El de reconciliación paga poco al arrancar y mucho por actualización, con la factura proporcional al tamaño de la vista. El de grano fino paga mucho al arrancar y casi nada por actualización, con la factura proporcional al tamaño del cambio. Es la misma disyuntiva que existe entre escanear una tabla y mantener un índice: el índice cuesta memoria y cuesta mantenerlo en cada escritura, y solo compensa si vas a consultar muchas más veces de las que escribes. Traducido a interfaces, el grano fino compensa cuando el número de actualizaciones por sesión es alto frente al número de creaciones de nodo. Un panel de trading, un editor colaborativo o una visualización animada están claramente en ese régimen. Una página de contenido que se pinta una vez y apenas cambia está claramente en el contrario, y ahí el grano fino paga la construcción del grafo para no amortizarla nunca. Que en la práctica el grano fino gane en tantas comparativas se debe a que la mayoría de las aplicaciones interactivas viven en el primer régimen, no a una superioridad incondicional del algoritmo.

⚔️ Compara los dos modelos con el mismo caso
  1. Implementa la lista de mil tareas con el motor de esta lección y con el diff de la lección anterior.
  2. Instrumenta ambos para contar operaciones sobre el DOM y operaciones en JavaScript por actualización.
  3. Mide marcar una tarea como hecha, y luego mide sustituir la lista entera por otra distinta.
  4. Explica por qué en el segundo caso la distancia entre ambos modelos se reduce tanto.