Un elemento personalizzato è una classe registrata con un nome di tag tramite customElements.define(). La registrazione e il ciclo di vita di base sono ben compresi; le regole riportate di seguito riguardano le parti che vengono regolarmente implementate in modo errato.
Ciclo di vita: le parti che vengono implementate in modo errato
I callback stessi sono ben documentati; questi sono i concetti errati che causano bug:
connectedCallbackpuò essere eseguito più di una volta. Viene attivato a ogni inserimento, quindi lo spostamento dell'elemento lo esegue di nuovo. Rendi la configurazione idempotente: non dare per scontato che venga eseguita una sola volta.connectedMoveCallbackignora la coppia di disconnessione/riconnessione durante uno spostamento. Quando un elemento viene spostato conNode.moveBefore(), questo callback viene attivato anzichédisconnectedCallback→connectedCallback, quindi lo stato, lo stato attivo e la riproduzione di iframe/media sopravvivono allo spostamento. Definiscilo quando la configurazione o la disattivazione sono costose o vengono eseguite di nuovo in modo osservabile.- Smantellamento in
disconnectedCallback. Rimuovi listener, timer e osservatori globali per evitare perdite, ma l'elemento potrebbe essere reinserito, quindi non considerare la disattivazione come permanente. - Chiama prima
super()nel costruttore e non fare affidamento sulla presenza del DOM completo. La chiamata asuper()dopo aver toccatothisgenera unReferenceError. Gli attributi sono solitamente (ma non sempre) presenti; l'analisi di elenchi lunghi di elementi secondari potrebbe non essere stata completata. UtilizzaconnectedCallbackper l'inizializzazione one-shot,slotchangeper monitorare gli elementi secondari diretti eattributeChangedCallbackper monitorare gli attributi.connectedCallbackpotrebbe comunque attivarsi prima che tutti i nodi secondari vengano analizzati, quindi non ignorare le chiamateslotchange/attributeChangedCallbacksuccessive. attributeChangedCallbacksi attiva solo perobservedAttributese dovrebbe rimanere economico per gli attributi che non influiscono sull'output.- Gli hook del ciclo di vita e
observedAttributesvengono acquisiti come snapshot al momento della registrazione.customElements.define()legge entrambi una volta; l'aggiunta di un hook o l'estensione diobservedAttributesnella classe dopo la chiamata viene ignorata senza avvisi. In particolare,attributeChangedCallbacknon viene mai attivato, a meno cheobservedAttributesnon fosse già presente al momento didefine().
Tempistiche dell'upgrade e :defined
Un elemento può esistere nel DOM prima che la sua classe venga registrata (nel codice HTML visualizzato dal server o quando lo script viene caricato in ritardo). Finché non viene definito, è un HTMLElement inerte senza alcun comportamento; quando customElements.define() viene eseguito, il browser esegue l'upgrade di ogni elemento corrispondente già presente nel documento, eseguendo il relativo costruttore e poi connectedCallback.
Due conseguenze che i modelli di solito non considerano:
- Gli attributi e gli elementi secondari potrebbero essere già presenti quando il costruttore/
connectedCallbackviene eseguito durante l'upgrade. Leggi gli attributi esistenti inconnectedCallbackper sincronizzare lo stato iniziale e non dare mai per scontato che l'elemento sia vuoto. - I nodi secondari potrebbero comunque mancare anche in
connectedCallback: l'analizzatore HTML può produrre un'analisi intermedia di elenchi secondari lunghi, pertanto il callback a volte viene eseguito prima che ogni nodo secondario venga collegato. Per il pattern (slotchange+attributeChangedCallback), consulta il punto relativo al costruttore nella sezione Ciclo di vita riportata sopra.
Nascondi gli elementi non definiti per evitare un flash di contenuti non formattati, ma fail open, in modo che i contenuti rimangano raggiungibili se lo script non viene mai caricato. Anima opacity con un ritardo anziché nasconderlo con visibility: hidden, in modo che l'elemento si riveli dopo un timeout anche se non viene mai eseguito l'upgrade:
my-element:not(:defined) {
opacity: 0;
/* Reveal anyway after 2s, so a failed or slow script never hides content for good. */
animation: defined-fallback 0.2s 2s forwards;
}
@keyframes defined-fallback { to { opacity: 1; } }
Limita l'ambito di questi guard agli elementi che ne hanno bisogno o a un sottoalbero specifico tramite :has(:not(:defined)), anziché all'intero body. Un widget lento e non critico (ad esempio un pulsante di condivisione) non deve ritardare la visualizzazione dell'intera pagina.
Riflessione attributo ⇄ proprietà
Mantieni sincronizzati l'attributo HTML e la proprietà JS in modo che l'elemento sia utilizzabile sia dal markup che dallo script. I valori degli attributi sono sempre stringhe; forzali.
class MyToggle extends HTMLElement {
static observedAttributes = ['label', 'count', 'disabled'];
// String attribute reflected via getter/setter.
get label() { return this.getAttribute('label') ?? ''; }
set label(v) {
v ? this.setAttribute('label', v) : this.removeAttribute('label');
}
// Numeric attribute: coerce from string on read, serialize on write.
get count() { return Number(this.getAttribute('count')) || 0; }
set count(v) { this.setAttribute('count', String(v)); }
// Boolean attribute: present = true, absent = false. Never set it to "false".
get disabled() { return this.hasAttribute('disabled'); }
set disabled(on) { this.toggleAttribute('disabled', !!on); }
attributeChangedCallback(name, oldVal, newVal) {
if (oldVal !== newVal) this.render();
}
}
Dove si trova il valore
L'esempio precedente mantiene il DOM come fonte attendibile: ogni getter legge getAttribute. Questa è l'impostazione predefinita corretta per una manciata di attributi semplici di tipo stringa: esiste un solo posto in cui lo stato può risiedere, quindi non può desincronizzarsi.
Per i componenti più ricchi, mantieni la fonte di attendibilità in un campo JS e rifletti verso l'esterno in un attributo solo quando qualcos'altro deve vederlo, ad esempio il targeting CSS o un consumer che lo legge. La lettura di un campo è molto più economica rispetto all'analisi e alla coercizione di un attributo a ogni accesso e i valori non stringa non vengono mai sottoposti a round trip tramite la serializzazione. Questo è il motivo per cui librerie come Lit mantengono lo stato in JS e riflettono in modo selettivo anziché trattare gli attributi come spazio di archiviazione: riflettono solo ciò che il mondo esterno deve osservare, non ogni proprietà.
Errori comuni:
- Disciplina
observedAttributes: elenca solo gli attributi la cui modifica deve eseguire nuovamente il rendering o attivare un effetto collaterale. L'osservazione di tutto aggiunge un callback a ogni mutazione senza alcun vantaggio. - Loop di riflessione: un setter che scrive un attributo rientrerà in
attributeChangedCallback. Proteggi con un controllooldVal !== newVal, come sopra. - Stato solo stile: per lo stato interno che esiste solo per gestire lo stile (caricamento, attivo, espanso), preferisci gli stati personalizzati rispetto alla riflessione di un attributo sintetico. Consulta la sezione Stati personalizzati di seguito. Esegui il mirroring su un attributo solo quando i consumatori devono essere in grado di impostare lo stato dal markup.
Stati personalizzati
Gli stati personalizzati sono flag booleani che un elemento espone su se stesso per il targeting CSS, impostati tramite ElementInternals e abbinati alla pseudoclasse :state(). Utilizzali per lo stato della UI interna di runtime (busy, expanded, invalid) anziché riflettere un attributo sintetico: non possono entrare in conflitto con gli attributi di un consumer, non vengono mai visualizzati nel DOM e sono di sola lettura dall'esterno (un consumer può applicare uno stile a :state(), ma non può falsificarlo).
class AsyncButton extends HTMLElement {
#internals;
#action = async () => {};
constructor() {
super();
this.#internals = this.attachInternals();
}
set action(fn) { this.#action = fn; } // host supplies the work to run
async run() {
if (this.#internals.states.has('busy')) return; // guard re-entry
this.#internals.states.add('busy');
this.#internals.states.delete('success');
try {
await this.#action();
this.#internals.states.add('success');
} catch {
this.#internals.states.add('error');
} finally {
this.#internals.states.delete('busy');
}
}
}
CustomStateSet è un set, quindi gli stati indipendenti compongono (busy più error) senza inventare un enum data-status combinato.
Lo stato personalizzato è solo di runtime. Non viene serializzato nel DOM, quindi non sopravvive a un ricaricamento, alla ricreazione dell'elemento o al rendering del server. Se lo stato deve essere impostato in modo dichiarativo nel markup o deve persistere tra i caricamenti, è un compito per un attributo (vedi Dove si trova il valore): ripristinalo in connectedCallback dalla tua fonte di attendibilità. Regola generale: stato personalizzato per ciò che il componente deriva e possiede in fase di runtime e che deve solo definire lo stile; un attributo per ciò che deve essere impostato dal markup o serializzato.
Per applicare uno stile a questi stati, sia all'interno del componente sia dal foglio di stile di un consumer, consulta Applicazione di stili ai componenti web.
Convenzioni di denominazione
- Stato veramente privato: campi
#nativi (#count) per l'hard encapsulation; un prefisso_solo quando le sottoclassi devono ancora raggiungerlo. Tieni presente che i campi#non vengono ereditati: una sottoclasse non può visualizzare i campi privati di una classe base. - Le proprietà con chiave di simbolo sono una via di mezzo: nascoste dall'enumerazione ordinaria, ma raggiungibili da qualsiasi codice che contiene il simbolo (esportalo o esponilo come campo
staticnella classe). Poiché ogni simbolo è univoco, due simboli che condividono una descrizione non entrano mai in conflitto, il che è utile quando una sottoclasse o un elemento di pari livello necessita di un accesso controllato che i campi#non possono fornire. - Non nascondere gli attributi globali (
style,class,id,slot,part,title,lang,dir) con le tue proprietà, altrimenti il loro comportamento integrato verrà interrotto. Tieni presente chedisabledè globale solo sui controlli dei moduli; su altri elementi devi implementare l'effetto autonomamente.
Quando assegni un nome a una nuova superficie API, segui le convenzioni della piattaforma: utilizza il kebab case in minuscolo con almeno un trattino per i nomi dei tag degli elementi personalizzati (ad esempio my-toggle), lowerCamelCase per le proprietà e i metodi e i nomi degli attributi in minuscolo sincronizzati con le proprietà corrispondenti.