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

  1. El viatge d'un esdeveniment: captura, objectiu i bombolla
  2. Una demostració en tres nivells
  3. Escoltar a la fase de captura
  4. Aturar el viatge: stopPropagation i stopImmediatePropagation
  5. preventDefault no és el mateix (taula comparativa)
  6. Esdeveniments que no fan bombolla, i els seus equivalents que sí
  7. Delegació d'esdeveniments
  8. event.target.closest() i dataset: el patró complet
  9. Nómada Tasques: el tauler amb un únic gestor
  10. Esdeveniments personalitzats: CustomEvent i detail
  11. Desacoblar la vista del model amb esdeveniments
  12. AbortController: retirar molts gestors de cop
  13. Errors Habituals i Consells
  14. Exercicis
  15. Conclusió

  1. 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:

  1. Captura (capturing): l'esdeveniment baixa des de window fins a l'element objectiu, passant per tots els seus avantpassats.
  2. Objectiu (target): l'esdeveniment arriba a l'element on ha passat realment.
  3. 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: 1 en captura, 2 a l'objectiu, 3 en 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']
});

  1. 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:

  • target val BUTTON als quatre. L'objectiu no canvia durant el viatge: és sempre on ha passat l'esdeveniment. El que canvia és currentTarget, 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.

  1. 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 focus o error en imatges, des d'un contenidor. És el truc de l'apartat 6.

En el 95 % dels casos, la bombolla és el que vols.

  1. Aturar el viatge: stopPropagation i stopImmediatePropagation

esdeveniment.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
  // …
});

  1. 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.

  1. 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 });

  1. 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:

  1. 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.
  2. 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.
  3. 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).

  1. event.target.closest() i dataset: el patró complet

Un 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 resol closest(). Si el teu botó conté un <span> amb una icona, el target d'un clic sobre la icona serà el <span>, no el botó. Comprovar esdeveniment.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 un TypeError.
  • dataset.accio converteix 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.

  1. 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.

  1. Esdeveniments personalitzats: CustomEvent i detail

Fins 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 és false per defecte, al revés que la majoria d'esdeveniments natius. Si te n'oblides, el gestor de document no 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');

  1. 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: 124

I 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.

  1. AbortController: retirar molts gestors de cop

Recorda 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 cop

Detalls que convé saber:

  • Una signal avortada no es pot reutilitzar: cal crear un AbortController nou. Si registres un gestor amb un senyal ja avortat, simplement no es registra.
  • signal i once es combinen sense problema: { once: true, signal }.
  • AbortController no é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.target on calia closest(). Si el botó conté una icona, target serà 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 un TypeError.
  • Oblidar bubbles: true en un CustomEvent. 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, i stopPropagation() no cancel·la l'acció per defecte. Són eixos independents: repassa la taula de l'apartat 5.
  • Intentar delegar focus o blur. No fan bombolla. Fes servir focusin/focusout, o registra amb { capture: true }.
  • Delegar a document quan 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 a document.
  • Reutilitzar un AbortController ja 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òdul ESDEVENIMENTS, com a l'apartat 11, perquè un error tipogràfic sigui un undefined visible 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ó:

captura  · section
captura  · ul
captura  · li
bombolla · li
bombolla · ul
bombolla · section

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:

captura  · section
captura  · ul

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 consola

El 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

Mòdul 2: Estructures de Control

Mòdul 3: Funcions

Mòdul 4: Objectes i Arrays

Mòdul 5: Objectes i Funcions Avançades

Mòdul 6: El Model d'Objectes del Document (DOM)

Mòdul 7: APIs del Navegador i Temes Avançats

Mòdul 8: Proves i Depuració

Mòdul 9: Rendiment i Optimització

Mòdul 10: Frameworks i Llibreries de JavaScript

Mòdul 11: Projecte Final

© Copyright 2026. Tots els drets reservats