La lección anterior terminó con un problema honesto: conectarAcciones registra un manejador por cada botón de cada tarea, y en cuanto empieces a crear los <li> desde JavaScript, los elementos nuevos nacerán sin manejador. La solución no es reconectar todo cada vez, sino entender que un evento no ocurre solo en un elemento: viaja por el árbol del DOM, de la raíz hasta el objetivo y de vuelta. Aprovechando ese viaje se puede poner un único manejador en el contenedor que atienda a todos sus hijos, presentes y futuros. En esta lección aprenderás las tres fases de ese recorrido, las tres formas de interrumpirlo (y en qué se diferencian), el patrón de la delegación —probablemente la técnica más rentable de todo el módulo— y los eventos personalizados, que te permitirán que la vista y el modelo se comuniquen sin conocerse.
Contenido
- El viaje de un evento: captura, objetivo y burbujeo
- Una demostración en tres niveles
- Escuchar en la fase de captura
- Detener el viaje:
stopPropagationystopImmediatePropagation preventDefaultno es lo mismo (tabla comparativa)- Eventos que no burbujean, y sus equivalentes que sí
- Delegación de eventos
event.target.closest()ydataset: el patrón completo- Nómada Tareas: el tablero con un único manejador
- Eventos personalizados:
CustomEventydetail - Desacoplar la vista del modelo con eventos
AbortController: retirar muchos manejadores de golpe- Errores Comunes y Consejos
- Ejercicios
- Conclusión
- El viaje de un evento: captura, objetivo y burbujeo
Cuando haces clic en el botón de una tarea, ese clic no ocurre «en el botón» y ya está. Ocurre en el botón, que está dentro de un <li>, que está dentro de un <ul>, que está dentro de una <section>… El navegador considera que el evento le concierne a toda esa cadena, y la recorre en tres fases:
- Captura (capturing): el evento desciende desde
windowhasta el elemento objetivo, pasando por todos sus antepasados. - Objetivo (target): el evento llega al elemento donde ocurrió realmente.
- Burbujeo (bubbling): el evento asciende desde el objetivo hasta
window, otra vez por todos los antepasados.
flowchart TD
W1["window"] --> D1["document"]
D1 --> S1["section.tablero"]
S1 --> U1["ul#lista-tareas"]
U1 --> L1["li[data-id=6]"]
L1 --> B["button.tarea__accion<br/>◀ FASE 2 · OBJETIVO"]
B --> L2["li[data-id=6]"]
L2 --> U2["ul#lista-tareas"]
U2 --> S2["section.tablero"]
S2 --> D2["document"]
D2 --> W2["window"]
W1 -.- CAP["FASE 1 · CAPTURA<br/>de fuera hacia dentro"]
W2 -.- BUR["FASE 3 · BURBUJEO<br/>de dentro hacia fuera"]
Por defecto, addEventListener registra el manejador en la fase de burbujeo. Por eso, en la lección anterior, un manejador puesto en el <li> se disparaba al hacer clic en el botón que contiene: el evento nació en el botón y burbujeó hasta el <li>.
Dos propiedades del objeto Event describen este viaje:
event.eventPhase:1en captura,2en el objetivo,3en burbujeo.event.composedPath(): el array completo de nodos por los que pasa, del objetivo hacia arriba.
boton.addEventListener('click', (e) => {
console.log(e.composedPath().map((n) => n.nodeName ?? n.constructor.name));
// ['BUTTON', 'LI', 'UL', 'SECTION', 'MAIN', 'BODY', 'HTML', '#document', 'Window']
});
- Una demostración en tres niveles
La mejor forma de interiorizar esto es verlo. Registra un manejador en cada nivel y haz un solo clic en el botón:
const section = document.querySelector('.tablero');
const ul = document.querySelector('#lista-tareas');
const li = document.querySelector('[data-id="6"]');
const boton = li.querySelector('.tarea__accion');
function espiar(nombre) {
return (evento) => {
const fases = { 1: 'CAPTURA', 2: 'OBJETIVO', 3: 'BURBUJEO' };
console.log(`${fases[evento.eventPhase]} · ${nombre} · target=${evento.target.tagName}`);
};
}
section.addEventListener('click', espiar('section'));
ul.addEventListener('click', espiar('ul'));
li.addEventListener('click', espiar('li'));
boton.addEventListener('click', espiar('button'));Un clic sobre el botón imprime:
OBJETIVO · button · target=BUTTON BURBUJEO · li · target=BUTTON BURBUJEO · ul · target=BUTTON BURBUJEO · section · target=BUTTON
Cuatro manejadores ejecutados con un solo clic. Observaciones fundamentales:
targetvaleBUTTONen los cuatro. El objetivo no cambia durante el viaje: es siempre donde ocurrió el evento. Lo que cambia escurrentTarget, que en cada línea es el elemento donde registraste ese manejador concreto.- No aparece ninguna línea de CAPTURA, porque los cuatro manejadores se registraron en burbujeo (el valor por defecto).
- El orden es de dentro hacia fuera. Primero el más profundo, luego sus antepasados.
Esta es exactamente la propiedad que hace posible la delegación: un manejador en el <ul> ya está recibiendo los clics de todos sus descendientes.
- Escuchar en la fase de captura
Con { capture: true } el manejador se registra en la fase descendente:
section.addEventListener('click', espiar('section CAPTURA'), { capture: true });
ul.addEventListener('click', espiar('ul CAPTURA'), { capture: true });
li.addEventListener('click', espiar('li')); // burbujeo
boton.addEventListener('click', espiar('button'));Ahora el mismo clic imprime:
CAPTURA · section CAPTURA · target=BUTTON CAPTURA · ul CAPTURA · target=BUTTON OBJETIVO · button · target=BUTTON BURBUJEO · li · target=BUTTON
La captura va de fuera hacia dentro y siempre ocurre antes que el burbujeo. Existe también una forma abreviada heredada, addEventListener('click', fn, true), con el tercer parámetro booleano; hoy se prefiere el objeto de opciones por legibilidad.
¿Cuándo se usa la captura? Rara vez, y siempre con un motivo concreto:
- Interceptar antes que nadie, por ejemplo para registrar analítica o para bloquear la interacción con toda una zona de la interfaz mientras se guarda algo.
- Capturar eventos que no burbujean, como
focusoerroren imágenes, desde un contenedor. Es el truco del apartado 6.
En el 95 % de los casos, el burbujeo es lo que quieres.
- Detener el viaje:
stopPropagation y stopImmediatePropagation
stopPropagation y stopImmediatePropagationevento.stopPropagation() detiene el recorrido: los manejadores que quedaban por delante en el árbol no se ejecutan.
li.addEventListener('click', (evento) => {
evento.stopPropagation();
console.log('el li corta aquí');
});
// Un clic en el botón imprime:
// OBJETIVO · button
// el li corta aquí
// … y NADA más: 'ul' y 'section' ya no se enteran.evento.stopImmediatePropagation() es más contundente: además de detener el viaje hacia arriba, impide que se ejecuten los demás manejadores del mismo elemento.
li.addEventListener('click', (e) => { e.stopImmediatePropagation(); console.log('primero'); });
li.addEventListener('click', () => console.log('segundo')); // ✗ nunca se ejecuta
// Con stopPropagation() en lugar de stopImmediatePropagation(),
// 'segundo' SÍ se ejecutaría, y solo se cortaría la subida hacia el <ul>.Aviso importante: stopPropagation es peligroso. Es una decisión que tomas en un componente y que afecta a todo el resto de la aplicación, incluida la parte que todavía no has escrito. Escenario típico: pones stopPropagation en una tarjeta para que un clic no active algo del contenedor; tres semanas después alguien añade un manejador en document para cerrar un menú desplegable al hacer clic fuera, y ese menú deja de cerrarse encima de tus tarjetas. El fallo es imposible de encontrar porque no hay ningún error.
La alternativa casi siempre es mejor: que el manejador de arriba compruebe si el evento le concierne.
// ✗ El hijo impone su criterio a todos
boton.addEventListener('click', (e) => e.stopPropagation());
// ✓ El padre decide con una guarda
ul.addEventListener('click', (evento) => {
if (evento.target.closest('.tarea__accion') === null) return; // no me concierne
// …
});
preventDefault no es lo mismo (tabla comparativa)
preventDefault no es lo mismo (tabla comparativa)Estos tres métodos se confunden constantemente, y hacen cosas completamente distintas:
| Método | Qué hace | Qué NO hace |
|---|---|---|
preventDefault() |
Cancela la acción por defecto del navegador (seguir un enlace, enviar un formulario, desplazar con la barra espaciadora) | No detiene la propagación: el evento sigue subiendo |
stopPropagation() |
Impide que el evento siga hacia los siguientes elementos del recorrido | No cancela la acción por defecto; no impide los otros manejadores del mismo elemento |
stopImmediatePropagation() |
Lo anterior y además impide los demás manejadores del mismo elemento | No cancela la acción por defecto |
Corolario práctico:
enlace.addEventListener('click', (evento) => {
evento.stopPropagation();
// El navegador SIGUE navegando: falta preventDefault()
});
enlace.addEventListener('click', (evento) => {
evento.preventDefault();
// El evento SIGUE burbujeando hasta document: es lo normal y casi siempre deseable
});Un detalle útil: cuando un manejador ha llamado a preventDefault(), los manejadores posteriores del recorrido pueden detectarlo con evento.defaultPrevented, lo que permite escribir código cooperativo en lugar de código que se pisa.
- Eventos que no burbujean, y sus equivalentes que sí
No todos los eventos burbujean. Los más importantes que no lo hacen:
| Evento que no burbujea | Equivalente que sí burbujea | Nota |
|---|---|---|
focus |
focusin |
Se dispara antes de que el elemento reciba el foco |
blur |
focusout |
Se dispara antes de perderlo |
mouseenter |
mouseover |
Recuerda que mouseover se dispara también con los hijos |
mouseleave |
mouseout |
Igual |
load, error (en <img>, <script>) |
— | Solo se pueden capturar con { capture: true } |
Consecuencia directa: no puedes delegar focus ni blur, porque nunca llegan al contenedor.
// ✗ No funciona: 'focus' no burbujea
formulario.addEventListener('focus', (e) => console.log('foco en', e.target.name));
// ✓ Opción A: usar el equivalente que sí burbujea
formulario.addEventListener('focusin', (e) => console.log('foco en', e.target.name));
// ✓ Opción B: escuchar en la fase de captura
formulario.addEventListener('focus', (e) => console.log('foco en', e.target.name), { capture: true });La opción A es la recomendable: es más clara y no obliga a razonar sobre fases.
Para load y error de imágenes solo queda la captura, y resulta muy práctica para detectar de golpe cualquier imagen rota de la página:
document.addEventListener('error', (evento) => {
if (evento.target.tagName === 'IMG') {
evento.target.classList.add('imagen-rota');
}
}, { capture: true });
- Delegación de eventos
La delegación consiste en registrar un solo manejador en un antepasado común y decidir dentro qué hacer según de dónde venga el evento. Aprovecha el burbujeo del apartado 1.
flowchart TD
subgraph SIN["Sin delegación · 6 manejadores"]
U1["ul#lista-tareas"] --> A1["li 1 → manejador"]
U1 --> A2["li 2 → manejador"]
U1 --> A3["li 3 → manejador"]
U1 --> A4["li 4 → manejador"]
U1 --> A5["li 5 → manejador"]
U1 --> A6["li 6 → manejador"]
end
subgraph CON["Con delegación · 1 manejador"]
U2["ul#lista-tareas<br/>★ único manejador"] --> B1["li 1"]
U2 --> B2["li 2"]
U2 --> B3["li 3"]
U2 --> B4["li 4"]
U2 --> B5["li 5"]
U2 --> B6["li 6"]
end
Las tres ventajas, por orden de importancia:
- Funciona con elementos que todavía no existen. Es la razón principal. Cuando en 06-05 crees un
<li>nuevo y lo insertes en el<ul>, sus clics burbujearán hasta el manejador que ya estaba puesto. No hay que reconectar nada, y por tanto no hay riesgo de duplicar manejadores ni de olvidarse de uno. - Menos memoria y menos trabajo de registro. Un manejador en lugar de doscientos. Con seis tareas la diferencia es irrelevante; con una lista larga, no. La cuestión se trata a fondo en Gestión de Memoria y Manipulación Eficiente del DOM.
- Un único punto donde leer la lógica de interacción. Toda la respuesta a los clics de la lista está en una función, no repartida por seis sitios.
También tiene inconvenientes que conviene conocer: el manejador recibe todos los clics de la zona, así que necesita una guarda clara al principio; y no sirve para eventos que no burbujean (apartado 6).
event.target.closest() y dataset: el patrón completo
event.target.closest() y dataset: el patrón completoUn manejador delegado siempre tiene la misma forma, y merece la pena memorizarla:
ul.addEventListener('click', (evento) => {
// 1 · ¿El clic viene de un elemento que me interesa?
const boton = evento.target.closest('[data-accion]');
if (boton === null) return; // guarda: no me concierne
// 2 · ¿Está dentro de mi contenedor? (protege de casos raros)
if (!ul.contains(boton)) return;
// 3 · ¿A qué tarea pertenece?
const li = boton.closest('li[data-id]');
const id = Number(li.dataset.id); // ¡conversión explícita!
// 4 · ¿Qué acción hay que ejecutar?
const accion = boton.dataset.accion; // 'avanzar' | 'eliminar' | …
console.log({ id, accion });
});Cada paso tiene su porqué:
evento.targetes el elemento más profundo, y ese es el problema que resuelveclosest(). Si tu botón contiene un<span>con un icono, eltargetde un clic sobre el icono será el<span>, no el botón. Comprobarevento.target.matches('[data-accion]')fallaría en ese caso;closest()sube hasta encontrar el botón y funciona siempre.- La guarda
=== nulles obligatoria. Un clic en el hueco entre dos tareas también llega a tu manejador, y sin la guarda tendrías unTypeError. dataset.accionconvierte el manejador en un despachador genérico: no hay una rama por botón, sino un dato en el HTML que dice qué hacer. Es el diccionario de consulta de 02-03 aplicado a la interfaz.Number(li.dataset.id)es la conversión de frontera de 06-02: la página habla en strings, el modelo en números.
- Nómada Tareas: el tablero con un único manejador
Rehacemos el controlador de la lección anterior. Ahora el HTML incluye dos acciones por tarea y usa data-accion:
<ul id="lista-tareas" class="lista-tareas">
<li class="tarea" data-id="1">
<span class="tarea__titulo"></span>
<span class="tarea__meta"></span>
<button type="button" class="tarea__accion" data-accion="avanzar"></button>
<button type="button" class="tarea__accion" data-accion="reabrir">Reabrir</button>
</li>
<!-- … el resto de tareas, con la misma estructura … -->
</ul>Y el controlador completo, con un solo addEventListener:
// js/vista/controlador.js
import { HOY } from '../util/fechas.js';
import { pintarTarea, pintarResumen } from './pintar.js';
const SIGUIENTE = Object.freeze({ pendiente: 'en-curso', 'en-curso': 'hecha', hecha: null });
const ETIQUETA = Object.freeze({ pendiente: 'Empezar', 'en-curso': 'Marcar hecha', hecha: 'Completada' });
/** Traduce una acción de la interfaz al estado destino del modelo. */
function estadoDestino(accion, tarea) {
if (accion === 'avanzar') return SIGUIENTE[tarea.estado];
if (accion === 'reabrir') return tarea.estado === 'en-curso' ? 'pendiente' : null;
return null;
}
export function conectarTablero({ lista, resumen, tablero, hoy = HOY, signal }) {
/** Refresca un <li> completo: textos, clases y botones. */
function refrescar(li, tarea) {
pintarTarea(li, tarea, hoy);
const avanzar = li.querySelector('[data-accion="avanzar"]');
avanzar.textContent = ETIQUETA[tarea.estado];
avanzar.disabled = SIGUIENTE[tarea.estado] === null;
avanzar.setAttribute('aria-label', `${ETIQUETA[tarea.estado]}: ${tarea.titulo}`);
const reabrir = li.querySelector('[data-accion="reabrir"]');
reabrir.disabled = tarea.estado !== 'en-curso';
reabrir.setAttribute('aria-label', `Devolver a pendiente: ${tarea.titulo}`);
}
// ── UN ÚNICO MANEJADOR PARA TODA LA LISTA ────────────────────────────────
lista.addEventListener('click', (evento) => {
const boton = evento.target.closest('button[data-accion]');
if (boton === null || !lista.contains(boton)) return; // no me concierne
const li = boton.closest('li[data-id]');
const tarea = tablero.buscarPorId(Number(li.dataset.id));
if (tarea === null) return;
const destino = estadoDestino(boton.dataset.accion, tarea);
if (destino === null) return;
try {
tablero.cambiarEstado(tarea.id, destino); // R6: el modelo valida la transición
} catch (error) {
console.error(error.describir?.() ?? error.message);
return;
}
refrescar(li, tarea);
pintarResumen(resumen, tablero, hoy);
// Avisamos al resto de la aplicación (apartado 11)
li.dispatchEvent(new CustomEvent('tarea:cambiada', {
bubbles: true,
detail: { id: tarea.id, estado: tarea.estado, titulo: tarea.titulo }
}));
}, { signal });
// Pintado inicial
for (const li of lista.querySelectorAll('li[data-id]')) {
const tarea = tablero.buscarPorId(Number(li.dataset.id));
if (tarea !== null) refrescar(li, tarea);
}
pintarResumen(resumen, tablero, hoy);
}Compara con la versión de 06-03: allí había un addEventListener dentro de un bucle; aquí hay uno solo, fuera de todo bucle. Y lo importante es lo que no hay que cambiar cuando en la lección siguiente los <li> se creen desde JavaScript: nada. El manejador ya está puesto en el <ul>, y cualquier botón que aparezca dentro le llegará por burbujeo.
- Eventos personalizados:
CustomEvent y detail
CustomEvent y detailHasta ahora todos los eventos venían del navegador. Pero tú también puedes crear y disparar los tuyos, y eso convierte al DOM en un canal de comunicación entre las partes de tu aplicación.
// Crear
const evento = new CustomEvent('tarea:cambiada', {
detail: { id: 6, estado: 'en-curso', titulo: 'Presupuesto de la carpintería' },
bubbles: true, // ¿burbujea? Por defecto false
cancelable: true // ¿se puede llamar a preventDefault()? Por defecto false
});
// Disparar sobre un elemento (o sobre document/window)
li.dispatchEvent(evento);
// Escuchar, en cualquier antepasado si burbujea
document.addEventListener('tarea:cambiada', (e) => {
console.log(`La tarea ${e.detail.id} pasó a ${e.detail.estado}`);
});Cuatro puntos importantes:
detailes el único sitio donde van tus datos. Es una propiedad de solo lectura y puede contener cualquier valor: un objeto, un array, un número.bubblesesfalsepor defecto, al revés que la mayoría de eventos nativos. Si lo olvidas, el manejador dedocumentno se entera nunca y perderás un buen rato buscando el fallo.dispatchEventes síncrono. No encola nada: ejecuta los manejadores inmediatamente y devuelve el control cuando terminan. Es distinto del comportamiento de los eventos del usuario, que sí pasan por la cola de tareas (05-07).- La convención de nombres
dominio:accion(tarea:cambiada,tablero:actualizado,filtro:aplicado) evita colisiones con eventos nativos presentes y futuros, y hace evidente en el código de qué se está hablando.
Si el evento es cancelable, dispatchEvent devuelve false cuando algún manejador llamó a preventDefault(). Eso permite que un evento actúe como una petición de permiso:
const seguir = li.dispatchEvent(new CustomEvent('tarea:antes-de-cerrar', {
bubbles: true, cancelable: true, detail: { id: tarea.id }
}));
if (!seguir) {
console.log('Alguien ha vetado el cierre de la tarea.');
return;
}
tablero.cambiarEstado(tarea.id, 'hecha');
- Desacoplar la vista del modelo con eventos
Este es el uso valioso. Sin eventos personalizados, cada parte de la interfaz que deba reaccionar a un cambio tiene que ser conocida por quien lo provoca:
// ✗ El controlador tiene que conocer a todo el mundo
tablero.cambiarEstado(id, destino);
refrescarLista();
refrescarResumen();
refrescarGraficoDeCarga();
refrescarContadorDeVencidas();
// … y cada nueva pieza obliga a tocar esta funciónCon eventos, quien provoca el cambio solo anuncia que ha ocurrido, y quien esté interesado se suscribe:
flowchart LR
U["Clic de Marta"] --> C["controlador.js<br/>delegado en el ul"]
C --> M["modelo<br/>tablero.cambiarEstado()"]
M --> C
C -- "dispatchEvent<br/>tarea:cambiada" --> DOC["document"]
DOC --> V1["Lista de tareas<br/>repinta el li"]
DOC --> V2["Resumen<br/>recalcula horas"]
DOC --> V3["Registro de actividad<br/>añade una línea"]
El controlador no sabe cuántos oyentes hay ni qué hacen. Añadir un cuarto no le obliga a cambiar ni una línea. En Nómada Tareas:
// js/vista/eventos.js — nombres centralizados, para no escribir cadenas sueltas
export const EVENTOS = Object.freeze({
TAREA_CAMBIADA: 'tarea:cambiada',
TAREA_CREADA: 'tarea:creada',
TABLERO_ACTUALIZADO:'tablero:actualizado',
FILTRO_APLICADO: 'filtro:aplicado'
});
/** Dispara un evento de la aplicación desde un elemento (burbujea siempre). */
export function emitir(origen, tipo, detail = {}) {
return origen.dispatchEvent(new CustomEvent(tipo, { bubbles: true, detail }));
}// js/vista/registro.js — una vista nueva que nadie tuvo que "conectar"
import { EVENTOS } from './eventos.js';
export function conectarRegistro(contenedor, { signal } = {}) {
document.addEventListener(EVENTOS.TAREA_CAMBIADA, (evento) => {
const { titulo, estado } = evento.detail;
const linea = document.createElement('li');
linea.textContent = `${new Date().toLocaleTimeString('es-ES')} · ${titulo} → ${estado}`;
contenedor.prepend(linea);
}, { signal });
}// js/app.js
import { Tablero } from './modelo/tablero.js';
import { crearBacklog } from './datos/backlog.js';
import { HOY } from './util/fechas.js';
import { conectarTablero } from './vista/controlador.js';
import { conectarRegistro } from './vista/registro.js';
import { EVENTOS } from './vista/eventos.js';
const tablero = new Tablero('Taller Nómada', crearBacklog());
conectarTablero({
lista: document.querySelector('#lista-tareas'),
resumen: document.querySelector('#resumen'),
tablero,
hoy: HOY
});
conectarRegistro(document.querySelector('#registro'));
// Un oyente global para depurar durante el desarrollo
document.addEventListener(EVENTOS.TAREA_CAMBIADA, (e) => {
console.log('[evento]', e.type, e.detail, '· esfuerzo ahora:', tablero.esfuerzo);
});Un clic en «Empezar» sobre la carpintería produce ahora, en cascada y sin que ninguna pieza conozca a las demás:
[evento] tarea:cambiada { id: 6, estado: 'en-curso', titulo: 'Presupuesto de la carpintería' } · esfuerzo ahora: 124Y en la pantalla: el <li> repintado, el resumen actualizado y una línea nueva en el registro de actividad.
Cuándo usar eventos personalizados y cuándo no. Son ideales para comunicar de forma ascendente o lateral (un componente informa de que algo pasó, sin saber quién escucha). No son adecuados para comunicaciones de uno a uno donde una llamada a función directa es más clara: si el controlador necesita el resultado inmediato de algo, llámalo. Y ojo con abusar: una aplicación donde todo se comunica por eventos es difícil de seguir, porque la traza deja de leerse de arriba abajo. Un puñado de eventos bien nombrados es una arquitectura; treinta son un laberinto.
AbortController: retirar muchos manejadores de golpe
AbortController: retirar muchos manejadores de golpeRecuerda el problema de 06-03: para retirar un manejador hace falta la referencia exacta de la función. Con diez manejadores repartidos, la limpieza se convierte en diez líneas y diez variables.
AbortController lo resuelve. Es un objeto con una propiedad signal y un método abort(). Si pasas esa signal como opción a addEventListener, todos los manejadores registrados con ella se retiran al llamar a abort():
const controlador = new AbortController();
const { signal } = controlador;
lista.addEventListener('click', alHacerClic, { signal });
barra.addEventListener('click', alFiltrar, { signal });
document.addEventListener('keydown', alPulsarTecla, { signal });
window.addEventListener('resize', alRedimensionar, { signal });
// Una sola línea retira los cuatro:
controlador.abort();Esto encaja perfectamente con el patrón de la lección: cada función conectarX acepta una signal y la propaga, de modo que desmontar la interfaz entera es trivial.
// js/app.js
const controlador = new AbortController();
const { signal } = controlador;
conectarTablero({ lista, resumen, tablero, hoy: HOY, signal });
conectarRegistro(document.querySelector('#registro'), { signal });
// Por ejemplo, al cambiar de vista o al recargar los datos:
// controlador.abort(); ← toda la interacción queda desconectada de golpeDetalles que conviene saber:
- Una
signalabortada no se puede reutilizar: hay que crear unAbortControllernuevo. Si registras un manejador con una señal ya abortada, simplemente no se registra. signalyoncese combinan sin problema:{ once: true, signal }.AbortControllerno es exclusivo de los eventos: es el mecanismo estándar de cancelación en el navegador, y lo volverás a usar para cancelar peticiones de red en Peticiones Robustas.
Errores Comunes y Consejos
- Usar
evento.targetdonde hacía faltaclosest(). Si el botón contiene un icono,targetserá el icono.evento.target.closest('[data-accion]')es el patrón correcto, siempre. - Olvidar la guarda
if (elemento === null) return;en un manejador delegado. Recibes todos los clics del contenedor, incluidos los que no van a ningún sitio, y sin guarda obtienes unTypeError. - Olvidar
bubbles: trueen unCustomEvent. El evento se dispara, no falla nada y nadie lo oye. Es el fallo más desconcertante de la lección, porque no hay ningún síntoma. - Abusar de
stopPropagation(). Rompe funcionalidades que ni siquiera existen todavía y no deja rastro. Prefiere una guarda en el manejador de arriba. - Creer que
preventDefault()detiene el burbujeo. No lo hace, ystopPropagation()no cancela la acción por defecto. Son ejes independientes: repasa la tabla del apartado 5. - Intentar delegar
focusoblur. No burbujean. Usafocusin/focusout, o registra con{ capture: true }. - Delegar en
documentcuando bastaba el contenedor. Cuanto más cerca esté el manejador del objetivo, menos eventos irrelevantes tendrá que descartar. Delega en el<ul>, no endocument. - Reutilizar un
AbortControllerya abortado. No vuelve a funcionar. Crea uno nuevo cada vez que montes la interfaz. - Consejo: nombra los eventos con
dominio:accion. Evita colisiones y documenta la intención. Y centraliza las cadenas en un móduloEVENTOS, como en el apartado 11, para que un error tipográfico sea unundefinedvisible y no un evento que nadie escucha. - Consejo: usa
getEventListeners($0)en la consola de Chrome. Muestra todos los manejadores del elemento seleccionado; es la mejor forma de verificar que la delegación no dejó manejadores duplicados por el camino.
Ejercicios
Ejercicio 1 · El experimento de las fases
Registra seis manejadores de click —captura y burbujeo en section, ul y li— y haz un clic en el botón de una tarea. Predice por escrito el orden antes de ejecutar, y luego compruébalo. Después añade stopPropagation() en el manejador de captura del <ul> y explica exactamente qué manejadores dejan de ejecutarse y por qué.
Ejercicio 2 · Delegación con dos acciones y confirmación
Amplía el manejador delegado de #lista-tareas para que soporte una tercera acción, data-accion="archivar", que oculte el <li> con hidden. Antes de archivar debe emitir un evento tarea:antes-de-archivar cancelable, y solo continuar si nadie lo ha vetado. Después escribe un oyente que vete el archivado de cualquier tarea que no esté hecha. Todo con un único addEventListener en el <ul> (más el oyente del veto).
Ejercicio 3 · Panel de carga desacoplado
Crea js/vista/carga.js con una función conectarCarga(contenedor, tablero, { signal }) que dibuje las horas abiertas por responsable (Iván 25, Lucía 14, Marta 6) y se actualice sola cada vez que se emita tarea:cambiada, sin que el controlador del tablero tenga que saber que este panel existe. Usa AbortController en app.js para poder desconectarlo todo desde la consola.
Soluciones
Ejercicio 1
for (const [nombre, elem] of [['section', section], ['ul', ul], ['li', li]]) {
elem.addEventListener('click', () => console.log(`captura · ${nombre}`), { capture: true });
elem.addEventListener('click', () => console.log(`burbujeo · ${nombre}`));
}Orden al hacer clic en el botón:
Primero la bajada completa (de fuera hacia dentro), luego la subida completa (de dentro hacia fuera). El <li> aparece en las dos listas porque tiene un manejador en cada fase; como el objetivo real es el <button>, para el <li> ambas son fases de recorrido normales.
Con stopPropagation() en el manejador de captura del <ul>, la salida se reduce a:
Se corta todo lo demás: el evento ni siquiera llega al <li>, ni al <button> (que era el objetivo), ni vuelve a subir. Es la demostración más contundente de por qué stopPropagation en captura es tan destructivo: cancela el evento para todo el subárbol, incluido el elemento donde el usuario hizo clic de verdad.
Ejercicio 2
import { emitir, EVENTOS } from './eventos.js';
lista.addEventListener('click', (evento) => {
const boton = evento.target.closest('button[data-accion]');
if (boton === null || !lista.contains(boton)) return;
const li = boton.closest('li[data-id]');
const tarea = tablero.buscarPorId(Number(li.dataset.id));
if (tarea === null) return;
if (boton.dataset.accion === 'archivar') {
const permitido = li.dispatchEvent(new CustomEvent('tarea:antes-de-archivar', {
bubbles: true, cancelable: true, detail: { id: tarea.id, estado: tarea.estado }
}));
if (!permitido) {
console.warn(`Archivado vetado para «${tarea.titulo}» (${tarea.estado}).`);
return;
}
li.hidden = true;
emitir(lista, EVENTOS.TABLERO_ACTUALIZADO, tablero.resumen(HOY));
return;
}
// … las acciones 'avanzar' y 'reabrir', como en el apartado 9
});
// El veto, en otro módulo que el controlador desconoce por completo
document.addEventListener('tarea:antes-de-archivar', (evento) => {
if (evento.detail.estado !== 'hecha') evento.preventDefault();
});Dos claves. La primera: dispatchEvent devuelve false si algún manejador llamó a preventDefault(), y eso solo funciona si el evento se creó con cancelable: true; sin esa opción, preventDefault() se ignora y dispatchEvent devuelve siempre true. La segunda: el controlador no contiene la regla «solo se archiva lo hecho». Esa regla vive en otro módulo, y podría cambiarse o retirarse sin tocar el controlador. Es exactamente el desacoplamiento que buscamos.
Ejercicio 3
// js/vista/carga.js
import { EVENTOS } from './eventos.js';
export function conectarCarga(contenedor, tablero, { signal } = {}) {
function pintar() {
const horas = tablero.horasPorResponsable(); // { Iván: 25, Lucía: 14, Marta: 6 }
const total = Object.values(horas).reduce((s, h) => s + h, 0);
contenedor.replaceChildren(); // vaciar sin innerHTML
for (const [quien, h] of Object.entries(horas).sort((a, b) => b[1] - a[1])) {
const fila = document.createElement('li');
fila.textContent = `${quien}: ${h} h (${Math.round((h / total) * 100)} %)`;
fila.style.setProperty('--porcentaje', `${(h / total) * 100}%`);
contenedor.append(fila);
}
}
pintar();
document.addEventListener(EVENTOS.TAREA_CAMBIADA, pintar, { signal });
}// js/app.js
const controlador = new AbortController();
const { signal } = controlador;
conectarTablero({ lista, resumen, tablero, hoy: HOY, signal });
conectarCarga(document.querySelector('#carga'), tablero, { signal });
globalThis.desconectar = () => controlador.abort(); // para probar desde la consolaEl panel arranca con Iván: 25 h (56 %), Lucía: 14 h (31 %), Marta: 6 h (13 %), sumando las 45 h abiertas canónicas. Al marcar la carpintería como hecha, Iván baja a 20 h y todos los porcentajes se recalculan solos, sin que controlador.js mencione este módulo ni una vez: la comunicación pasa entera por el evento tarea:cambiada. Y ejecutando desconectar() en la consola, el abort() retira de golpe el manejador delegado del tablero y el del panel de carga. Los métodos createElement, append y replaceChildren que aparecen aquí son justo el tema de la lección siguiente.
Conclusión
Ya sabes cómo viaja un evento y cómo aprovecharlo. El recorrido tiene tres fases —captura de fuera hacia dentro, objetivo, y burbujeo de dentro hacia fuera—, addEventListener registra en burbujeo salvo que pidas { capture: true }, y durante todo el trayecto target no cambia (es donde ocurrió el evento) mientras currentTarget es distinto en cada manejador. Puedes interrumpir el viaje con stopPropagation() y, de forma más contundente, con stopImmediatePropagation(), que además cancela los otros manejadores del mismo elemento; y sabes que ninguno de los dos es lo mismo que preventDefault(), que cancela la acción por defecto del navegador y no toca la propagación. También sabes que stopPropagation es una decisión con consecuencias globales y que casi siempre es preferible una guarda en el manejador de arriba. Conoces los eventos que no burbujean —focus, blur, load, error, mouseenter— y sus alternativas: focusin/focusout o la fase de captura.
Sobre esa base tienes el patrón más rentable del módulo: la delegación. Un único manejador en ul#lista-tareas que atiende los clics de todos los botones de todas las tareas, con la forma canónica de cuatro pasos: evento.target.closest('[data-accion]'), guarda contra null, closest('li[data-id]') para saber de qué tarea se trata, y Number(li.dataset.id) para cruzar la frontera hacia el modelo. Funciona con elementos que aún no existen —que es la razón principal para usarla y lo que hará que la lección siguiente no rompa nada—, consume una fracción de la memoria y concentra toda la lógica de interacción en un sitio.
Y tienes el mecanismo para que las piezas de la aplicación se hablen sin conocerse: los eventos personalizados. new CustomEvent('tarea:cambiada', { bubbles: true, detail }) y dispatchEvent, con la convención dominio:accion, el recordatorio de que bubbles es false por defecto y de que el disparo es síncrono, y la posibilidad de crear eventos cancelable que funcionan como una petición de permiso. Con ellos, el controlador del tablero se limita a anunciar lo que ha pasado, y el resumen, el registro de actividad y el panel de carga por responsable —25 h de Iván, 14 de Lucía, 6 de Marta— se actualizan solos. Cierra el conjunto AbortController: una signal compartida que permite retirar de un plumazo todos los manejadores registrados con ella, el mismo mecanismo de cancelación que reaparecerá con las peticiones de red en el Módulo 7.
Queda una carencia grande y muy visible: la lista sigue teniendo unos pocos <li> escritos a mano en el HTML, mientras el backlog tiene seis tareas y en 06-07 podrás dar de alta más. La página no puede seguir siendo un molde fijo que rellenamos; tiene que construirse a partir del modelo. Cómo se crea un elemento desde cero, cómo se inserta en el sitio exacto, cómo se elimina sin dejar fugas de memoria, cómo se insertan cien nodos sin castigar al navegador y cómo se clona una plantilla declarada en el HTML es Creación y Eliminación de Elementos del DOM, donde escribirás vista/tarjeta.js y convertirás por fin una Tarea del modelo en su <li> completo.
Curso de JavaScript: De Principiante a Avanzado
Módulo 1: Introducción a JavaScript
- ¿Qué es JavaScript?
- Configuración de tu Entorno de Desarrollo
- Tu Primer Programa en JavaScript
- Sintaxis y Conceptos Básicos de JavaScript
- Variables y Tipos de Datos
- Operadores Básicos
- Conversión de Tipos y Comparaciones
- El Proyecto del Curso: Nómada Tareas
Módulo 2: Estructuras de Control
- Sentencias Condicionales
- Bucles: for, while, do-while
- Sentencias Switch
- Control del Flujo: break, continue y Bucles Anidados
- Manejo de Errores con try-catch
Módulo 3: Funciones
- Definición y Llamada de Funciones
- Expresiones de Función y Funciones Flecha
- Parámetros y Valores de Retorno
- Ámbito y Closures
- Hoisting y el Contexto de Ejecución
- Funciones de Orden Superior
- Recursividad
Módulo 4: Objetos y Arrays
- Introducción a los Objetos
- Métodos de Objeto y la Palabra Clave
this - Arrays: Conceptos Básicos y Métodos
- Iteración sobre Arrays
- Buscar, Ordenar y Agregar Datos: find, sort y reduce
- Desestructuración de Arrays
- Desestructuración de Objetos, Spread y Rest
- JSON y Copias de Objetos
Módulo 5: Objetos y Funciones Avanzadas
- Prototipos y Herencia
- Clases y Programación Orientada a Objetos
- Encapsulación: Getters, Setters y Campos Privados
- Módulos e Importación/Exportación
- JavaScript Asíncrono: Callbacks
- Promesas y Async/Await
- El Bucle de Eventos y la Cola de Microtareas
- Iteradores y Generadores
Módulo 6: El Modelo de Objetos del Documento (DOM)
- Introducción al DOM
- Selección y Manipulación de Elementos del DOM
- Manejo de Eventos
- Propagación, Delegación y Eventos Personalizados
- Creación y Eliminación de Elementos del DOM
- Renderizado de Listas y Plantillas HTML
- Manejo y Validación de Formularios
Módulo 7: APIs del Navegador y Temas Avanzados
- Almacenamiento Local y de Sesión
- Fetch API y AJAX
- Peticiones Robustas: Errores, Timeouts y AbortController
- WebSockets
- Service Workers y Aplicaciones Web Progresivas (PWAs)
- APIs del Navegador Esenciales
- Introducción a WebAssembly
Módulo 8: Pruebas y Depuración
- Depuración de JavaScript
- Calidad de Código: ESLint, Prettier y Convenciones
- Pruebas Unitarias con Jest
- Dobles de Prueba: Mocks, Stubs y Spies
- Pruebas de Integración
- Pruebas de Extremo a Extremo con Cypress
Módulo 9: Rendimiento y Optimización
- Medir Antes de Optimizar: DevTools y Web Vitals
- Optimización del Rendimiento de JavaScript
- Gestión de Memoria
- Manipulación Eficiente del DOM
- Carga Perezosa y División de Código
Módulo 10: Frameworks y Librerías de JavaScript
- Por Qué Existen los Frameworks
- Introducción a React
- Gestión de Estado con Redux
- Conceptos Básicos de Vue.js
- Conceptos Básicos de Angular
- Elegir el Framework Adecuado
