TaskFlow ja té, des del mòdul 3, un primer contacte amb els esdeveniments: <task-card> alterna el seu estat intern expandida en fer clic, mitjançant @click="${this.alternarExpandida}" en la seva plantilla. En aquell moment es va deixar l'explicació pendent per no desviar l'atenció del tema de fons d'aquella lliçó, l'estat intern. Ha arribat el moment de tornar sobre aquesta sintaxi i entendre-la a fons: què fa exactament el prefix @, en què es diferencia d'escoltar esdeveniments "a mà" amb addEventListener, quina informació porta amb si l'objecte event, com configurar el listener amb opcions com capture o once, i per què el valor de this dins d'un manejador d'esdeveniments de Lit es comporta com caldria esperar sense que faci falta cap truc addicional.
Contingut
- La sintaxi
@eventoen les plantilles de Lit - Què passa realment darrere de
@evento - L'objecte
eventievent.target - Opcions del listener amb la sintaxi d'objecte
- El binding de
thisen els manejadors - Formalitzant el clic de
<task-card> - Tancament: el següent pas, comunicar cap a fora
- La sintaxi
@evento en les plantilles de Lit
@evento en les plantilles de LitDins d'una plantilla html, qualsevol atribut que comenci per @ no és un atribut HTML en absolut: és una instrucció per a Lit que li indica "afegeix un listener d'aquest esdeveniment sobre aquest element, i crida aquesta funció quan es dispari". El nom que segueix el @ és el nom de l'esdeveniment del DOM tal qual, sense el prefix on que usen els atributs HTML clàssics (onclick, onchange...):
render() {
return html`
<button @click="${this.gestionarClic}">Marcar como hecha</button>
<input @input="${this.gestionarInput}" />
<form @submit="${this.gestionarEnvio}"></form>
`;
}El valor entre cometes, després del signe igual, és una expressió de tipus funció: una referència al mètode que Lit ha d'invocar quan l'esdeveniment es produeixi. És important no confondre això amb invocar la funció immediatament (@click="${this.gestionarClic()}", amb parèntesis, seria un error quasi segur): el que es passa a Lit és la pròpia funció, sense executar, perquè sigui el navegador qui decideixi quan cridar-la, exactament en el moment en què passa l'esdeveniment.
Aquesta sintaxi funciona amb qualsevol esdeveniment del DOM, no només amb els més habituals com click o input: @mouseenter, @keydown, @focus, @dragstart, o qualsevol altre nom d'esdeveniment estàndard del navegador és vàlid després del @. També funciona, com es veurà a la lliçó següent, amb esdeveniments personalitzats creats per altres components, sense cap diferència de sintaxi.
- Què passa realment darrere de
@evento
@evento@evento no és màgia exclusiva de Lit inventada des de zero: per sota, quan Lit processa una plantilla i troba un binding de tipus esdeveniment, crida addEventListener(nombreDelEvento, funcion) sobre l'element del DOM corresponent, exactament el mateix mètode natiu que s'usaria escrivint JavaScript sense cap framework pel mig. La diferència real està en la comoditat i en la integració amb la resta del sistema de plantilles de Lit:
// Con addEventListener manual, fuera de Lit
const boton = document.querySelector('button');
boton.addEventListener('click', gestionarClic);
// Con Lit, declarativo dentro de la plantilla
html`<button @click="${gestionarClic}">Marcar como hecha</button>`Amb addEventListener manual, cal localitzar primer l'element (normalment amb querySelector o getElementById), i aquest codi de localització i subscripció viu separat de la plantilla que descriu l'estructura de l'element, el que obliga a mantenir mentalment la relació entre ambdós fragments de codi. Amb @evento, la subscripció està escrita just al costat de l'element al qual pertany, dins de la mateixa plantilla declarativa, així que n'hi ha prou de llegir l'html per saber quins esdeveniments escolta cada element.
A més, Lit gestiona automàticament el cicle de vida del listener: quan la plantilla es torna a renderitzar i l'element en qüestió continua sent el mateix node del DOM (l'habitual, com es va veure al mòdul 2), Lit reutilitza el listener existent en lloc d'afegir-ne un de nou a cada actualització; i si l'element s'elimina del DOM perquè una expressió condicional deixa de renderitzar-lo, el propi navegador allibera aquest node i el seu listener sense que calgui cridar removeEventListener manualment, tal com passa amb qualsevol node del DOM que es retira. Això evita un dels errors més comuns quan es treballa amb esdeveniments "a mà": oblidar-se d'eliminar un listener i acabar amb manejadors fantasma executant-se sobre elements que ja no haurien d'estar actius.
- L'objecte
event i event.target
event i event.targetTot manejador d'esdeveniments, tant si es declara amb @evento a Lit com si es registra amb addEventListener fora de qualsevol framework, rep automàticament un argument: l'objecte Event (o una de les seves subclasses, com MouseEvent o InputEvent) que descriu l'esdeveniment que s'ha produït. Lit no canvia res en aquest punt: l'objecte que arriba al manejador és el mateix objecte estàndard del navegador, amb totes les seves propietats habituals.
gestionarClic(event) {
console.log(event.type); // "click"
console.log(event.target); // el elemento del DOM que originó el evento
}event.target és, quasi sempre, la propietat més útil de l'objecte esdeveniment: apunta a l'element concret del DOM sobre el qual va passar l'esdeveniment originalment. Això és especialment rellevant quan el listener no està posat directament sobre l'element que l'usuari ha polsat, sinó sobre un element contenidor (una tècnica anomenada delegació d'esdeveniments):
render() {
return html`
<ul @click="${this.gestionarClicEnLista}">
<li data-id="1">Tarea 1</li>
<li data-id="2">Tarea 2</li>
<li data-id="3">Tarea 3</li>
</ul>
`;
}
gestionarClicEnLista(event) {
const idPulsado = event.target.dataset.id;
console.log(`Se ha pulsado la tarea ${idPulsado}`);
}Gràcies al fet que els esdeveniments del DOM es propaguen per defecte des de l'element on passen cap als seus ancestres (un mecanisme anomenat bubbling, sobre el qual es tornarà a la lliçó següent), un únic @click a l'<ul> és capaç de capturar el clic sobre qualsevol dels seus <li>, i event.target permet distingir sobre quin d'ells va passar realment.
Convé no confondre event.target amb event.currentTarget: target és sempre l'element més profund on es va originar l'esdeveniment, mentre que currentTarget és l'element sobre el qual està posat el listener que s'està executant en aquell moment (a l'exemple anterior, sempre l'<ul>, sigui quin sigui el <li> polsat). Dins d'un manejador declarat amb @evento sobre un element concret, event.currentTarget sol coincidir amb aquell element; la distinció es torna rellevant, sobretot, en escenaris de delegació com l'anterior.
- Opcions del listener amb la sintaxi d'objecte
addEventListener natiu admet, com a tercer argument, un objecte d'opcions que modifica com es comporta el listener: capture (per escoltar en la fase de captura de l'esdeveniment en lloc de la fase de bombolleig), once (perquè el listener s'executi una única vegada i s'elimini automàticament després) i passive (per indicar al navegador que el manejador mai cridarà preventDefault(), la qual cosa li permet optimitzar el scroll en esdeveniments tàctils i de rodeta). Lit exposa aquestes mateixes opcions sense sortir de la sintaxi declarativa, usant una forma alternativa d'escriure el valor del binding: en lloc de passar directament la funció, es passa un objecte amb dues propietats, handleEvent (la funció manejadora) i la resta d'opcions al mateix nivell:
render() {
return html`
<button
@click="${{ handleEvent: () => this.gestionarClic(), once: true }}"
>
Confirmar (solo una vez)
</button>
`;
}Aquest objecte es recolza en un detall poc conegut de l'especificació d'addEventListener: el navegador accepta, com a segon argument, tant una funció directament com qualsevol objecte que tingui un mètode handleEvent, i l'invoca de la mateixa manera en ambdós casos. Lit aprofita exactament aquesta capacitat de l'estàndard per permetre afegir opcions sense inventar cap sintaxi pròpia. La taula següent resumeix les tres opcions disponibles:
| Opció | Efecte |
|---|---|
capture: true |
El listener s'executa en la fase de captura (de l'arrel del document cap a l'element), abans que l'esdeveniment arribi a l'element on va passar i comenci a bombollejar cap amunt. |
once: true |
El listener s'executa com a màxim una vegada; després de la primera invocació, el navegador l'elimina automàticament, com si s'hagués cridat removeEventListener. |
passive: true |
Declara que el manejador mai cridarà event.preventDefault(), el que permet al navegador optimitzar certs esdeveniments (especialment touchstart, touchmove i wheel) sense esperar que el manejador acabi per decidir si fa scroll. |
A la pràctica, dins de TaskFlow, aquestes opcions s'usen amb moderació: la immensa majoria dels manejadors d'aquest curs són simples funcions sense necessitat d'opcions addicionals, i convé reservar-les per als casos concrets en els quals aporten alguna cosa (per exemple, once: true en un botó de confirmació que no hauria de poder-se polsar dues vegades per error, o passive: true en un listener de scroll sobre una llista llarga de tasques).
- El binding de
this en els manejadors
this en els manejadorsUn detall que sorprèn a qui arriba a Lit des de JavaScript "a pèl" és que @click="${this.alternarExpandida}" funciona correctament, i dins d'alternarExpandida() la paraula this es refereix a la instància del component, sense necessitat de cap .bind(this) explícit. Per entendre per què, cal recordar com funciona this en JavaScript: en un mètode normal de classe, this depèn de com es crida la funció, no d'on es va declarar. Si s'extragués una funció del seu objecte i es cridés solta, this deixaria d'apuntar a la instància:
class TaskCard extends LitElement {
alternarExpandida() {
this.expandida = !this.expandida; // "this" depende de cómo se invoque este método
}
}
const metodo = tarjeta.alternarExpandida;
metodo(); // ERROR: this ya no es la instancia de TaskCard, es undefined (en modo estricto)Quan Lit afegeix el listener amb addEventListener, i el navegador dispara l'esdeveniment, la funció s'invoca en un context que, per defecte, no preserva el this original del mètode de classe. Tot i això, el patró habitual d'aquest curs —declarar els manejadors com mètodes normals de la classe (alternarExpandida() { ... }) i passar-los amb @click="${this.alternarExpandida}"— funciona sense problemes perquè Lit vincula automàticament el this dels mètodes declarats a render() a la instància del component, gràcies al fet que internament Lit embolcalla la referència a la funció de manera que, en invocar-la, preserva el context de la instància des de la qual es va llegir (this.alternarExpandida recorda a quin this pertany).
Aquesta comoditat no sempre està garantida en tots els escenaris (per exemple, si s'extreu la funció manualment en una variable solta abans de passar-la a la plantilla, com a l'exemple anterior), així que convé tenir presents dues alternatives explícites, especialment si en algun moment apareix un this inesperat dins d'un manejador:
class TaskCard extends LitElement {
// Opción A: class field con arrow function.
// Las arrow functions no tienen su propio "this": lo capturan
// del contexto donde se definen, que aquí es la propia instancia.
alternarExpandida = () => {
this.expandida = !this.expandida;
};
// Opción B: arrow function en línea, directamente en la plantilla.
render() {
return html`
<article @click="${() => { this.expandida = !this.expandida; }}">
...
</article>
`;
}
}L'opció A (class field amb arrow function) és una alternativa robusta i molt usada a la pràctica: en ser una arrow function, captura el this lèxic del constructor de la instància en el moment en què es crea el camp, i aquell this queda fixat per sempre, sigui quina sigui la manera en què després s'invoqui la funció. L'opció B, una arrow function declarada directament dins de la plantilla, també captura correctament this pel mateix motiu, però té un cost subtil: en ser una funció nova a cada crida a render(), Lit no pot reconèixer-la com "la mateixa" funció entre actualitzacions, el que l'obliga a treure el listener anterior i posar-ne un de nou a cada renderitzat. Per al patró d'aquest curs, amb mètodes normals de classe referenciats com this.metodo, això no suposa cap problema perquè Lit ja resol el binding de this automàticament, així que les opcions A i B queden com a alternatives a conèixer, no com el patró per defecte.
- Formalitzant el clic de
<task-card>
<task-card>Amb tota la teoria anterior, es pot ara llegir amb detall real el manejador de clic que <task-card> ja tenia des del mòdul 3, sense res de nou a afegir al comportament, però sí a la comprensió:
// 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() {
super();
this.titulo = 'Tarea sin título';
this.estado = 'pendiente';
this.prioridad = 3;
this.urgente = false;
this.expandida = false;
this.fechaLimite = null;
}
alternarExpandida(event) {
console.log('Evento recibido:', event.type, 'sobre', event.target.tagName);
this.expandida = !this.expandida;
}
render() {
return html`
<article @click="${this.alternarExpandida}">
<h3>${this.titulo}</h3>
${this.renderInsigniaEstado()}
<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>
`;
}
}Ara alternarExpandida declara explícitament el paràmetre event, encara que en aquest cas concret no es necessita cap dada específica d'ell més enllà de constatar que ha arribat (la línia de console.log és només il·lustrativa, per veure a la consola del navegador quin tipus d'esdeveniment i quin element el va originar; al codi real de producció es retiraria). El listener està posat directament sobre l'<article>, així que un clic a qualsevol part de la targeta —el títol, la insígnia d'estat, l'avís d'urgència— dispara el manejador, i event.target seria, segons on es faci clic exactament, l'<h3>, el <span> de la insígnia o el propi <article>, gràcies al bombolleig d'esdeveniments esmentat a l'apartat 3. Aquest és exactament el motiu pel qual n'hi ha prou d'un únic @click a l'element arrel de la plantilla per capturar clics sobre tota la targeta, sense necessitat de repetir el listener a cada element intern.
Errors Comuns i Consells
- Escriure
@click="${this.metodo()}"amb parèntesis: això invocametodo()immediatament durant el renderitzat, en lloc de passar-lo com a referència perquè el navegador el cridi més tard; quasi sempre és un error, tret quemetodo()estigui dissenyat deliberadament per retornar una altra funció (un patró poc freqüent en aquest curs). - Usar
onclick="..."dins d'una plantilla de Lit: aquesta és la sintaxi d'atribut HTML clàssic, avaluada pel navegador de manera totalment diferent (com una cadena de codi que s'executa amb accés limitat a l'àmbit de la classe); a Lit, el listener declaratiu correcte sempre usa el prefix@, maion. - Extreure un mètode a una variable solta abans de passar-lo a la plantilla: com s'ha explicat a l'apartat 5, alguna cosa com
const fn = this.alternarExpandida; html<button @click="${fn}">``` perd elthiscorrecte tret que s'hagi declarat com a class field amb arrow function; convé passar semprethis.metododirectament dins de la plantilla, o usar el patró de class field. - Oblidar que les opcions del listener necessiten l'objecte
handleEvent: escriure@click="${{ once: true }}", sense la propietathandleEvent, no executa cap manejador; la sintaxi d'objecte exigeix sempre inclourehandleEventjunt amb les opcions que es vulguin usar.
Exercicis
- Afegeix a
<task-card>un segon manejador,gestionarTeclado(event), enllaçat amb@keydownsobre l'<article>, que cridithis.alternarExpandida(event)únicament quanevent.keysigui'Enter'o' '(barra espaiadora), perquè la targeta també es pugui expandir amb el teclat. - Usant la sintaxi d'objecte vista a l'apartat 4, modifica el
@clickde l'<article>de<task-card>perquè el listener usionce: true, i explica amb les teves pròpies paraules quin comportament observable canviaria en provar-ho al navegador (pista: pensa en quantes vegades es podria expandir i contreure la targeta). - Escriu un petit component
<contador-clics>amb un<button>el manejador de clic del qual rebi l'eventi mostri per consolaevent.currentTargetievent.target; afegeix dins del botó un<span>amb una icona de text, fes clic exactament sobre aquell<span>, i explica per què ambdós valors difereixen en aquest cas concret però coincideixen si es fa clic a qualsevol altra part del botó.
Solucions
gestionarTeclado(event) {
if (event.key === 'Enter' || event.key === ' ') {
this.alternarExpandida(event);
}
}
render() {
return html`
<article @click="${this.alternarExpandida}" @keydown="${this.gestionarTeclado}" tabindex="0">
...
</article>
`;
}S'afegeix també tabindex="0" perquè un <article> no és, per defecte, un element on l'usuari pugui dur el focus amb el teclat (a diferència d'un <button> o un <a>); sense focus, l'esdeveniment keydown mai arribaria a produir-se sobre ell en polsar tecles.
-
Amb
once: true, el listener declicks'elimina automàticament després de la primera vegada que es dispara. A la pràctica, això significa que la targeta es podria expandir (o contreure, segons el valor inicial d'expandida) una única vegada amb el clic; a partir d'aquí, clics successius sobre la targeta no tindrien cap efecte, perquè el navegador ja hauria retirat el listener després de la primera execució. Per al cas real de<task-card>, on s'espera poder expandir i contreure repetidament,once: trueseria contraproduent; té sentit, en canvi, en accions que deliberadament només han de poder passar una vegada, com confirmar l'enviament d'un formulari.
class ContadorClics extends LitElement {
gestionarClic(event) {
console.log('target:', event.target);
console.log('currentTarget:', event.currentTarget);
}
render() {
return html`
<button @click="${this.gestionarClic}">
<span>★</span> Pulsa aquí
</button>
`;
}
}Si el clic passa exactament sobre el <span> amb la icona ★, event.target apunta a aquell <span> (l'element més profund on es va originar físicament el clic), mentre que event.currentTarget continua apuntant al <button>, perquè és sobre aquell element on està realment posat el listener amb @click. Si en canvi el clic passa sobre el text "Pulsa aquí" o sobre qualsevol zona del botó fora del <span>, event.target coincideix amb el propi <button>, i per tant amb event.currentTarget. La diferència només apareix quan el clic físic passa sobre un element fill diferent d'aquell sobre el qual està posat el listener.
Conclusió
En aquesta lliçó s'ha formalitzat alguna cosa que TaskFlow ja usava des del mòdul 3: la sintaxi @evento de Lit per escoltar esdeveniments del DOM de manera declarativa, la seva relació directa amb addEventListener natiu, el paper d'event.target per saber sobre quin element concret va passar un esdeveniment, les opcions de listener (capture, once, passive) mitjançant la sintaxi d'objecte amb handleEvent, i per què el this dins dels manejadors de Lit apunta correctament a la instància del component sense necessitat de bind explícit en el patró habitual d'aquest curs.
Tot el que s'ha vist fins aquí, no obstant, continua passant dins d'un únic component: <task-card> reacciona als seus propis clics modificant el seu propi estat intern, però no té cap manera de contar-ho a ningú més. A la lliçó següent, "Esdeveniments Personalitzats: Comunicació de Fill a Pare", es farà el salt que de veritat connecta els components de TaskFlow entre ells: es crearà un esdeveniment personalitzat propi, tarea-cambiada, perquè <task-card> pugui avisar a qui la contingui que l'usuari ha canviat l'estat d'una tasca, sense que la targeta necessiti conèixer ni modificar directament res del seu component pare.
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
