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 addEventListener y removeEventListener
  • Usar las opciones once, passive y signal (con AbortController)
  • Conocer el objeto Event: type, target, currentTarget, preventDefault y stopPropagation
  • 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 CustomEvent y dispatchEvent
  • Implementar debounce y throttle a 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, document o window.
  • 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í con signal, 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í que onclick="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: signal es la forma moderna de “limpiar” cuando un componente desaparece: crea un AbortController por componente, pásalo a todos sus addEventListener y a sus fetch, y llama a abort() al destruirlo. Te olvidas de guardar cada función para removeEventListener.

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 comprobar e.target en 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.pointerType te 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 key cuando te importa qué ha escrito el usuario o el significado de la tecla: 'Enter', 'Escape', 'ArrowUp', 'Tab', ' ' (espacio), letras.
  • Usa code cuando 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, mousemove y input pueden dispararse decenas de veces por segundo. Si el manejador hace algo costoso, usa debounce o throttle (sección 9). Y para tareas como “detectar si un elemento es visible” o “reaccionar al tamaño de un elemento”, los navegadores ofrecen IntersectionObserver y ResizeObserver (módulo 18), mucho más eficientes que escuchar scroll y resize.

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
  1. Captura: el evento baja desde window hasta el padre del objetivo.
  2. Objetivo: llega al elemento pulsado (target).
  3. 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, load y scroll en elementos (no en el documento) no lo hacen. Para foco, usa sus versiones que sí burbujean, focusin y focusout. Puedes comprobarlo con evento.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 no e.target.matches('.borrar'). Si el botón tuviera un icono dentro (<button class="borrar"><svg>...</svg></button>), e.target sería el icono, no el botón. closest sube 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 con closest('[data-accion]') escala muy bien: puedes añadir acciones nuevas solo con HTML y un if (o un objeto de funciones acciones[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 que dispatchEvent es síncrono: todos los manejadores se ejecutan antes de que dispatchEvent devuelva 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 un a. El evento click de un button se dispara también con el teclado, así que no necesitas escuchar keydown para 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-visible para 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 === modal funciona porque, al no tener padding, el único punto en el que el target es el propio dialog es su fondo (::backdrop). Los clics en la imagen o los botones tienen otro target.

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.relatedTarget es 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 visibilitychange y document.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