Un élément personnalisé est une classe enregistrée par rapport à un nom de balise via customElements.define(). L'enregistrement et le cycle de vie de base sont bien compris. Les règles ci-dessous ciblent les parties qui sont régulièrement implémentées de manière incorrecte.
Cycle de vie : les parties qui sont mal implémentées
Les rappels eux-mêmes sont bien documentés. Voici les idées reçues qui provoquent des bugs :
connectedCallbackpeut s'exécuter plusieurs fois. Il se déclenche à chaque insertion, donc le déplacement de l'élément le réexécute. Rendez la configuration idempotente : ne partez pas du principe qu'il n'y aura qu'une seule première exécution.connectedMoveCallbackignore la paire déconnexion/reconnexion lors d'un déplacement. Lorsqu'un élément est déplacé avecNode.moveBefore(), ce rappel se déclenche au lieu dedisconnectedCallback→connectedCallback. Ainsi, l'état, la sélection et la lecture de l'iframe/du contenu multimédia sont conservés. Définissez-le lorsque votre configuration ou votre suppression est coûteuse ou s'exécute à nouveau de manière observable.- Suppression dans
disconnectedCallback. Supprimez les écouteurs, minuteurs et observateurs globaux pour éviter les fuites. Toutefois, l'élément peut être réinséré, donc ne considérez pas la suppression comme permanente. - Appelez d'abord
super()dans le constructeur et ne dépendez pas de la présence de l'intégralité du DOM. L'appel desuper()après avoir touchéthisgénère uneReferenceError. Les attributs sont généralement (mais pas toujours) présents. Les longues listes d'enfants n'ont peut-être pas fini d'être analysées. UtilisezconnectedCallbackpour une initialisation ponctuelle,slotchangepour surveiller les enfants directs etattributeChangedCallbackpour surveiller les attributs.connectedCallbackpeut toujours se déclencher avant que tous les enfants ne soient analysés. N'ignorez donc pas les appelsslotchange/attributeChangedCallbacksuivants. attributeChangedCallbackne se déclenche que pourobservedAttributes, et doit rester peu coûteux pour les attributs qui n'affectent pas la sortie.- Les hooks de cycle de vie et
observedAttributessont enregistrés dans un instantané lors de l'enregistrement.customElements.define()lit les deux une fois. L'ajout d'un hook ou l'extension deobservedAttributessur la classe après l'appel est ignoré de manière silencieuse. En particulier,attributeChangedCallbackne se déclenche jamais, sauf siobservedAttributesétait déjà présent au moment dedefine().
Calendrier de mise à niveau et :defined
Un élément peut exister dans le DOM avant que sa classe ne soit enregistrée (dans le code HTML affiché par le serveur ou lorsque le script se charge tardivement). Tant qu'il n'est pas défini, il s'agit d'un HTMLElement inerte sans aucun de vos comportements. Lorsque customElements.define() s'exécute, le navigateur met à niveau chaque élément correspondant déjà présent dans le document, en exécutant son constructeur, puis connectedCallback.
Deux conséquences que les modèles ignorent généralement :
- Les attributs et les enfants peuvent déjà être présents lorsque votre constructeur/
connectedCallbacks'exécute lors de la mise à niveau. Lisez les attributs existants dansconnectedCallbackpour synchroniser l'état initial et ne supposez jamais que l'élément était vide au départ. - Il est possible que des enfants soient toujours manquants, même dans
connectedCallback. L'analyseur HTML peut générer des listes d'enfants longues en cours d'analyse. Le rappel s'exécute donc parfois avant que chaque enfant ne soit associé. Pour connaître le modèle (slotchange+attributeChangedCallback), consultez la section sur le constructeur dans Cycle de vie ci-dessus.
Masquez les éléments non définis pour éviter l'affichage d'un contenu sans style, mais autorisez l'accès en cas d'échec afin que le contenu reste accessible si le script ne se charge jamais. Animez opacity avec un délai plutôt que de masquer l'élément avec visibility: hidden, afin qu'il s'affiche après un délai d'expiration, même s'il n'est jamais mis à niveau :
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; } }
Limitez ces protections aux éléments qui en ont besoin, ou à une sous-arborescence spécifique via :has(:not(:defined)), plutôt qu'à l'ensemble de l'élément body. Un widget lent et non critique (un bouton de partage, par exemple) ne doit pas retarder l'affichage de l'ensemble de la page.
Réflexion attribut ⇄ propriété
Synchronisez l'attribut HTML et la propriété JS pour que l'élément soit utilisable à la fois à partir du balisage et du script. Les valeurs d'attribut sont toujours des chaînes. Forcez-les.
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();
}
}
Où se trouve la valeur
L'exemple ci-dessus conserve le DOM comme source de vérité : chaque getter lit getAttribute. C'est la valeur par défaut appropriée pour quelques attributs simples de type chaîne : l'état ne peut se trouver qu'à un seul endroit, il ne peut donc pas se désynchroniser.
Pour les composants plus riches, conservez la source de vérité dans un champ JS et ne la reflétez vers l'extérieur dans un attribut que si un autre élément doit la voir (ciblage CSS ou lecture par un consommateur). La lecture d'un champ est beaucoup moins coûteuse que l'analyse et la conversion d'un attribut à chaque accès, et les valeurs autres que des chaînes ne sont jamais soumises à un aller-retour via la sérialisation. C'est pourquoi les bibliothèques telles que Lit conservent l'état dans JS et reflètent de manière sélective plutôt que de traiter les attributs comme un espace de stockage : ne reflètent que ce que le monde extérieur doit observer, et non chaque propriété.
Problèmes liés
- Discipline
observedAttributes: ne listez que les attributs dont la modification doit entraîner un nouveau rendu ou déclencher un effet secondaire. Observer tout ajoute un rappel à chaque mutation sans aucun avantage. - Boucles de réflexion : un setter qui écrit un attribut réintègre
attributeChangedCallback. Protégez-le avec une vérificationoldVal !== newVal, comme ci-dessus. - État de style uniquement : pour l'état interne qui n'existe que pour piloter le style (chargement, actif, développé), privilégiez les états personnalisés plutôt que de refléter un attribut synthétique (voir États personnalisés ci-dessous). Ne reflétez un attribut que lorsque les consommateurs doivent pouvoir définir l'état à partir du balisage.
États personnalisés
Les états personnalisés sont des indicateurs booléens qu'un élément expose sur lui-même pour que CSS puisse les cibler. Ils sont définis via ElementInternals et mis en correspondance avec la pseudo-classe :state(). Utilisez-les pour l'état interne de l'UI d'exécution (busy, expanded, invalid) au lieu de refléter un attribut synthétique : ils ne peuvent pas entrer en conflit avec les attributs d'un consommateur, n'apparaissent jamais dans le DOM et sont en lecture seule de l'extérieur (un consommateur peut appliquer un style à :state(), mais ne peut pas le falsifier).
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 est un ensemble, de sorte que les états indépendants se composent (busy plus error) sans inventer d'énumération data-status combinée.
L'état personnalisé n'est disponible qu'au moment de l'exécution. Il n'est pas sérialisé dans le DOM. Il ne survit donc pas à un actualisation, à la recréation de l'élément ni au rendu côté serveur. Si l'état doit être défini de manière déclarative dans le balisage ou persister entre les chargements, c'est le rôle d'un attribut (voir Où se trouve la valeur). Restaurez-le dans connectedCallback à partir de votre source de vérité. Règle générale : un état personnalisé pour ce que le composant dérive et possède au moment de l'exécution et qu'il n'a besoin que de styler ; un attribut pour ce qui doit être défini à partir du balisage ou sérialisé.
Pour appliquer un style à ces états, à la fois dans le composant et à partir de la feuille de style d'un consommateur, consultez Appliquer un style aux composants Web.
Conventions d'attribution de noms
- État véritablement privé : champs
#natifs (#count) pour une encapsulation stricte ; préfixe_uniquement lorsque les sous-classes doivent encore y accéder. Notez que les champs#ne sont pas hérités : une sous-classe ne peut pas voir les champs privés d'une classe de base. - Les propriétés à clé de symbole sont un compromis : elles sont masquées de l'énumération ordinaire, mais accessibles par tout code contenant le symbole (exportez-le ou exposez-le en tant que champ
staticsur la classe). Comme chaque symbole est unique, deux symboles partageant une description n'entrent jamais en conflit. C'est pratique lorsqu'une sous-classe ou un élément frère a besoin d'un accès contrôlé que les champs#ne peuvent pas fournir. - Ne masquez pas les attributs globaux (
style,class,id,slot,part,title,lang,dir) avec vos propres propriétés, car cela interrompt leur comportement intégré. Notez quedisabledn'est global que sur les commandes de formulaire. Sur les autres éléments, vous devez implémenter son effet vous-même.
Lorsque vous nommez une nouvelle surface d'API, suivez les conventions de la plate-forme : utilisez des minuscules séparées par des tirets avec au moins un trait d'union pour les noms de balises d'éléments personnalisés (par exemple, my-toggle), lowerCamelCase pour les propriétés et les méthodes, et des noms d'attributs en minuscules synchronisés avec leurs propriétés correspondantes.