7.3 · Web components
Les diapositives de la séance 7
- expliquer à quoi servent les web components et ce qu’ils apportent face à un framework
- enregistrer une balise personnalisée avec
customElements.define - utiliser les méthodes de cycle de vie :
constructor,connectedCallback,attributeChangedCallback - rendre un attribut réactif grâce à
observedAttributes - pour aller plus loin : distinguer un attribut HTML d’une propriété JavaScript, et encapsuler le style d’un composant avec le Shadow DOM
Les classes et l’héritage (3.2 · Contrats, héritage et polymorphisme), et la manipulation du DOM (7.2 · Formulaires).
Ce chapitre se travaille après la séance 7, à votre rythme. Seul le composant minimal est au programme de l’évaluation 2 : tout ce qui précède la section Le composant minimal, en résumé, et les exercices marqués « essentiel ». La suite est pour aller plus loin. La page Préparer l’évaluation 2 situe ce chapitre dans le plan de révision.
À quoi ça sert
Vous avez écrit un composant « carte de profil » : un bloc HTML, du CSS et un peu de comportement. Vous voulez le réutiliser dix fois dans la page, et dans un autre projet l’an prochain. Les options classiques ont chacune leur défaut :
- copier-coller le HTML : dix copies à corriger à chaque changement ;
- une fonction qui génère du HTML : il faut penser à l’appeler, et le CSS reste global ;
- un framework (React, Vue, Svelte) : cela fonctionne très bien, mais votre composant ne sera réutilisable que dans ce framework, et sa version majeure courante.
Les web components sont la réponse du standard : vous définissez une vraie balise HTML, <profile-card>, que le navigateur comprend nativement. Elle fonctionne dans une page statique, dans Astro, dans React, dans un CMS : partout, sans dépendance, et sans date de péremption.
La balise la plus simple possible
src/scripts/app.ts
class HelloWorld extends HTMLElement {
connectedCallback() {
this.textContent = "Hello World!";
}
}
customElements.define("hello-world", HelloWorld);Trois lignes utiles :
- la classe hérite de
HTMLElement: votre composant est un élément du DOM, avecthis.textContent,this.setAttribute,this.querySelector, etc. ; connectedCallbackest appelé par le navigateur quand l’élément entre dans le document ;customElements.defineassocie le nom de balise à la classe.
À partir de là, <hello-world></hello-world> fonctionne dans n’importe quelle page qui charge ce script.
Le nom d’une balise personnalisée doit contenir un tiret : hello-world est valide, helloworld est refusé avec une SyntaxError. Cette règle n’est pas arbitraire : elle garantit que votre balise n’entrera jamais en collision avec un futur élément standard, qui n’en contient jamais.
Deuxième contrainte : les balises personnalisées ne sont pas auto-fermantes. Écrivez <hello-world></hello-world>, jamais <hello-world />.
Le cycle de vie
Un composant est une classe fille de HTMLElement (extends, super() en premier dans le constructeur). Ses méthodes de cycle de vie, comme connectedCallback, sont des méthodes redéfinies que le navigateur appelle au bon moment. Voir 3.2 · L’héritage et 3.2 · Redéfinir une méthode.
Le navigateur appelle certaines méthodes à des moments précis :
| Méthode | Appelée quand | À y mettre |
|---|---|---|
constructor() |
l’élément est créé en mémoire | initialiser des champs, créer le Shadow DOM |
connectedCallback() |
l’élément est inséré dans le document | produire l’affichage, poser les écouteurs |
disconnectedCallback() |
l’élément est retiré du document | retirer les écouteurs, arrêter les minuteries |
attributeChangedCallback(nom, ancien, nouveau) |
un attribut observé change | mettre à jour l’affichage |
static get observedAttributes() |
au moment du define |
retourner la liste des attributs à surveiller |
Ne touchez pas au DOM dans le constructor : ni attributs, ni enfants. Au moment où il s’exécute, l’élément peut ne pas encore être attaché, et ses attributs ne sont pas forcément lus. La spécification l’interdit d’ailleurs explicitement pour les éléments créés par l’analyseur HTML. Tout ce qui relève de l’affichage va dans connectedCallback.
Un composant avec un attribut
src/scripts/app.ts
class HelloName extends HTMLElement {
name: string = "Remi";
// Le navigateur lit cette liste pour savoir quels attributs surveiller
static get observedAttributes(): string[] {
return ["name"];
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (name === "name") {
this.name = newValue;
this.render();
}
}
connectedCallback() {
this.render();
}
// Un seul endroit qui produit l'affichage, appelé depuis les deux callbacks
render() {
this.textContent = `Hello ${this.name}!`;
}
}
customElements.define("hello-name", HelloName);attributeChangedCallback ne déclenche pas connectedCallback. Si vous mettiez tout votre code d’affichage dans connectedCallback, changer l’attribut ne changerait rien à l’écran.
C’est exactement pour cela qu’on extrait une méthode render(), appelée depuis les deux endroits. Retenez le schéma : les callbacks mettent l’état à jour, render() produit l’affichage.
Un attribut non déclaré dans observedAttributes peut être modifié sans que rien ne se passe : le navigateur n’appelle attributeChangedCallback que pour les attributs de cette liste.
Utiliser le composant
Dans le HTML, aussi simplement que n’importe quelle balise :
src/pages/index.astro
<hello-world></hello-world>
<hello-name></hello-name>
<hello-name name="Charlotte"></hello-name>
<hello-name id="third" name="Eva"></hello-name>Ou par le code :
const helloName = document.createElement("hello-name") as HelloName;
helloName.setAttribute("name", "Monsieur DEBUT");
document.body.appendChild(helloName);
// Plus tard, modifier l'attribut suffit : attributeChangedCallback fait le reste
const third = document.getElementById("third") as HelloName;
third.setAttribute("name", "John");Le composant minimal, en résumé
C’est le modèle à savoir écrire sans aide, et celui que rappelle l’aide-mémoire de l’évaluation 2 :
src/scripts/app.ts
class MonElement extends HTMLElement {
static get observedAttributes(): string[] {
return ["mon-attribut"];
}
connectedCallback(): void {
this.render();
}
attributeChangedCallback(): void {
this.render();
}
render(): void {
this.textContent = this.getAttribute("mon-attribut") ?? "";
}
}
customElements.define("mon-element", MonElement);Cinq points à vérifier dans chacun de vos composants :
- la classe hérite de
HTMLElement; - le nom de la balise contient un tiret ;
- chaque attribut qui doit changer l’affichage figure dans
observedAttributes; connectedCallbacketattributeChangedCallbackappellent la même méthoderender();getAttributerenvoiestring | null: prévoyez une valeur de repli avec??.
Si render() produit du HTML avec innerHTML, n’y interpolez jamais la valeur d’un attribut : un attribut peut venir d’une source extérieure. Utilisez textContent, comme ci-dessus.
Pour aller plus loin
Ce qui suit n’est pas au programme de l’évaluation 2. C’est ce qui fait passer d’un composant qui fonctionne à un composant réutilisable dans n’importe quelle page.
Attribut ou propriété ?
Ce sont deux choses distinctes, et la confusion est fréquente.
| Attribut HTML | Propriété JavaScript | |
|---|---|---|
| Écriture | el.setAttribute("name", "Eva") |
el.name = "Eva" |
| Visible dans l’inspecteur | oui | non |
| Type | toujours une chaîne | n’importe lequel |
Déclenche attributeChangedCallback |
oui | non |
Dans l’exemple ci-dessus, seul setAttribute provoque un nouveau rendu. Pour qu’affecter la propriété fonctionne aussi, on ajoute un setter qui répercute vers l’attribut :
set name(valeur: string) {
this.setAttribute("name", valeur);
}
get name(): string {
return this.getAttribute("name") ?? "Remi";
}Puisque les attributs sont des chaînes, un attribut booléen se code par sa présence (<mon-bouton disabled>) et se teste avec this.hasAttribute("disabled").
Le Shadow DOM
Jusqu’ici, le contenu du composant vit dans la page ordinaire : votre CSS global le décore, et son CSS à lui déborde sur le reste de la page. Pour un composant destiné à être réutilisé ailleurs, c’est un problème.
Le Shadow DOM est un sous-arbre DOM attaché à l’élément et isolé du reste :
- le CSS défini à l’intérieur ne sort pas ;
- le CSS de la page n’entre pas (sauf les propriétés héritées comme
coloroufont-family, et les variables CSS que vous exposez volontairement) ; document.querySelectorne voit pas les éléments internes.
class ProfileCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
const nom = this.getAttribute("nom") ?? "anonyme";
this.shadowRoot!.innerHTML = `
<style>
/* Ce sélecteur ne touchera QUE les .carte de ce composant */
.carte { border: 1px solid #ccc; padding: 1rem; border-radius: 8px; }
h2 { margin: 0; font-size: 1.1rem; }
</style>
<div class="carte"><h2></h2></div>
`;
this.shadowRoot!.querySelector("h2")!.textContent = nom;
}
}
customElements.define("profile-card", ProfileCard);mode: "open" rend this.shadowRoot accessible depuis l’extérieur ; mode: "closed" le masque. Utilisez open : le mode fermé gêne surtout le débogage et les outils de test, sans apporter de véritable sécurité.
innerHTML et données extérieures
L’exemple utilise innerHTML pour le squelette, qui est une constante écrite par vous : c’est sans risque. Mais le nom, lui, vient d’un attribut, donc potentiellement d’une source externe. Il est injecté avec textContent, précisément pour qu’une valeur comme <img src=x onerror=alert(1)> reste du texte. N’interpolez jamais une donnée variable dans une chaîne passée à innerHTML.
Pour laisser l’utilisateur du composant fournir son propre contenu, placez un <slot></slot> dans le Shadow DOM : les enfants écrits entre <profile-card> et </profile-card> viendront s’y afficher.
Exercices
Exercice 7.9 : <date-time>
essentiel
Créez un composant <date-time> qui affiche la date et l’heure courantes, formatées en français avec toLocaleString("fr-FR").
- Version simple : l’heure est affichée au moment de l’insertion dans la page.
- Version vivante : l’affichage se met à jour chaque seconde avec
setInterval. - Arrêtez la minuterie avec
clearIntervaldansdisconnectedCallback.
Critère de réussite : après suppression de l’élément avec element.remove(), plus aucun console.log périodique n’apparaît. Expliquez ce qui se passerait sans le point 3.
src/scripts/app.ts
// 1. Version simple
class DateTimeSimple extends HTMLElement {
connectedCallback() {
this.textContent = new Date().toLocaleString("fr-FR");
}
}
customElements.define("date-time-simple", DateTimeSimple);
// 2 et 3. Version vivante, avec libération de la minuterie
class DateTime extends HTMLElement {
private intervalId: number | null = null;
connectedCallback() {
this.render();
// window.setInterval renvoie un number ; le setTimeout de Node renvoie
// un objet, d'où le préfixe window. pour obtenir le bon type
this.intervalId = window.setInterval(() => {
console.log("tick");
this.render();
}, 1000);
}
disconnectedCallback() {
if (this.intervalId !== null) {
clearInterval(this.intervalId);
this.intervalId = null;
}
}
private render() {
this.textContent = new Date().toLocaleString("fr-FR");
}
}
customElements.define("date-time", DateTime);Pour vérifier, dans la console du navigateur :
document.querySelector("date-time")?.remove();
// les "tick" doivent cesser immédiatementLe point clé. Sans le clearInterval, retirer l’élément du document n’arrête pas la minuterie. Le navigateur continue d’appeler la fonction toutes les secondes, et celle-ci écrit dans un élément qui n’est plus affiché nulle part : travail inutile, console.log fantômes, batterie consommée. Pire, la fonction fléchée capture this ; tant que la minuterie vit, le ramasse-miettes ne peut pas libérer l’élément ni son sous-arbre. C’est une fuite mémoire, et elle passe inaperçue jusqu’à ce qu’une page crée et détruise des centaines de composants.
Règle générale : tout ce que vous démarrez dans connectedCallback (minuterie, écouteur posé sur window ou document, ResizeObserver, connexion WebSocket) doit être arrêté dans disconnectedCallback.
Exercice 7.10 : <greet-custom>
essentiel transfert
Créez un composant <greet-custom> qui salue selon l’heure de la journée : « Bonjour » avant midi, « Bon après-midi » jusqu’à 18 h, « Bonsoir » ensuite.
- Ajoutez un attribut observé
namepour personnaliser le message. - Ajoutez un attribut observé
heurequi force une heure donnée, afin de pouvoir tester les trois cas sans attendre le soir.
Critère de réussite : document.querySelector("greet-custom")!.setAttribute("heure", "20") met à jour le message immédiatement.
src/scripts/app.ts
class GreetCustom extends HTMLElement {
static get observedAttributes(): string[] {
return ["name", "heure"];
}
connectedCallback() {
this.render();
}
// Peu importe lequel a changé : on relit tout et on réaffiche
attributeChangedCallback() {
this.render();
}
private get prenom(): string {
return this.getAttribute("name") ?? "";
}
// Un attribut est TOUJOURS une chaîne : il faut convertir et se méfier
private get heure(): number {
const brut: string | null = this.getAttribute("heure");
if (brut === null) {
return new Date().getHours();
}
const valeur: number = Number(brut);
if (Number.isNaN(valeur) || valeur < 0 || valeur > 23) {
return new Date().getHours();
}
return valeur;
}
private render(): void {
const heure: number = this.heure;
let salutation: string = "Bonjour";
if (heure >= 18) {
salutation = "Bonsoir";
} else if (heure >= 12) {
salutation = "Bon après-midi";
}
this.textContent =
this.prenom === "" ? `${salutation} !` : `${salutation} ${this.prenom} !`;
}
}
customElements.define("greet-custom", GreetCustom);src/pages/index.astro
<greet-custom></greet-custom>
<greet-custom name="Eva"></greet-custom>
<greet-custom name="Eva" heure="20"></greet-custom>Le point clé. Deux pièges se cachent ici.
D’abord l’ordre des tests. Écrire if (heure >= 12) ... else if (heure >= 18) ne donnera jamais « Bonsoir » : à 20 h, la première condition est déjà vraie. Quand des seuils se recouvrent, on les teste du plus restrictif au plus large.
Ensuite le type. getAttribute retourne string | null, jamais un nombre. Sans Number(...), la comparaison "20" >= 18 fonctionnerait par coercition implicite, mais "abc" >= 18 vaudrait false silencieusement et vous afficheriez « Bonjour » sans comprendre pourquoi. On convertit explicitement et on retombe sur l’heure réelle si la valeur est absurde.
Enfin, attributeChangedCallback est appelé avant connectedCallback pour les attributs écrits directement dans le HTML. Comme render() ne fait que définir this.textContent, cela ne pose pas de problème : le composant s’affiche une fois de trop, sans effet visible. Si render() devait lire des enfants ou mesurer l’élément, il faudrait ajouter un garde-fou if (!this.isConnected) return;.
Exercice 7.11 : Encapsuler le style
approfondissement
Reprenez <greet-custom> et passez-le au Shadow DOM.
- Donnez-lui un style interne : fond coloré, coins arrondis, une police différente.
- Ajoutez dans la page une règle CSS globale
p { color: red; }et vérifiez qu’elle n’affecte pas l’intérieur du composant. - Vérifiez que
document.querySelector("p")ne trouve pas les paragraphes internes. - Exposez la couleur de fond par une variable CSS
--fond, afin que l’utilisateur du composant puisse la changer depuis l’extérieur.
Dans le Shadow DOM : background: var(--fond, #eee);. Dans la page : greet-custom { --fond: lightblue; }. Les variables CSS traversent la frontière du Shadow DOM, contrairement aux sélecteurs.
Critère de réussite : le composant garde son apparence quel que soit le CSS de la page hôte, sauf pour ce que vous avez volontairement exposé.
src/scripts/app.ts
class GreetCustom extends HTMLElement {
static get observedAttributes(): string[] {
return ["name", "heure"];
}
constructor() {
super();
// Seule opération autorisée dans le constructor : créer le Shadow DOM.
// Aucun accès aux attributs ni aux enfants ici.
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
// ... les accesseurs prenom et heure, inchangés par rapport à l'exercice 7.10
private render(): void {
if (this.shadowRoot === null) {
return;
}
const heure: number = this.heure;
let salutation: string = "Bonjour";
if (heure >= 18) {
salutation = "Bonsoir";
} else if (heure >= 12) {
salutation = "Bon après-midi";
}
const style: HTMLStyleElement = document.createElement("style");
style.textContent = `
:host { display: inline-block; }
p {
background: var(--fond, #eeeeee);
color: #222222;
font-family: Georgia, serif;
border-radius: 8px;
padding: 0.5rem 1rem;
margin: 0;
}
`;
const p: HTMLParagraphElement = document.createElement("p");
p.textContent =
this.prenom === "" ? `${salutation} !` : `${salutation} ${this.prenom} !`;
// replaceChildren vide le shadow root avant d'y remettre le contenu
this.shadowRoot.replaceChildren(style, p);
}
}
customElements.define("greet-custom", GreetCustom);src/pages/index.astro
<style>
p { color: red; } /* n'entre PAS dans le composant */
greet-custom { --fond: lightblue; } /* traverse la frontière */
</style>
<greet-custom name="Eva"></greet-custom>
<p>Ce paragraphe de la page, lui, est bien rouge.</p>Vérifications demandées, dans la console :
// 3. le paragraphe interne est invisible depuis le document
document.querySelectorAll("p").length; // 1, pas 2
// pour l'atteindre, il faut passer par le shadow root
document.querySelector("greet-custom")?.shadowRoot?.querySelector("p");Le point clé. La frontière du Shadow DOM arrête les sélecteurs, pas l’héritage. Le sélecteur p de la page ne désignera jamais le paragraphe interne, dans aucun sens : votre CSS n’en sort pas non plus. En revanche, une propriété héritée continue de descendre : si la page écrivait greet-custom { color: red; }, la couleur serait héritée par le contenu interne, d’où le color: #222222 explicite, qui s’en protège.
Les variables CSS suivent exactement la même règle : --fond est héritée, donc var(--fond, #eeeeee) la récupère si la page la définit, et retombe sur la valeur par défaut sinon. C’est le mécanisme standard pour ouvrir volontairement quelques points de personnalisation sans exposer tout le style interne. ::part() en est le complément quand une variable ne suffit pas.
Une alternative valable à la reconstruction complète dans render() : créer le <style> une seule fois dans connectedCallback et ne mettre à jour que le textContent du paragraphe ensuite. Plus efficace, un peu plus verbeux.
Au-delà du cours
Les web components sont la base de plusieurs design systems d’entreprise, utilisables tels quels dans vos projets : Shoelace, Material Web, Microsoft FAST, IBM Carbon, Adobe Spectrum et USWDS pour l’administration américaine.
Une bibliothèque de composants fournit des briques ; un design system y ajoute des règles d’usage, une charte graphique et une documentation. Voir aussi open-wc.org pour les bonnes pratiques.
À retenir
- Une classe qui étend
HTMLElementplus uncustomElements.define("mon-tag", MaClasse)suffisent - Le nom de balise doit contenir un tiret, et la balise n’est jamais auto-fermante
- Rien qui touche au DOM dans le
constructor: l’affichage se fait dansconnectedCallback attributeChangedCallbackn’est appelé que pour les attributs listés dansobservedAttributes, et il ne rappelle pasconnectedCallback: extrayez unrender()- Un attribut est toujours une chaîne ; une propriété JavaScript ne déclenche aucun callback
- Le Shadow DOM isole le style dans les deux sens ; libérez les minuteries dans
disconnectedCallback