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

El motor de re-render por dentro

Qué hace mecánicamente un ciclo de reconciliación: reejecutar la función, producir un árbol descriptivo, compararlo con el anterior y aplicar el mínimo de mutaciones. Con el diff implementado para que se vea el coste.

⏱ 19 min

Para juzgar la reactividad de grano fino con honestidad hay que entender primero, y bien, qué hace el modelo que pretende sustituir. Un motor de reconciliación no es ingenuo ni es lento por descuido: es una máquina cuidadosamente optimizada alrededor de una apuesta concreta —que reconstruir la descripción es barato y que comparar es más barato aún que rastrear— y esa apuesta gana en más casos de los que la propaganda del grano fino admite.

🎯 Al terminar esta lección sabrás
  • Reconstruir el ciclo completo de un motor de reconciliación.
  • Implementar un diff de listas con claves y ver de dónde sale su coste.
  • Entender por qué el árbol descriptivo intermedio no es desperdicio gratuito.
  • Identificar en qué punto exacto del ciclo se paga el trabajo.

El ciclo completo

Un motor de reconciliación repite cuatro pasos por cada actualización. Vamos a implementarlos con la mínima infraestructura, sobre un árbol descriptivo trivial.

// Un nodo descriptivo: tipo, propiedades, hijos
const h = (tipo, props, ...hijos) => ({ tipo, props: props || {}, hijos: hijos.flat() });

// Paso 1: reejecutar la funcion de vista
function render(estado) {
  return h('ul', null, estado.tareas.map(t =>
    h('li', { clase: t.hecha ? 'hecha' : '' }, t.texto)
  ));
}

El paso 1 devuelve un árbol de objetos planos. Nada ha tocado el DOM todavía. Ese árbol es la descripción de lo que debería verse, y se produce entero cada vez, con independencia de cuánto haya cambiado.

// Paso 2 y 3: comparar el arbol nuevo con el anterior y emitir parches
function diff(viejo, nuevo, camino = []) {
  if (viejo === undefined) return [{ op: 'crear', camino, nodo: nuevo }];
  if (nuevo === undefined) return [{ op: 'borrar', camino }];
  if (viejo.tipo !== nuevo.tipo) return [{ op: 'reemplazar', camino, nodo: nuevo }];

  const parches = [];
  for (const k of new Set([...Object.keys(viejo.props), ...Object.keys(nuevo.props)])) {
    if (viejo.props[k] !== nuevo.props[k]) {
      parches.push({ op: 'prop', camino, clave: k, valor: nuevo.props[k] });
    }
  }
  const n = Math.max(viejo.hijos.length, nuevo.hijos.length);
  for (let i = 0; i < n; i++) {
    const a = viejo.hijos[i], b = nuevo.hijos[i];
    if (typeof a === 'string' || typeof b === 'string') {
      if (a !== b) parches.push({ op: 'texto', camino: [...camino, i], valor: b });
    } else {
      parches.push(...diff(a, b, [...camino, i]));
    }
  }
  return parches;
}

El paso 4, aplicar los parches al DOM, es mecánico y no aporta nada conceptual. Lo interesante es que el número de parches es proporcional al cambio real, mientras que el coste de calcularlos es proporcional al tamaño del árbol. Esa asimetría es la definición del modelo.

El diff de listas es el punto caliente

Comparar dos árboles arbitrarios es un problema con coste cúbico en el caso general. Ningún motor real lo resuelve en general: todos aplican heurísticas que reducen el coste a lineal a cambio de perder optimalidad en casos concretos.

La heurística universal es la de posición y tipo: se compara el hijo i del viejo con el hijo i del nuevo, y si los tipos difieren se destruye y se reconstruye en vez de buscar el nodo en otra posición. Es lo que hace el bucle anterior, y explica el comportamiento que todo el mundo ha sufrido: insertar un elemento al principio de una lista sin claves rehace la lista entera, porque todas las posiciones se desplazan.

Las claves son el parche a esa heurística. Con clave, el diff no compara por posición sino por identidad.

function diffLista(viejos, nuevos) {
  const indice = new Map(viejos.map((n, i) => [n.props.clave, { n, i }]));
  const parches = [];
  nuevos.forEach((nuevo, j) => {
    const previo = indice.get(nuevo.props.clave);
    if (!previo) { parches.push({ op: 'crear', pos: j, nodo: nuevo }); return; }
    indice.delete(nuevo.props.clave);
    if (previo.i !== j) parches.push({ op: 'mover', de: previo.i, a: j });
    parches.push(...diff(previo.n, nuevo));
  });
  for (const { i } of indice.values()) parches.push({ op: 'borrar', pos: i });
  return parches;
}

Sigue siendo lineal en el número de elementos de la lista, no en el número de elementos que cambiaron. Con mil filas y una modificada, este código construye un mapa de mil entradas y hace mil comparaciones para emitir un parche. Ese es, con precisión, el trabajo que la reactividad de grano fino evita.

📝
Las claves no aceleran el diff, lo hacen correcto

Un malentendido extendido es que las claves hacen que el diff sea más rápido. No: el recorrido sigue siendo lineal, y construir el mapa incluso añade trabajo. Lo que hacen es cambiar el resultado, evitando destrucciones y reconstrucciones innecesarias que serían mucho más caras en el DOM y que además perderían el estado interno de los elementos, como el foco o la posición de scroll. La ganancia está en los parches emitidos, no en el coste de calcularlos.

Qué se gana con el árbol intermedio

El árbol descriptivo parece puro desperdicio: se construye para compararlo y tirarlo. Pero compra tres cosas concretas que conviene reconocer antes de descartarlo.

Ausencia total de suscripciones. No hay aristas que mantener, no hay memoria por dependencia, no hay nada que dar de baja. Una clase entera de bugs —fugas de suscripciones, aristas obsoletas, efectos zombis— simplemente no existe. El nivel 7 dedica cinco lecciones a un mecanismo, el árbol de dueños, cuya razón de ser es resolver un problema que este modelo no tiene.

Independencia de la ruta. El resultado depende solo del estado actual, nunca de la secuencia de cambios que llevó hasta él. En un motor incremental, un fallo en la propagación deja el sistema en un estado que no corresponde a ninguna entrada; aquí, cada ciclo parte de cero y se autocorrige. Es una propiedad de robustez que se paga cara pero que vale mucho.

Un punto único de interrupción. Como el trabajo consiste en producir un árbol antes de tocar nada, se puede pausar a mitad, descartar y empezar de nuevo. Toda la maquinaria de renderizado interrumpible, transiciones y prioridades de React se apoya en esa propiedad. Un motor de grano fino que escribe en el DOM en cuanto propaga no puede ofrecer eso sin añadir una capa de indirección que le quitaría buena parte de su ventaja.

flowchart TB
E[cambio de estado] --> R[reejecutar la funcion de vista]
R --> A[arbol descriptivo nuevo]
A --> D[comparar con el arbol anterior]
D --> P[lista de parches]
P --> M[mutar el DOM]
A -.se guarda para el proximo ciclo.-> G[arbol anterior]
style E fill:#f9e2af,color:#11111b
style R fill:#89b4fa,color:#11111b
style A fill:#cba6f7,color:#11111b
style D fill:#fab387,color:#11111b
style P fill:#94e2d5,color:#11111b
style M fill:#a6e3a1,color:#11111b
style G fill:#94e2d5,color:#11111b
El coste esta en el JavaScript, no en el DOM

La crítica popular al modelo de reconciliación dice que el árbol descriptivo es lento porque el DOM es lento. Está mal diagnosticado, y el diagnóstico erróneo lleva a optimizaciones que no sirven. Los parches emitidos por un buen reconciliador son prácticamente los mismos que emitiría un motor de grano fino: si solo cambió un texto, ambos escriben un textContent y nada más. El trabajo en el DOM es equivalente. Lo que no es equivalente es todo lo que ocurre antes: reejecutar las funciones de componente, asignar los objetos descriptivos, recorrerlos comparando. Eso es JavaScript puro y es proporcional al tamaño del subárbol reejecutado, no al del cambio. La consecuencia práctica es contundente y va contra la intuición: en una aplicación con componentes cuya lógica es cara —formateo, filtrado, cálculos— la reconciliación pierde por goleada aunque el DOM apenas se toque; y en una aplicación con componentes triviales pero con muchísimos nodos, la diferencia se estrecha hasta ser irrelevante frente al coste del layout, que ambos pagan igual. Por eso las comparativas sintéticas de listas de diez mil filas exageran tanto la ventaja: miden justo el caso donde el trabajo pre-DOM domina y el layout está artificialmente minimizado.

Dónde se paga

Resumiendo el ciclo por coste: reejecutar la función de vista es proporcional al tamaño del subárbol invalidado; construir la descripción es proporcional al número de nodos producidos, con una asignación por nodo; comparar es proporcional al número de nodos comparados; aplicar es proporcional al número de cambios reales.

Los tres primeros dependen del tamaño de la vista. El cuarto depende del tamaño del cambio. Un motor de grano fino elimina los tres primeros y deja solo el cuarto. Ese es, sin más adornos, el argumento entero, y la lección siguiente muestra a qué precio.

⚔️ Cuenta el trabajo de un ciclo
  1. Toma el diff de esta lección e instrumenta un contador de comparaciones.
  2. Construye un árbol de quinientos nodos y cambia un solo texto de una hoja.
  3. Anota cuántas comparaciones hicieron falta para emitir un único parche.
  4. Repite con la lista de mil elementos y una fila modificada, con y sin claves, y explica la diferencia entre el número de comparaciones y el número de parches.