En el Nivel 0 viste el DOM de lejos: una representación en árbol de la página que el navegador construye al leer el HTML. En el Nivel 1 escribiste HTML a mano. Ahora llega el paso que conecta ambas cosas: manipular ese árbol desde JavaScript.
Esto es lo que hace React (y cualquier framework) por debajo. Entenderlo bien aquí es lo que te permite, más adelante, razonar sobre rendimiento, problemas de sincronización y decisiones de arquitectura en lugar de seguir recetas de memoria.
Seguimos con el Overwatch Team Builder: esta vez pintamos la plantilla de héroes desde JS y la hacemos interactiva, sin frameworks.
El DOM como API#
Cuando el navegador termina de leer el HTML, construye un árbol de objetos en memoria: el
Document Object Model. Cada etiqueta se convierte en un nodo; cada nodo tiene propiedades y
métodos que podemos leer y modificar desde JS. Lo que llamas document en el navegador es la
raíz de ese árbol.
Lo crucial es que el DOM es vivo: si modificas un nodo, la pantalla se actualiza al instante. No hace falta recargar la página.
Seleccionar nodos#
Para tocar un elemento primero hay que encontrarlo. Los métodos que más usarás:
// coge del DOM el nodo con id "plantilla"
const contenedor = document.querySelector('#plantilla');
// coge el primer botón que tenga ambas clases
const boton = document.querySelector('.filtro.activo');
// Varios elementos (todos los que coincidan): devuelve un NodeList
// coge TODOS los nodos con clase "hero-card"
const tarjetas = document.querySelectorAll('.hero-card');
// ⚠ NodeList NO es un array: tiene .forEach(), pero NO tiene .map(), .filter() ni .reduce().
// Si necesitas esos métodos (que acabas de aprender), conviértelo primero:
// spread: convierte el NodeList en un array de verdad
const arr = [...tarjetas];
// ahora sí puedes usar map/filter/reduce
const nombres = arr.map(t => t.querySelector('.hero-nombre').textContent);
// Por id (atajo histórico, aún muy usado)
// equivale a querySelector('#app'), sin el #
const app = document.getElementById('app');querySelector y querySelectorAll aceptan cualquier selector CSS que ya conoces: de
clase, de id, combinados, anidados. Si el elemento no existe, querySelector devuelve null
y el código revienta si intentas usar ese null: comprueba siempre antes de operar. El patrón es
un simple if con el nodo (un nodo es truthy; null es falsy, como viste con los valores):
// querySelector devuelve el nodo, o null si no encuentra nada en la página.
const cabecera = document.querySelector('h1');
// Comprueba ANTES de tocarlo: si fuera null, este if te ahorra el error.
if (cabecera) {
// solo entras aquí si el nodo existe de verdad
console.log('La cabecera existe: ya puedo trabajar con ella');
} else {
// rama para cuando no está: avisas en vez de reventar
console.log('No hay ningún h1 en la página');
}Leer y modificar#
Una vez tienes el nodo, puedes:
Texto y contenido:
// cogemos del DOM el nodo de la etiqueta h1
const h1 = document.querySelector('h1');
// Leer o escribir el texto visible (sin etiquetas)
// sobreescribe el texto del h1; nunca parsea HTML
h1.textContent = 'Overwatch Team Builder';
// innerHTML parsea el string como HTML → útil para contenido controlado,
// pero NUNCA con datos del usuario (riesgo de inyección de código, XSS).
// sustituye TODO el contenido interno del nodo por el HTML que le pasas
contenedor.innerHTML = '<p>Hola</p>';La regla práctica: si el valor viene del usuario (input, URL, API externa), usa textContent.
Si es HTML que escribes tú mismo y controlas, innerHTML ahorra trabajo.
Clases:
// añade la clase "seleccionado" sin pisar las demás que ya tenga
tarjeta.classList.add('seleccionado');
// quita la clase "seleccionado" si existe; no hace nada si no estaba
tarjeta.classList.remove('seleccionado');
// añade si no está, quita si está — perfecto para estados que alternan
tarjeta.classList.toggle('seleccionado');
// segundo argumento: si es true fuerza añadir la clase aunque ya estuviera
tarjeta.classList.toggle('seleccionado', true);
// si es false fuerza quitarla aunque no estuviera — útil en filtros
tarjeta.classList.toggle('seleccionado', false);
// devuelve true si el nodo tiene esa clase, false si no
tarjeta.classList.contains('seleccionado');classList.toggle es el patrón para estados que alternan: modo oscuro, elemento activo,
acordeón abierto/cerrado. Con el segundo argumento booleano puedes forzar el resultado
sin importar el estado previo: lo verás en el playground al marcar el botón activo del
filtro con b.classList.toggle('activo', b === boton).
Atributos y estilos:
// escribe o sobreescribe un atributo cualquiera en el nodo
boton.setAttribute('aria-pressed', 'true');
// lee el valor de ese atributo (devuelve string o null si no existe)
boton.getAttribute('aria-pressed');
// Atributos data-*: para guardar TUS propios datos en el HTML.
// escribe data-rol="Tanque" en el nodo (dataset.rol ↔ atributo data-rol)
boton.dataset.rol = 'Tanque';
// y se lee de vuelta igual de fácil → 'Tanque'
const rolDelBoton = boton.dataset.rol;
// acceso directo como propiedad: equivale a setAttribute('src', ...)
imagen.src = '/heroes/tracer.png';
// aplica un estilo inline al nodo (preferible manipular clases con classList)
nodo.style.display = 'none';En general manipular clases es mejor que manipular style directamente: las clases mantienen
los estilos en el CSS, donde son más fáciles de mantener y de reutilizar.
Un apunte sobre los atributos data-*: son atributos que te inventas tú —data-rol,
data-id, data-estado— para colgar datos a medida en un elemento del HTML. Se leen y
escriben con la propiedad dataset, que toma el nombre del atributo, le quita el data- y te
lo entrega como propiedad: data-rol se convierte en dataset.rol (si el atributo lleva
guiones, como data-hero-id, une las palabras: dataset.heroId). Es el sitio estándar para
guardar, por ejemplo, el rol al que filtra un botón y recuperarlo cuando el usuario hace clic.
Lo verás repetido por todo este capítulo.
Crear y montar nodos#
En lugar de pisar el HTML con innerHTML, puedes construir nodos de forma programática:
function crearTarjetaHeroe(heroe) {
// crea un nodo <article> nuevo, todavía fuera del DOM
const article = document.createElement('article');
// asigna la clase CSS que lo estilará
article.className = 'hero-card';
// escribe el atributo data-rol; en el HTML queda data-rol="Tanque"
article.dataset.rol = heroe.rol;
// crea un <p> hijo para el nombre
const nombre = document.createElement('p');
nombre.className = 'hero-nombre';
// asigna el texto; seguro aunque contenga < o >
nombre.textContent = heroe.nombre;
// inserta el <p> dentro del <article>
article.append(nombre);
// devuelve el nodo construido, listo para montar
return article;
}
// Montarlo en el DOM
// cogemos del DOM el nodo con id "plantilla"
const contenedor = document.querySelector('#plantilla');
// inserta el <article> al final del contenedor
contenedor.append(crearTarjetaHeroe(tracer));append añade al final del padre. Si quieres insertar en una posición concreta, tienes
prepend, before y after:
// cogemos del DOM el <ul> contenedor de tarjetas
const lista = document.querySelector('#plantilla');
// creamos un <li> nuevo para Tracer
const nueva = document.createElement('li');
nueva.className = 'hero-card';
// le ponemos el texto visible
nueva.textContent = 'Tracer';
// inserta nueva como PRIMER hijo de la lista (antes de todas las tarjetas)
lista.prepend(nueva);
// before y after insertan el nodo como HERMANO, fuera del padre, no como hijo:
// cogemos la primera tarjeta del DOM
const primera = lista.querySelector('.hero-card');
// creamos otro <li>
const extra = document.createElement('li');
extra.className = 'hero-card';
extra.textContent = 'Encabezado';
// coloca extra justo ANTES de primera, al mismo nivel en el árbol
primera.before(extra);
// primera.after(extra); // alternativa: lo colocaría justo DESPUÉS de primeraPara insertar muchos elementos a la vez sin un repintado por cada uno, usa un
DocumentFragment: es un contenedor temporal fuera del DOM activo que se monta en una
sola operación:
// contenedor temporal fuera del DOM; no provoca repintados
const fragmento = document.createDocumentFragment();
// acumulamos todos los nodos en el fragmento
heroes.forEach((h) => fragmento.append(crearTarjetaHeroe(h)));
// volcamos el fragmento al DOM en una sola operación: un solo repintado
contenedor.append(fragmento);Eventos#
Un evento es algo que ocurre en la página: un clic, una pulsación de tecla, el formulario
que se envía, el scroll que avanza. Puedes reaccionar a cualquiera de ellos con
addEventListener:
// cogemos del DOM el nodo con id "btn-filtrar"
const boton = document.querySelector('#btn-filtrar');
// registramos un handler: se ejecutará cada vez que se haga clic
boton.addEventListener('click', function (event) {
// event es el objeto con información sobre el evento
// event.target es el nodo concreto que recibió el clic (puede ser un hijo del listener)
// leemos el texto del nodo pulsado
console.log('Clic en ' + event.target.textContent);
});El segundo argumento es la función que se ejecutará: el handler o manejador. Puede ser
una función anónima como arriba, una arrow function, o una función con nombre. El parámetro
event (o e, como se abrevia) lleva información sobre lo que ocurrió: event.target es el
elemento concreto que lo disparó y event.type es el tipo de evento. Según el tipo tendrás
propiedades adicionales.
Quitar un listener con removeEventListener
addEventListener registra el handler pero no lo borra solo. Si en algún momento necesitas
dejar de escuchar un evento (por ejemplo, al destruir un componente o al finalizar una
animación), usas removeEventListener con la misma referencia a la función:
// necesitamos guardar la función en una variable para poder quitarla después
function manejarClic(e) {
// lógica que se ejecuta al hacer clic
console.log('Clic en ' + e.target.textContent);
}
// registramos el handler con la referencia guardada
boton.addEventListener('click', manejarClic);
// más tarde, cuando ya no queremos escuchar el evento:
// removeEventListener necesita la MISMA referencia; una función nueva no funciona
boton.removeEventListener('click', manejarClic);La razón más habitual para necesitar removeEventListener es cuando añades listeners
dentro de una función que se llama varias veces: cada llamada registra un handler nuevo y el
nodo acaba con handlers duplicados que se disparan a la vez. La solución habitual en este
capítulo es la delegación: un solo listener en el contenedor padre, nunca uno por hijo,
lo que elimina el problema de raíz.
Aquí están los eventos más usados, cada uno con su ejemplo:
input se dispara en cada carácter que el usuario escribe. event.target.value es lo que
hay en el campo en ese momento:
// cogemos el <input> del DOM
const campoBusqueda = document.querySelector('#busqueda');
// se dispara con CADA pulsación de tecla
campoBusqueda.addEventListener('input', (e) => {
// e.target es el input; .value es su contenido actual
const texto = e.target.value;
// por ejemplo: "Buscando: Trac"
console.log('Buscando: ' + texto);
});keydown se dispara al pulsar cualquier tecla. event.key da el nombre de la tecla pulsada:
// escuchamos en el documento entero
document.addEventListener('keydown', (e) => {
// e.key es el nombre de la tecla: "Escape", "Enter", "a", "1"...
if (e.key === 'Escape') {
console.log('Se pulsó Escape');
}
});submit se dispara al enviar un formulario. Sin event.preventDefault(), el navegador
recarga la página (comportamiento por defecto de los formularios HTML):
// cogemos el <form> del DOM
const formulario = document.querySelector('#form-heroe');
formulario.addEventListener('submit', (e) => {
// cancelamos el comportamiento por defecto: la página NO se recarga
e.preventDefault();
// leemos el valor del input de nombre
const nombre = document.querySelector('#nombre').value;
console.log('Héroe enviado: ' + nombre);
});Para eventos de ratón como click o mousemove, event.clientX y event.clientY dan la
posición del cursor en píxeles dentro de la ventana:
// se dispara cada vez que el ratón se mueve
document.addEventListener('mousemove', (e) => {
// posición en px desde la esquina superior izquierda
console.log('X: ' + e.clientX + ' Y: ' + e.clientY);
});Los eventos más usados en el día a día: click, input, change (al salir del campo con
un valor distinto al que tenía al entrar), submit, keydown, scroll.
¿Por qué no onclick en el HTML? Porque mezcla comportamiento y estructura en el mismo
fichero, solo admite un handler por evento, y complica los tests. Con addEventListener el JS
está donde debe estar: en el .js.
Subir por el árbol con .closest()#
Antes de ver la delegación, hay un método del DOM que la hace posible: .closest(selector).
Dado un nodo, sube por sus ancestros (incluyendo el propio nodo) y devuelve el primero que
casa con el selector CSS que le pases. Si no encuentra ninguno, devuelve null.
// Supón esta estructura HTML:
// <ul id="lista">
// <li class="hero-card">
// <p class="hero-nombre">Tracer</p>
// </li>
// </ul>
// cogemos el <p> directamente (el nodo más profundo)
const parrafo = document.querySelector('.hero-nombre');
// sube desde el <p> hasta el primer ancestro (o él mismo) con clase "hero-card"
const tarjeta = parrafo.closest('.hero-card');
// tarjeta es el <li class="hero-card"> → encontró el primer ancestro que casa
console.log('Tarjeta encontrada: ' + tarjeta.className);
// si no hay ningún ancestro que case, devuelve null
const inexistente = parrafo.closest('.no-existe');
// inexistente es null → hay que comprobarlo antes de usarlo
console.log('No existe: ' + inexistente);Esto es fundamental en la delegación de eventos: cuando el usuario hace clic en el <p> del
nombre, event.target apunta al <p> (el nodo más profundo que recibió el clic), no a la
tarjeta. .closest('.hero-card') sube desde ese <p> hasta encontrar la tarjeta, sin
importar en qué hijo interior cayó exactamente el clic.
Delegación de eventos#
Imagina que tienes una lista con cien tarjetas de héroe y quieres que cada una reaccione a un clic. Una opción es añadir un listener a cada tarjeta: cien listeners en memoria.
La opción inteligente es aprovechar el bubbling: cuando haces clic en un elemento, el evento sube por el árbol —burbujea— hasta llegar al documento. Puedes capturarlo en cualquier ancestro:
// cogemos del DOM el nodo con id "plantilla"
const lista = document.querySelector('#plantilla');
// un solo listener en el padre; el evento burbujea desde los hijos
lista.addEventListener('click', (e) => {
// e.target es el nodo más profundo que recibió el clic (puede ser un <p> dentro de la tarjeta)
// closest sube por los ancestros buscando el primero que coincide con el selector; devuelve null si no encuentra
const tarjeta = e.target.closest('.hero-card');
// el clic no cayó sobre ninguna tarjeta (por ejemplo, en el hueco entre tarjetas)
if (!tarjeta) return;
// alterna la clase: la añade si no estaba, la quita si estaba
tarjeta.classList.toggle('seleccionado');
});Aquí conviene separar dos propiedades del evento que se confunden a todas horas:
event.currentTargetes siempre el nodo donde registraste el listener (aquí,lista, el padre).event.targetes el nodo exacto sobre el que ocurrió el clic, que puede ser un hijo muy profundo (el<p>del nombre).
En delegación trabajas con target (dónde cayó el clic de verdad) y subes con .closest() hasta
el elemento que te interesa; currentTarget siempre apunta al padre que escucha, pase lo que pase.
Un solo listener maneja todas las tarjetas, las que están ahora y las que añadas después. Es el patrón que están bajo el capó de cualquier framework: React no pone un listener por componente, sino uno global en la raíz del documento.
Accesibilidad con ARIA#
El HTML semántico (<button>, <a>, <input>) ya comunica su propósito a los lectores de
pantalla. Pero a veces construyes elementos interactivos con etiquetas genéricas —<div>,
<article>— que no tienen semántica de interacción por defecto. Ahí entra ARIA (Accessible
Rich Internet Applications): un conjunto de atributos HTML que añaden significado para las
tecnologías de asistencia sin cambiar la apariencia visual.
Los tres atributos que aparecen en el tier excelente del ejercicio:
-
role: declara qué tipo de widget es el elemento.role="checkbox"le dice al lector de pantalla “este nodo es una casilla que puede estar marcada o no”. Sin él, un<article>es simplemente “región” y no se anuncia como algo activable. -
aria-checked: el estado actual de ese checkbox."true"= seleccionado,"false"= no seleccionado. Debe mantenerse sincronizado con lo que muestra el CSS: si añades la claseseleccionado, también actualizasaria-checkeda"true". -
aria-live: indica a los lectores de pantalla que una zona de la página puede cambiar sin que el foco se mueva a ella.aria-live="polite"anuncia el cambio cuando el usuario termina lo que está haciendo (sin interrumpir);"assertive"interrumpe al instante (para errores urgentes).
// creamos un <article> que actuará como checkbox clicable
const tarjeta = document.createElement('article');
// role: anuncia al lector de pantalla qué tipo de widget es
tarjeta.setAttribute('role', 'checkbox');
// aria-checked: estado inicial no seleccionado
tarjeta.setAttribute('aria-checked', 'false');
// tabindex 0: hace el nodo enfocable con Tab (navegación por teclado)
tarjeta.setAttribute('tabindex', '0');
// al hacer clic, alternamos la clase visual Y sincronizamos el atributo ARIA
tarjeta.addEventListener('click', () => {
// classList.toggle devuelve true si se añadió, false si se quitó
const ahora = tarjeta.classList.toggle('seleccionado');
// sincronizamos el estado ARIA con el visual
tarjeta.setAttribute('aria-checked', String(ahora));
});La regla de oro: el estado visible y el estado ARIA deben moverse siempre juntos. Si el
CSS dice “seleccionado” pero aria-checked sigue en "false", el lector de pantalla anuncia
algo diferente a lo que ve el usuario con visión.
aria-live se usa de otra forma: con una zona de estado, un nodo que el lector de pantalla
vigila y anuncia cuando su texto cambia, sin mover el foco hasta él. Es lo ideal para confirmar
acciones —“Mercy añadida”, “3 héroes en la plantilla”— a quien navega sin ver la pantalla:
// Una zona viva: el lector de pantalla anunciará sus cambios de texto.
const aviso = document.createElement('p');
// polite = espera a que el usuario haga una pausa, no interrumpe lo que esté oyendo.
aviso.setAttribute('aria-live', 'polite');
// (suele ocultarse a la vista con CSS, pero el lector de pantalla sí la lee)
function anunciar(mensaje) {
// basta con cambiar el texto: aria-live se encarga de que se anuncie
aviso.textContent = mensaje;
}
// Por ejemplo, justo después de seleccionar a un héroe:
anunciar('Mercy añadida a la plantilla');Pruébalo#
El playground pinta la plantilla completa y conecta filtros y selección. Observa cómo la
delegación permite que el filtro funcione con un solo listener en el <nav> y que la
selección funcione con uno en <ul>, sea cual sea el número de héroes. Edita el código y
experimenta.
Comprueba lo que sabes#
Pregunta 1 de 5
Tienes este HTML: `<p id="estado">Cargando...</p>`. ¿Cómo cambias su texto a "Listo" con JS?
Tu turno#
La página ya tiene su HTML y sus estilos. Tu tarea es escribir index.js: pintar las tarjetas,
conectar los filtros y hacer seleccionables las tarjetas. Cuando lo tengas, despliega las
soluciones y fíjate en el salto de un nivel al siguiente.
Ejercicio · en esta página
Pinta y conecta la plantilla de héroes
El HTML de la página ya está: un contenedor vacío y cuatro botones de filtro. Tu trabajo en index.js es pintarla y hacerla interactiva: crear una tarjeta por héroe, reaccionar al clic en los botones de filtro y permitir seleccionar tarjetas con un clic.
Paso 1: Que funcione
- Se ven las ocho tarjetas al cargar la página.
- Los botones filtran correctamente y el botón activo se marca con la clase "activo".
- Hacer clic en una tarjeta alterna la clase "seleccionado" (el efecto visual aparece).
Paso 2: Que esté pulido
- Las tarjetas se crean con createElement y textContent, no con innerHTML concatenando datos.
- Los listeners usan addEventListener, no onclick inline ni el atributo onclick en el HTML.
- Hay al menos un listener delegado (en el contenedor padre, no uno por tarjeta).
- El render y los eventos están en funciones separadas con responsabilidad única.
Paso 3: Que sea excelente
- El winrate se calcula una sola vez (al enriquecer los datos) y no se vuelve a calcular.
- Delegación en ambas zonas: filtros y selección.
- Las tarjetas se insertan con DocumentFragment: un solo repintado del DOM.
- Las tarjetas son accesibles por teclado: tabindex, role="checkbox" y aria-checked.
Ver soluciones
const heroes = [
{ nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ nombre: "Reinhardt", rol: "Tanque", partidas: 90, victorias: 51 },
{ nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 130 },
{ nombre: "Genji", rol: "Daño", partidas: 150, victorias: 72 },
{ nombre: "Ana", rol: "Apoyo", partidas: 110, victorias: 66 },
{ nombre: "Winston", rol: "Tanque", partidas: 80, victorias: 38 },
{ nombre: "Lucio", rol: "Apoyo", partidas: 95, victorias: 58 },
{ nombre: "Pharah", rol: "Daño", partidas: 70, victorias: 39 },
];
// Pintamos todo el contenedor de golpe con innerHTML.
// En cada clic de filtro, borramos todo y volvemos a construir el HTML.
function winrate(h) {
// Calculamos el porcentaje de victorias sobre partidas jugadas
// toFixed(1) devuelve string con un decimal
return ((h.victorias / h.partidas) * 100).toFixed(1);
}
function pintarHeroes(lista) {
// cogemos del DOM el nodo con id "plantilla"
const contenedor = document.getElementById("plantilla");
if (!lista.length) {
// Si no hay héroes para este filtro, mostramos un mensaje vacío
contenedor.innerHTML =
'<p class="vacio">No hay héroes para este filtro.</p>';
return;
}
let html = "";
for (let i = 0; i < lista.length; i++) {
const h = lista[i];
// Concatenamos el HTML de cada tarjeta como string: rápido pero peligroso con datos externos
html += '<div class="hero-card" data-rol="' + h.rol + '">';
html += '<p class="hero-nombre">' + h.nombre + "</p>";
html += '<p class="hero-rol">' + h.rol + "</p>";
html += '<div class="hero-stats">';
html +=
'<div class="stat"><span class="stat-etiqueta">Partidas</span><span class="stat-valor">' +
h.partidas +
"</span></div>";
html +=
'<div class="stat"><span class="stat-etiqueta">Victorias</span><span class="stat-valor">' +
h.victorias +
"</span></div>";
html +=
'<div class="stat"><span class="stat-etiqueta">Winrate</span><span class="stat-valor">' +
winrate(h) +
"%</span></div>";
html += "</div>";
html += "</div>";
}
// sustituye TODO el contenido del contenedor por el HTML construido
contenedor.innerHTML = html;
// Clic en cada tarjeta: un listener por elemento.
// ⚠ Patrón antiguo: onclick en bucle — cada tarjeta recibe su propio handler.
// Si pintarHeroes se llama de nuevo (filtro), los nodos nuevos no tienen listener
// hasta que se vuelve a llamar. La delegación (solución "mejor") evita este problema.
// coge todos los .hero-card dentro del contenedor
const tarjetas = contenedor.querySelectorAll(".hero-card");
for (let i = 0; i < tarjetas.length; i++) {
// ⚠ Patrón antiguo: un onclick por nodo, descartado al repintar.
tarjetas[i].onclick = function () {
// alterna la clase "seleccionado": añade si no estaba, quita si estaba
this.classList.toggle("seleccionado");
};
}
}
// Filtros: un onclick inline por botón.
// ⚠ Patrón antiguo: onclick asigna el handler directamente como propiedad del nodo.
// Solo admite UN handler por evento; si asignas otro lo sobreescribe sin avisar.
// La solución "mejor" y "excelente" usan addEventListener, que no tiene ese límite.
// estado que guarda qué filtro está activo
let rolActual = "todos";
// coge del DOM todos los nodos con clase "filtro"
const botones = document.querySelectorAll(".filtro");
for (let i = 0; i < botones.length; i++) {
// ⚠ Patrón antiguo: onclick reemplaza cualquier handler previo.
botones[i].onclick = function () {
// leemos el rol guardado en data-rol del botón pulsado
rolActual = this.dataset.rol;
// Marcar activo: quitamos "activo" a todos y se la ponemos solo al que se pulsó
for (let j = 0; j < botones.length; j++) {
// quita la clase "activo" del botón j
botones[j].classList.remove("activo");
}
// añade "activo" al botón pulsado (this)
this.classList.add("activo");
// Volver a pintar TODO el DOM filtrado
const filtrados =
rolActual === "todos"
? heroes
: // filtramos el array por rol
heroes.filter(function (h) {
return h.rol === rolActual;
});
// re-renderizamos con los datos filtrados
pintarHeroes(filtrados);
};
}
// pintamos la lista completa al arrancar
pintarHeroes(heroes); Por qué este nivel
- Funciona: pinta los héroes, el filtro responde y se puede seleccionar una tarjeta.
- Construye el HTML con concatenación de strings e innerHTML: rápido de escribir, pero si los datos vienen del usuario cualquier valor con < o > puede romper la página o inyectar código.
- Un listener por tarjeta: si la lista tiene cien héroes, hay cien handlers en memoria, y los que se pintan después de un filtro no tienen listener hasta que se repinta de nuevo.
- La lógica de datos, render y eventos está mezclada en el mismo bloque: cuesta seguirla y más aún cambiarla.
const heroes = [
{ nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ nombre: "Reinhardt", rol: "Tanque", partidas: 90, victorias: 51 },
{ nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 130 },
{ nombre: "Genji", rol: "Daño", partidas: 150, victorias: 72 },
{ nombre: "Ana", rol: "Apoyo", partidas: 110, victorias: 66 },
{ nombre: "Winston", rol: "Tanque", partidas: 80, victorias: 38 },
{ nombre: "Lucio", rol: "Apoyo", partidas: 95, victorias: 58 },
{ nombre: "Pharah", rol: "Daño", partidas: 70, victorias: 39 },
];
// ── Render ──────────────────────────────────────────────
// Crea los nodos con createElement/textContent en lugar de
// concatenar HTML: los datos del usuario no pueden inyectar
// etiquetas aunque solo sea por disciplina.
function crearTarjeta(h) {
// calculamos el porcentaje de victorias
const winrate = ((h.victorias / h.partidas) * 100).toFixed(1);
// creamos un <article> nuevo, todavía fuera del DOM
const card = document.createElement("article");
// asignamos la clase CSS que lo estilará
card.className = "hero-card";
// escribe data-rol en el nodo; lo usará el filtro para comparar
card.dataset.rol = h.rol;
// creamos un <p> para el nombre del héroe
const nombre = document.createElement("p");
nombre.className = "hero-nombre";
// textContent: seguro aunque el nombre contenga < o >
nombre.textContent = h.nombre;
// creamos un <p> para el rol
const rol = document.createElement("p");
rol.className = "hero-rol";
// asignamos el texto del rol
rol.textContent = h.rol;
// contenedor para los tres bloques de estadísticas
const stats = document.createElement("div");
stats.className = "hero-stats";
const statsData = [
{ etiqueta: "Partidas", valor: h.partidas },
{ etiqueta: "Victorias", valor: h.victorias },
{ etiqueta: "Winrate", valor: winrate + "%" },
];
statsData.forEach(({ etiqueta, valor }) => {
// un bloque <div> por cada estadística
const stat = document.createElement("div");
stat.className = "stat";
// etiqueta descriptiva ("Partidas", "Victorias"…)
const lbl = document.createElement("span");
lbl.className = "stat-etiqueta";
// textContent: texto puro, sin parsear HTML
lbl.textContent = etiqueta;
// valor numérico de la estadística
const val = document.createElement("span");
val.className = "stat-valor";
// asignamos el valor como texto
val.textContent = valor;
// insertamos etiqueta y valor dentro del bloque stat
stat.append(lbl, val);
// insertamos el bloque stat dentro del contenedor de stats
stats.append(stat);
});
// montamos los hijos dentro del <article>
card.append(nombre, rol, stats);
// devolvemos el nodo listo para insertar en el DOM
return card;
}
function renderHeroes(lista) {
// cogemos del DOM el nodo con id "plantilla"
const contenedor = document.querySelector("#plantilla");
// Limpiamos antes de volver a pintar
// vaciamos el contenedor; los nodos anteriores se descartan
contenedor.innerHTML = "";
if (!lista.length) {
// creamos un mensaje de lista vacía
const vacio = document.createElement("p");
vacio.className = "vacio";
vacio.textContent = "No hay héroes para este filtro.";
// lo insertamos al final del contenedor
contenedor.append(vacio);
return;
}
// creamos e insertamos una tarjeta por héroe
lista.forEach((h) => contenedor.append(crearTarjeta(h)));
}
// ── Filtros ──────────────────────────────────────────────
// addEventListener en lugar de onclick inline: respeta el
// principio de separación entre HTML y JS, y no pisamos
// listeners que pudieran existir ya.
function iniciarFiltros() {
// cogemos del DOM el nodo con clase "filtros"
const nav = document.querySelector(".filtros");
// un solo listener en el <nav>; los clics en los botones burbujean hasta aquí
nav.addEventListener("click", (e) => {
// buscamos el .filtro más cercano al nodo pulsado
const boton = e.target.closest(".filtro");
// clic en el hueco del nav, fuera de cualquier botón: ignoramos
if (!boton) return;
// Actualizar estado visual de los botones
// quitamos "activo" a todos
document
.querySelectorAll(".filtro")
.forEach((b) => b.classList.remove("activo"));
// añadimos "activo" solo al botón pulsado
boton.classList.add("activo");
// leemos el rol guardado en data-rol del botón
const rol = boton.dataset.rol;
// filtramos el array
const filtrados =
rol === "todos" ? heroes : heroes.filter((h) => h.rol === rol);
// re-renderizamos con los datos filtrados
renderHeroes(filtrados);
});
}
// ── Selección ────────────────────────────────────────────
// Un listener en #plantilla (delegación): solo un handler
// aunque haya ocho tarjetas. Usar event.target para saber
// qué tarjeta fue pulsada.
function iniciarSeleccion() {
// un solo listener en el contenedor padre
document.querySelector("#plantilla").addEventListener("click", (e) => {
// e.target puede ser un <p> interior; closest sube hasta el .hero-card
const card = e.target.closest(".hero-card");
// clic fuera de cualquier tarjeta: ignoramos
if (!card) return;
// alterna la clase: la añade si no estaba, la quita si estaba
card.classList.toggle("seleccionado");
});
}
// ── Arranque ─────────────────────────────────────────────
// pintamos la lista completa al arrancar
renderHeroes(heroes);
// conectamos los botones de filtro
iniciarFiltros();
// activamos la selección por clic en tarjetas
iniciarSeleccion(); Por qué es mejor que el anterior
- Cada tarjeta se crea con createElement y textContent: los datos del usuario no pueden convertirse en etiquetas HTML aunque contengan caracteres especiales.
- addEventListener en lugar de onclick: el JS queda separado del HTML y se pueden añadir más listeners al mismo evento sin pisarse.
- Delegación en el filtro: un solo listener en el <nav> comprueba event.target.closest(".filtro"); no importa cuántos botones haya.
- Tres funciones con responsabilidades claras (crearTarjeta, renderHeroes, iniciarFiltros): cada una hace una sola cosa y se lee de corrido.
// ── Datos ────────────────────────────────────────────────
// Una sola fuente de verdad, inmutable.
// El winrate se calcula una vez aquí y no vuelve a calcularse.
const heroesRaw = [
{ nombre: "Tracer", rol: "Daño", partidas: 120, victorias: 78 },
{ nombre: "Reinhardt", rol: "Tanque", partidas: 90, victorias: 51 },
{ nombre: "Mercy", rol: "Apoyo", partidas: 200, victorias: 130 },
{ nombre: "Genji", rol: "Daño", partidas: 150, victorias: 72 },
{ nombre: "Ana", rol: "Apoyo", partidas: 110, victorias: 66 },
{ nombre: "Winston", rol: "Tanque", partidas: 80, victorias: 38 },
{ nombre: "Lucio", rol: "Apoyo", partidas: 95, victorias: 58 },
{ nombre: "Pharah", rol: "Daño", partidas: 70, victorias: 39 },
];
// Enriquecemos una sola vez: el resto del código solo lee .winrate.
// map devuelve un array nuevo; el original heroesRaw no se modifica.
const heroes = heroesRaw.map((h) => ({
...h,
// calculamos el porcentaje de victorias y lo guardamos en el objeto
winrate: ((h.victorias / h.partidas) * 100).toFixed(1),
}));
// ── Estado de la UI (mínimo y explícito) ─────────────────
// guarda qué filtro está activo; arranca mostrando todos los héroes
let rolActivo = "todos";
// ── Capa de datos ─────────────────────────────────────────
// Funciones puras: misma entrada → misma salida, sin efectos.
function filtrarPorRol(lista, rol) {
// devuelve todos o solo los del rol pedido
return rol === "todos" ? lista : lista.filter((h) => h.rol === rol);
}
// ── Capa de render ─────────────────────────────────────────
// Solo lee datos ya calculados; no hace cálculos aquí.
// textContent para cada dato de usuario: imposible inyectar HTML.
function crearTarjeta(h) {
// creamos un <article> nuevo, todavía fuera del DOM
const card = document.createElement("article");
// asignamos la clase CSS que lo estilará
card.className = "hero-card";
// escribe data-rol en el nodo; lo usarán los filtros para comparar
card.dataset.rol = h.rol;
// Usar <button> para la tarjeta clicable sería lo ideal en accesibilidad,
// pero aquí la tarjeta tiene contenido no interactivo dentro, así que
// mantenemos article y añadimos tabindex + role="checkbox" para que sea
// navegable por teclado (patrón ARIA "checkable item").
// hace el nodo enfocable con Tab
card.setAttribute("tabindex", "0");
// anuncia al lector de pantalla que este elemento es un checkbox
card.setAttribute("role", "checkbox");
// estado inicial: no seleccionado
card.setAttribute("aria-checked", "false");
// nombre descriptivo para el lector de pantalla
card.setAttribute("aria-label", `Seleccionar ${h.nombre}`);
// creamos un <p> para el nombre del héroe
const pNombre = document.createElement("p");
pNombre.className = "hero-nombre";
// textContent: XSS imposible aunque el nombre tenga caracteres especiales
pNombre.textContent = h.nombre;
// creamos un <p> para el rol
const pRol = document.createElement("p");
pRol.className = "hero-rol";
// asignamos el texto del rol
pRol.textContent = h.rol;
// contenedor para los tres bloques de estadísticas
const statsDiv = document.createElement("div");
statsDiv.className = "hero-stats";
[
{ etiqueta: "Partidas", valor: String(h.partidas) },
{ etiqueta: "Victorias", valor: String(h.victorias) },
{ etiqueta: "Winrate", valor: `${h.winrate}%` },
].forEach(({ etiqueta, valor }) => {
// un bloque <div> por cada estadística
const stat = document.createElement("div");
stat.className = "stat";
// etiqueta descriptiva ("Partidas", "Victorias"…)
const lbl = document.createElement("span");
lbl.className = "stat-etiqueta";
// textContent: texto puro, sin parsear HTML
lbl.textContent = etiqueta;
// valor numérico o de porcentaje
const val = document.createElement("span");
val.className = "stat-valor";
// asignamos el valor como texto
val.textContent = valor;
// insertamos etiqueta y valor dentro del bloque stat
stat.append(lbl, val);
// insertamos el bloque stat dentro del contenedor de stats
statsDiv.append(stat);
});
// montamos todos los hijos dentro del <article>
card.append(pNombre, pRol, statsDiv);
// devolvemos el nodo listo para insertar en el DOM
return card;
}
function renderHeroes(lista) {
// cogemos del DOM el nodo con id "plantilla"
const contenedor = document.querySelector("#plantilla");
// vaciamos el contenedor antes de volver a pintar
contenedor.innerHTML = "";
if (!lista.length) {
// mensaje para lista vacía
const p = document.createElement("p");
p.className = "vacio";
p.textContent = "No hay héroes para este filtro.";
// insertamos el mensaje al final del contenedor
contenedor.append(p);
return;
}
// DocumentFragment: un solo repintado del DOM en lugar de N inserciones.
// contenedor temporal fuera del DOM activo; no provoca repintados
const fragmento = document.createDocumentFragment();
// acumulamos todas las tarjetas en el fragmento
lista.forEach((h) => fragmento.append(crearTarjeta(h)));
// volcamos el fragmento al DOM en una sola operación
contenedor.append(fragmento);
}
// ── Capa de eventos ───────────────────────────────────────
// Delegación en ambos casos: un solo listener por zona,
// independientemente de cuántos héroes o botones haya.
function iniciarFiltros() {
// un solo listener en el nav de filtros
document.querySelector(".filtros").addEventListener("click", (e) => {
// Ignorar clics que no caigan sobre un .filtro (huecos, padding, etc.)
// buscamos el .filtro más cercano al nodo pulsado
const boton = e.target.closest(".filtro");
// clic fuera de cualquier botón de filtro: ignoramos
if (!boton) return;
// actualizamos el estado: guardamos el rol del botón pulsado
rolActivo = boton.dataset.rol;
// Actualizar estado visual: solo tocamos los atributos necesarios.
document.querySelectorAll(".filtro").forEach((b) => {
// classList.toggle con segundo arg: true añade, false quita
b.classList.toggle("activo", b === boton);
});
// filtramos los datos y re-renderizamos
renderHeroes(filtrarPorRol(heroes, rolActivo));
});
}
function iniciarSeleccion() {
// un solo listener en el contenedor padre
document.querySelector("#plantilla").addEventListener("click", (e) => {
// e.target puede ser un <p> interior; closest sube hasta el .hero-card
const card = e.target.closest(".hero-card");
// clic fuera de cualquier tarjeta: ignoramos
if (!card) return;
// Toggle visual + aria-checked sincronizados: accesibilidad y CSS alineados.
// alterna la clase y devuelve true si se añadió
const seleccionado = card.classList.toggle("seleccionado");
// sincronizamos el estado ARIA con el visual
card.setAttribute("aria-checked", String(seleccionado));
});
// Soporte de teclado: Space y Enter también activan la tarjeta.
// segundo listener: teclas en el contenedor
document.querySelector("#plantilla").addEventListener("keydown", (e) => {
// ignoramos cualquier tecla que no sea Space o Enter
if (e.key !== " " && e.key !== "Enter") return;
// buscamos la tarjeta enfocada
const card = e.target.closest(".hero-card");
if (!card) return;
// evitamos el scroll de la página con Space
e.preventDefault();
// mismo toggle que con el ratón
const seleccionado = card.classList.toggle("seleccionado");
// sincronizamos el estado ARIA
card.setAttribute("aria-checked", String(seleccionado));
});
}
// ── Arranque ──────────────────────────────────────────────
// pintamos la lista al arrancar (rolActivo = 'todos')
renderHeroes(filtrarPorRol(heroes, rolActivo));
// conectamos los botones de filtro
iniciarFiltros();
// activamos selección por clic y por teclado
iniciarSeleccion(); Por qué es mejor que el anterior
- El winrate se calcula una vez al enriquecer los datos con map; el resto del código solo lee h.winrate. En una lista grande, recalcular en cada render o comparación lastra el rendimiento.
- DocumentFragment: las tarjetas se construyen fuera del DOM activo y se insertan en una sola operación, lo que evita un repintado por tarjeta.
- Delegación en ambas zonas (filtros y selección): un solo listener por zona, aunque haya cien héroes o diez botones.
- Accesibilidad real: tabindex="0", role="checkbox" y aria-checked mantienen la tarjeta navegable por teclado (Tab + Space/Enter) y anunciable por un lector de pantalla, no solo clicable con ratón.
- Separación limpia: datos → render → eventos. Cambiar cómo se filtran los datos no obliga a tocar el render ni los listeners.