frontend / html
Web Components : custom elements, templates et shadow DOM
Explication
Ce que vous allez apprendre
- Créer un
<template>de markup réutilisable, cloné à la demande - Définir un Custom Element en étendant
HTMLElement - Isoler le style d'un composant avec le Shadow DOM
- Utiliser
<slot>pour projeter du contenu fourni par l'utilisateur du composant - Comprendre pourquoi le nom d'un custom element doit obligatoirement contenir un tiret
Dans quel contexte ?
Une équipe frontend veut réutiliser le même bloc "carte produit" sur trois projets différents (site vitrine, boutique, back-office admin), sans dépendre d'un framework JavaScript commun aux trois. En créant un Web Component <carte-produit> avec Shadow DOM, le composant s'utilise comme une balise HTML native, embarque son propre CSS isolé (aucun risque qu'un button { color: red; } global du back-office ne déteigne dessus), et fonctionne à l'identique dans n'importe quel projet, avec ou sans framework.
Étape 1 : jusqu'ici, des balises fournies
Jusqu'ici, vous avez utilisé les balises fournies par HTML : <div>, <button>, <article>... Les Web Components permettent d'aller plus loin en créant de VRAIES nouvelles balises personnalisées, comme <carte-produit>.
Étape 2 : la première brique, le <template>
Commençons par la brique la plus simple. Le <template> définit un morceau de markup "inerte" : il existe dans le document mais n'est jamais affiché tel quel, uniquement cloné à la demande — un peu comme un moule à gâteau, qui ne sert à rien tout seul mais permet de reproduire une forme à volonté.
Étape 3 : donner un comportement, le Custom Element
Une fois ce moule défini, il faut lui donner vie. Le Custom Element — une classe JavaScript qui étend HTMLElement — attache un comportement à cette structure : c'est lui qui décide quand et comment le template est utilisé.
Étape 4 : isoler le composant, le Shadow DOM
Il reste un problème à résoudre : dans une grande application, un CSS mal ciblé (button { color: red; } par exemple) peut accidentellement affecter des éléments à l'autre bout de la page.
Le Shadow DOM répond exactement à ce problème : il isole complètement le style et le markup internes du composant. Le CSS de la page ne "fuit" pas à l'intérieur, et inversement — chaque composant devient une bulle hermétique.
| Brique | Rôle |
|---|---|
<template> | Markup inerte, jamais rendu, cloné à la demande |
Custom Element (extends HTMLElement) | Comportement JS attaché à la balise |
Shadow DOM (attachShadow) | Isolation du style et du markup interne |
<slot> | Projection du contenu fourni par l'utilisateur |
Prérequis
Cette leçon suppose une bonne aisance en JavaScript orienté objet (classes, constructor, méthodes) ; c'est la leçon la plus technique de ce cours HTML.
Étape 5 : pourquoi cette isolation est si précieuse
Grâce à elle, les styles définis à l'intérieur d'un composant ne s'appliquent qu'à lui, quoi qu'il se passe ailleurs sur la page. C'est un vrai encapsulement, comparable à ce qu'offre la programmation orientée objet pour le code.
Le piège du nommage à connaître
Un dernier détail technique mais obligatoire : la spécification impose qu'un nom de custom element contienne un tiret (carte-produit, jamais carteproduit).
Cette contrainte garantit qu'aucune future balise HTML native ne pourra jamais entrer en conflit avec vos éléments personnalisés, puisque le HTML standard n'utilise jamais de tiret dans ses noms de balises.
Piège fréquent
Écrire customElements.define("carteproduit", CarteProduit) sans tiret lève une exception JavaScript au chargement de la page : Failed to execute 'define' on 'CustomElementRegistry'. Le tiret n'est pas une convention de style, c'est une exigence stricte de la spécification.
Et ensuite ?
Une fois les Web Components compris, la prochaine étape s'attaque à un sujet tout aussi important : la performance de chargement, avec le lazy loading et les resource hints.
Commandes & code
Web Components : custom elements, templates et shadow DOM
Créer des éléments HTML réutilisables, encapsulés, sans dépendre d'un framework.
<!-- template : markup inerte, cloné à la demande, jamais rendu tel quel -->
<template id="tpl-carte-produit">
<style>
.carte { border: 1px solid #ddd; border-radius: 8px; padding: 16px; }
.prix { color: #2563eb; font-weight: bold; }
</style>
<div class="carte">
<h3><slot name="titre">Titre par défaut</slot></h3>
<p class="prix"><slot name="prix">0€</slot></p>
<slot>Description par défaut</slot>
</div>
</template>// Custom element : encapsule structure, style et comportement
class CarteProduit extends HTMLElement {
static get observedAttributes() {
return ["disponible"]; // attributs surveillés pour attributeChangedCallback
}
constructor() {
super();
// Shadow DOM : isole le style/markup interne du reste de la page
// "open" = accessible depuis l'extérieur via element.shadowRoot
const shadow = this.attachShadow({ mode: "open" });
const template = document.getElementById("tpl-carte-produit");
shadow.appendChild(template.content.cloneNode(true));
}
connectedCallback() {
// appelé quand l'élément est inséré dans le DOM
console.log("Carte produit ajoutée à la page");
}
disconnectedCallback() {
// appelé quand l'élément est retiré : nettoyer listeners/timers ici
}
attributeChangedCallback(nom, ancienneValeur, nouvelleValeur) {
if (nom === "disponible") {
this.shadowRoot.querySelector(".carte").classList.toggle(
"indisponible",
nouvelleValeur === "false"
);
}
}
}
// Le nom DOIT contenir un tiret : réservé aux éléments custom par la spec
customElements.define("carte-produit", CarteProduit);<!-- Usage : un vrai élément HTML, utilisable partout -->
<carte-produit disponible="true">
<span slot="titre">Chaise ergonomique</span>
<span slot="prix">129€</span>
Bois massif, garantie 5 ans.
</carte-produit>
<carte-produit disponible="false">
<span slot="titre">Bureau réglable</span>
<span slot="prix">349€</span>
Hauteur ajustable électriquement.
</carte-produit>// Communication : props, events, attributs — pas de framework, juste la plateforme
class SelecteurQuantite extends HTMLElement {
connectedCallback() {
this.innerHTML = `
<button data-action="moins">-</button>
<span data-role="valeur">1</span>
<button data-action="plus">+</button>
`;
this.valeur = 1;
this.addEventListener("click", (e) => {
const action = e.target.dataset.action;
if (!action) return;
this.valeur = Math.max(1, this.valeur + (action === "plus" ? 1 : -1));
this.querySelector('[data-role="valeur"]').textContent = this.valeur;
// Custom event : pattern standard pour remonter une info au parent
this.dispatchEvent(new CustomEvent("quantite-changee", {
detail: { valeur: this.valeur },
bubbles: true, // traverse le DOM comme un événement natif
}));
});
}
}
customElements.define("selecteur-quantite", SelecteurQuantite);document.querySelector("selecteur-quantite")
.addEventListener("quantite-changee", (e) => {
console.log("Nouvelle quantité :", e.detail.valeur);
});Résumé
customElements.defineexige un nom avec tiret (mon-element), jamais un mot simple.- Le Shadow DOM isole le CSS interne : les styles de la page n'y entrent pas (et inversement).
<slot>projette le contenu léger (light DOM) fourni par l'utilisateur du composant.CustomEvent+bubbles: trueest le canal standard pour remonter des données au parent.
Exercices pratiques
Mission : rendre carte-produit.js réutilisable sur 3 projets
Objectif : Corriger un Web Component mal nommé et non isolé, destiné à être réutilisé sur trois projets aux CSS globaux différents.
Contexte
Une équipe frontend veut réutiliser le même composant "carte produit" sur trois projets (site vitrine, boutique, back-office admin), sans framework commun. Un premier essai échoue au chargement avec l'erreur Failed to execute 'define' on 'CustomElementRegistry', car le code appelait customElements.define("carteproduit", CarteProduit). Le back-office possède aussi une règle CSS globale button { color: red; } qui ne doit surtout pas affecter les boutons internes du composant.
Ta mission : corriger le nommage et isoler correctement le composant.