Saltar al contenido

Actualizar en vez de recrear

El coste real de reconstruir el DOM de un gráfico, la conciliación por clave sin framework, el fragmento de documento, y el patrón de leer todo antes de escribir nada.

Por qué recrear es caro

raiz.replaceChildren() seguido de la construcción completa hace, en cada actualización:

  • destruir n nodos, con el trabajo de desmontaje que eso implica;
  • crear n nodos nuevos;
  • recalcular el estilo de los n nodos, emparejando cada selector CSS contra cada uno;
  • rehacer el layout y el pintado del subárbol entero.

Con cincuenta barras es imperceptible. Con dos mil puntos y una actualización por segundo, es el hilo principal ocupado permanentemente.

Y hay un coste que no es de rendimiento y que suele importar más: se pierde la identidad. Un nodo nuevo no tiene estado anterior, así que no hay nada desde donde transicionar. Cualquier animación de cambio de valor es imposible. También se pierde el foco del teclado si estaba en uno de esos nodos, y se cierra cualquier tooltip abierto.

La conciliación por clave

La idea: cada marca tiene una clave estable derivada del dato. Al actualizar, se comparan las claves nuevas con las que ya hay en el DOM, y se decide qué crear, qué actualizar y qué eliminar.

JS
const NS = 'http://www.w3.org/2000/svg';function conciliar(padre, datos, {  clave,          // (d) -> string  crear,          // (d) -> Element  actualizar,     // (nodo, d) -> void  eliminar = n => n.remove(),}) {  // Indice de lo que hay ahora  const previos = new Map();  for (const n of padre.children) {    const k = n.dataset.clave;    if (k != null) previos.set(k, n);  }  const fragmento = document.createDocumentFragment();  const vistos = new Set();  for (const d of datos) {    const k = String(clave(d));    vistos.add(k);    let nodo = previos.get(k);    if (!nodo) {      nodo = crear(d);      nodo.dataset.clave = k;      fragmento.appendChild(nodo);    }    actualizar(nodo, d);  }  if (fragmento.childNodes.length) padre.appendChild(fragmento);  for (const [k, n] of previos) {    if (!vistos.has(k)) eliminar(n);  }}

Uso con un gráfico de barras:

JS
conciliar(grupoBarras, barras, {  clave: b => b.etiqueta,  crear: () => {    const r = document.createElementNS(NS, 'rect');    r.setAttribute('class', 'barra');    r.setAttribute('rx', 2);    return r;  },  actualizar: (r, b) => {    r.setAttribute('x', b.x);    r.setAttribute('y', b.y);    r.setAttribute('width', b.ancho);    r.setAttribute('height', b.alto);  },});

Con eso, una actualización de datos donde las etiquetas no cambian no crea ni destruye ningún nodo: solo escribe cuatro atributos por barra. Y como los nodos persisten, una transición de CSS funciona:

CSS
.barra {  transition: y 400ms cubic-bezier(.2,.8,.2,1),              height 400ms cubic-bezier(.2,.8,.2,1);}@media (prefers-reduced-motion: reduce) { .barra { transition: none; } }

Ese transition sobre y y height requiere que sean propiedades de geometría reconocidas por CSS, lo que ya se vio en el nivel 18. Si tu objetivo no las soporta, la alternativa es transicionar transform, que funciona en todas partes:

JS
r.setAttribute('transform', `translate(0,${b.y}) scale(1,${b.alto / 100})`);

Con la salvedad de que escalar deforma el radio de las esquinas y el trazo, así que hay que compensarlo con vector-effect: non-scaling-stroke o aceptarlo.

El fragmento de documento

En la conciliación, los nodos nuevos se acumulan en un DocumentFragment y se insertan de una vez. La diferencia importa: cada appendChild sobre un nodo conectado al documento marca el subárbol como sucio; un appendChild sobre un fragmento no, porque el fragmento no está en el documento.

Con mil nodos nuevos, insertarlos uno a uno frente a insertarlos en un fragmento es una diferencia medible en los motores actuales, aunque menor de lo que era: los navegadores agrupan bastante el trabajo. Sigue siendo la forma correcta y no cuesta nada.

Lo que sí produce una diferencia enorme es mezclar lecturas y escrituras.

Leer todo antes de escribir nada

El anti-patrón:

JS
// Mal: fuerza un layout por iteracionfor (const t of etiquetas) {  const caja = t.getBBox();              // lectura: fuerza layout  t.setAttribute('x', caja.x + 4);       // escritura: invalida el layout}

Cada getBBox después de una escritura obliga al navegador a recalcular el layout para dar una respuesta correcta. Con cien etiquetas son cien cálculos de layout completos en un solo fotograma. Es el patrón de degradación más caro que existe en el DOM y el más fácil de escribir sin querer.

El arreglo:

JS
// Bien: una sola invalidacionconst cajas = etiquetas.map(t => t.getBBox());   // todas las lecturasetiquetas.forEach((t, i) => {                     // todas las escrituras  t.setAttribute('x', cajas[i].x + 4);});

Un solo cálculo de layout, porque todas las lecturas ocurren antes de la primera escritura.

Las lecturas que fuerzan layout en SVG son las que hay que reconocer: getBBox, getBoundingClientRect, getTotalLength, getComputedTextLength, getScreenCTM, y las propiedades de offsetWidth y compañía en elementos HTML. Si tu código las llama dentro de un bucle que también escribe, ahí está el problema.