Shadow DOM déclaratif

Une nouvelle façon d'implémenter et d'utiliser le Shadow DOM directement en HTML

Le Shadow DOM déclaratif est une fonctionnalité standard de la plate-forme Web, compatible avec Chrome à partir de la version 90. Notez que la spécification de cette fonctionnalité a été modifiée en 2023 (y compris le changement de nom : shadowroot a été renommé shadowrootmode). Les versions standardisées les plus récentes de toutes les parties de la fonctionnalité ont été intégrées à la version 124 de Chrome.

Shadow DOM est l'une des trois normes de Web Components, complétée par des modèles HTML et des éléments personnalisés. Shadow DOM permet de limiter les styles CSS à une sous-arborescence DOM spécifique et d'isoler cette sous-arborescence du reste du document. L'élément <slot> nous permet de contrôler où les enfants d'un élément personnalisé doivent être insérés dans son arborescence d'ombres. Ces fonctionnalités combinées permettent de créer un système permettant de créer des composants autonomes réutilisables qui s'intègrent parfaitement aux applications existantes, comme un élément HTML intégré.

Jusqu'à présent, le seul moyen d'utiliser le Shadow DOM était de construire une racine fantôme à l'aide de JavaScript:

const host = document.getElementById('host');
const shadowRoot = host.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>';

Une API impérative comme celle-ci fonctionne parfaitement pour le rendu côté client: les mêmes modules JavaScript qui définissent nos éléments personnalisés créent également leurs Shadow Roots et définissent leur contenu. Cependant, de nombreuses applications Web doivent afficher le contenu côté serveur ou en HTML statique au moment de la compilation. Cela peut être un élément important pour offrir une expérience raisonnable aux visiteurs qui ne peuvent pas toujours exécuter JavaScript.

Les justifications concernant le rendu côté serveur varient d'un projet à l'autre. Certains sites Web doivent fournir du code HTML entièrement fonctionnel basé sur le serveur afin de respecter les consignes en matière d'accessibilité, tandis que d'autres choisissent de proposer une expérience non JavaScript de référence pour garantir de bonnes performances sur les appareils ou les connexions lentes.

Auparavant, il était difficile d'utiliser le Shadow DOM en combinaison avec le rendu côté serveur, car il n'existait pas de moyen intégré d'exprimer les Shadow Roots dans le code HTML généré par le serveur. L'association de racines fantômes à des éléments DOM qui ont déjà été affichés sans ces éléments a également des conséquences sur les performances. Cela peut entraîner un décalage de la mise en page après le chargement de la page ou l'affichage temporaire d'un contenu sans style ("FOUC") lors du chargement des feuilles de style de la racine de l'ombre.

Le Shadow DOM déclaratif (DSD) élimine cette limitation et applique le Shadow DOM au serveur.

Créer une racine d'ombre déclarative

Une racine fantôme déclarative est un élément <template> avec un attribut shadowrootmode:

<host-element>
  <template shadowrootmode="open">
    <slot></slot>
  </template>
  <h2>Light content</h2>
</host-element>

Un élément de modèle comportant l'attribut shadowrootmode est détecté par l'analyseur HTML et appliqué immédiatement en tant que racine fantôme de son élément parent. Chargement du balisage HTML pur de l'exemple de résultats ci-dessus dans l'arborescence DOM suivante:

<host-element>
  #shadow-root (open)
  <slot>
    ↳
    <h2>Light content</h2>
  </slot>
</host-element>

Cet exemple de code suit les conventions du panneau "Éléments des outils pour les développeurs Chrome" pour afficher le contenu Shadow DOM. Par exemple, le caractère 🏠 représente le contenu Light DOM inséré.

Cela nous permet de bénéficier des avantages de l'encapsulation et de la projection des emplacements du Shadow DOM en HTML statique. Aucun code JavaScript n'est nécessaire pour produire l'intégralité de l'arborescence, y compris la racine fantôme.

Hydratation des composants

Le Shadow DOM déclaratif peut être utilisé seul pour encapsuler des styles ou personnaliser l'emplacement des enfants, mais il est plus puissant lorsqu'il est utilisé avec des éléments personnalisés. Les composants créés à l'aide d'éléments personnalisés sont automatiquement mis à niveau à partir du code HTML statique. Avec l'introduction du Shadow DOM déclaratif, il est désormais possible pour un élément personnalisé de disposer d'une racine fantôme avant sa mise à niveau.

Un élément personnalisé mis à niveau à partir d'un code HTML incluant une racine fantôme déclarative aura déjà cette racine fantôme associée. Cela signifie que l'élément aura une propriété shadowRoot déjà disponible lorsqu'il est instancié, sans que votre code en crée explicitement une. Il est préférable de vérifier dans this.shadowRoot s'il existe une racine fantôme existante dans le constructeur de votre élément. S'il existe déjà une valeur, le code HTML de ce composant inclut une racine fantôme déclarative. Si la valeur est nulle, cela signifie qu'aucune racine d'ombre déclarative n'est présente dans le code HTML ou que le navigateur n'est pas compatible avec le Shadow DOM déclaratif.

<menu-toggle>
  <template shadowrootmode="open">
    <button>
      <slot></slot>
    </button>
  </template>
  Open Menu
</menu-toggle>
<script>
  class MenuToggle extends HTMLElement {
    constructor() {
      super();

      // Detect whether we have SSR content already:
      if (this.shadowRoot) {
        // A Declarative Shadow Root exists!
        // wire up event listeners, references, etc.:
        const button = this.shadowRoot.firstElementChild;
        button.addEventListener('click', toggle);
      } else {
        // A Declarative Shadow Root doesn't exist.
        // Create a new shadow root and populate it:
        const shadow = this.attachShadow({mode: 'open'});
        shadow.innerHTML = `<button><slot></slot></button>`;
        shadow.firstChild.addEventListener('click', toggle);
      }
    }
  }

  customElements.define('menu-toggle', MenuToggle);
</script>

Les éléments personnalisés existent depuis un certain temps et jusqu'à présent, il n'y avait aucune raison de rechercher une racine fantôme existante avant d'en créer une à l'aide de attachShadow(). Le Shadow DOM déclaratif inclut une légère modification qui permet aux composants existants de fonctionner malgré cela: appeler la méthode attachShadow() sur un élément avec une racine fantôme déclarative existante ne génère pas d'erreur. À la place, la racine fantôme déclarative est vidée et renvoyée. Cela permet aux composants plus anciens qui ne sont pas conçus pour le Shadow DOM déclaratif de continuer à fonctionner, car les racines déclaratives sont conservées jusqu'à ce qu'un remplacement impératif soit créé.

Pour les éléments personnalisés nouvellement créés, une nouvelle propriété ElementInternals.shadowRoot fournit un moyen explicite d'obtenir une référence à la racine fantôme déclarative existante, à la fois ouverte et fermée. Cela permet de rechercher et d'utiliser une racine fantôme déclarative, tout en revenant à attachShadow() lorsqu'aucune racine n'a été fournie.

class MenuToggle extends HTMLElement {
  constructor() {
    super();

    const internals = this.attachInternals();

    // check for a Declarative Shadow Root:
    let shadow = internals.shadowRoot;
    if (!shadow) {
      // there wasn't one. create a new Shadow Root:
      shadow = this.attachShadow({
        mode: 'open'
      });
      shadow.innerHTML = `<button><slot></slot></button>`;
    }

    // in either case, wire up our event listener:
    shadow.firstChild.addEventListener('click', toggle);
  }
}
customElements.define('menu-toggle', MenuToggle);

Une ombre par racine

Une racine fantôme déclarative n'est associée qu'à son élément parent. Cela signifie que les racines fantômes sont toujours colocalisées avec leur élément associé. Cette décision de conception garantit que les racines fantômes peuvent être diffusées comme le reste d'un document HTML. Cette méthode est également pratique pour la création et la génération, car l'ajout d'une racine fantôme à un élément ne nécessite pas la gestion d'un registre des racines fantômes existantes.

Associer des racines fantômes à leur élément parent ne permet pas d'initialiser plusieurs éléments à partir de la même racine d'ombre déclarative <template>. Toutefois, cela est peu probable dans la plupart des cas où le Shadow DOM déclaratif est utilisé, car le contenu de chaque racine fantôme est rarement identique. Bien que le code HTML affiché par le serveur contienne souvent des structures d'éléments répétées, leur contenu diffère généralement (par exemple, de légères variations de texte ou d'attributs). Étant donné que le contenu d'une racine fantôme déclarative sérialisée est entièrement statique, la mise à niveau de plusieurs éléments à partir d'une seule racine fantôme déclarative ne fonctionnera que si les éléments sont identiques. Enfin, l'impact de racines fantômes similaires répétées sur la taille du transfert réseau est relativement faible en raison des effets de la compression.

À l'avenir, il sera peut-être possible de revoir les racines fantômes partagées. Si le DOM est compatible avec la modélisation intégrée, les racines fantômes déclaratives peuvent être traitées comme des modèles instanciés afin de construire la racine fantôme pour un élément donné. La conception actuelle du Shadow DOM déclaratif autorise cette possibilité à l'avenir en limitant l'association de la racine fantôme à un seul élément.

Le streaming, c'est cool

L'association de racines fantômes déclaratives directement à leur élément parent simplifie le processus de mise à niveau et de leur association à cet élément. Les racines fantômes déclaratives sont détectées lors de l'analyse HTML et sont associées immédiatement lorsque leur balise d'ouverture <template> est détectée. Le code HTML analysé dans <template> est analysé directement dans la racine fantôme. Il peut donc être "en flux continu": rendu tel qu'il est reçu.

<div id="el">
  <script>
    el.shadowRoot; // null
  </script>

  <template shadowrootmode="open">
    <!-- shadow realm -->
  </template>

  <script>
    el.shadowRoot; // ShadowRoot
  </script>
</div>

Analyseur uniquement

Le Shadow DOM déclaratif est une fonctionnalité de l'analyseur HTML. Cela signifie qu'une racine fantôme déclarative ne sera analysée et associée que pour les balises <template> ayant un attribut shadowrootmode présentes lors de l'analyse HTML. En d'autres termes, les racines fantômes déclaratives peuvent être construites lors de l'analyse HTML initiale:

<some-element>
  <template shadowrootmode="open">
    shadow root content for some-element
  </template>
</some-element>

Définir l'attribut shadowrootmode d'un élément <template> n'a aucun effet, et le modèle reste un élément de modèle ordinaire:

const div = document.createElement('div');
const template = document.createElement('template');
template.setAttribute('shadowrootmode', 'open'); // this does nothing
div.appendChild(template);
div.shadowRoot; // null

Pour éviter certaines considérations de sécurité importantes, vous ne pouvez pas non plus créer de racines fantômes déclaratives à l'aide d'API d'analyse de fragments telles que innerHTML ou insertAdjacentHTML(). Le seul moyen d'analyser du code HTML en appliquant des racines d'ombre déclaratives est d'utiliser setHTMLUnsafe() ou parseHTMLUnsafe():

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  div.setHTMLUnsafe(html); // Shadow roots included
  const newDocument = Document.parseHTMLUnsafe(html); // Also here
</script>

Rendu côté serveur avec style

Les feuilles de style intégrées et externes sont entièrement compatibles avec les racines d'ombres déclaratives à l'aide des tags standards <style> et <link>:

<nineties-button>
  <template shadowrootmode="open">
    <style>
      button {
        color: seagreen;
      }
    </style>
    <link rel="stylesheet" href="/comicsans.css" />
    <button>
      <slot></slot>
    </button>
  </template>
  I'm Blue
</nineties-button>

Les styles spécifiés de cette manière sont également hautement optimisés: si la même feuille de style est présente dans plusieurs racines d'ombres déclaratives, elle n'est chargée et analysée qu'une seule fois. Le navigateur utilise un seul CSSStyleSheet de sauvegarde partagé par toutes les racines fantômes, éliminant ainsi la surcharge de mémoire en double.

Les feuilles de style constructibles ne sont pas compatibles avec le Shadow DOM déclaratif. En effet, il n'existe actuellement aucun moyen de sérialiser des feuilles de style constructibles en HTML ni d'y faire référence lors de l'insertion de adoptedStyleSheets.

Éviter les flashs de contenu sans style

Un problème potentiel dans les navigateurs qui ne sont pas encore compatibles avec le Shadow DOM déclaratif est d'éviter le "flash of unstyled content" (FOUC), où le contenu brut est affiché pour les éléments personnalisés qui n'ont pas encore été mis à niveau. Avant le Shadow DOM déclaratif, une technique courante pour éviter l'FOUC était d'appliquer une règle de style display:none aux éléments personnalisés qui n'ont pas encore été chargés, car leur racine fantôme n'était pas associé ni rempli. Ainsi, le contenu ne s'affiche pas tant qu'il n'est pas "prêt":

<style>
  x-foo:not(:defined) > * {
    display: none;
  }
</style>

Avec l'introduction du Shadow DOM déclaratif, les éléments personnalisés peuvent être affichés ou créés en HTML de sorte que leur contenu fantôme soit en place et prêt avant le chargement de l'implémentation du composant côté client:

<x-foo>
  <template shadowrootmode="open">
    <style>h2 { color: blue; }</style>
    <h2>shadow content</h2>
  </template>
</x-foo>

Dans ce cas, la règle display:none "FOUC" empêche l'affichage du contenu de la racine fantôme déclarative. Toutefois, si vous supprimez cette règle, les navigateurs non compatibles avec le Shadow DOM déclaratif afficheront du contenu incorrect ou sans style, jusqu'à ce que le polyfill du Shadow DOM déclaratif se charge et convertit le modèle de racine fantôme en une véritable racine fantôme.

Heureusement, vous pouvez résoudre ce problème en CSS en modifiant la règle de style FOUC. Dans les navigateurs compatibles avec le Shadow DOM déclaratif, l'élément <template shadowrootmode> est immédiatement converti en racine fantôme, ne laissant aucun élément <template> dans l'arborescence DOM. Les navigateurs qui ne prennent pas en charge le Shadow DOM déclaratif préservent l'élément <template>, que nous pouvons utiliser pour empêcher la FOUC:

<style>
  x-foo:not(:defined) > template[shadowrootmode] ~ *  {
    display: none;
  }
</style>

Au lieu de masquer l'élément personnalisé non défini, la règle "FOUC" révisée masque ses enfants lorsqu'ils suivent un élément <template shadowrootmode>. Une fois l'élément personnalisé défini, la règle ne correspond plus. La règle est ignorée dans les navigateurs qui acceptent le Shadow DOM déclaratif, car l'enfant <template shadowrootmode> est supprimé lors de l'analyse HTML.

Détection de fonctionnalités et compatibilité avec les navigateurs

Le Shadow DOM déclaratif est disponible depuis Chrome 90 et Edge 91, mais il utilisait un ancien attribut non standard appelé shadowroot au lieu de l'attribut standardisé shadowrootmode. Le nouvel attribut shadowrootmode et le comportement de streaming sont disponibles dans Chrome 111 et Edge 111.

En tant que nouvelle API de plate-forme Web, le Shadow DOM déclaratif n'est pas encore compatible avec tous les navigateurs. La compatibilité des navigateurs peut être détectée en vérifiant l'existence d'une propriété shadowRootMode sur le prototype de HTMLTemplateElement:

function supportsDeclarativeShadowDOM() {
  return HTMLTemplateElement.prototype.hasOwnProperty('shadowRootMode');
}

Polyfill

La création d'un polyfill simplifié pour le Shadow DOM déclaratif est relativement simple, car un polyfill n'a pas besoin de répliquer parfaitement la sémantique temporelle ni les caractéristiques de l'analyseur uniquement qui concernent une implémentation de navigateur. Pour émuler un Shadow DOM déclaratif, nous pouvons analyser le DOM pour rechercher tous les éléments <template shadowrootmode>, puis les convertir en racines d'ombre associées à leur élément parent. Ce processus peut être effectué une fois le document prêt ou déclenché par des événements plus spécifiques tels que les cycles de vie des éléments personnalisés.

(function attachShadowRoots(root) {
  root.querySelectorAll("template[shadowrootmode]").forEach(template => {
    const mode = template.getAttribute("shadowrootmode");
    const shadowRoot = template.parentNode.attachShadow({ mode });
    shadowRoot.appendChild(template.content);
    template.remove();
    attachShadowRoots(shadowRoot);
  });
})(document);

Complément d'informations