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.
Reconstruir el SVG entero en cada actualización es la forma más simple de escribir un gráfico y la que garantiza que no se pueda animar y que se atragante con mil elementos. La alternativa no es adoptar un framework: es una conciliación por clave de cuarenta líneas que reutiliza los nodos existentes, mantiene la identidad de cada marca y permite que las transiciones de CSS hagan el trabajo.
- Cuantificar el coste de reconstruir frente a actualizar.
- Implementar una conciliación por clave sin framework.
- Usar
DocumentFragmentpara insertar en lote. - Aplicar el patrón de leer primero y escribir después para evitar el layout forzado.
Por qué recrear es caro
raiz.replaceChildren() seguido de la construcción completa hace, en cada actualización:
- destruir
nnodos, con el trabajo de desmontaje que eso implica; - crear
nnodos nuevos; - recalcular el estilo de los
nnodos, 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.
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:
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:
.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:
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:
// Mal: fuerza un layout por iteracion
for (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:
// Bien: una sola invalidacion
const cajas = etiquetas.map(t => t.getBBox()); // todas las lecturas
etiquetas.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.
La tentación al escribir la conciliación es usar el índice del array como clave. Funciona, es más simple, y produce un fallo específico que solo se ve cuando los datos cambian de orden o de tamaño.
Con la clave por índice, el nodo en la posición 3 representa siempre «el cuarto elemento de la lista», no «el elemento con etiqueta Abril». Si filtras la serie y desaparece Febrero, el nodo que representaba a Marzo pasa a representar a Abril. Con una transición activa, la barra de Marzo se anima hasta el valor de Abril: el usuario ve una barra que crece cuando en realidad ha cambiado de identidad.
El resultado es una animación que cuenta una historia falsa. En un panel de control eso no es un detalle estético: es información incorrecta presentada de forma convincente.
Con una clave derivada del dato, la barra de Marzo se elimina y la de Abril se queda donde está. La animación cuenta lo que ha pasado.
Dos matices sobre la elección de la clave.
Tiene que ser estable entre actualizaciones. Un identificador de base de datos, una fecha, un nombre. Nunca un valor que cambie con el dato, y nunca un objeto (se convertiría en la cadena de siempre).
Tiene que ser única dentro del conjunto. Dos elementos con la misma clave hacen que el segundo sobrescriba al primero en el índice y que uno de los dos desaparezca. Si tus datos pueden tener duplicados, la clave es la combinación que los distingue.
Y una comprobación barata que detecta el problema en desarrollo:
if (new Set(datos.map(clave)).size !== datos.length) {
console.warn('claves duplicadas en la conciliación');
}Este mismo razonamiento es el que hay detrás del requisito de clave en las listas de cualquier framework de interfaz, y es la razón de que el aviso exista. En un gráfico animado las consecuencias son más visibles que en una lista, porque el error se convierte en movimiento.
Monta un gráfico de barras con conciliación por clave y transiciones, y pruébalo con tres cambios: reordenar los datos, eliminar un elemento del medio, y añadir uno al principio. Después cambia la clave por el índice y repite las tres pruebas. Graba las animaciones y compáralas: la diferencia entre las dos versiones es exactamente el argumento del callout.