Home / Frontend / Manipular el DOM con JavaScript: guía práctica para principiantes
Manipular el DOM con JavaScript: guía práctica para principiantes

Manipular el DOM con JavaScript: guía práctica para principiantes

21 October 2026 · 19 min de lectura ··

El puente entre JavaScript y lo que ves en pantalla

Cuando pulsas un botón y aparece un menú, cuando escribes en un buscador y se filtran los resultados o cuando marcas una tarea como completada y cambia de color, hay JavaScript modificando la página en tiempo real. Todo eso es posible gracias al DOM, la representación que el navegador construye de cada documento HTML y que los programas pueden leer y alterar.

Aprender a manipular el DOM es uno de los primeros pasos importantes en el desarrollo web. Aunque hoy muchas aplicaciones se construyen con frameworks como React, Vue, Svelte o Angular, todos ellos se apoyan en las mismas APIs del navegador. Entenderlas permite depurar problemas que los frameworks no explican, escribir componentes ligeros sin dependencias y añadir interactividad a sitios estáticos sin cargar librerías pesadas.

En esta guía vamos a recorrer todo lo necesario para trabajar con el DOM usando JavaScript moderno: qué es exactamente, cómo seleccionar elementos, cómo moverse por el árbol, cómo crear, modificar, mover y eliminar nodos, cómo cambiar estilos y atributos, y cómo responder a las acciones del usuario con eventos. A lo largo del artículo construiremos una pequeña lista de tareas interactiva y terminaremos con las buenas prácticas de rendimiento y seguridad, y los errores más frecuentes.

Qué es el DOM

El DOM, Document Object Model o modelo de objetos del documento, es una representación en memoria de la página web. Cuando el navegador recibe un archivo HTML, lo analiza y construye un árbol de objetos en el que cada etiqueta, cada fragmento de texto e incluso cada comentario se convierte en un nodo.

Ese árbol de nodos es lo que JavaScript manipula. El HTML original no cambia: lo que cambia es el árbol en memoria, y el navegador vuelve a dibujar la página para reflejarlo.

Tomemos un documento sencillo:

<!DOCTYPE html>
<html lang="es">
    <head>
        <title>Mis tareas</title>
    </head>
    <body>
        <main id="app">
            <h1 class="titulo">Mis tareas</h1>
            <ul id="lista">
                <li class="tarea">Comprar café</li>
                <li class="tarea hecha">Revisar el correo</li>
            </ul>
        </main>
        <script type="module" src="app.js"></script>
    </body>
</html>

El navegador lo convierte en un árbol como este:

document
└── html
    ├── head
    │   └── title
    │       └── "Mis tareas"
    └── body
        ├── main#app
        │   ├── h1.titulo
        │   │   └── "Mis tareas"
        │   └── ul#lista
        │       ├── li.tarea
        │       │   └── "Comprar café"
        │       └── li.tarea.hecha
        │           └── "Revisar el correo"
        └── script

Hay varios tipos de nodos, pero los dos más importantes son los nodos de elemento, que corresponden a las etiquetas, y los nodos de texto, que contienen el texto que hay dentro de ellas. El punto de entrada a todo el árbol es el objeto global document.

Un detalle práctico importante: el script se carga al final del body y con type="module". Los módulos se ejecutan de forma diferida, después de que el navegador haya construido el árbol, de modo que los elementos ya existen cuando el código intenta acceder a ellos. Si usas scripts clásicos en el head, el atributo defer consigue el mismo efecto.

Seleccionar elementos

Antes de modificar un elemento hay que encontrarlo. El DOM ofrece varios métodos de selección, y conviene conocer sus diferencias.

Los métodos más modernos y versátiles son querySelector y querySelectorAll, que aceptan cualquier selector CSS válido. El primero devuelve el primer elemento que coincide, o null si no hay ninguno; el segundo devuelve todos los que coinciden:

const app = document.querySelector("#app");
const titulo = document.querySelector(".titulo");
const primeraTarea = document.querySelector("#lista > li");
const tareasHechas = document.querySelectorAll(".tarea.hecha");
const enlacesExternos = document.querySelectorAll('a[href^="https://"]');

console.log(tareasHechas.length);
1

Dominar querySelector y querySelectorAll es suficiente para la gran mayoría de los casos, porque cualquier cosa que sepas seleccionar en CSS la puedes seleccionar en JavaScript.

Los métodos clásicos siguen siendo útiles y muy rápidos:

const lista = document.getElementById("lista");
const tareas = document.getElementsByClassName("tarea");
const parrafos = document.getElementsByTagName("p");

getElementById es el método más directo para buscar por identificador, que debe ser único en la página. Los otros dos devuelven colecciones de elementos.

Hay una diferencia sutil pero importante entre las colecciones que devuelven unos y otros métodos. getElementsByClassName y getElementsByTagName devuelven una HTMLCollection viva, que se actualiza automáticamente si el documento cambia. querySelectorAll devuelve una NodeList estática, una instantánea del momento de la consulta:

const vivas = document.getElementsByClassName("tarea");
const estaticas = document.querySelectorAll(".tarea");

const nueva = document.createElement("li");
nueva.className = "tarea";
document.getElementById("lista").append(nueva);

console.log(vivas.length, estaticas.length);
3 2

Además, las NodeList admiten forEach directamente, mientras que las HTMLCollection no. Para usar los métodos de los arrays en cualquiera de ellas, basta con convertirlas:

const textos = [...document.querySelectorAll(".tarea")].map(
    (li) => li.textContent
);
const pendientes = Array.from(document.getElementsByClassName("tarea")).filter(
    (li) => !li.classList.contains("hecha")
);

También puedes buscar dentro de un elemento concreto en lugar de en todo el documento, lo que es más eficiente y evita coincidencias no deseadas:

const lista = document.querySelector("#lista");
const primera = lista.querySelector("li");

Recorrer el árbol

Una vez que tienes un elemento, puedes moverte a sus vecinos sin hacer nuevas búsquedas. El DOM ofrece propiedades para navegar hacia arriba, hacia abajo y hacia los lados:

const lista = document.querySelector("#lista");

lista.parentElement; // main#app
lista.children; // HTMLCollection con los <li>
lista.firstElementChild; // primer <li>
lista.lastElementChild; // último <li>
lista.firstElementChild.nextElementSibling; // segundo <li>
lista.lastElementChild.previousElementSibling; // penúltimo <li>

Existen propiedades equivalentes sin la palabra Element, como childNodes, firstChild o nextSibling, pero incluyen también los nodos de texto, entre ellos los saltos de línea y espacios del HTML. Por eso, para navegar entre etiquetas, las variantes con Element son casi siempre las que necesitas:

console.log(lista.childNodes.length, lista.children.length);
5 2

Los cinco nodos son los dos li más tres nodos de texto con los saltos de línea que hay entre ellos.

El método closest es especialmente útil: sube por el árbol desde un elemento y devuelve el primer ancestro, incluido el propio elemento, que coincida con un selector. Lo usaremos mucho al trabajar con eventos:

const boton = document.querySelector(".tarea button");
const tarea = boton.closest(".tarea");
const contenedor = boton.closest("#app");

Y matches comprueba si un elemento coincide con un selector:

if (tarea.matches(".hecha")) {
    console.log("Tarea completada");
}

Leer y cambiar el contenido

Para modificar lo que muestra un elemento hay dos propiedades principales, y elegir bien entre ellas es importante tanto por seguridad como por rendimiento.

textContent lee o escribe el texto de un elemento. Todo lo que asignes se trata como texto literal, nunca como HTML:

const titulo = document.querySelector(".titulo");
titulo.textContent = "Tareas de hoy";

innerHTML lee o escribe el contenido como HTML, de modo que el navegador analiza la cadena y crea los elementos correspondientes:

const aviso = document.querySelector("#aviso");
aviso.innerHTML = "<strong>Atención:</strong> tienes tareas vencidas";

La regla de oro sobre textContent frente a innerHTML es clara: nunca uses innerHTML con datos que provengan del usuario o de una fuente externa. Si lo haces, un atacante podría inyectar código malicioso, una vulnerabilidad conocida como cross-site scripting o XSS:

const comentario = '<img src=x onerror="alert(document.cookie)">';

// Peligroso: el navegador ejecuta el código del atributo onerror
caja.innerHTML = comentario;

// Seguro: se muestra el texto tal cual
caja.textContent = comentario;

Para el texto, usa siempre textContent. Reserva innerHTML para plantillas HTML que controlas por completo, y aun así, valora si crear los elementos con los métodos del DOM no sería más seguro y claro.

Existe también innerText, que se parece a textContent pero tiene en cuenta los estilos: ignora el texto oculto con CSS y respeta los saltos de línea visuales. Es más lento, porque obliga al navegador a calcular el diseño, así que salvo que necesites ese comportamiento, textContent es la opción preferible.

Para los campos de formulario, el contenido se lee y se escribe con la propiedad value:

const entrada = document.querySelector("#nueva-tarea");
console.log(entrada.value);
entrada.value = "";

Crear e insertar elementos

Crear un elemento nuevo es un proceso en tres pasos: crearlo, configurarlo y colocarlo en el árbol. Hasta que no se inserta, el elemento existe solo en memoria y no aparece en la página:

const lista = document.querySelector("#lista");

const tarea = document.createElement("li");
tarea.className = "tarea";
tarea.textContent = "Llamar al dentista";

lista.append(tarea);

Para insertar elementos, el DOM moderno ofrece un conjunto de métodos muy expresivos que trabajan desde el elemento de referencia:

lista.append(nodo1, nodo2, "texto"); // al final, dentro de la lista
lista.prepend(nodo); // al principio, dentro de la lista
referencia.before(nodo); // justo antes de la referencia, como hermano
referencia.after(nodo); // justo después de la referencia, como hermano
referencia.replaceWith(nodo); // sustituye la referencia
referencia.remove(); // elimina la referencia

Estos métodos sustituyen con ventaja a los clásicos appendChild, insertBefore, replaceChild y removeChild, que obligan a trabajar siempre desde el padre y solo aceptan nodos. Los métodos modernos aceptan varios argumentos a la vez y también cadenas de texto, que se convierten automáticamente en nodos de texto seguros. Los clásicos siguen funcionando y verás mucho código que los usa:

// Equivalentes clásicos
lista.appendChild(tarea);
lista.insertBefore(tarea, lista.firstElementChild);
lista.replaceChild(nuevaTarea, tarea);
lista.removeChild(nuevaTarea);

El método replaceChildren vacía un elemento y, opcionalmente, coloca nuevos hijos en una sola operación, una forma clara de redibujar una lista completa:

lista.replaceChildren(); // vacía la lista
lista.replaceChildren(tarea1, tarea2, tarea3); // la sustituye por estos elementos

Un detalle que sorprende a muchos principiantes: un nodo solo puede estar en un lugar del árbol. Si insertas un elemento que ya está en el documento, no se duplica, sino que se mueve. Para duplicarlo hay que clonarlo explícitamente con cloneNode(true), donde el argumento indica que se copien también sus descendientes.

Cuando necesitas insertar HTML en una posición concreta, insertAdjacentHTML es más eficiente que reasignar innerHTML, porque no destruye ni vuelve a crear el contenido existente. Acepta cuatro posiciones: beforebegin, afterbegin, beforeend y afterend. Se le aplican las mismas advertencias de seguridad que a innerHTML.

Plantillas y fragmentos para estructuras complejas

Cuando un elemento tiene una estructura interna compleja, crearlo nodo a nodo con createElement se vuelve tedioso. Una alternativa elegante es la etiqueta template, cuyo contenido el navegador analiza pero no muestra:

<template id="plantilla-tarea">
    <li class="tarea">
        <label>
            <input type="checkbox" class="tarea-check" />
            <span class="tarea-texto"></span>
        </label>
        <button type="button" class="tarea-borrar" aria-label="Eliminar tarea">
            ✕
        </button>
    </li>
</template>
const plantilla = document.querySelector("#plantilla-tarea");

function crearTarea(texto, hecha = false) {
    const nodo = plantilla.content.firstElementChild.cloneNode(true);
    nodo.querySelector(".tarea-texto").textContent = texto;
    nodo.querySelector(".tarea-check").checked = hecha;
    nodo.classList.toggle("hecha", hecha);
    return nodo;
}

Al clonar la plantilla y rellenar los huecos con textContent, combinamos la comodidad de escribir HTML con la seguridad de no interpretar los datos como marcado.

Cuando hay que insertar muchos elementos a la vez, conviene agruparlos primero en un DocumentFragment, un contenedor ligero que no forma parte del documento. Al insertar el fragmento, todos sus hijos se añaden en una única operación, lo que reduce el trabajo del navegador:

const datos = [
    "Comprar fruta",
    "Pagar la luz",
    "Reservar mesa",
    "Enviar informe",
];

const fragmento = document.createDocumentFragment();
for (const texto of datos) {
    fragmento.append(crearTarea(texto));
}
document.querySelector("#lista").append(fragmento);

El uso combinado de DocumentFragment y template es una de las formas más limpias de renderizar listas sin ningún framework.

Atributos, datos personalizados y clases

Además del contenido, a menudo hay que modificar los atributos de los elementos. Los métodos genéricos son getAttribute, setAttribute, removeAttribute, hasAttribute y toggleAttribute:

const enlace = document.querySelector("a.ayuda");
enlace.setAttribute("href", "/ayuda");
enlace.setAttribute("target", "_blank");
enlace.setAttribute("rel", "noopener");

const boton = document.querySelector("#enviar");
boton.toggleAttribute("disabled", true);

Muchos atributos estándar también están disponibles como propiedades del elemento, como id, href, src, checked o disabled, lo que resulta más cómodo y devuelve valores ya interpretados.

Para guardar información propia en un elemento se usan los atributos data-*, accesibles desde JavaScript mediante la propiedad dataset. Los nombres con guiones se convierten automáticamente a camelCase:

<li class="tarea" data-id="42" data-fecha-limite="2026-10-20">
    Enviar informe
</li>
const tarea = document.querySelector('[data-id="42"]');
console.log(tarea.dataset.id, tarea.dataset.fechaLimite);
tarea.dataset.prioridad = "alta";
42 2026-10-20

Para cambiar el aspecto visual, la forma recomendada no es modificar estilos uno a uno, sino añadir y quitar clases definidas en CSS. La propiedad classList lo hace muy sencillo:

const tarea = document.querySelector(".tarea");

tarea.classList.add("destacada");
tarea.classList.remove("hecha");
tarea.classList.toggle("hecha"); // añade si no está, quita si está
tarea.classList.toggle("hecha", true); // fuerza el estado
tarea.classList.replace("destacada", "archivada");
console.log(tarea.classList.contains("archivada"));
.tarea.hecha {
    text-decoration: line-through;
    opacity: 0.6;
}

Separar los estilos en CSS y limitar JavaScript a cambiar clases mantiene cada lenguaje en su papel y facilita el mantenimiento. Cuando necesites valores dinámicos, como una posición calculada o un color elegido por el usuario, la propiedad style permite cambiarlos directamente, y las variables CSS ofrecen un punto intermedio muy útil:

const barra = document.querySelector(".progreso");
barra.style.setProperty("--avance", "72%");
barra.style.width = "var(--avance)";

Responder a las acciones del usuario

Los eventos son lo que convierte una página estática en una aplicación interactiva. Cada vez que el usuario hace clic, escribe, envía un formulario o mueve el ratón, el navegador genera un evento al que podemos reaccionar.

La forma recomendada de escuchar eventos es addEventListener, que recibe el tipo de evento y la función que se ejecutará:

const boton = document.querySelector("#saludar");

boton.addEventListener("click", (evento) => {
    console.log("Has hecho clic en", evento.currentTarget.id);
});

Frente a los atributos HTML como onclick="...", addEventListener mantiene el JavaScript separado del marcado, permite registrar varios manejadores para el mismo evento y ofrece opciones adicionales. Las más útiles son once, que elimina el manejador tras ejecutarse una vez; passive, que indica que no se llamará a preventDefault y mejora el rendimiento del desplazamiento; y signal, que permite eliminar manejadores con un AbortController:

const controlador = new AbortController();

window.addEventListener("resize", ajustarDiseno, {
    signal: controlador.signal,
});
window.addEventListener("scroll", actualizarBarra, {
    passive: true,
    signal: controlador.signal,
});

// Más tarde, elimina todos los manejadores asociados de una sola vez
controlador.abort();

El objeto evento que recibe el manejador contiene información sobre lo ocurrido. target es el elemento donde se originó el evento y currentTarget, el elemento que tiene el manejador. El método preventDefault cancela el comportamiento por defecto, como el envío de un formulario o la navegación de un enlace:

const formulario = document.querySelector("#form-tarea");

formulario.addEventListener("submit", (evento) => {
    evento.preventDefault();
    const datos = new FormData(formulario);
    const texto = String(datos.get("texto") ?? "").trim();
    if (!texto) return;
    document.querySelector("#lista").append(crearTarea(texto));
    formulario.reset();
});

Escuchar el evento submit del formulario, en lugar del clic del botón, tiene una ventaja: también responde cuando el usuario pulsa Enter en un campo, y respeta la validación nativa del navegador.

Propagación y delegación de eventos

Cuando ocurre un evento en un elemento, no se queda ahí. Primero viaja desde la raíz del documento hasta el elemento, en la fase de captura, y después vuelve a subir hasta la raíz, en la fase de burbujeo. Por defecto, los manejadores se ejecutan durante el burbujeo.

Esta propagación hace posible una técnica muy potente: en lugar de asignar un manejador a cada elemento de una lista, se asigna uno solo al contenedor, que recibe los eventos de todos sus hijos. La delegación de eventos en JavaScript reduce el número de manejadores, consume menos memoria y, sobre todo, funciona automáticamente con elementos que se añaden después:

const lista = document.querySelector("#lista");

lista.addEventListener("click", (evento) => {
    const botonBorrar = evento.target.closest(".tarea-borrar");
    if (botonBorrar) {
        botonBorrar.closest(".tarea").remove();
    }
});

lista.addEventListener("change", (evento) => {
    if (evento.target.matches(".tarea-check")) {
        const tarea = evento.target.closest(".tarea");
        tarea.classList.toggle("hecha", evento.target.checked);
    }
});

El método closest es la clave de este patrón, porque el clic puede producirse sobre un elemento interior del botón, como un icono, y closest encuentra el botón real subiendo por el árbol.

Si en algún momento necesitas impedir que un evento siga propagándose, existe stopPropagation, pero conviene usarlo con moderación: detener la propagación puede romper otros manejadores, como los de analítica o los de cierre de menús al hacer clic fuera.

Proyecto completo: una lista de tareas sin frameworks

Juntando todo lo anterior, podemos construir una pequeña aplicación de tareas que guarda su estado en el almacenamiento local del navegador. Este es el HTML, que reutiliza la plantilla #plantilla-tarea de la sección anterior:

<main id="app">
    <h1>Mis tareas</h1>
    <form id="form-tarea">
        <label for="texto">Nueva tarea</label>
        <input
            id="texto"
            name="texto"
            required
            maxlength="120"
            autocomplete="off"
        />
        <button type="submit">Añadir</button>
    </form>
    <p id="resumen" aria-live="polite"></p>
    <ul id="lista"></ul>
</main>
<!-- Aquí va el <template id="plantilla-tarea"> mostrado antes -->

Y este el JavaScript, organizado en torno a un estado único que se vuelve a dibujar cada vez que cambia:

// app.js
const CLAVE = "tareas-v1";
const lista = document.querySelector("#lista");
const formulario = document.querySelector("#form-tarea");
const resumen = document.querySelector("#resumen");
const plantilla = document.querySelector("#plantilla-tarea");

let tareas = cargar();

function cargar() {
    try {
        return JSON.parse(localStorage.getItem(CLAVE)) ?? [];
    } catch {
        return [];
    }
}

function guardar() {
    try {
        localStorage.setItem(CLAVE, JSON.stringify(tareas));
    } catch {
        // Almacenamiento no disponible: la app sigue funcionando en memoria
    }
}

function render() {
    const fragmento = document.createDocumentFragment();
    for (const { id, texto, hecha } of tareas) {
        const nodo = plantilla.content.firstElementChild.cloneNode(true);
        nodo.dataset.id = id;
        nodo.classList.toggle("hecha", hecha);
        nodo.querySelector(".tarea-texto").textContent = texto;
        nodo.querySelector(".tarea-check").checked = hecha;
        fragmento.append(nodo);
    }
    lista.replaceChildren(fragmento);

    const pendientes = tareas.filter((t) => !t.hecha).length;
    resumen.textContent = `${pendientes} de ${tareas.length} tareas pendientes`;
}

function actualizar(cambio) {
    tareas = cambio(tareas);
    guardar();
    render();
}

formulario.addEventListener("submit", (e) => {
    e.preventDefault();
    const texto = new FormData(formulario).get("texto").toString().trim();
    if (!texto) return;
    actualizar((ts) => [
        ...ts,
        { id: crypto.randomUUID(), texto, hecha: false },
    ]);
    formulario.reset();
    formulario.texto.focus();
});

lista.addEventListener("change", (e) => {
    if (!e.target.matches(".tarea-check")) return;
    const id = e.target.closest(".tarea").dataset.id;
    actualizar((ts) =>
        ts.map((t) => (t.id === id ? { ...t, hecha: !t.hecha } : t))
    );
});

lista.addEventListener("click", (e) => {
    const boton = e.target.closest(".tarea-borrar");
    if (!boton) return;
    const id = boton.closest(".tarea").dataset.id;
    actualizar((ts) => ts.filter((t) => t.id !== id));
});

render();

Este patrón de mantener los datos en un estado, modificarlo con funciones y redibujar la vista a partir de él es exactamente la idea que popularizaron los frameworks modernos. El atributo aria-live del resumen hace que los lectores de pantalla anuncien los cambios en el número de tareas pendientes.

Observar cambios en la página

Además de los eventos del usuario, el navegador ofrece APIs para reaccionar a cambios en el propio DOM o en la posición de los elementos.

IntersectionObserver detecta cuándo un elemento entra o sale del área visible. Es la forma eficiente de implementar carga diferida de imágenes, animaciones al hacer scroll o paginación infinita, sin escuchar el evento scroll constantemente:

const observador = new IntersectionObserver(
    (entradas) => {
        for (const entrada of entradas) {
            if (entrada.isIntersecting) {
                entrada.target.classList.add("visible");
                observador.unobserve(entrada.target);
            }
        }
    },
    { threshold: 0.2 }
);

document.querySelectorAll(".aparecer").forEach((el) => observador.observe(el));

MutationObserver notifica cuando se añaden, eliminan o modifican nodos o atributos dentro de un elemento. Es útil para integrarse con contenido generado por otras librerías o widgets de terceros que no ofrecen eventos propios.

ResizeObserver, por su parte, avisa cuando cambia el tamaño de un elemento, algo que el evento resize de la ventana no detecta.

Rendimiento: cómo evitar que la página se vuelva lenta

Cada cambio en el DOM puede obligar al navegador a recalcular estilos, rehacer el diseño de la página y volver a pintarla. Hacerlo de forma descuidada en bucles o en eventos frecuentes provoca interfaces lentas y entrecortadas.

La primera recomendación es agrupar las inserciones. Insertar cien elementos uno a uno puede provocar más trabajo que insertarlos todos de una vez con un DocumentFragment o con replaceChildren.

La segunda es evitar alternar lecturas y escrituras de propiedades de diseño. Leer propiedades como offsetHeight o getBoundingClientRect después de modificar estilos obliga al navegador a calcular el diseño inmediatamente, un fenómeno conocido como layout thrashing:

// Lento: cada lectura fuerza un recálculo tras la escritura anterior
for (const caja of cajas) {
    caja.style.width = caja.offsetWidth + 10 + "px";
}

// Mejor: primero todas las lecturas, después todas las escrituras
const anchos = cajas.map((caja) => caja.offsetWidth);
cajas.forEach((caja, i) => {
    caja.style.width = anchos[i] + 10 + "px";
});

La tercera es sincronizar las animaciones y actualizaciones visuales con requestAnimationFrame, que ejecuta el código justo antes del siguiente fotograma.

La cuarta es limitar los eventos muy frecuentes, como input, scroll o resize, con técnicas de debounce o throttle, y usar los observadores en lugar de escuchar el scroll cuando sea posible.

function debounce(fn, espera = 250) {
    let temporizador;
    return (...args) => {
        clearTimeout(temporizador);
        temporizador = setTimeout(() => fn(...args), espera);
    };
}

const buscador = document.querySelector("#buscar");
buscador.addEventListener(
    "input",
    debounce((e) => filtrar(e.target.value), 300)
);

Con este envoltorio, la función de filtrado solo se ejecuta cuando el usuario deja de escribir durante 300 milisegundos, en lugar de hacerlo con cada pulsación de tecla. En listas grandes, esta diferencia se nota de inmediato en la fluidez de la interfaz.

Por último, mide antes de optimizar. El panel de rendimiento de las herramientas para desarrolladores de Chrome, Edge o Firefox muestra qué partes del código provocan recálculos de estilo y de diseño, y cuánto tiempo consume cada fotograma. Optimizar sin datos suele llevar a complicar el código en zonas que no eran el verdadero cuello de botella.

Errores comunes y cómo resolverlos

Al empezar a trabajar con el DOM aparecen siempre los mismos errores.

El más frecuente es intentar acceder a un elemento que todavía no existe o cuyo selector está mal escrito:

Uncaught TypeError: Cannot read properties of null (reading 'addEventListener')

Significa que querySelector devolvió null. Comprueba que el selector es correcto, incluido el # o el punto, y que el script se ejecuta después de que el HTML se haya analizado, usando defer, type="module" o colocando el script al final del body.

El segundo es llamar a métodos de arrays sobre una HTMLCollection:

Uncaught TypeError: document.getElementsByClassName(...).forEach is not a function

Convierte la colección con Array.from o el operador de propagación, o usa querySelectorAll, cuya NodeList sí tiene forEach.

El tercero es asignar manejadores a elementos que se crean después. Si añades elementos dinámicamente, los manejadores registrados antes no se aplican a ellos. La solución es la delegación de eventos en un contenedor estable.

El cuarto es ejecutar la función en lugar de pasarla al registrar un evento. Escribir boton.addEventListener('click', guardar()) llama a guardar inmediatamente y registra su resultado. La forma correcta es addEventListener('click', guardar) o envolverla en una función flecha.

El quinto es usar innerHTML con datos del usuario, con el consiguiente riesgo de XSS. Usa textContent para el texto y crea los elementos con los métodos del DOM o con plantillas.

El sexto es reasignar innerHTML += '...' dentro de un bucle. Cada reasignación destruye y vuelve a crear todo el contenido del elemento, perdiendo los manejadores de eventos de los hijos y el estado de los campos de formulario, además de ser muy lento.

Cuándo usar un framework

Con todo lo visto, puedes construir interfaces completas sin ninguna librería. Para muchos casos, como añadir interactividad a un sitio de contenido, crear widgets aislados o prototipar, el DOM nativo es la opción más ligera y rápida.

A medida que la aplicación crece, mantener manualmente la sincronización entre los datos y la interfaz se vuelve costoso. Ahí es donde frameworks como React, Vue, Svelte, Angular o Solid aportan valor: gestionan automáticamente las actualizaciones del DOM a partir del estado, organizan el código en componentes reutilizables y ofrecen herramientas para el enrutado, la gestión de datos y las pruebas.

También existen soluciones intermedias, como los Web Components nativos, que permiten crear elementos personalizados reutilizables con las APIs estándar del navegador, o librerías ligeras como Alpine.js y htmx, que añaden interactividad declarativa sin un proceso de compilación.

Elijas lo que elijas, conocer el DOM te hará mejor desarrollador, porque todas estas herramientas terminan llamando a las mismas APIs que hemos visto.

Conclusiones

El DOM es la representación en memoria de la página web y la interfaz que permite a JavaScript leerla y modificarla. Entender que se trata de un árbol de nodos, con elementos, textos y relaciones de parentesco, es la base para trabajar con cualquier interfaz web.

Los métodos querySelector y querySelectorAll permiten seleccionar cualquier elemento con selectores CSS, y propiedades como children, parentElement o los métodos closest y matches facilitan moverse por el árbol. Para modificar la página, textContent es la opción segura para el texto, mientras que createElement, las plantillas y los fragmentos permiten construir estructuras complejas sin riesgo de inyección. Los métodos modernos append, prepend, before, after, replaceWith, replaceChildren y remove simplifican la inserción y eliminación de nodos.

classList y dataset separan limpiamente el comportamiento de la presentación y los datos, y addEventListener, combinado con la delegación de eventos, permite responder a las acciones del usuario de forma eficiente incluso en contenido dinámico. Los observadores de intersección, mutación y tamaño completan el conjunto de herramientas para reaccionar a lo que ocurre en la página.

Por último, cuidar el rendimiento agrupando cambios y evitando lecturas forzadas del diseño, y proteger la aplicación evitando innerHTML con datos externos, marcan la diferencia entre un código que funciona y uno que funciona bien. La mejor forma de interiorizar todo esto es practicar: construye pequeños proyectos como una lista de tareas, un buscador con filtros o una galería de imágenes, y verás cómo el DOM deja de tener secretos.