Shadow DOM déclaratif

Le Shadow DOM déclaratif est une fonctionnalité de plate-forme Web standard, compatible avec Chrome depuis la version 90. Notez que les spécifications de cette fonctionnalité ont changé en 2023 (y compris le changement de nom de shadowroot en shadowrootmode), et que les versions normalisées les plus récentes de toutes les parties de la fonctionnalité sont disponibles dans la version 124 de Chrome.

Navigateurs pris en charge

  • Chrome: 111.
  • Edge: 111.
  • Firefox: 123.
  • Safari: 16.4.

Source

Shadow DOM est l'une des trois normes Web Components, avec les modèles HTML et les éléments personnalisés. Shadow DOM permet de limiter le champ d'application des styles CSS à un sous-arbre DOM spécifique et d'isoler ce sous-arbre du reste du document. L'élément <slot> nous permet de contrôler l'emplacement où les enfants d'un élément personnalisé doivent être insérés dans son arbre d'ombre. Ces fonctionnalités combinées permettent de créer un système permettant de créer des composants autonomes et 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 Shadow DOM était de créer 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 racines d'ombre et définissent leur contenu. Toutefois, de nombreuses applications Web doivent afficher le contenu côté serveur ou en HTML statique au moment de la compilation. Cela peut être important pour offrir une expérience raisonnable aux visiteurs qui ne peuvent pas exécuter JavaScript.

Les justifications du rendu côté serveur (SSR) varient d'un projet à l'autre. Certains sites Web doivent fournir du code HTML côté serveur entièrement fonctionnel pour respecter les consignes d'accessibilité, tandis que d'autres choisissent de proposer une expérience de base sans JavaScript afin de garantir de bonnes performances sur des connexions ou des appareils lents.

Historiquement, il était difficile d'utiliser Shadow DOM avec le rendu côté serveur, car il n'existait aucun moyen intégré d'exprimer les racines fantômes dans le code HTML généré par le serveur. L'association de racines d'ombre à des éléments DOM déjà affichés sans elles 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 un affichage temporaire d'un contenu sans style ("FOUC") lors du chargement des feuilles de style de l'élément racine d'ombre.

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

Créer un root d'ombre déclaratif

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 avec l'attribut shadowrootmode est détecté par l'analyseur HTML et appliqué immédiatement en tant que racine d'ombre de son élément parent. Le chargement du balisage HTML pur de l'exemple ci-dessus génère 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" de Chrome DevTools pour afficher le contenu du Shadow DOM. Par exemple, le caractère représente le contenu Light DOM enfichable.

Nous profitons ainsi des avantages de l'encapsulation et de la projection d'emplacements du Shadow DOM en HTML statique. Aucun code JavaScript n'est nécessaire pour générer l'intégralité de l'arborescence, y compris le nœud racine d'ombre.

Hydratation des composants

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

Un élément personnalisé mis à niveau à partir d'un code HTML qui inclut une racine fantôme déclarative aura déjà cette racine fantôme associée. Cela signifie que l'élément dispose déjà d'une propriété shadowRoot lorsqu'il est instancié, sans que votre code n'en crée explicitement une. Il est préférable de vérifier dans this.shadowRoot si une racine d'ombre existe dans le constructeur de votre élément. Si une valeur existe déjà, le code HTML de ce composant inclut un racine d'ombre déclarative. Si la valeur est nulle, aucun racine d'ombre déclarative n'était présent dans le code HTML ou le navigateur n'est pas compatible avec le DOM d'ombre 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. Jusqu'à présent, il n'était pas nécessaire de vérifier l'existence d'une racine d'ombre avant d'en créer une à l'aide de attachShadow(). Le Shadow DOM déclaratif inclut une petite modification qui permet aux composants existants de fonctionner malgré cela: l'appel de la méthode attachShadow() sur un élément avec une racine fantôme déclarative existante ne génère pas d'erreur. Au lieu de cela, le nœud racine d'ombre déclaratif est vidé et renvoyé. Cela permet aux anciens composants qui ne sont pas compilés 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 permet d'obtenir de manière explicite une référence à l'élément racine d'ombre déclaratif existant, à la fois ouvert et fermé. Vous pouvez l'utiliser pour rechercher et utiliser n'importe quel racine d'ombre déclarative, tout en revenant à attachShadow() si aucun n'a été fourni.

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

Un root d'ombre déclaratif n'est associé qu'à son élément parent. Cela signifie que les racines d'ombre sont toujours colocalisées avec leur élément associé. Cette décision de conception garantit que les racines d'ombre peuvent être diffusées comme le reste d'un document HTML. Il est également pratique pour la création et la génération, car l'ajout d'une racine d'ombre à un élément ne nécessite pas de gérer un registre des racines d'ombre existantes.

L'inconvénient d'associer des racines d'ombre à leur élément parent est qu'il est impossible d'initialiser plusieurs éléments à partir du même <template> de racine d'ombre déclarative. Toutefois, cela n'a probablement pas d'importance dans la plupart des cas où le DOM fantôme déclaratif est utilisé, car le contenu de chaque racine fantôme est rarement identique. Bien que le code HTML généré 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'un racine d'ombre déclarative sérialisée est entièrement statique, la mise à niveau de plusieurs éléments à partir d'une seule racine d'ombre déclarative ne fonctionnerait que si les éléments étaient identiques. Enfin, l'impact des racines d'ombre similaires répétées sur la taille de transfert réseau est relativement faible en raison des effets de la compression.

À l'avenir, il sera peut-être possible de revenir sur les racines d'ombre partagées. Si le DOM est compatible avec la création de modèles intégrée, les racines fantômes déclaratives peuvent être traitées comme des modèles instanciés afin de créer la racine fantôme d'un élément donné. La conception actuelle du Shadow DOM déclaratif permet de le faire à l'avenir en limitant l'association de la racine fantôme à un seul élément.

Le streaming est cool

Associer des racines d'ombre déclaratives directement à leur élément parent simplifie le processus de mise à niveau et d'association à cet élément. Les racines d'ombre déclaratives sont détectées lors de l'analyse HTML et associées immédiatement lorsque leur balise <template> d'ouverture est rencontrée. Le code HTML analysé dans <template> est analysé directement dans la racine d'ombre. Il peut donc être "streamé": affiché au fur et à mesure de sa réception.

<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'un racine de shadow DOM déclaratif ne sera analysé et associé que pour les balises <template> avec un attribut shadowrootmode qui sont présentes lors de l'analyse HTML. En d'autres termes, les racines d'ombre déclaratives peuvent être créées 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> ne sert à rien, 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 certains points importants de sécurité, les racines d'ombre déclaratives ne peuvent pas non plus être créées à l'aide d'API d'analyse de fragments telles que innerHTML ou insertAdjacentHTML(). Le seul moyen d'analyser le code HTML avec des racines fantômes déclaratives appliquées 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'ombre déclaratives à l'aide des balises <style> et <link> standards:

<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 très optimisés: si la même feuille de style est présente dans plusieurs racines d'ombre 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 d'ombre, ce qui élimine les coûts liés aux doublons de mémoire.

Les feuilles de style construesibles 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 construesibles en HTML, ni de s'y référer lors du remplissage de adoptedStyleSheets.

Éviter l'affichage temporaire de contenu non stylisé

Un problème potentiel dans les navigateurs qui ne prennent pas encore en charge le DOM de l'ombre déclaratif est l'évitement du "flash de contenu non stylisé" (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 les FOUC consistait à 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'a pas été associée ni renseignée. Ainsi, le contenu n'est pas affiché 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 d'ombre 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 "FOUC" display:none empêcherait l'affichage du contenu du root d'ombre déclaratif. Toutefois, si vous supprimez cette règle, les navigateurs qui ne prennent pas en charge le Shadow DOM déclaratif afficheront un contenu incorrect ou sans style jusqu'à ce que le polyfill du Shadow DOM déclaratif charge et convertisse le modèle de racine d'ombre en racine d'ombre réelle.

Heureusement, ce problème peut être résolu 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, ce qui ne laisse aucun élément <template> dans l'arborescence DOM. Les navigateurs qui ne sont pas compatibles avec le Shadow DOM déclaratif conservent l'élément <template>, que nous pouvons utiliser pour éviter les FOUC:

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

Au lieu de masquer l'élément personnalisé qui n'est pas encore 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 compatibles avec le Shadow DOM déclaratif, car l'enfant <template shadowrootmode> est supprimé lors de l'analyse HTML.

Détection des 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 shadowrootmode standardisé. L'attribut shadowrootmode et le comportement de streaming plus récents sont disponibles dans Chrome 111 et Edge 111.

En tant que nouvelle API de plate-forme Web, le DOM déclaratif de l'ombre n'est pas encore compatible avec tous les navigateurs. Vous pouvez détecter la compatibilité du navigateur 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 reproduire parfaitement la sémantique temporelle ni les caractéristiques réservées à l'analyseur auxquelles une implémentation de navigateur s'intéresse. Pour polyfiller le Shadow DOM déclaratif, nous pouvons analyser le DOM pour trouver tous les éléments <template shadowrootmode>, puis les convertir en racines fantômes 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);

Documentation complémentaire