Módulo 15: Eventos
Objetivos del módulo
- Entender qué es un evento y cómo reacciona una página a lo que hace el usuario
- Registrar y eliminar manejadores con
addEventListeneryremoveEventListener - Usar las opciones
once,passiveysignal(conAbortController) - Conocer el objeto
Event:type,target,currentTarget,preventDefaultystopPropagation - Trabajar con eventos de ratón, teclado, foco, formularios, scroll y redimensionado
- Comprender la propagación de eventos: fase de captura y fase de burbuja
- Aplicar la delegación de eventos en listas y tablas dinámicas
- Crear y lanzar eventos personalizados con
CustomEventydispatchEvent - Implementar
debounceythrottlea mano - Hacer interfaces accesibles desde el teclado
1. ¿Qué es un evento?
📘 Concepto: Un evento es una señal de que algo ha ocurrido: el usuario ha hecho clic, ha pulsado una tecla, ha escrito en un campo, la página ha terminado de cargar, la ventana ha cambiado de tamaño… Tu código puede escuchar esos eventos y ejecutar una función (el manejador o listener) cada vez que ocurren.
Es como el timbre de una casa: no te quedas mirando la puerta todo el día, sino que registras qué hacer cuando suene (“abrir la puerta”) y sigues con tu vida. Cuando suena, el navegador ejecuta tu manejador. Encaja con lo que viste en el módulo 12: cada evento es una macrotarea que el event loop ejecuta cuando la pila está libre.
La programación web es dirigida por eventos: tu código prepara la página y luego espera a que pasen cosas.
2. addEventListener y removeEventListener
const boton = document.querySelector('#saludar');
boton.addEventListener('click', () => {
console.log('¡Hola!');
});
La sintaxis es objetivo.addEventListener(tipo, manejador, opciones?):
- objetivo: cualquier elemento,
documentowindow. - tipo: el nombre del evento, sin el prefijo
on('click', no'onclick'). - manejador: la función que se ejecutará. Recibe como argumento el objeto evento.
Puedes registrar varios manejadores para el mismo evento; se ejecutan en el orden en que se registraron:
boton.addEventListener('click', registrarEstadistica);
boton.addEventListener('click', mostrarMensaje);
Eliminar un manejador
removeEventListener necesita la misma función (la misma referencia) que se usó al registrar:
function alPulsar() {
console.log('Pulsado');
}
boton.addEventListener('click', alPulsar);
boton.removeEventListener('click', alPulsar); // ✅ funciona: es la misma función
boton.addEventListener('click', () => console.log('Hola'));
boton.removeEventListener('click', () => console.log('Hola')); // ❌ no hace nada: es OTRA función
⚠️ Importante: Si piensas quitar un manejador, guárdalo en una variable o constante. Una arrow function escrita directamente en la llamada no se puede quitar después con
removeEventListener(pero sí consignal, sección 3).
¿Por qué no onclick en el HTML?
Verás código antiguo así:
<button onclick="saludar()">Saludar</button>
O asignando la propiedad: boton.onclick = saludar;. Funcionan, pero tienen inconvenientes:
onclick="..." en HTML | elemento.onclick = fn | addEventListener | |
|---|---|---|---|
| Separa HTML y JavaScript | ❌ | ✅ | ✅ |
| Varios manejadores para el mismo evento | ❌ | ❌ (el segundo sustituye al primero) | ✅ |
Opciones (once, passive, signal, captura) | ❌ | ❌ | ✅ |
| Funciona con módulos ES | ❌ (la función debe ser global) | ✅ | ✅ |
| Compatible con una CSP estricta | ❌ | ✅ | ✅ |
💡 Consejo: Usa siempre
addEventListener. Como viste en el módulo 13, las funciones de un módulo no son globales, así queonclick="saludar()"ni siquiera las encuentra. Además, las políticas de seguridad de contenido (CSP) estrictas bloquean el JavaScript escrito en atributos HTML.
3. Opciones: once, passive y signal
El tercer argumento puede ser un objeto de opciones:
once: solo la primera vez
boton.addEventListener('click', () => console.log('Solo me verás una vez'), { once: true });
El manejador se elimina automáticamente después de ejecutarse. Ideal para “aceptar las cookies”, animaciones de bienvenida o promesas que esperan un evento (como esperarClic del módulo 12).
passive: no voy a llamar a preventDefault
window.addEventListener('wheel', actualizarIndicador, { passive: true });
document.addEventListener('touchmove', seguirDedo, { passive: true });
En eventos de desplazamiento (wheel, touchstart, touchmove), el navegador tiene que esperar a tu manejador por si llamas a preventDefault() para impedir el scroll. Con passive: true prometes que no lo harás, y el navegador puede desplazar la página sin esperar, lo que da un scroll mucho más fluido. Los navegadores ya aplican passive: true por defecto a wheel y touch* cuando se registran en window, document o body.
signal: quitar manejadores con un AbortController
En el módulo 12 usaste AbortController para cancelar peticiones. La misma señal sirve para quitar manejadores, ¡incluso varios a la vez!
const controlador = new AbortController();
const { signal } = controlador;
window.addEventListener('resize', () => console.log('Redimensionando'), { signal });
window.addEventListener('scroll', () => console.log('Desplazando'), { signal });
document.addEventListener('keydown', (e) => console.log(e.key), { signal });
// Más tarde, por ejemplo al cerrar un panel o salir de una vista:
controlador.abort(); // ✅ quita los tres manejadores de golpe
💡 Consejo:
signales la forma moderna de “limpiar” cuando un componente desaparece: crea unAbortControllerpor componente, pásalo a todos susaddEventListenery a susfetch, y llama aabort()al destruirlo. Te olvidas de guardar cada función pararemoveEventListener.
| Opción | Valor | Efecto |
|---|---|---|
once | true | Se ejecuta una sola vez y se elimina |
passive | true | Promete no llamar a preventDefault (mejor scroll) |
signal | AbortSignal | Al abortar la señal, se elimina el manejador |
capture | true | Escucha en la fase de captura (sección 6) |
4. El objeto Event
Cada manejador recibe un objeto evento con información sobre lo que ha pasado. Por convención se llama evento, event o e:
document.querySelector('#lista').addEventListener('click', (evento) => {
console.log(evento.type); // "click"
console.log(evento.target); // el elemento exacto donde se hizo clic (p. ej., un <li>)
console.log(evento.currentTarget); // el elemento que tiene el listener (el <ul>)
console.log(evento.timeStamp); // milisegundos desde que se cargó la página
console.log(evento.isTrusted); // true si lo ha generado el usuario, false si lo creó el código
});
target frente a currentTarget
Es una de las distinciones más importantes de este módulo:
target: el elemento donde se originó el evento (el más profundo, el que se pulsó realmente).currentTarget: el elemento cuyo manejador se está ejecutando ahora mismo.
<button id="comprar"><span class="icono">🛒</span> Comprar</button>
document.querySelector('#comprar').addEventListener('click', (e) => {
console.log(e.target); // <span class="icono"> si pulsas en el icono; <button> si pulsas en el texto
console.log(e.currentTarget); // siempre el <button>
});
preventDefault(): cancelar la acción por defecto
Muchos eventos tienen una acción por defecto del navegador: un enlace navega, un formulario se envía y recarga la página, una casilla se marca, el clic derecho abre el menú contextual… preventDefault() la impide:
// Que el formulario no recargue la página: lo gestionamos con JavaScript (módulo 16)
formulario.addEventListener('submit', (e) => {
e.preventDefault();
console.log('Enviando con fetch...');
});
// Un enlace que abre una vista previa en lugar de navegar
enlace.addEventListener('click', (e) => {
e.preventDefault();
abrirVistaPrevia(enlace.href);
// e.defaultPrevented vale ahora true: otros manejadores pueden comprobarlo
});
stopPropagation(): detener la propagación
Impide que el evento siga viajando a otros elementos (lo entenderás del todo en la sección 6):
botonDentroDeTarjeta.addEventListener('click', (e) => {
e.stopPropagation(); // la tarjeta (el padre) no se enterará de este clic
borrarElemento();
});
⚠️ Importante: No abuses de
stopPropagation(). Rompe la delegación de eventos y puede hacer que otros componentes (un menú que se cierra al hacer clic fuera, una herramienta de analítica…) dejen de funcionar sin que sepas por qué. Normalmente es mejor comprobare.targeten el manejador del padre.
| Propiedad / método | Para qué sirve |
|---|---|
type | Nombre del evento ('click', 'keydown'…) |
target | Elemento donde se originó |
currentTarget | Elemento cuyo manejador se está ejecutando |
preventDefault() | Cancela la acción por defecto del navegador |
defaultPrevented | true si ya se canceló |
stopPropagation() | Detiene la propagación a otros elementos |
stopImmediatePropagation() | Además, impide que se ejecuten otros manejadores del mismo elemento |
isTrusted | true si lo generó el usuario |
eventPhase | 1 = captura, 2 = en el objetivo, 3 = burbuja |
5. Tipos de eventos más usados
Ratón y puntero
| Evento | Cuándo | Burbujea |
|---|---|---|
click | Pulsar y soltar el botón principal (también con Intro/Espacio en un botón) | Sí |
dblclick | Doble clic | Sí |
contextmenu | Clic derecho (antes de abrir el menú contextual) | Sí |
mousedown / mouseup | Pulsar / soltar cualquier botón | Sí |
mousemove | Mover el ratón (¡se dispara muchísimas veces!) | Sí |
mouseover / mouseout | Entrar / salir de un elemento o de sus hijos | Sí |
mouseenter / mouseleave | Entrar / salir del elemento (ignora los hijos) | No |
pointerdown, pointermove, pointerup | Igual que los de ratón, pero para ratón, dedo y lápiz | Sí |
const zona = document.querySelector('#zona');
zona.addEventListener('mousemove', (e) => {
// Coordenadas relativas a la ventana visible y a la página completa
console.log(`Ventana: ${e.clientX}, ${e.clientY} | Página: ${e.pageX}, ${e.pageY}`);
});
zona.addEventListener('click', (e) => {
if (e.shiftKey) console.log('Clic con Mayús pulsada');
console.log('Botón:', e.button); // 0 principal, 1 central, 2 secundario
});
💡 Consejo: Si tu interfaz debe funcionar en móviles y tabletas, usa los eventos de puntero (
pointer*): unifican ratón, táctil y lápiz con una sola API.e.pointerTypete dice cuál es ('mouse','touch'o'pen').
Teclado
Los eventos son keydown (al pulsar una tecla; se repite si la mantienes pulsada) y keyup (al soltarla). keypress está obsoleto: no lo uses.
key frente a code
| Propiedad | Qué indica | Al pulsar la tecla A | Con Mayús + A | En un teclado AZERTY francés, misma tecla física |
|---|---|---|---|---|
key | El carácter o el nombre de la tecla | 'a' | 'A' | 'q' |
code | La tecla física, según su posición | 'KeyA' | 'KeyA' | 'KeyA' |
- Usa
keycuando te importa qué ha escrito el usuario o el significado de la tecla:'Enter','Escape','ArrowUp','Tab',' '(espacio), letras. - Usa
codecuando te importa la posición de la tecla, como en los juegos (las teclas WASD deben estar en el mismo sitio en cualquier distribución de teclado).
document.addEventListener('keydown', (e) => {
console.log(`key: "${e.key}" | code: "${e.code}" | repetida: ${e.repeat}`);
});
Atajos de teclado
Las teclas modificadoras se consultan con ctrlKey, shiftKey, altKey y metaKey (la tecla ⌘ de Mac o la tecla Windows):
document.addEventListener('keydown', (e) => {
// Ctrl+S en Windows/Linux, ⌘+S en Mac
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault(); // evita el "Guardar página como..." del navegador
guardarDocumento();
}
// Escape cierra lo que esté abierto
if (e.key === 'Escape') cerrarPanel();
// "/" enfoca el buscador, salvo que ya estemos escribiendo en un campo
const escribiendo = e.target.closest('input, textarea, select, [contenteditable]');
if (e.key === '/' && !escribiendo) {
e.preventDefault();
document.querySelector('#buscador').focus();
}
});
⚠️ Importante: Los atajos con una sola tecla (sin Ctrl/Alt) deben desactivarse cuando el usuario está escribiendo en un campo, o no podrá teclear esa letra. Además, no sobrescribas atajos importantes del navegador o de los lectores de pantalla.
Foco
| Evento | Cuándo | Burbujea |
|---|---|---|
focus | El elemento recibe el foco | No |
blur | El elemento pierde el foco | No |
focusin | Como focus, pero burbujea | Sí |
focusout | Como blur, pero burbujea | Sí |
const formulario = document.querySelector('#registro');
// focusin burbujea: un solo listener para todos los campos del formulario
formulario.addEventListener('focusin', (e) => e.target.closest('.campo')?.classList.add('activo'));
formulario.addEventListener('focusout', (e) => e.target.closest('.campo')?.classList.remove('activo'));
// Mover el foco desde código
document.querySelector('#nombre').focus();
console.log(document.activeElement); // el elemento que tiene el foco ahora
Formularios: input y change
| Evento | Cuándo se dispara |
|---|---|
input | Con cada cambio del valor (cada tecla, pegar, borrar…) |
change | Cuando el cambio se confirma: al salir del campo de texto, o inmediatamente en casillas, botones de opción y select |
submit | Al enviar el formulario (en el form, no en el botón) |
reset | Al restablecer el formulario |
const campo = document.querySelector('#nombre');
campo.addEventListener('input', () => (document.querySelector('#vista-previa').textContent = campo.value));
document.querySelector('#pais').addEventListener('change', (e) => console.log('País:', e.target.value));
Los formularios tienen su propio módulo (el 16), donde verás todo esto a fondo.
Scroll y redimensionado
// scroll: se dispara MUCHAS veces por segundo mientras se desplaza
window.addEventListener('scroll', () => {
const desplazado = window.scrollY;
document.querySelector('#volver-arriba').hidden = desplazado < 400;
}, { passive: true });
// resize: al cambiar el tamaño de la ventana
window.addEventListener('resize', () => {
console.log(`Nuevo tamaño: ${window.innerWidth} × ${window.innerHeight}`);
});
💡 Consejo:
scroll,resize,mousemoveyinputpueden dispararse decenas de veces por segundo. Si el manejador hace algo costoso, usadebounceothrottle(sección 9). Y para tareas como “detectar si un elemento es visible” o “reaccionar al tamaño de un elemento”, los navegadores ofrecenIntersectionObserveryResizeObserver(módulo 18), mucho más eficientes que escucharscrollyresize.
Otros eventos útiles
| Evento | Objetivo | Cuándo |
|---|---|---|
DOMContentLoaded | document | El HTML está analizado (módulo 14) |
load | window, img | Todo (o esa imagen) ha terminado de cargar |
error | img, script, window | Fallo al cargar un recurso, o error no capturado |
beforeunload | window | El usuario va a abandonar la página |
visibilitychange | document | La pestaña pasa a segundo plano o vuelve (document.hidden) |
online / offline | window | Cambia la conexión a Internet |
copy, paste | Elementos, document | Copiar y pegar |
6. Propagación: captura y burbuja
Cuando haces clic en un elemento, el evento no ocurre solo en ese elemento. Imagina este HTML:
<div id="tarjeta">
<p id="texto">
<button id="boton">Pulsar</button>
</p>
</div>
Si pulsas el botón, también has hecho clic, en cierto modo, en el párrafo, en el div, en el body… El evento recorre el árbol en tres fases:
flowchart TD
subgraph Captura["1. Fase de captura (baja)"]
W1["window"] --> D1["document"] --> H1["html / body"] --> DIV1["div#tarjeta"] --> P1["p#texto"]
end
subgraph Objetivo["2. Fase de objetivo"]
B["button#boton (target)"]
end
subgraph Burbuja["3. Fase de burbuja (sube)"]
P3["p#texto"] --> DIV3["div#tarjeta"] --> H3["html / body"] --> D3["document"] --> W3["window"]
end
P1 --> B
B --> P3
- Captura: el evento baja desde
windowhasta el padre del objetivo. - Objetivo: llega al elemento pulsado (
target). - Burbuja (bubbling): sube de nuevo desde el objetivo hasta
window, como una burbuja en el agua.
Por defecto, addEventListener escucha en la fase de burbuja. Con { capture: true } (o true como tercer argumento) escucha en la de captura:
const registrar = (nombre) => (e) => console.log(`${nombre} (fase ${e.eventPhase})`);
document.querySelector('#tarjeta').addEventListener('click', registrar('div captura'), { capture: true });
document.querySelector('#tarjeta').addEventListener('click', registrar('div burbuja'));
document.querySelector('#texto').addEventListener('click', registrar('p burbuja'));
document.querySelector('#boton').addEventListener('click', registrar('botón'));
// Al pulsar el botón, la consola muestra:
// div captura (fase 1)
// botón (fase 2)
// p burbuja (fase 3)
// div burbuja (fase 3)
⚠️ Importante: Casi todos los eventos burbujean, pero no todos:
focus,blur,mouseenter,mouseleave,loadyscrollen elementos (no en el documento) no lo hacen. Para foco, usa sus versiones que sí burbujean,focusinyfocusout. Puedes comprobarlo conevento.bubbles.
💡 Consejo: En el día a día casi nunca necesitarás la fase de captura. Lo que sí usarás constantemente es la burbuja, porque permite la técnica más útil de este módulo: la delegación de eventos.
7. Delegación de eventos
📘 Concepto: La delegación de eventos consiste en poner un único manejador en un elemento padre en lugar de uno en cada hijo. Gracias a la burbuja, los clics de los hijos llegan al padre, y con
e.target.closest(...)averiguas en qué hijo se produjeron.
Es como el conserje de un edificio: en lugar de poner un buzón en cada piso, todo el correo llega a portería y el conserje mira el destinatario.
Ventajas:
- Un solo manejador en vez de cientos: menos memoria y código más simple.
- Funciona con elementos que aún no existen: si añades elementos nuevos a la lista, ya están “escuchados” sin hacer nada.
- No hay que volver a registrar manejadores después de re-renderizar (algo que hiciste en la lista de tareas del módulo 14).
Ejemplo 1: lista dinámica
<input id="nuevo" placeholder="Nuevo elemento">
<button id="anadir" type="button">Añadir</button>
<ul id="lista">
<li data-id="1"><span>Leche</span> <button type="button" class="borrar">Borrar</button></li>
<li data-id="2"><span>Pan</span> <button type="button" class="borrar">Borrar</button></li>
</ul>
const lista = document.querySelector('#lista');
let siguienteId = 3;
// ✅ UN manejador para todos los botones, presentes y futuros
lista.addEventListener('click', (e) => {
const botonBorrar = e.target.closest('.borrar');
if (botonBorrar) {
botonBorrar.closest('li').remove();
return;
}
const span = e.target.closest('li > span');
if (span) span.classList.toggle('tachado');
});
document.querySelector('#anadir').addEventListener('click', () => {
const campo = document.querySelector('#nuevo');
if (!campo.value.trim()) return;
// Object.assign crea y configura el elemento en una sola expresión
const span = Object.assign(document.createElement('span'), { textContent: campo.value.trim() });
const boton = Object.assign(document.createElement('button'), { type: 'button', className: 'borrar', textContent: 'Borrar' });
const li = document.createElement('li');
li.dataset.id = siguienteId++;
li.append(span, ' ', boton);
lista.append(li); // ¡su botón ya funciona sin añadir ningún listener!
campo.value = '';
});
⚠️ Importante: Usa
e.target.closest('.borrar')y noe.target.matches('.borrar'). Si el botón tuviera un icono dentro (<button class="borrar"><svg>...</svg></button>),e.targetsería el icono, no el botón.closestsube hasta encontrarlo. Y comprueba que el resultado está dentro de tu contenedor si el selector pudiera coincidir con algo de fuera (lista.contains(boton)).
Ejemplo 2: tabla con acciones y ordenación
<table id="tabla-alumnos">
<thead>
<tr>
<th><button type="button" data-ordenar="nombre">Nombre</button></th>
<th><button type="button" data-ordenar="nota">Nota</button></th>
<th>Acciones</th>
</tr>
</thead>
<tbody></tbody>
</table>
const alumnos = [
{ id: 1, nombre: 'Lucía', nota: 8.5 },
{ id: 2, nombre: 'Marcos', nota: 6 },
{ id: 3, nombre: 'Aitana', nota: 9.2 },
];
const tabla = document.querySelector('#tabla-alumnos');
const cuerpo = tabla.querySelector('tbody');
const crear = (etiqueta, propiedades = {}) => Object.assign(document.createElement(etiqueta), propiedades);
function crearFila(alumno) {
const fila = crear('tr');
fila.dataset.id = alumno.id;
const subir = crear('button', { type: 'button', textContent: '+0,5' });
const eliminar = crear('button', { type: 'button', textContent: 'Eliminar' });
subir.dataset.accion = 'subir';
eliminar.dataset.accion = 'eliminar';
const acciones = crear('td');
acciones.append(subir, ' ', eliminar);
fila.append(crear('td', { textContent: alumno.nombre }), crear('td', { textContent: alumno.nota.toFixed(1) }), acciones);
return fila;
}
function renderizar(datos) {
cuerpo.replaceChildren(...datos.map(crearFila));
}
// Un solo manejador para TODA la tabla: cabecera y filas
tabla.addEventListener('click', (e) => {
const boton = e.target.closest('button');
if (!boton) return;
// Botones de la cabecera: ordenar
if (boton.dataset.ordenar) {
const campo = boton.dataset.ordenar;
alumnos.sort((a, b) => (campo === 'nota' ? b.nota - a.nota : a.nombre.localeCompare(b.nombre)));
renderizar(alumnos);
return;
}
// Botones de las filas: actuar sobre ese alumno
const id = Number(boton.closest('tr').dataset.id);
const indice = alumnos.findIndex((a) => a.id === id);
if (boton.dataset.accion === 'subir') alumnos[indice].nota = Math.min(10, alumnos[indice].nota + 0.5);
if (boton.dataset.accion === 'eliminar') alumnos.splice(indice, 1);
renderizar(alumnos);
});
renderizar(alumnos);
💡 Consejo: El patrón
data-accion="..."+ un único manejador conclosest('[data-accion]')escala muy bien: puedes añadir acciones nuevas solo con HTML y unif(o un objeto de funcionesacciones[boton.dataset.accion]()).
8. Eventos personalizados: CustomEvent y dispatchEvent
Además de los eventos del navegador, puedes crear los tuyos. Sirven para que distintas partes de tu aplicación se comuniquen sin conocerse directamente: un componente anuncia “¡ha pasado esto!” y quien esté interesado lo escucha.
// Crear y lanzar un evento con datos en "detail"
const evento = new CustomEvent('producto-anadido', {
detail: { id: 42, nombre: 'Auriculares', precio: 59.9 }, // datos que quieras enviar
bubbles: true, // que burbujee (por defecto los eventos creados NO burbujean)
cancelable: true, // que se pueda llamar a preventDefault()
});
boton.dispatchEvent(evento);
// En otra parte de la aplicación (por ejemplo, el icono del carrito)
document.addEventListener('producto-anadido', (e) => {
console.log(`Añadido: ${e.detail.nombre} (${e.detail.precio} €)`);
actualizarContadorCarrito();
});
Si el evento es cancelable, dispatchEvent devuelve false cuando algún manejador llama a preventDefault(). Así puedes preguntar “¿alguien se opone?” antes de hacer algo:
function borrarConConfirmacion(elemento) {
const seguir = elemento.dispatchEvent(
new CustomEvent('antes-de-borrar', { bubbles: true, cancelable: true, detail: { id: elemento.dataset.id } }),
);
if (seguir) elemento.remove();
}
// Un manejador puede vetar el borrado
document.addEventListener('antes-de-borrar', (e) => {
if (e.detail.id === '1') {
e.preventDefault();
console.log('El elemento 1 está protegido');
}
});
💡 Consejo: Usa nombres en minúsculas con guiones (
producto-anadido,tarea:completada) para no chocar con eventos nativos actuales o futuros. Y recuerda quedispatchEventes síncrono: todos los manejadores se ejecutan antes de quedispatchEventdevuelva el control.
📘 Concepto: Cualquier objeto puede emitir eventos si extiende
EventTarget. Es una forma sencilla de crear tus propios “emisores”:class Carrito extends EventTarget { anadir(p) { ...; this.dispatchEvent(new CustomEvent('cambio', { detail: p })); } }.
9. Debounce y throttle
Algunos eventos se disparan muchísimas veces seguidas. Si cada vez haces algo costoso (una petición al servidor, un cálculo de layout…), la página se resiente. Dos técnicas lo resuelven:
| Técnica | Idea | Analogía | Casos de uso |
|---|---|---|---|
| Debounce | Espera a que el evento deje de producirse durante X ms y entonces ejecuta una vez | El ascensor que espera a que deje de entrar gente para cerrar la puerta | Buscador mientras escribes, guardar borrador, validar al terminar de escribir, resize |
| Throttle | Ejecuta como mucho una vez cada X ms, aunque el evento se produzca más | Un grifo que gotea a ritmo constante, por mucho que abras la llave | scroll, mousemove, arrastrar, botones de disparo en juegos |
Implementar debounce
function debounce(fn, espera = 300) {
let temporizador;
return function (...args) {
clearTimeout(temporizador); // cada llamada cancela la anterior...
temporizador = setTimeout(() => fn.apply(this, args), espera); // ...y reprograma
};
}
Es un closure (módulo 05): la función devuelta recuerda temporizador entre llamadas. Usamos function (y no una arrow function) y fn.apply(this, args) para que this y los argumentos lleguen intactos a fn.
const buscador = document.querySelector('#buscador');
const buscar = debounce((texto) => {
console.log('Buscando en el servidor:', texto); // una sola vez cuando dejas de teclear
}, 400);
buscador.addEventListener('input', (e) => buscar(e.target.value));
Implementar throttle
function throttle(fn, intervalo = 200) {
let ultimaEjecucion = 0;
let temporizador = null;
let ultimosArgs;
return function (...args) {
ultimosArgs = args; // recordamos siempre la llamada más reciente
const restante = intervalo - (Date.now() - ultimaEjecucion);
if (restante <= 0) {
// Ha pasado el intervalo: ejecutamos ya (y anulamos la ejecución pendiente, si la hay)
clearTimeout(temporizador);
temporizador = null;
ultimaEjecucion = Date.now();
fn.apply(this, args);
} else if (!temporizador) {
// Dentro del intervalo: programamos una ejecución al final con los últimos argumentos
temporizador = setTimeout(() => {
ultimaEjecucion = Date.now();
temporizador = null;
fn.apply(this, ultimosArgs);
}, restante);
}
};
}
window.addEventListener('scroll', throttle(() => {
console.log('Posición:', window.scrollY); // como mucho 5 veces por segundo
}, 200), { passive: true });
La ejecución final programada garantiza que el último valor no se pierde (por ejemplo, la posición final del scroll).
Comparación real
Este programa simula diez eventos, uno cada 50 ms, y se puede ejecutar con Node (node comparar.mjs, con las dos funciones anteriores copiadas al principio):
const inicio = Date.now();
const log = (tipo) => (v) => console.log(`${tipo} ${v} a los ~${Math.round((Date.now() - inicio) / 50) * 50} ms`);
const d = debounce(log('debounce'), 300);
const t = throttle(log('throttle'), 200);
// 10 "eventos", uno cada 50 ms (del ms 50 al 500)
for (let i = 1; i <= 10; i++) setTimeout(() => { d(i); t(i); }, i * 50);
throttle 1 a los ~50 ms
throttle 5 a los ~250 ms
throttle 9 a los ~450 ms
throttle 10 a los ~650 ms
debounce 10 a los ~800 ms
El throttle ejecuta de forma regular (y termina con el último valor); el debounce espera a que los eventos paren y ejecuta una sola vez, 300 ms después del último.
10. Accesibilidad: eventos para todos
Mucha gente navega solo con teclado (por discapacidad motora, por eficiencia o con lectores de pantalla). Si tu interfaz solo funciona con ratón, la excluyes.
Usa elementos interactivos reales
<!-- ❌ Un div no recibe el foco con Tab, no reacciona a Intro/Espacio
y el lector de pantalla no anuncia que se puede pulsar -->
<div class="boton" onclick="guardar()">Guardar</div>
<!-- ✅ Un button lo trae todo de serie: foco, teclado, rol y estado deshabilitado -->
<button type="button" id="guardar">Guardar</button>
| Elemento | Gratis con el elemento nativo |
|---|---|
button | Foco con Tab, click con Intro y Espacio, rol “botón”, disabled |
a con href | Foco, Intro para seguir el enlace, rol “enlace”, abrir en otra pestaña |
input, select, textarea | Foco, edición, etiquetas asociadas con label |
dialog | Foco atrapado en el modal, Escape para cerrar, fondo inerte |
details / summary | Desplegable accesible sin JavaScript |
💡 Consejo: Regla rápida: si al pulsarlo pasa algo en la página, es un
button; si te lleva a otra URL, es una. El eventoclickde unbuttonse dispara también con el teclado, así que no necesitas escucharkeydownpara eso.
Gestiona el foco
- Cuando abras un modal o un panel, mueve el foco a su interior; al cerrarlo, devuélvelo al elemento que lo abrió.
- No quites el contorno de foco (
outline: none) sin dar una alternativa. Usa:focus-visiblepara mostrarlo solo al navegar con teclado. - Si un elemento personalizado debe ser enfocable,
tabindex="0"lo añade al orden de tabulación (pero piensa antes si un elemento nativo te sirve).
Comunica el estado
Usa atributos ARIA para que los lectores de pantalla sepan qué ocurre:
botonMenu.setAttribute('aria-expanded', String(estaAbierto)); // ¿el menú está desplegado?
botonFavorito.setAttribute('aria-pressed', String(esFavorito)); // botón de alternar
Y respeta las preferencias del usuario: si tiene activado “reducir movimiento”, evita animaciones grandes (window.matchMedia('(prefers-reduced-motion: reduce)').matches).
11. Ejemplos completos
11.1. Galería de imágenes con modal
Usamos el elemento dialog, que con showModal() ya gestiona el foco, el fondo inerte y la tecla Escape. Las imágenes son de Picsum Photos (picsum.photos), un servicio gratuito de imágenes de ejemplo.
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Galería</title>
<style>
.galeria { display: grid; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: 0.5rem; }
.galeria button { padding: 0; border: 0; background: none; cursor: zoom-in; }
.galeria img { width: 100%; display: block; border-radius: 0.25rem; }
dialog { padding: 0; border: 0; max-width: min(90vw, 50rem); }
dialog::backdrop { background: rgb(0 0 0 / 0.8); }
dialog img { width: 100%; display: block; }
.controles { display: flex; justify-content: space-between; align-items: center; padding: 0.5rem; }
</style>
<script type="module" src="galeria.js"></script>
</head>
<body>
<h1>Galería</h1>
<div class="galeria" id="galeria"></div>
<dialog id="modal" aria-label="Imagen ampliada">
<img id="modal-imagen" alt="">
<div class="controles">
<button type="button" data-accion="anterior" aria-label="Imagen anterior">◀</button>
<span id="modal-pie"></span>
<button type="button" data-accion="siguiente" aria-label="Imagen siguiente">▶</button>
<button type="button" data-accion="cerrar">Cerrar</button>
</div>
</dialog>
</body>
</html>
// galeria.js
const fotos = [10, 20, 30, 40, 50, 60].map((id, i) => ({
miniatura: `https://picsum.photos/id/${id}/300/200`,
grande: `https://picsum.photos/id/${id}/1200/800`,
titulo: `Fotografía ${i + 1}`,
}));
const galeria = document.querySelector('#galeria');
const modal = document.querySelector('#modal');
const imagenModal = document.querySelector('#modal-imagen');
const pie = document.querySelector('#modal-pie');
let actual = 0;
// Miniaturas: cada una es un botón (accesible con teclado)
galeria.append(
...fotos.map((foto, indice) => {
const boton = document.createElement('button');
boton.type = 'button';
boton.dataset.indice = indice;
boton.setAttribute('aria-label', `Ampliar ${foto.titulo}`);
const img = document.createElement('img');
img.src = foto.miniatura;
img.alt = foto.titulo;
img.loading = 'lazy';
boton.append(img);
return boton;
}),
);
function mostrar(indice) {
actual = (indice + fotos.length) % fotos.length; // da la vuelta en ambos sentidos
imagenModal.src = fotos[actual].grande;
imagenModal.alt = fotos[actual].titulo;
pie.textContent = `${fotos[actual].titulo} (${actual + 1} de ${fotos.length})`;
}
// Delegación: un manejador para todas las miniaturas
galeria.addEventListener('click', (e) => {
const boton = e.target.closest('button[data-indice]');
if (!boton) return;
mostrar(Number(boton.dataset.indice));
modal.showModal(); // abre como modal: foco dentro, Escape cierra
});
// Delegación también dentro del modal
modal.addEventListener('click', (e) => {
if (e.target === modal) return modal.close(); // clic en el fondo (fuera del contenido)
const accion = e.target.closest('[data-accion]')?.dataset.accion;
if (accion === 'anterior') mostrar(actual - 1);
if (accion === 'siguiente') mostrar(actual + 1);
if (accion === 'cerrar') modal.close();
});
// Flechas del teclado solo mientras el modal está abierto
document.addEventListener('keydown', (e) => {
if (!modal.open) return;
if (e.key === 'ArrowLeft') mostrar(actual - 1);
if (e.key === 'ArrowRight') mostrar(actual + 1);
});
// Al cerrar, devolvemos el foco a la miniatura que corresponde a la foto actual
modal.addEventListener('close', () => {
galeria.querySelector(`[data-indice="${actual}"]`).focus();
});
💡 Consejo: El truco
e.target === modalfunciona porque, al no tener padding, el único punto en el que eltargetes el propiodialoges su fondo (::backdrop). Los clics en la imagen o los botones tienen otrotarget.
11.2. Menú desplegable accesible
<nav class="menu">
<button type="button" id="boton-menu" aria-expanded="false" aria-controls="lista-menu">Cuenta ▾</button>
<ul id="lista-menu" hidden>
<li><a href="/perfil">Mi perfil</a></li>
<li><a href="/pedidos">Mis pedidos</a></li>
<li><a href="/salir">Cerrar sesión</a></li>
</ul>
</nav>
const botonMenu = document.querySelector('#boton-menu');
const listaMenu = document.querySelector('#lista-menu');
function abrir() {
listaMenu.hidden = false;
botonMenu.setAttribute('aria-expanded', 'true');
listaMenu.querySelector('a').focus(); // foco al primer enlace
}
function cerrar({ devolverFoco = true } = {}) {
listaMenu.hidden = true;
botonMenu.setAttribute('aria-expanded', 'false');
if (devolverFoco) botonMenu.focus();
}
botonMenu.addEventListener('click', () => (listaMenu.hidden ? abrir() : cerrar()));
// Escape cierra; flechas arriba/abajo se mueven entre los enlaces
listaMenu.addEventListener('keydown', (e) => {
const enlaces = [...listaMenu.querySelectorAll('a')];
const posicion = enlaces.indexOf(document.activeElement);
if (e.key === 'Escape') cerrar();
if (e.key === 'ArrowDown') {
e.preventDefault(); // que no desplace la página
enlaces[(posicion + 1) % enlaces.length].focus();
}
if (e.key === 'ArrowUp') {
e.preventDefault();
enlaces[(posicion - 1 + enlaces.length) % enlaces.length].focus();
}
});
// Clic fuera del menú: se cierra (sin robar el foco a lo que se haya pulsado)
document.addEventListener('click', (e) => {
if (!listaMenu.hidden && !e.target.closest('.menu')) cerrar({ devolverFoco: false });
});
// Si el foco sale del menú con Tab, también se cierra
document.querySelector('.menu').addEventListener('focusout', (e) => {
if (!e.currentTarget.contains(e.relatedTarget)) cerrar({ devolverFoco: false });
});
📘 Concepto: En
focusout,e.relatedTargetes el elemento que va a recibir el foco. Si no está dentro del menú, el usuario se ha ido a otra parte y podemos cerrarlo.
11.3. Juego de teclado: atrapa las estrellas
Un personaje (🙂) se mueve por una cuadrícula con las flechas o con WASD. Hay que recoger estrellas (⭐) antes de que acabe el tiempo.
<!doctype html>
<html lang="es">
<head>
<meta charset="utf-8">
<title>Atrapa las estrellas</title>
<style>
body { font-family: system-ui, sans-serif; text-align: center; }
#tablero { display: inline-grid; grid-template-columns: repeat(8, 2.5rem); gap: 2px; background: #ccc; padding: 2px; }
.celda { width: 2.5rem; height: 2.5rem; background: white; display: grid; place-items: center; font-size: 1.5rem; }
#tablero:focus-visible { outline: 3px solid steelblue; }
</style>
<script type="module" src="juego.js"></script>
</head>
<body>
<h1>Atrapa las estrellas</h1>
<p>Muévete con las flechas o con W, A, S, D. Pulsa en el tablero para empezar.</p>
<p><strong id="marcador">Puntos: 0</strong> · <span id="tiempo">Tiempo: 30</span></p>
<div id="tablero" tabindex="0" role="application" aria-label="Tablero de juego"></div>
<p><button type="button" id="reiniciar">Reiniciar</button></p>
</body>
</html>
// juego.js
const TAMANO = 8;
const DURACION = 30;
const tablero = document.querySelector('#tablero');
const marcador = document.querySelector('#marcador');
const tiempoTexto = document.querySelector('#tiempo');
// "code" (posición física) para que WASD funcione en cualquier distribución de teclado
const DIRECCIONES = {
ArrowUp: [0, -1], KeyW: [0, -1],
ArrowDown: [0, 1], KeyS: [0, 1],
ArrowLeft: [-1, 0], KeyA: [-1, 0],
ArrowRight: [1, 0], KeyD: [1, 0],
};
let estado;
let intervalo;
// Creamos las 64 celdas una sola vez
const celdas = Array.from({ length: TAMANO * TAMANO }, () => {
const celda = document.createElement('div');
celda.className = 'celda';
return celda;
});
tablero.append(...celdas);
const posicionAleatoria = () => ({
x: Math.floor(Math.random() * TAMANO),
y: Math.floor(Math.random() * TAMANO),
});
function pintar() {
celdas.forEach((celda) => (celda.textContent = ''));
celdas[estado.estrella.y * TAMANO + estado.estrella.x].textContent = '⭐';
celdas[estado.jugador.y * TAMANO + estado.jugador.x].textContent = estado.jugando ? '🙂' : '😴';
marcador.textContent = `Puntos: ${estado.puntos}`;
tiempoTexto.textContent = `Tiempo: ${estado.tiempo}`;
}
function iniciar() {
clearInterval(intervalo);
estado = { jugador: { x: 0, y: 0 }, estrella: posicionAleatoria(), puntos: 0, tiempo: DURACION, jugando: true };
intervalo = setInterval(() => {
estado.tiempo--;
if (estado.tiempo === 0) {
clearInterval(intervalo);
estado.jugando = false;
tiempoTexto.textContent = '¡Fin!';
}
pintar();
}, 1000);
pintar();
tablero.focus();
}
tablero.addEventListener('keydown', (e) => {
const direccion = DIRECCIONES[e.code];
if (!direccion || !estado.jugando) return;
e.preventDefault(); // que las flechas no desplacen la página
const [dx, dy] = direccion;
// Math.min/Math.max impiden salir del tablero
estado.jugador.x = Math.max(0, Math.min(TAMANO - 1, estado.jugador.x + dx));
estado.jugador.y = Math.max(0, Math.min(TAMANO - 1, estado.jugador.y + dy));
const recogida = estado.jugador.x === estado.estrella.x && estado.jugador.y === estado.estrella.y;
if (recogida) {
estado.puntos++;
estado.estrella = posicionAleatoria();
}
pintar();
// Avisamos DESPUÉS de pintar, para que quien escuche vea el tablero actualizado
if (recogida) {
tablero.dispatchEvent(new CustomEvent('estrella-recogida', { detail: { puntos: estado.puntos } }));
}
});
// Evento personalizado: celebramos cada 5 puntos
tablero.addEventListener('estrella-recogida', (e) => {
if (e.detail.puntos % 5 === 0) marcador.textContent += ' 🎉';
});
tablero.addEventListener('click', () => tablero.focus());
document.querySelector('#reiniciar').addEventListener('click', iniciar);
iniciar();
✏️ Ejercicio: Añade obstáculos (🧱) en posiciones aleatorias que el jugador no pueda atravesar, y haz que la partida se pause (deteniendo el intervalo) cuando la pestaña pase a segundo plano, usando el evento
visibilitychangeydocument.hidden.
12. Ejercicios
Ejercicio 1: Contador con límites
Crea un contador con tres botones: “−”, “+” y “Reiniciar”. El valor no puede bajar de 0 ni subir de 10; cuando llegue a un límite, deshabilita el botón correspondiente (disabled). Añade atajos de teclado: + y - para sumar y restar, y r para reiniciar (salvo si el foco está en un campo de texto).
Ejercicio 2: Visor de eventos
Crea una caja y, debajo, un registro. Muestra en el registro el tipo y las coordenadas de mousedown, mouseup, click, dblclick y contextmenu (impide el menú contextual dentro de la caja). Añade un botón “Dejar de escuchar” que quite todos los manejadores con un solo AbortController.
Ejercicio 3: Captura y burbuja
Crea tres div anidados (abuelo, padre, hijo) con colores distintos. Registra en cada uno un manejador en fase de captura y otro en fase de burbuja que muestren su nombre y eventPhase. Pulsa en el hijo y explica el orden. Después añade stopPropagation() en el padre (burbuja) y observa qué cambia.
Ejercicio 4: Delegación en una lista de la compra
Implementa una lista de la compra con un campo y un botón para añadir productos. Cada producto tiene botones “✔” (marcar como comprado, alterna una clase) y “✖” (eliminar). Usa un único manejador en la lista, con data-accion y closest. Muestra cuántos productos quedan por comprar.
Ejercicio 5: Buscador con debounce
Crea un campo de búsqueda que filtre una lista de 50 países (escríbelos en un array). Aplica debounce de 300 ms y muestra en un contador cuántas veces se ha ejecutado realmente el filtrado frente a cuántos eventos input se han producido.
Ejercicio 6: Barra de progreso de lectura
Crea una página larga y una barra fija arriba que muestre el porcentaje leído (scrollY / (scrollHeight - innerHeight)). Actualízala con throttle y un listener pasivo. Añade un botón “Volver arriba” que solo aparezca al bajar más de 300 px.
Ejercicio 7: Carrito con eventos personalizados
Crea una clase Carrito extends EventTarget con métodos anadir(producto) y quitar(id) que lancen un CustomEvent('cambio', { detail: { productos, total } }). Conecta dos “componentes” independientes que escuchen ese evento: un contador en la cabecera y un resumen con el total. Ninguno de los dos debe llamar directamente al otro.
Ejercicio 8: Pestañas accesibles
Construye un componente de pestañas (role="tablist", role="tab", role="tabpanel") donde se cambie de pestaña con clic y con las flechas izquierda/derecha, actualizando aria-selected y mostrando solo el panel activo con hidden.
Resumen
| Concepto | Sintaxis / clave |
|---|---|
| Registrar | el.addEventListener('click', manejador, opciones?) |
| Quitar | el.removeEventListener('click', mismaFuncion) o { signal } + controlador.abort() |
| Opciones | { once: true }, { passive: true }, { signal }, { capture: true } |
| Origen / manejador actual | e.target / e.currentTarget |
| Cancelar acción por defecto | e.preventDefault() |
| Detener propagación | e.stopPropagation() (con moderación) |
| Ratón y puntero | click, dblclick, contextmenu, mousemove, pointerdown… clientX, pageX |
| Teclado | keydown/keyup; e.key (carácter) frente a e.code (tecla física) |
| Modificadores | e.ctrlKey, e.metaKey, e.shiftKey, e.altKey |
| Foco | focus/blur (no burbujean), focusin/focusout (sí), document.activeElement |
| Formularios | input (cada cambio), change (al confirmar), submit |
| Scroll / tamaño | scroll (pasivo), resize; mejor observers para visibilidad y tamaño |
| Propagación | Captura (baja) → objetivo → burbuja (sube); por defecto se escucha en burbuja |
| Delegación | Un manejador en el padre + e.target.closest(selector) |
| Eventos propios | el.dispatchEvent(new CustomEvent('nombre', { detail, bubbles: true })) |
| Debounce | Ejecuta una vez cuando los eventos paran durante X ms |
| Throttle | Ejecuta como mucho una vez cada X ms |
| Accesibilidad | Elementos nativos (button, a, dialog), gestionar el foco, aria-expanded |