<task-card> sap, des del mòdul 3, reaccionar a un clic modificant el seu propi estat intern. Però una mirada honesta a TaskFlow deixa clar que això no n'hi ha prou: si l'usuari vol marcar una tasca com a "feta" des de la seva targeta, aquest canvi ha d'arribar d'alguna manera a <task-list>, que és qui manté l'array real de tasques. Aquesta lliçó resol aquest problema amb el mecanisme estàndard dels Web Components perquè un fill es comuniqui amb qui el conté: els esdeveniments personalitzats, construïts amb CustomEvent, despatxats amb dispatchEvent, i dissenyats per travessar netament la frontera del Shadow DOM. Al final de la lliçó, <task-card> tindrà un petit selector d'estat i emetrà un esdeveniment tarea-cambiada cada vegada que l'usuari l'utilitzi.
Contingut
- Per què un fill no ha de tocar l'estat del seu pare directament
CustomEvent: un esdeveniment amb dades pròpiesbubblesicomposed: com viatja un esdeveniment a través del Shadow DOM- Despatxar l'esdeveniment amb
dispatchEvent - Convenció de noms per a esdeveniments personalitzats
- Afegint un selector d'estat a
<task-card> - Emetent
tarea-cambiada - Tancament: qui escolta aquest esdeveniment
- Per què un fill no ha de tocar l'estat del seu pare directament
Imagina, per un moment, una solució temptadora però equivocada al problema plantejat a la introducció: que <task-card> rebi, com a propietat, una referència directa a l'array tareas de <task-list> (o fins i tot una referència al propi component <task-list>), i que en canviar l'estat d'una tasca, <task-card> modifiqui aquest array directament, cercant l'element que li correspon i mutant-lo en el lloc.
Aquest enfocament, encara que sembli estalviar codi, trenca una de les idees més importants del disseny de components ben encapsulats: un component fill no hauria de conèixer, i encara menys modificar, l'estructura interna de dades del seu pare. Si <task-card> hagués de saber que viu dins d'un array gestionat per <task-list>, deixaria de ser un component reutilitzable de forma aïllada: no es podria usar <task-card> solta en cap altra part de l'aplicació (una vista de detall d'una sola tasca, per exemple) sense arrossegar amb ella aquesta dependència cap a una estructura de dades que, en aquest altre context, ni tan sols existiria.
L'alternativa correcta, i la que segueix l'estàndard de Web Components des del seu origen, és justament la contrària quant a direcció de la responsabilitat: el fill no canvia res pel seu compte al pare; simplement anuncia que alguna cosa ha passat, sense saber ni necessitar saber qui l'està escoltant ni què en farà d'aquesta informació. És responsabilitat exclusiva de qui escolta (normalment el pare, encara que no necessàriament) decidir què fer amb l'avís: actualitzar el seu propi estat, ignorar-lo, o reenviar-lo al seu torn cap amunt. Aquest patró —el fill anuncia, el pare decideix— és exactament per a què existeixen els esdeveniments personalitzats.
CustomEvent: un esdeveniment amb dades pròpies
CustomEvent: un esdeveniment amb dades pròpiesEl navegador permet crear esdeveniments propis, no natius, mitjançant el constructor CustomEvent, que és una subclasse de la classe Event estàndard (la mateixa família que els esdeveniments de clic o teclat ja vistos a la lliçó anterior). La seva diferència principal respecte a un esdeveniment natiu és que es pot adjuntar qualsevol dada personalitzada a través de la propietat detail:
El primer argument del constructor és el nom de l'esdeveniment (una cadena de text triada lliurement, sobre la convenció de la qual es parlarà a l'apartat 5); el segon és un objecte d'opcions on detail pot contenir qualsevol valor de JavaScript: un objecte, un array, un número, fins i tot undefined si l'esdeveniment no necessita transportar cap dada addicional més enllà del fet d'haver-se produït. Qui escolti aquest esdeveniment accedirà a aquesta informació llegint event.detail, exactament igual que es llegeix qualsevol altra propietat de l'objecte esdeveniment.
bubbles i composed: com viatja un esdeveniment a través del Shadow DOM
bubbles i composed: com viatja un esdeveniment a través del Shadow DOMCrear un CustomEvent no n'hi ha prou per si sol perquè arribi on cal: per defecte, un esdeveniment no fa bombolla (no es propaga cap als elements ancestres) i, si l'element que el despatxa viu dins d'un shadow root, l'esdeveniment no surt d'aquesta frontera de Shadow DOM. Ambdós comportaments s'activen explícitament amb dues opcions del constructor:
const evento = new CustomEvent('tarea-cambiada', {
detail: { id: 3, nuevoEstado: 'hecha' },
bubbles: true,
composed: true,
});bubbles: truefa que l'esdeveniment, després de disparar-se a l'element origen, es propagui cap amunt a través de tots els seus ancestres a l'arbre del DOM, exactament el mateix mecanisme de bombolla esmentat a la lliçó anterior a propòsit deevent.target. Sense aquesta opció, l'esdeveniment només el podria escoltar qui tingui un listener posat directament sobre l'element que el despatxa, la qual cosa és gairebé inútil a la pràctica: ningú fora del propi component té, ni hauria de tenir, una referència directa a l'element intern concret que va originar l'esdeveniment.composed: trueés l'opció específica de Web Components, i la que sol generar més dubtes: fa que l'esdeveniment pugui travessar la frontera del Shadow DOM, és a dir, sortir del shadow root on es va originar cap al DOM lleuger exterior. Recorda que, com es va explicar al mòdul 4, el Shadow DOM encapsula deliberadament el que passa dins d'un component; aquesta mateixa encapsulació, sensecomposed: true, aturaria la bombolla de l'esdeveniment just al límit del shadow root, i impediria que<task-list>, que viu fora del shadow root de<task-card>, arribés a assabentar-se de res.
La combinació d'ambdues opcions és, a la pràctica, la que s'utilitza gairebé universalment per a esdeveniments personalitzats que un component despatxa amb la intenció que el seu pare (o qualsevol ancestre) l'escolti: sense bubbles: true l'esdeveniment no puja; sense composed: true, encara que pugi, queda atrapat dins del propi shadow root del component que l'origina. Oblidar qualsevol de les dues és, amb diferència, l'error més freqüent en treballar amb esdeveniments personalitzats en Web Components, i es detalla a l'apartat d'errors comuns d'aquesta lliçó.
- Despatxar l'esdeveniment amb
dispatchEvent
dispatchEventUn CustomEvent, un cop creat, no passa per si sol: cal despatxar-lo explícitament sobre un element del DOM, mitjançant el mètode dispatchEvent, heretat per tot element (inclosa qualsevol classe que estengui LitElement, que al seu torn estén HTMLElement) directament de la plataforma web:
class TaskCard extends LitElement {
notificarCambioDeEstado(nuevoEstado) {
this.dispatchEvent(
new CustomEvent('tarea-cambiada', {
detail: { nuevoEstado },
bubbles: true,
composed: true,
})
);
}
}this.dispatchEvent(...), cridat sobre la pròpia instància del component, fa que l'esdeveniment s'origini exactament a <task-card> (l'element personalitzat en si mateix, no un node intern del seu shadow root), i a partir d'aquí, gràcies a bubbles: true i composed: true, es propagui cap amunt a través del DOM lleuger exterior: primer cap al seu element pare immediat (normalment el <div class="lista"> de <task-list>), després cap a <task-list> mateix, i així successivament cap a qualsevol ancestre que tingui un listener posat per a tarea-cambiada.
Cal notar que dispatchEvent és una crida síncrona: tots els listeners que estiguin escoltant aquest esdeveniment en aquell moment s'executen immediatament, abans que dispatchEvent acabi d'executar-se i el codi continuï a la línia següent. Això no sol tenir implicacions pràctiques en l'ús habitual d'aquest curs, però convé saber-ho si mai cal raonar sobre l'ordre exacte en què s'executen diferents peces de codi.
- Convenció de noms per a esdeveniments personalitzats
El nom triat per a un esdeveniment personalitzat és una cadena de text lliure, però la comunitat de Web Components segueix, de forma gairebé universal, una convenció senzilla que convé respectar:
- Minúscules i amb guions (
kebab-case), igual que els noms dels propis elements personalitzats:tarea-cambiada, notareaCambiadaniTareaCambiada. Els noms d'esdeveniments natius del navegador (click,mouseenter) no distingeixen majúscules de minúscules en el seu tractament intern, però per a esdeveniments personalitzats,kebab-caseés la convenció dominant i la que segueix el propi Lit en la seva documentació i exemples. - Sense el prefix
on: un error freqüent, sobretot en qui ve d'altres frameworks, és anomenar l'esdevenimenton-tarea-cambiada. El prefixones reserva, per convenció del DOM, per als noms de les propietats manejadores (onclick,onchange), no per als noms dels esdeveniments en si mateixos; l'esdeveniment s'anomenaclick, noonclick, i de la mateixa manera l'esdeveniment personalitzat s'hauria d'anomenartarea-cambiada, noon-tarea-cambiada. - Un nom que descrigui el fet ocorregut, no l'acció a realitzar:
tarea-cambiada(una cosa que ja ha passat) és preferible acambiar-tarea(una ordre). Això reforça la idea de l'apartat 1: el fill anuncia un fet consumat sobre si mateix, no dona una ordre al seu pare sobre què fer.
- Afegint un selector d'estat a
<task-card>
<task-card>Fins ara, <task-card> només permetia veure l'estat d'una tasca (amb renderInsigniaEstado()), no canviar-lo. Per poder disparar l'esdeveniment tarea-cambiada amb una dada real, cal primer una manera que l'usuari triï un nou estat des de la pròpia targeta: un <select> senzill, amb les tres opcions ja utilitzades a renderInsigniaEstado().
// src/components/task-card.js
class TaskCard extends LitElement {
static properties = {
titulo: { type: String },
estado: { type: String },
prioridad: { type: Number },
urgente: { type: Boolean },
expandida: { state: true },
fechaLimite: { converter: conversorDeFecha, attribute: 'fecha-limite' },
};
// ...constructor sin cambios...
gestionarCambioDeSelector(event) {
// Atura la propagació del "change" natiu del <select>: l'esdeveniment
// que interessa a qui utilitzi <task-card> no és "el select ha canviat",
// sinó l'esdeveniment personalitzat propi que es despatxa just a sota.
event.stopPropagation();
const nuevoEstado = event.target.value;
this.estado = nuevoEstado;
this.notificarCambioDeEstado(nuevoEstado);
}
notificarCambioDeEstado(nuevoEstado) {
this.dispatchEvent(
new CustomEvent('tarea-cambiada', {
detail: { nuevoEstado },
bubbles: true,
composed: true,
})
);
}
renderSelectorEstado() {
return html`
<select @change="${this.gestionarCambioDeSelector}" .value="${this.estado}">
<option value="pendiente">Pendiente</option>
<option value="en-progreso">En progreso</option>
<option value="hecha">Hecha</option>
</select>
`;
}
render() {
return html`
<article @click="${this.alternarExpandida}">
<h3>${this.titulo}</h3>
${this.renderInsigniaEstado()}
${this.renderSelectorEstado()}
<p>Prioridad: ${this.prioridad}</p>
${this.urgente && html`<p class="aviso">⚠ Urgente</p>`}
${this.expandida
? html`<div class="detalle"><p>Estado interno: la tarjeta está expandida.</p></div>`
: ''}
</article>
`;
}
}Dos detalls mereixen atenció abans de passar a l'esdeveniment en si mateix. Primer, .value="${this.estado}" utilitza el binding de propietat amb punt, ja vist en mòduls anteriors, en lloc d'un atribut value normal: això assegura que el <select> mostri sempre l'opció corresponent al valor actual de this.estado, encara que aquest valor canviï per una via diferent al propi selector (per exemple, si en el futur <task-list> tornés a baixar una propietat estado actualitzada). Segon, event.stopPropagation() dins de gestionarCambioDeSelector evita que l'esdeveniment natiu change del <select> continuï fent bombolla cap amunt: no tindria sentit que <task-list> (o qualsevol altre ancestre) hagués de distingir entre el change genèric d'un <select> intern de <task-card> i qualsevol altre change que pogués produir-se en un altre lloc de la interfície; l'esdeveniment realment rellevant cap a fora és l'esdeveniment personalitzat tarea-cambiada, no l'esdeveniment natiu que l'origina internament.
- Emetent
tarea-cambiada
tarea-cambiadaEl flux complet, de principi a fi, queda així: l'usuari obre el <select> d'una targeta i tria "Hecha"; el navegador dispara un esdeveniment natiu change sobre el <select>; gestionarCambioDeSelector el captura, atura la seva propagació, actualitza this.estado (el qual dispara, com qualsevol propietat reactiva, un nou renderitzat de la pròpia targeta, ara mostrant la insígnia corresponent al nou estat) i crida notificarCambioDeEstado('hecha'); aquest mètode crea i despatxa un CustomEvent anomenat tarea-cambiada, amb detail: { nuevoEstado: 'hecha' }, configurat amb bubbles: true i composed: true perquè pugui sortir del shadow root de <task-card> i arribar fins a qui estigui escoltant a fora, típicament <task-list>.
És important notar que, en aquest punt de la lliçó, <task-card> ja ha fet tot el que li correspon: ha actualitzat la seva pròpia aparença (mitjançant this.estado) i ha anunciat el canvi cap a fora (mitjançant l'esdeveniment). No sap, ni li importa, si algú està escoltant aquest esdeveniment, ni què farà aquesta escolta amb la informació rebuda. Aquesta responsabilitat, deliberadament, es trasllada a la lliçó següent, on <task-list> escoltarà tarea-cambiada i decidirà què fer-ne.
- Tancament: qui escolta aquest esdeveniment
Per comprovar, sense avançar encara el contingut complet de la lliçó següent, que l'esdeveniment realment es despatxa i arriba fins fora del component, n'hi ha prou amb un listener mínim col·locat directament en HTML, fora de qualsevol shadow root:
<task-card titulo="Revisar el PR de autenticación" estado="pendiente"></task-card>
<script>
document.querySelector('task-card').addEventListener('tarea-cambiada', (event) => {
console.log('Nuevo estado recibido en el padre:', event.detail.nuevoEstado);
});
</script>Aquest listener, afegit amb addEventListener normal sobre la instància de l'element (exactament el mateix mètode vist a la lliçó anterior per a esdeveniments natius, perquè, com es va explicar a l'apartat 2, un CustomEvent és, en el fons, un Event com qualsevol altre), confirma que l'esdeveniment surt del Shadow DOM de <task-card> i es pot escoltar des de fora amb les eines més bàsiques del DOM, sense cap necessitat que qui escolti sigui un altre component de Lit. A la pràctica de TaskFlow, però, qui escoltarà aquest esdeveniment no serà un script solt sinó <task-list>, mitjançant la mateixa sintaxi declarativa @evento ja coneguda, aplicada aquesta vegada a un esdeveniment personalitzat en lloc d'un natiu del navegador.
Errors Comuns i Consells
- Oblidar
composed: true: és, amb diferència, l'error més freqüent en depurar "el meu esdeveniment personalitzat no arriba al meu pare". Si el component que despatxa l'esdeveniment té Shadow DOM (com qualsevolLitElement), i l'esdeveniment no portacomposed: true, la bombolla s'atura exactament al límit del shadow root, i ningú fora d'aquest límit el rebrà mai, per molt que tingui posat un listener aparentment correcte. - Oblidar
bubbles: true: sense aquesta opció, l'esdeveniment no es propaga en absolut cap amunt; només el rebria un listener posat directament sobre l'element exacte que el despatxa, la qual cosa, a la pràctica de comunicació entre components, gairebé mai és útil. - Anomenar l'esdeveniment amb el prefix
on: com es va explicar a l'apartat 5,on-tarea-cambiadatrenca la convenció estàndard; el nom de l'esdeveniment descriu el fet ocorregut (tarea-cambiada), mai porta el prefix reservat per a les propietats manejadores del DOM. - Ficar massa lògica de decisió dins del component que despatxa l'esdeveniment:
<task-card>es limita a anunciar que l'usuari ha triat un nou estat; no li correspon a<task-card>decidir, per exemple, si aquest canvi d'estat és vàlid segons alguna regla de negoci més àmplia (com impedir marcar una tasca com a "feta" si té subtasques pendents). Aquestes decisions són responsabilitat de qui escolta l'esdeveniment, no de qui l'emet. - Oblidar
event.stopPropagation()en l'esdeveniment natiu que origina el personalitzat: sigestionarCambioDeSelectorno atura la propagació delchangenatiu del<select>, ambdós esdeveniments (elchangenatiu i eltarea-cambiadapersonalitzat) fan bombolla cap a fora, i qualsevol ancestre que per casualitat escoltichangede forma genèrica (poc habitual, però possible) rebria un esdeveniment que no li correspon interpretar.
Exercicis
- Afegeix a
<task-card>un botó "Eliminar tarea" que despatxi un nou esdeveniment personalitzattarea-eliminada, ambdetailbuit (no cal transportar cap dada, ja que qui escolti ja sap sobre quina instància concreta de<task-card>s'ha produït l'esdeveniment), configurat ambbubbles: trueicomposed: true. - Explica, basant-te en l'apartat 3, què passaria si
<task-card>despatxéstarea-cambiadaambbubbles: trueperò sensecomposed: true, en el cas concret que<task-list>(que viu fora del shadow root de cada<task-card>) tingués un listener@tarea-cambiadaposat sobre cada targeta. - Un company d'equip proposa que, en lloc de despatxar un esdeveniment,
<task-card>rebi una funció de callback com a propietat (per exemple,.onCambioDeEstado="${miFuncion}") i la cridi directament quan l'usuari canviï l'estat. Compara aquesta alternativa amb el patró d'esdeveniments personalitzats vist en aquesta lliçó, assenyalant almenys un avantatge dels esdeveniments personalitzats davant d'aquesta alternativa.
Solucions
notificarEliminacion() {
this.dispatchEvent(
new CustomEvent('tarea-eliminada', {
bubbles: true,
composed: true,
})
);
}
render() {
return html`
<article @click="${this.alternarExpandida}">
...
<button @click="${(event) => { event.stopPropagation(); this.notificarEliminacion(); }}">
Eliminar tarea
</button>
</article>
`;
}Cal notar que aquí també fa falta event.stopPropagation() dins del manejador del botó: sense ella, el clic sobre "Eliminar tarea" faria bombolla igualment fins a l'<article> i dispararia a més alternarExpandida, expandint o contraient la targeta just quan l'usuari només volia eliminar-la.
-
Sense
composed: true, l'esdevenimenttarea-cambiada, encara que tinguibubbles: true, quedaria atrapat dins del shadow root de la pròpia<task-card>que el despatxa i mai arribaria a travessar aquesta frontera cap al DOM lleuger exterior, on viu<task-list>. El listener@tarea-cambiadaposat per<task-list>sobre cada<task-card>, encara que estigui sintàcticament ben escrit, simplement no es dispararia mai: l'esdeveniment existiria i faria bombolla, però només dins d'un arbre (el shadow root de<task-card>) que<task-list>no pot veure. -
El patró de callback com a propietat obligaria
<task-list>a passar una funció concreta a cada<task-card>, i aquest enllaç seria exclusiu entre aquestes dues instàncies concretes: qualsevol altre codi que volgués assabentar-se també del canvi d'estat (per exemple, un component d'estadístiques que s'afegís més endavant a TaskFlow) hauria de modificar<task-card>per acceptar una segona funció de callback, o encadenar crides manualment. Amb el patró d'esdeveniments, en canvi, qualsevol nombre de listeners pot escoltartarea-cambiadade forma independent, sense que<task-card>necessiti saber quants són ni modificar res per admetre'n un més; és el mateix mecanisme estàndard que ja permet que un botó natiu tingui diversosaddEventListener('click', ...)simultanis sense conflicte entre ells.
Conclusió
En aquesta lliçó s'ha resolt el problema central de la comunicació de fill a pare en Web Components: per què un component fill no ha de tocar directament l'estat del seu pare, com construir un CustomEvent amb dades pròpies a detail, per què fan falta tant bubbles: true com composed: true perquè l'esdeveniment travessi la frontera del Shadow DOM, com despatxar-lo amb dispatchEvent, i la convenció de noms en minúscules i guions, sense el prefix on. <task-card> ja té, com a resultat, un selector d'estat real que emet tarea-cambiada cada vegada que l'usuari tria un nou valor.
Queda, però, una peça pendent: <task-card> anuncia el canvi, però ningú l'està recollint encara de forma útil. A la lliçó següent, "Comunicació de Pare a Fill amb Propietats", <task-list> escoltarà tarea-cambiada amb @tarea-cambiada, actualitzarà el seu propi array tareas de forma immutable, i aquest array actualitzat tornarà a baixar com a propietat cap a totes les targetes, tancant així el cicle complet de comunicació entre <task-card> i <task-list>.
Curs de Lit
Mòdul 1: Introducció a Lit i Web Components
- Què són els Web Components i per què Lit?
- Configuració de l'Entorn de Desenvolupament
- El Teu Primer Component Lit
- Anatomia d'un Component Lit
Mòdul 2: Plantilles Reactives i Renderitzat
- El Motor de Plantilles de Lit
- Expressions i Interpolació en Plantilles
- Renderitzat Condicional
- Renderitzat de Llistes
- El Cicle de Renderitzat
Mòdul 3: Propietats i Estat Reactiu
- Propietats Reactives
- Estat Intern amb @state
- Tipus de Propietats i Conversors Personalitzats
- Atributs vs Propietats i Reflexió
Mòdul 4: Estils en Components Lit
- CSS Encapsulat amb Shadow DOM
- Estils Compartits entre Components
- Variables CSS Personalitzades i Theming
- Slots i Estilitzat de Contingut Distribuït
Mòdul 5: Esdeveniments i Comunicació entre Components
- Gestió d'Esdeveniments DOM en Plantilles
- Esdeveniments Personalitzats: Comunicació de Fill a Pare
- Comunicació de Pare a Fill amb Propietats
- Patrons de Comunicació entre Components Germans
Mòdul 6: Cicle de Vida i Comportament Avançat
- Callbacks del Cicle de Vida
- Hooks Reactius: willUpdate, updated i firstUpdated
- Controladors Reactius
- Mixins i Composició de Comportament
Mòdul 7: Directives i Funcionalitats Avançades de Plantilles
- Directives Incorporades: classMap, styleMap i ifDefined
- Directives Personalitzades
- Renderitzat Asíncron amb until
- Context Compartit amb @lit/context
Mòdul 8: Integració, Interoperabilitat i Desplegament
- Utilitzar Components Lit en HTML Pla
- Integrar Lit amb React, Vue i Angular
- Renderitzat al Servidor amb @lit-labs/ssr
- Empaquetatge, Publicació i TypeScript
Mòdul 9: Proves i Bones Pràctiques
- Proves Unitàries amb Web Test Runner
- Accessibilitat en Web Components
- Rendiment i Optimització
- Patrons i Antipatrons Comuns
