La lliçó anterior va acabar amb un problema honest: connectarAccions registra un gestor per cada botó de cada tasca, i tan bon punt comencis a crear els <li> des de JavaScript, els elements nous naixeran sense gestor. La solució no és reconnectar-ho tot cada vegada, sinó entendre que un esdeveniment no passa només en un element: viatja per l'arbre del DOM, de l'arrel fins a l'objectiu i de tornada. Aprofitant aquell viatge es pot posar un únic gestor al contenidor que atengui tots els seus fills, presents i futurs. En aquesta lliçó aprendràs les tres fases d'aquell recorregut, les tres maneres d'interrompre'l (i en què es diferencien), el patró de la delegació —probablement la tècnica més rendible de tot el mòdul— i els esdeveniments personalitzats, que permetran que la vista i el model es comuniquin sense conèixer-se.
Contingut
- El viatge d'un esdeveniment: captura, objectiu i bombolla
- Una demostració en tres nivells
- Escoltar a la fase de captura
- Aturar el viatge:
stopPropagationistopImmediatePropagation preventDefaultno és el mateix (taula comparativa)- Esdeveniments que no fan bombolla, i els seus equivalents que sí
- Delegació d'esdeveniments
event.target.closest()idataset: el patró complet- Nómada Tasques: el tauler amb un únic gestor
- Esdeveniments personalitzats:
CustomEventidetail - Desacoblar la vista del model amb esdeveniments
AbortController: retirar molts gestors de cop- Errors Habituals i Consells
- Exercicis
- Conclusió
- El viatge d'un esdeveniment: captura, objectiu i bombolla
Quan fas clic al botó d'una tasca, aquell clic no passa «al botó» i prou. Passa al botó, que és dins d'un <li>, que és dins d'un <ul>, que és dins d'una <section>… El navegador considera que l'esdeveniment concerneix tota aquella cadena, i la recorre en tres fases:
- Captura (capturing): l'esdeveniment baixa des de
windowfins a l'element objectiu, passant per tots els seus avantpassats. - Objectiu (target): l'esdeveniment arriba a l'element on ha passat realment.
- Bombolla (bubbling): l'esdeveniment puja des de l'objectiu fins a
window, un altre cop per tots els avantpassats.
flowchart TD
W1["window"] --> D1["document"]
D1 --> S1["section.tauler"]
S1 --> U1["ul#llista-tasques"]
U1 --> L1["li[data-id=6]"]
L1 --> B["button.tasca__accio<br/>◀ FASE 2 · OBJECTIU"]
B --> L2["li[data-id=6]"]
L2 --> U2["ul#llista-tasques"]
U2 --> S2["section.tauler"]
S2 --> D2["document"]
D2 --> W2["window"]
W1 -.- CAP["FASE 1 · CAPTURA<br/>de fora cap a dins"]
W2 -.- BUR["FASE 3 · BOMBOLLA<br/>de dins cap a fora"]
Per defecte, addEventListener registra el gestor a la fase de bombolla. Per això, a la lliçó anterior, un gestor posat al <li> es disparava en fer clic al botó que conté: l'esdeveniment va néixer al botó i va fer bombolla fins al <li>.
Dues propietats de l'objecte Event descriuen aquest viatge:
event.eventPhase:1en captura,2a l'objectiu,3en bombolla.event.composedPath(): l'array complet de nodes pels quals passa, de l'objectiu cap amunt.
boto.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ó en tres nivells
La millor manera d'interioritzar això és veure-ho. Registra un gestor a cada nivell i fes un sol clic al botó:
const section = document.querySelector('.tauler');
const ul = document.querySelector('#llista-tasques');
const li = document.querySelector('[data-id="6"]');
const boto = li.querySelector('.tasca__accio');
function espiar(nom) {
return (esdeveniment) => {
const fases = { 1: 'CAPTURA', 2: 'OBJECTIU', 3: 'BOMBOLLA' };
console.log(`${fases[esdeveniment.eventPhase]} · ${nom} · target=${esdeveniment.target.tagName}`);
};
}
section.addEventListener('click', espiar('section'));
ul.addEventListener('click', espiar('ul'));
li.addEventListener('click', espiar('li'));
boto.addEventListener('click', espiar('button'));Un clic sobre el botó imprimeix:
OBJECTIU · button · target=BUTTON BOMBOLLA · li · target=BUTTON BOMBOLLA · ul · target=BUTTON BOMBOLLA · section · target=BUTTON
Quatre gestors executats amb un sol clic. Observacions fonamentals:
targetvalBUTTONals quatre. L'objectiu no canvia durant el viatge: és sempre on ha passat l'esdeveniment. El que canvia éscurrentTarget, que a cada línia és l'element on vas registrar aquell gestor concret.- No apareix cap línia de CAPTURA, perquè els quatre gestors s'han registrat en bombolla (el valor per defecte).
- L'ordre és de dins cap a fora. Primer el més profund, després els seus avantpassats.
Aquesta és exactament la propietat que fa possible la delegació: un gestor al <ul> ja està rebent els clics de tots els seus descendents.
- Escoltar a la fase de captura
Amb { capture: true } el gestor es registra a la fase descendent:
section.addEventListener('click', espiar('section CAPTURA'), { capture: true });
ul.addEventListener('click', espiar('ul CAPTURA'), { capture: true });
li.addEventListener('click', espiar('li')); // bombolla
boto.addEventListener('click', espiar('button'));Ara el mateix clic imprimeix:
CAPTURA · section CAPTURA · target=BUTTON CAPTURA · ul CAPTURA · target=BUTTON OBJECTIU · button · target=BUTTON BOMBOLLA · li · target=BUTTON
La captura va de fora cap a dins i sempre passa abans que la bombolla. Existeix també una forma abreujada heretada, addEventListener('click', fn, true), amb el tercer paràmetre booleà; avui es prefereix l'objecte d'opcions per llegibilitat.
Quan es fa servir la captura? Poques vegades, i sempre amb un motiu concret:
- Interceptar abans que ningú, per exemple per registrar analítica o per bloquejar la interacció amb tota una zona de la interfície mentre es desa alguna cosa.
- Capturar esdeveniments que no fan bombolla, com
focusoerroren imatges, des d'un contenidor. És el truc de l'apartat 6.
En el 95 % dels casos, la bombolla és el que vols.
- Aturar el viatge:
stopPropagation i stopImmediatePropagation
stopPropagation i stopImmediatePropagationesdeveniment.stopPropagation() atura el recorregut: els gestors que quedaven per endavant a l'arbre no s'executen.
li.addEventListener('click', (esdeveniment) => {
esdeveniment.stopPropagation();
console.log('el li talla aquí');
});
// Un clic al botó imprimeix:
// OBJECTIU · button
// el li talla aquí
// … i RES més: 'ul' i 'section' ja no se n'assabenten.esdeveniment.stopImmediatePropagation() és més contundent: a més d'aturar el viatge cap amunt, impedeix que s'executin els altres gestors del mateix element.
li.addEventListener('click', (e) => { e.stopImmediatePropagation(); console.log('primer'); });
li.addEventListener('click', () => console.log('segon')); // ✗ no s'executa mai
// Amb stopPropagation() en lloc de stopImmediatePropagation(),
// 'segon' SÍ que s'executaria, i només es tallaria la pujada cap al <ul>.Avís important: stopPropagation és perillós. És una decisió que prens en un component i que afecta tota la resta de l'aplicació, inclosa la part que encara no has escrit. Escenari típic: poses stopPropagation en una targeta perquè un clic no activi res del contenidor; tres setmanes després algú afegeix un gestor a document per tancar un menú desplegable en fer clic fora, i aquell menú deixa de tancar-se damunt de les teves targetes. La fallada és impossible de trobar perquè no hi ha cap error.
L'alternativa gairebé sempre és millor: que el gestor de dalt comprovi si l'esdeveniment el concerneix.
// ✗ El fill imposa el seu criteri a tothom
boto.addEventListener('click', (e) => e.stopPropagation());
// ✓ El pare decideix amb una guarda
ul.addEventListener('click', (esdeveniment) => {
if (esdeveniment.target.closest('.tasca__accio') === null) return; // no em concerneix
// …
});
preventDefault no és el mateix (taula comparativa)
preventDefault no és el mateix (taula comparativa)Aquests tres mètodes es confonen constantment, i fan coses completament diferents:
| Mètode | Què fa | Què NO fa |
|---|---|---|
preventDefault() |
Cancel·la l'acció per defecte del navegador (seguir un enllaç, enviar un formulari, desplaçar amb la barra espaiadora) | No atura la propagació: l'esdeveniment continua pujant |
stopPropagation() |
Impedeix que l'esdeveniment continuï cap als elements següents del recorregut | No cancel·la l'acció per defecte; no impedeix els altres gestors del mateix element |
stopImmediatePropagation() |
L'anterior i a més impedeix els altres gestors del mateix element | No cancel·la l'acció per defecte |
Corol·lari pràctic:
enllac.addEventListener('click', (esdeveniment) => {
esdeveniment.stopPropagation();
// El navegador CONTINUA navegant: falta preventDefault()
});
enllac.addEventListener('click', (esdeveniment) => {
esdeveniment.preventDefault();
// L'esdeveniment CONTINUA fent bombolla fins a document: és el normal i gairebé sempre desitjable
});Un detall útil: quan un gestor ha cridat preventDefault(), els gestors posteriors del recorregut ho poden detectar amb esdeveniment.defaultPrevented, cosa que permet escriure codi cooperatiu en lloc de codi que es trepitja.
- Esdeveniments que no fan bombolla, i els seus equivalents que sí
No tots els esdeveniments fan bombolla. Els més importants que no ho fan:
| Esdeveniment que no fa bombolla | Equivalent que sí que en fa | Nota |
|---|---|---|
focus |
focusin |
Es dispara abans que l'element rebi el focus |
blur |
focusout |
Es dispara abans de perdre'l |
mouseenter |
mouseover |
Recorda que mouseover es dispara també amb els fills |
mouseleave |
mouseout |
Igual |
load, error (en <img>, <script>) |
— | Només es poden capturar amb { capture: true } |
Conseqüència directa: no pots delegar focus ni blur, perquè no arriben mai al contenidor.
// ✗ No funciona: 'focus' no fa bombolla
formulari.addEventListener('focus', (e) => console.log('focus a', e.target.name));
// ✓ Opció A: fer servir l'equivalent que sí que en fa
formulari.addEventListener('focusin', (e) => console.log('focus a', e.target.name));
// ✓ Opció B: escoltar a la fase de captura
formulari.addEventListener('focus', (e) => console.log('focus a', e.target.name), { capture: true });L'opció A és la recomanable: és més clara i no obliga a raonar sobre fases.
Per a load i error d'imatges només queda la captura, i resulta molt pràctica per detectar de cop qualsevol imatge trencada de la pàgina:
document.addEventListener('error', (esdeveniment) => {
if (esdeveniment.target.tagName === 'IMG') {
esdeveniment.target.classList.add('imatge-trencada');
}
}, { capture: true });
- Delegació d'esdeveniments
La delegació consisteix a registrar un sol gestor en un avantpassat comú i decidir a dins què fer segons d'on vingui l'esdeveniment. Aprofita la bombolla de l'apartat 1.
flowchart TD
subgraph SENSE["Sense delegació · 6 gestors"]
U1["ul#llista-tasques"] --> A1["li 1 → gestor"]
U1 --> A2["li 2 → gestor"]
U1 --> A3["li 3 → gestor"]
U1 --> A4["li 4 → gestor"]
U1 --> A5["li 5 → gestor"]
U1 --> A6["li 6 → gestor"]
end
subgraph AMB["Amb delegació · 1 gestor"]
U2["ul#llista-tasques<br/>★ únic gestor"] --> B1["li 1"]
U2 --> B2["li 2"]
U2 --> B3["li 3"]
U2 --> B4["li 4"]
U2 --> B5["li 5"]
U2 --> B6["li 6"]
end
Els tres avantatges, per ordre d'importància:
- Funciona amb elements que encara no existeixen. És la raó principal. Quan a 06-05 creïs un
<li>nou i l'insereixis al<ul>, els seus clics faran bombolla fins al gestor que ja hi era. No cal reconnectar res, i per tant no hi ha risc de duplicar gestors ni d'oblidar-se'n cap. - Menys memòria i menys feina de registre. Un gestor en lloc de dos-cents. Amb sis tasques la diferència és irrellevant; amb una llista llarga, no. La qüestió es tracta a fons a Gestió de Memòria i Manipulació Eficient del DOM.
- Un únic punt on llegir la lògica d'interacció. Tota la resposta als clics de la llista és en una funció, no repartida per sis llocs.
També té inconvenients que convé conèixer: el gestor rep tots els clics de la zona, així que necessita una guarda clara al principi; i no serveix per a esdeveniments que no fan bombolla (apartat 6).
event.target.closest() i dataset: el patró complet
event.target.closest() i dataset: el patró completUn gestor delegat sempre té la mateixa forma, i val la pena memoritzar-la:
ul.addEventListener('click', (esdeveniment) => {
// 1 · El clic ve d'un element que m'interessa?
const boto = esdeveniment.target.closest('[data-accio]');
if (boto === null) return; // guarda: no em concerneix
// 2 · És dins del meu contenidor? (protegeix de casos rars)
if (!ul.contains(boto)) return;
// 3 · A quina tasca pertany?
const li = boto.closest('li[data-id]');
const id = Number(li.dataset.id); // conversió explícita!
// 4 · Quina acció cal executar?
const accio = boto.dataset.accio; // 'avancar' | 'eliminar' | …
console.log({ id, accio });
});Cada pas té el seu perquè:
esdeveniment.targetés l'element més profund, i aquell és el problema que resolclosest(). Si el teu botó conté un<span>amb una icona, eltargetd'un clic sobre la icona serà el<span>, no el botó. Comprovaresdeveniment.target.matches('[data-accio]')fallaria en aquell cas;closest()puja fins a trobar el botó i funciona sempre.- La guarda
=== nullés obligatòria. Un clic al buit entre dues tasques també arriba al teu gestor, i sense la guarda tindries unTypeError. dataset.accioconverteix el gestor en un despatxador genèric: no hi ha una branca per botó, sinó una dada a l'HTML que diu què fer. És el diccionari de consulta de 02-03 aplicat a la interfície.Number(li.dataset.id)és la conversió de frontera de 06-02: la pàgina parla en strings, el model en números.
- Nómada Tasques: el tauler amb un únic gestor
Refem el controlador de la lliçó anterior. Ara l'HTML inclou dues accions per tasca i fa servir data-accio:
<ul id="llista-tasques" class="llista-tasques">
<li class="tasca" data-id="1">
<span class="tasca__titol"></span>
<span class="tasca__meta"></span>
<button type="button" class="tasca__accio" data-accio="avancar"></button>
<button type="button" class="tasca__accio" data-accio="reobrir">Reobrir</button>
</li>
<!-- … la resta de tasques, amb la mateixa estructura … -->
</ul>I el controlador complet, amb un sol addEventListener:
// js/vista/controlador.js
import { AVUI } from '../util/dates.js';
import { pintarTasca, pintarResum } from './pintar.js';
const SEGUENT = Object.freeze({ pendent: 'en-curs', 'en-curs': 'feta', feta: null });
const ETIQUETA = Object.freeze({ pendent: 'Començar', 'en-curs': 'Marcar feta', feta: 'Completada' });
/** Tradueix una acció de la interfície a l'estat destí del model. */
function estatDesti(accio, tasca) {
if (accio === 'avancar') return SEGUENT[tasca.estat];
if (accio === 'reobrir') return tasca.estat === 'en-curs' ? 'pendent' : null;
return null;
}
export function connectarTauler({ llista, resum, tauler, avui = AVUI, signal }) {
/** Refresca un <li> complet: textos, classes i botons. */
function refrescar(li, tasca) {
pintarTasca(li, tasca, avui);
const avancar = li.querySelector('[data-accio="avancar"]');
avancar.textContent = ETIQUETA[tasca.estat];
avancar.disabled = SEGUENT[tasca.estat] === null;
avancar.setAttribute('aria-label', `${ETIQUETA[tasca.estat]}: ${tasca.titol}`);
const reobrir = li.querySelector('[data-accio="reobrir"]');
reobrir.disabled = tasca.estat !== 'en-curs';
reobrir.setAttribute('aria-label', `Tornar a pendent: ${tasca.titol}`);
}
// ── UN ÚNIC GESTOR PER A TOTA LA LLISTA ──────────────────────────────────
llista.addEventListener('click', (esdeveniment) => {
const boto = esdeveniment.target.closest('button[data-accio]');
if (boto === null || !llista.contains(boto)) return; // no em concerneix
const li = boto.closest('li[data-id]');
const tasca = tauler.cercarPerId(Number(li.dataset.id));
if (tasca === null) return;
const desti = estatDesti(boto.dataset.accio, tasca);
if (desti === null) return;
try {
tauler.canviarEstat(tasca.id, desti); // R6: el model valida la transició
} catch (error) {
console.error(error.descriure?.() ?? error.message);
return;
}
refrescar(li, tasca);
pintarResum(resum, tauler, avui);
// Avisem la resta de l'aplicació (apartat 11)
li.dispatchEvent(new CustomEvent('tasca:canviada', {
bubbles: true,
detail: { id: tasca.id, estat: tasca.estat, titol: tasca.titol }
}));
}, { signal });
// Pintat inicial
for (const li of llista.querySelectorAll('li[data-id]')) {
const tasca = tauler.cercarPerId(Number(li.dataset.id));
if (tasca !== null) refrescar(li, tasca);
}
pintarResum(resum, tauler, avui);
}Compara amb la versió de 06-03: allà hi havia un addEventListener dins d'un bucle; aquí n'hi ha un de sol, fora de qualsevol bucle. I l'important és el que no caldrà canviar quan a la lliçó següent els <li> es creïn des de JavaScript: res. El gestor ja està posat al <ul>, i qualsevol botó que hi aparegui dins li arribarà per bombolla.
- Esdeveniments personalitzats:
CustomEvent i detail
CustomEvent i detailFins ara tots els esdeveniments venien del navegador. Però tu també pots crear i disparar els teus, i això converteix el DOM en un canal de comunicació entre les parts de la teva aplicació.
// Crear
const esdeveniment = new CustomEvent('tasca:canviada', {
detail: { id: 6, estat: 'en-curs', titol: 'Pressupost de la fusteria' },
bubbles: true, // fa bombolla? Per defecte false
cancelable: true // es pot cridar preventDefault()? Per defecte false
});
// Disparar sobre un element (o sobre document/window)
li.dispatchEvent(esdeveniment);
// Escoltar, a qualsevol avantpassat si fa bombolla
document.addEventListener('tasca:canviada', (e) => {
console.log(`La tasca ${e.detail.id} ha passat a ${e.detail.estat}`);
});Quatre punts importants:
detailés l'únic lloc on van les teves dades. És una propietat de només lectura i pot contenir qualsevol valor: un objecte, un array, un número.bubblesésfalseper defecte, al revés que la majoria d'esdeveniments natius. Si te n'oblides, el gestor dedocumentno se n'assabenta mai i perdràs una bona estona buscant la fallada.dispatchEventés síncron. No encua res: executa els gestors immediatament i retorna el control quan acaben. És diferent del comportament dels esdeveniments de l'usuari, que sí que passen per la cua de tasques (05-07).- La convenció de noms
domini:accio(tasca:canviada,tauler:actualitzat,filtre:aplicat) evita col·lisions amb esdeveniments natius presents i futurs, i fa evident al codi de què s'està parlant.
Si l'esdeveniment és cancelable, dispatchEvent retorna false quan algun gestor ha cridat preventDefault(). Això permet que un esdeveniment actuï com una petició de permís:
const continuar = li.dispatchEvent(new CustomEvent('tasca:abans-tancar', {
bubbles: true, cancelable: true, detail: { id: tasca.id }
}));
if (!continuar) {
console.log('Algú ha vetat el tancament de la tasca.');
return;
}
tauler.canviarEstat(tasca.id, 'feta');
- Desacoblar la vista del model amb esdeveniments
Aquest és l'ús valuós. Sense esdeveniments personalitzats, cada part de la interfície que hagi de reaccionar a un canvi ha de ser coneguda per qui el provoca:
// ✗ El controlador ha de conèixer tothom
tauler.canviarEstat(id, desti);
refrescarLlista();
refrescarResum();
refrescarGraficCarrega();
refrescarComptadorVencudes();
// … i cada peça nova obliga a tocar aquesta funcióAmb esdeveniments, qui provoca el canvi només anuncia que ha passat, i qui hi estigui interessat s'hi subscriu:
flowchart LR
U["Clic de la Marta"] --> C["controlador.js<br/>delegat al ul"]
C --> M["model<br/>tauler.canviarEstat()"]
M --> C
C -- "dispatchEvent<br/>tasca:canviada" --> DOC["document"]
DOC --> V1["Llista de tasques<br/>repinta el li"]
DOC --> V2["Resum<br/>recalcula hores"]
DOC --> V3["Registre d'activitat<br/>afegeix una línia"]
El controlador no sap quants oients hi ha ni què fan. Afegir-ne un quart no l'obliga a canviar ni una línia. A Nómada Tasques:
// js/vista/esdeveniments.js — noms centralitzats, per no escriure cadenes soltes
export const ESDEVENIMENTS = Object.freeze({
TASCA_CANVIADA: 'tasca:canviada',
TASCA_CREADA: 'tasca:creada',
TAULER_ACTUALITZAT: 'tauler:actualitzat',
FILTRE_APLICAT: 'filtre:aplicat'
});
/** Dispara un esdeveniment de l'aplicació des d'un element (fa bombolla sempre). */
export function emetre(origen, tipus, detail = {}) {
return origen.dispatchEvent(new CustomEvent(tipus, { bubbles: true, detail }));
}// js/vista/registre.js — una vista nova que ningú no ha hagut de "connectar"
import { ESDEVENIMENTS } from './esdeveniments.js';
export function connectarRegistre(contenidor, { signal } = {}) {
document.addEventListener(ESDEVENIMENTS.TASCA_CANVIADA, (esdeveniment) => {
const { titol, estat } = esdeveniment.detail;
const linia = document.createElement('li');
linia.textContent = `${new Date().toLocaleTimeString('ca-ES')} · ${titol} → ${estat}`;
contenidor.prepend(linia);
}, { signal });
}// js/app.js
import { Tauler } from './model/tauler.js';
import { crearBacklog } from './dades/backlog.js';
import { AVUI } from './util/dates.js';
import { connectarTauler } from './vista/controlador.js';
import { connectarRegistre } from './vista/registre.js';
import { ESDEVENIMENTS } from './vista/esdeveniments.js';
const tauler = new Tauler('Taller Nómada', crearBacklog());
connectarTauler({
llista: document.querySelector('#llista-tasques'),
resum: document.querySelector('#resum'),
tauler,
avui: AVUI
});
connectarRegistre(document.querySelector('#registre'));
// Un oient global per depurar durant el desenvolupament
document.addEventListener(ESDEVENIMENTS.TASCA_CANVIADA, (e) => {
console.log('[esdeveniment]', e.type, e.detail, '· esforç ara:', tauler.esforc);
});Un clic a «Començar» sobre la fusteria produeix ara, en cascada i sense que cap peça conegui les altres:
[esdeveniment] tasca:canviada { id: 6, estat: 'en-curs', titol: 'Pressupost de la fusteria' } · esforç ara: 124I a la pantalla: el <li> repintat, el resum actualitzat i una línia nova al registre d'activitat.
Quan fer servir esdeveniments personalitzats i quan no. Són ideals per comunicar de manera ascendent o lateral (un component informa que ha passat alguna cosa, sense saber qui escolta). No són adequats per a comunicacions d'un a un on una crida a funció directa és més clara: si el controlador necessita el resultat immediat d'alguna cosa, crida-la. I compte amb abusar-ne: una aplicació on tot es comunica per esdeveniments és difícil de seguir, perquè la traça deixa de llegir-se de dalt a baix. Un grapat d'esdeveniments ben anomenats són una arquitectura; trenta són un laberint.
AbortController: retirar molts gestors de cop
AbortController: retirar molts gestors de copRecorda el problema de 06-03: per retirar un gestor cal la referència exacta de la funció. Amb deu gestors repartits, la neteja es converteix en deu línies i deu variables.
AbortController ho resol. És un objecte amb una propietat signal i un mètode abort(). Si passes aquella signal com a opció a addEventListener, tots els gestors registrats amb ella es retiren en cridar abort():
const controlador = new AbortController();
const { signal } = controlador;
llista.addEventListener('click', enFerClic, { signal });
barra.addEventListener('click', enFiltrar, { signal });
document.addEventListener('keydown', enPremerTecla, { signal });
window.addEventListener('resize', enRedimensionar, { signal });
// Una sola línia retira els quatre:
controlador.abort();Això encaixa perfectament amb el patró de la lliçó: cada funció connectarX accepta una signal i la propaga, de manera que desmuntar la interfície sencera és trivial.
// js/app.js
const controlador = new AbortController();
const { signal } = controlador;
connectarTauler({ llista, resum, tauler, avui: AVUI, signal });
connectarRegistre(document.querySelector('#registre'), { signal });
// Per exemple, en canviar de vista o en recarregar les dades:
// controlador.abort(); ← tota la interacció queda desconnectada de copDetalls que convé saber:
- Una
signalavortada no es pot reutilitzar: cal crear unAbortControllernou. Si registres un gestor amb un senyal ja avortat, simplement no es registra. signalioncees combinen sense problema:{ once: true, signal }.AbortControllerno és exclusiu dels esdeveniments: és el mecanisme estàndard de cancel·lació al navegador, i el tornaràs a fer servir per cancel·lar peticions de xarxa a Peticions Robustes.
Errors Habituals i Consells
- Fer servir
esdeveniment.targeton caliaclosest(). Si el botó conté una icona,targetserà la icona.esdeveniment.target.closest('[data-accio]')és el patró correcte, sempre. - Oblidar la guarda
if (element === null) return;en un gestor delegat. Reps tots els clics del contenidor, inclosos els que no van enlloc, i sense guarda obtens unTypeError. - Oblidar
bubbles: trueen unCustomEvent. L'esdeveniment es dispara, no falla res i ningú no el sent. És la fallada més desconcertant de la lliçó, perquè no hi ha cap símptoma. - Abusar de
stopPropagation(). Trenca funcionalitats que encara ni existeixen i no deixa rastre. Prefereix una guarda al gestor de dalt. - Creure que
preventDefault()atura la bombolla. No ho fa, istopPropagation()no cancel·la l'acció per defecte. Són eixos independents: repassa la taula de l'apartat 5. - Intentar delegar
focusoblur. No fan bombolla. Fes servirfocusin/focusout, o registra amb{ capture: true }. - Delegar a
documentquan n'hi havia prou amb el contenidor. Com més a prop estigui el gestor de l'objectiu, menys esdeveniments irrellevants haurà de descartar. Delega al<ul>, no adocument. - Reutilitzar un
AbortControllerja avortat. No torna a funcionar. Crea'n un de nou cada vegada que muntis la interfície. - Consell: anomena els esdeveniments amb
domini:accio. Evita col·lisions i documenta la intenció. I centralitza les cadenes en un mòdulESDEVENIMENTS, com a l'apartat 11, perquè un error tipogràfic sigui unundefinedvisible i no un esdeveniment que ningú no escolta. - Consell: fes servir
getEventListeners($0)a la consola de Chrome. Mostra tots els gestors de l'element seleccionat; és la millor manera de verificar que la delegació no ha deixat gestors duplicats pel camí.
Exercicis
Exercici 1 · L'experiment de les fases
Registra sis gestors de click —captura i bombolla a section, ul i li— i fes un clic al botó d'una tasca. Prediu per escrit l'ordre abans d'executar, i després comprova-ho. Després afegeix stopPropagation() al gestor de captura del <ul> i explica exactament quins gestors deixen d'executar-se i per què.
Exercici 2 · Delegació amb dues accions i confirmació
Amplia el gestor delegat de #llista-tasques perquè admeti una tercera acció, data-accio="arxivar", que amagui el <li> amb hidden. Abans d'arxivar ha d'emetre un esdeveniment tasca:abans-arxivar cancel·lable, i només continuar si ningú no l'ha vetat. Després escriu un oient que veti l'arxivat de qualsevol tasca que no estigui feta. Tot amb un únic addEventListener al <ul> (més l'oient del veto).
Exercici 3 · Panell de càrrega desacoblat
Crea js/vista/carrega.js amb una funció connectarCarrega(contenidor, tauler, { signal }) que dibuixi les hores obertes per responsable (Iván 25, Lucía 14, Marta 6) i s'actualitzi sola cada vegada que s'emeti tasca:canviada, sense que el controlador del tauler hagi de saber que aquest panell existeix. Fes servir AbortController a app.js per poder-ho desconnectar tot des de la consola.
Solucions
Exercici 1
for (const [nom, elem] of [['section', section], ['ul', ul], ['li', li]]) {
elem.addEventListener('click', () => console.log(`captura · ${nom}`), { capture: true });
elem.addEventListener('click', () => console.log(`bombolla · ${nom}`));
}Ordre en fer clic al botó:
Primer la baixada completa (de fora cap a dins), després la pujada completa (de dins cap a fora). El <li> apareix a les dues llistes perquè té un gestor a cada fase; com que l'objectiu real és el <button>, per al <li> totes dues són fases de recorregut normals.
Amb stopPropagation() al gestor de captura del <ul>, la sortida es redueix a:
Es talla tota la resta: l'esdeveniment ni tan sols arriba al <li>, ni al <button> (que era l'objectiu), ni torna a pujar. És la demostració més contundent de per què stopPropagation en captura és tan destructiu: cancel·la l'esdeveniment per a tot el subarbre, inclòs l'element on l'usuari ha fet clic de debò.
Exercici 2
import { emetre, ESDEVENIMENTS } from './esdeveniments.js';
llista.addEventListener('click', (esdeveniment) => {
const boto = esdeveniment.target.closest('button[data-accio]');
if (boto === null || !llista.contains(boto)) return;
const li = boto.closest('li[data-id]');
const tasca = tauler.cercarPerId(Number(li.dataset.id));
if (tasca === null) return;
if (boto.dataset.accio === 'arxivar') {
const permes = li.dispatchEvent(new CustomEvent('tasca:abans-arxivar', {
bubbles: true, cancelable: true, detail: { id: tasca.id, estat: tasca.estat }
}));
if (!permes) {
console.warn(`Arxivat vetat per a «${tasca.titol}» (${tasca.estat}).`);
return;
}
li.hidden = true;
emetre(llista, ESDEVENIMENTS.TAULER_ACTUALITZAT, tauler.resum(AVUI));
return;
}
// … les accions 'avancar' i 'reobrir', com a l'apartat 9
});
// El veto, en un altre mòdul que el controlador desconeix completament
document.addEventListener('tasca:abans-arxivar', (esdeveniment) => {
if (esdeveniment.detail.estat !== 'feta') esdeveniment.preventDefault();
});Dues claus. La primera: dispatchEvent retorna false si algun gestor ha cridat preventDefault(), i això només funciona si l'esdeveniment s'ha creat amb cancelable: true; sense aquella opció, preventDefault() s'ignora i dispatchEvent retorna sempre true. La segona: el controlador no conté la regla «només s'arxiva el que està fet». Aquella regla viu en un altre mòdul, i es podria canviar o retirar sense tocar el controlador. És exactament el desacoblament que busquem.
Exercici 3
// js/vista/carrega.js
import { ESDEVENIMENTS } from './esdeveniments.js';
export function connectarCarrega(contenidor, tauler, { signal } = {}) {
function pintar() {
const hores = tauler.horesPerResponsable(); // { Iván: 25, Lucía: 14, Marta: 6 }
const total = Object.values(hores).reduce((s, h) => s + h, 0);
contenidor.replaceChildren(); // buidar sense innerHTML
for (const [qui, h] of Object.entries(hores).sort((a, b) => b[1] - a[1])) {
const fila = document.createElement('li');
fila.textContent = `${qui}: ${h} h (${Math.round((h / total) * 100)} %)`;
fila.style.setProperty('--percentatge', `${(h / total) * 100}%`);
contenidor.append(fila);
}
}
pintar();
document.addEventListener(ESDEVENIMENTS.TASCA_CANVIADA, pintar, { signal });
}// js/app.js
const controlador = new AbortController();
const { signal } = controlador;
connectarTauler({ llista, resum, tauler, avui: AVUI, signal });
connectarCarrega(document.querySelector('#carrega'), tauler, { signal });
globalThis.desconnectar = () => controlador.abort(); // per provar des de la consolaEl panell arrenca amb Iván: 25 h (56 %), Lucía: 14 h (31 %), Marta: 6 h (13 %), sumant les 45 h obertes canòniques. En marcar la fusteria com a feta, l'Iván baixa a 20 h i tots els percentatges es recalculen sols, sense que controlador.js esmenti aquest mòdul ni una vegada: la comunicació passa sencera per l'esdeveniment tasca:canviada. I executant desconnectar() a la consola, l'abort() retira de cop el gestor delegat del tauler i el del panell de càrrega. Els mètodes createElement, append i replaceChildren que apareixen aquí són just el tema de la lliçó següent.
Conclusió
Ja saps com viatja un esdeveniment i com aprofitar-ho. El recorregut té tres fases —captura de fora cap a dins, objectiu, i bombolla de dins cap a fora—, addEventListener registra en bombolla tret que demanis { capture: true }, i durant tot el trajecte target no canvia (és on ha passat l'esdeveniment) mentre que currentTarget és diferent a cada gestor. Pots interrompre el viatge amb stopPropagation() i, de manera més contundent, amb stopImmediatePropagation(), que a més cancel·la els altres gestors del mateix element; i saps que cap dels dos no és el mateix que preventDefault(), que cancel·la l'acció per defecte del navegador i no toca la propagació. També saps que stopPropagation és una decisió amb conseqüències globals i que gairebé sempre és preferible una guarda al gestor de dalt. Coneixes els esdeveniments que no fan bombolla —focus, blur, load, error, mouseenter— i les seves alternatives: focusin/focusout o la fase de captura.
Sobre aquella base tens el patró més rendible del mòdul: la delegació. Un únic gestor a ul#llista-tasques que atén els clics de tots els botons de totes les tasques, amb la forma canònica de quatre passos: esdeveniment.target.closest('[data-accio]'), guarda contra null, closest('li[data-id]') per saber de quina tasca es tracta, i Number(li.dataset.id) per creuar la frontera cap al model. Funciona amb elements que encara no existeixen —que és la raó principal per fer-la servir i el que farà que la lliçó següent no trenqui res—, consumeix una fracció de la memòria i concentra tota la lògica d'interacció en un sol lloc.
I tens el mecanisme perquè les peces de l'aplicació es parlin sense conèixer-se: els esdeveniments personalitzats. new CustomEvent('tasca:canviada', { bubbles: true, detail }) i dispatchEvent, amb la convenció domini:accio, el recordatori que bubbles és false per defecte i que el dispar és síncron, i la possibilitat de crear esdeveniments cancelable que funcionen com una petició de permís. Amb ells, el controlador del tauler es limita a anunciar el que ha passat, i el resum, el registre d'activitat i el panell de càrrega per responsable —25 h de l'Iván, 14 de la Lucía, 6 de la Marta— s'actualitzen sols. Tanca el conjunt AbortController: una signal compartida que permet retirar d'una revolada tots els gestors registrats amb ella, el mateix mecanisme de cancel·lació que reapareixerà amb les peticions de xarxa al Mòdul 7.
Queda una mancança gran i molt visible: la llista continua tenint uns quants <li> escrits a mà a l'HTML, mentre que el backlog té sis tasques i a 06-07 en podràs donar d'alta més. La pàgina no pot continuar sent un motlle fix que omplim; s'ha de construir a partir del model. Com es crea un element des de zero, com s'insereix al lloc exacte, com s'elimina sense deixar fuites de memòria, com s'insereixen cent nodes sense castigar el navegador i com es clona una plantilla declarada a l'HTML és Creació i Eliminació d'Elements del DOM, on escriuràs vista/targeta.js i convertiràs per fi una Tasca del model en el seu <li> complet.
Curs de JavaScript: De Principiant a Avançat
Mòdul 1: Introducció a JavaScript
- Què és JavaScript?
- Configuració del teu Entorn de Desenvolupament
- El teu Primer Programa en JavaScript
- Sintaxi i Conceptes Bàsics de JavaScript
- Variables i Tipus de Dades
- Operadors Bàsics
- Conversió de Tipus i Comparacions
- El Projecte del Curs: Nómada Tasques
Mòdul 2: Estructures de Control
- Sentències Condicionals
- Bucles: for, while, do-while
- Sentències Switch
- Control del Flux: break, continue i Bucles Imbricats
- Gestió d'Errors amb try-catch
Mòdul 3: Funcions
- Definició i Crida de Funcions
- Expressions de Funció i Funcions Fletxa
- Paràmetres i Valors de Retorn
- Àmbit i Closures
- Hoisting i el Context d'Execució
- Funcions d'Ordre Superior
- Recursivitat
Mòdul 4: Objectes i Arrays
- Introducció als Objectes
- Mètodes d'Objecte i la Paraula Clau
this - Arrays: Conceptes Bàsics i Mètodes
- Iteració sobre Arrays
- Cercar, Ordenar i Agregar Dades: find, sort i reduce
- Desestructuració d'Arrays
- Desestructuració d'Objectes, Spread i Rest
- JSON i Còpies d'Objectes
Mòdul 5: Objectes i Funcions Avançades
- Prototips i Herència
- Classes i Programació Orientada a Objectes
- Encapsulació: Getters, Setters i Camps Privats
- Mòduls i Importació/Exportació
- JavaScript Asíncron: Callbacks
- Promeses i Async/Await
- El Bucle d'Esdeveniments i la Cua de Microtasques
- Iteradors i Generadors
Mòdul 6: El Model d'Objectes del Document (DOM)
- Introducció al DOM
- Selecció i Manipulació d'Elements del DOM
- Gestió d'Esdeveniments
- Propagació, Delegació i Esdeveniments Personalitzats
- Creació i Eliminació d'Elements del DOM
- Renderitzat de Llistes i Plantilles HTML
- Gestió i Validació de Formularis
Mòdul 7: APIs del Navegador i Temes Avançats
- Emmagatzematge Local i de Sessió
- Fetch API i AJAX
- Peticions Robustes: Errors, Timeouts i AbortController
- WebSockets
- Service Workers i Aplicacions Web Progressives (PWAs)
- APIs del Navegador Essencials
- Introducció a WebAssembly
Mòdul 8: Proves i Depuració
- Depuració de JavaScript
- Qualitat de Codi: ESLint, Prettier i Convencions
- Proves Unitàries amb Jest
- Dobles de Prova: Mocks, Stubs i Spies
- Proves d'Integració
- Proves d'Extrem a Extrem amb Cypress
Mòdul 9: Rendiment i Optimització
- Mesurar Abans d'Optimitzar: DevTools i Web Vitals
- Optimització del Rendiment de JavaScript
- Gestió de Memòria
- Manipulació Eficient del DOM
- Càrrega Diferida i Divisió de Codi
Mòdul 10: Frameworks i Llibreries de JavaScript
- Per Què Existeixen els Frameworks
- Introducció a React
- Gestió d'Estat amb Redux
- Conceptes Bàsics de Vue.js
- Conceptes Bàsics d'Angular
- Triar el Framework Adequat
