Retour au cours

frontend / html

Web Components : custom elements, templates et shadow DOM

Leçon 131 exercice

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.

BriqueRô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.

html
<!-- 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>
js
// 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);
html
<!-- 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>
js
// 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);
js
document.querySelector("selecteur-quantite")
    .addEventListener("quantite-changee", (e) => {
        console.log("Nouvelle quantité :", e.detail.valeur);
    });

Résumé

  • customElements.define exige 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: true est le canal standard pour remonter des données au parent.

Exercices pratiques

1 disponible
1

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.

Résoudre l’exercice →