Colecciones DOM en tiempo real, problemas de diseño y la explicación de la brecha de atributos
Aprenda por qué el DOM es un árbol renderizado en tiempo real, cómo eso provoca la omisión de elementos en el bucle y diseños forzados, y por qué los atributos de datos personalizados necesitan getAttribute.
El DOM tiene la reputación de ser lento, y la explicación habitual es que se trata simplemente de una estructura pesada. Esa explicación está en gran medida equivocada. Las operaciones del DOM son razonablemente rápidas; lo que realmente afecta el rendimiento es un patrón de codificación común que alterna entre solicitar información al DOM y ordenarle que cambie, una y otra vez dentro de un bucle. Para entender por qué, primero es necesario tener una imagen precisa de lo que es el DOM, y esa misma imagen explica otros dos comportamientos que suelen confundir a los desarrolladores: bucles que omiten elementos y atributos personalizados que devuelven undefined.
El DOM es un árbol dinámico, no una copia de tu HTML
Cuando el navegador analiza el HTML, no almacena la marcaje como texto. Crea un objeto para cada elemento y anida esos objetos según el marcado, formando así un árbol. Considere un pequeño catálogo de productos:
<div id="catalog">
<div class="product">
<h3>Desk Lamp</h3>
<span class="price">$34</span>
</div>
<div class="product">
<h3>Standing Mat</h3>
<span class="price">$58</span>
</div>
</div>
A partir de esto, el navegador construye un árbol de objetos en tiempo real a los que JavaScript puede acceder: #catalog contiene dos elementos .product, y cada uno de ellos alberga un <h3> y un <span>. Lo importante es que este árbol no es una descripción generada una sola vez y luego dejada de lado; es la estructura que el navegador está renderizando en este momento. Si se modifica una propiedad de uno de esos objetos nodales, la página lo refleja inmediatamente; no existe un paso separado de guardado ni una llamada de renderizado.
document.querySelector(".product h3").textContent = "Desk Lamp (Sale)";
Esta instrucción no es una solicitud que el navegador procesará más tarde en su nombre. La asignación en sí misma representa el cambio. Tener presente esta calidad “en tiempo real” es el modelo mental más útil que se puede tener para el DOM, y también es la causa de un error que casi todo el mundo comete en algún momento.
Por qué un bucle sobre una colección dinámica omite elementos
Supongamos que deseas eliminar todas las tarjetas de producto marcadas como agotadas. El bucle obvio se vería así:
const outOfStockCards = document.getElementsByClassName("out-of-stock");
for (let i = 0; i < outOfStockCards.length; i++) {
outOfStockCards[i].remove();
}
Parece correcto, pero deja de forma silenciosa un elemento cada dos. La razón es que getElementsByClassName no proporciona un array fijo. Devuelve una HTMLCollection dinámica que refleja el documento a medida que este cambia. Tan pronto como .remove() elimina la primera tarjeta, la colección pierde un elemento y todas las tarjetas restantes bajan un índice. El contador del bucle sigue avanzando, por lo que el elemento que acaba de ocupar el índice 0 nunca es visitado. La colección cambia de tamaño mientras se ejecuta el bucle, y aproximadamente la mitad de los elementos escapan.
La solución es hacer una copia estática antes de comenzar a modificarla:
const outOfStockCards = Array.from(document.getElementsByClassName("out-of-stock"));
for (const card of outOfStockCards) {
card.remove();
}
Array.from crea una instantánea de la colección en tiempo real y la convierte en un array ordinario, por lo que las eliminaciones posteriores no pueden reducir el conjunto sobre el cual se itera. Una opción más sencilla es document.querySelectorAll(".out-of-stock"), que devuelve un NodeList estático desde el principio y no requiere conversión alguna. Esa previsibilidad es una de las razones por las cuales querySelectorAll ha hecho que la mayoría de las APIs antiguas de búsqueda queden obsoletas en los códigos actuales. Si es necesario trabajar con una colección en tiempo real, iterar hacia atrás desde el último índice también evita este problema, aunque una instantánea estática suele ser más clara.
Problemas de rendimiento en el layout: la verdadera razón por la que el código DOM parece lento
Ahora, al problema real de rendimiento. Calcular el diseño, es decir, el tamaño y posición exactos de cada elemento, es un proceso costoso. Los navegadores evitan hacerlo más de lo necesario: cuando se modifican los estilos, no vuelven a calcular el diseño de inmediato. Recogen los cambios pendientes y calculan el diseño una sola vez, justo antes de dibujar el siguiente frame.
Esa estrategia solo funciona mientras tu código lo permita. Algunas propiedades y métodos, como offsetWidth, offsetHeight y getBoundingClientRect(), necesitan geometría actualizada para poder devolver un valor. Leerlos obliga al navegador a realizar el cálculo de diseño de forma síncrona, ya que no puede indicar el ancho de un elemento sin calcularlo primero.
El siguiente bucle intercala una lectura y una escritura para cada tarjeta:
// forces a full layout recalculation on every single iteration
const cards = document.querySelectorAll(".product");
cards.forEach((card) => {
const width = card.offsetWidth; // read: forces layout
card.style.width = width + 10 + "px"; // write: invalidates layout again
});
Cada iteración lee un ancho y luego escribe uno nuevo. La lectura no puede basarse en datos obsoletos, ya que los cambios de estilo almacenados en la iteración anterior podrían afectar el resultado. Por eso, el navegador descarta los cambios pendientes, vuelve a calcular el diseño, devuelve el número y, en la línea siguiente, invalida nuevamente ese diseño. Este ciclo se conoce como fluctuación en el diseño (o diseño síncrono forzado), y es lo que realmente experimentan las personas cuando dicen que el DOM es lento. El DOM realiza tareas costosas con mucha más frecuencia de la necesaria porque el código sigue exigiéndolo.
Divida el trabajo en dos pasos: primero todas las lecturas y luego todos los escritos:
const cards = document.querySelectorAll(".product");
const widths = Array.from(cards).map((card) => card.offsetWidth); // all reads, together
cards.forEach((card, i) => {
card.style.width = widths[i] + 10 + "px"; // all writes, together
});
Ahora el diseño se calcula una sola vez, cuando se lee la primera anchura, y las lecturas posteriores utilizan ese mismo resultado porque no se ha modificado nada en el intervalo. Las escrituras, a su vez, se agrupan y se procesan juntas antes de la siguiente actualización de pantalla. El DOM no se volvió más rápido; el código simplemente dejó de obligarlo a realizar el mismo cálculo en cada iteración.
A continuación se presentan algunas observaciones prácticas al respecto:
- La misma regla aplica a otras lecturas de geometría como
clientWidth,scrollTopygetComputedStyle(). - En aplicaciones más grandes, las lecturas y escrituras suelen ocurrir en funciones o componentes diferentes, por lo que puede producirse ineficiencia incluso cuando no haya un bucle sospechoso. Las herramientas de rendimiento del navegador resaltan los diseños forzados, lo que facilita su detección.
requestAnimationFrame es una forma común de agruparlas justo antes del renderizado.Las propiedades y atributos no son siempre lo mismo
Una última sorpresa proviene de la relación entre los atributos HTML y las propiedades JavaScript. Por lo general se reflejan mutuamente, pero este reflejo tiene límites, y esos límites son importantes en cuanto se agrega datos personalizados. Tome este elemento:
<div class="product" data-sku="LAMP-2201"></div>
Y este código que lo lee de tres maneras diferentes:
const el = document.querySelector(".product");
console.log(el.className); // "product" — standard attributes map to properties directly
console.log(el.sku); // undefined — custom attributes don't
console.log(el.getAttribute("data-sku")); // "LAMP-2201" — this is how you actually reach it
Los atributos estándar que el navegador conoce, como href, src y class, se exponen automáticamente como propiedades correspondientes. class aparece como className porque class era una palabra reservada en JavaScript. Los atributos personalizados no reciben ese trato, incluidos aquellos que llevan el prefijo data-, mecanismo estándar para almacenar metadatos propios en los elementos. No existe la propiedad el.sku, por lo que la búsqueda devuelve undefined; es necesario utilizar getAttribute y setAttribute para leer o modificar el valor. Como ventaja adicional, los navegadores también exponen los atributos data- a través del objeto dataset, de modo que el.dataset.sku devuelve el mismo valor. La inconsistencia es pequeña, pero es precisamente lo que genera confusión en el código.
Cuando esperas que un atributo personalizado se comporte como href por primera vez, obtendrás undefined.
Un modelo mental en lugar de tres trampas
Estos comportamientos no son detalles aislados; todos derivan de un hecho único: el DOM es una estructura dinámica que se renderiza en tiempo real, y no datos inactivos que solo se llenan una vez.
- Las colecciones dinámicas cambian mientras se recorren en bucle, por lo que es necesario tomar una instantánea o usar
querySelectorAllantes de realizar modificaciones. - Al leer información geométrica, el navegador se ve obligado a calcular el diseño de inmediato; por eso hay que realizar lecturas en lotes antes de las escrituras.
- Las propiedades de JavaScript se encuentran por encima de los atributos como una conveniencia, pero no reflejan todos ellos; por lo tanto, accede a los datos personalizados mediante
getAttributeodataset.
Cuando consideras el DOM como un árbol dinámico en lugar de una estructura de datos lenta, estas situaciones dejan de ser sorpresas y se convierten en consecuencias predecibles. Para obtener una visión más amplia de cómo se procesa la renderización, desde los cambios de estado hasta los píxeles en el contexto de un framework, consulta cómo React convierte las actualizaciones de estado en píxeles en la pantalla.
Lecturas relacionadas
- React Rendering Explained: Actualizaciones de estado a píxeles en la pantalla — Aprende cómo las fases de renderizado, reconciliación y confirmación de React se conectan con el proceso de maquetación, pintado y composición del navegador para generar píxeles.