Shadow DOM déclaratif

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

Le Shadow DOM déclaratif est une fonctionnalité de la plate-forme Web, en cours de normalisation. Elle est activée par défaut dans la version 111 de Chrome.

Shadow DOM est l'une des trois normes Web Components, complétées par les modèles HTML et les éléments personnalisés. Le 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 à un système de créer des composants autonomes et réutilisables qui s'intègrent parfaitement aux applications existantes, tout comme un élément HTML intégré.

Jusqu'à présent, la seule façon d'utiliser Shadow DOM consistait à 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 bien pour le rendu côté client: les mêmes modules JavaScript qui définissent nos éléments personnalisés créent également leurs racines fantômes et définissent leur contenu. Cependant, de nombreuses applications Web doivent afficher du contenu côté serveur ou en HTML statique au moment de la compilation. Cela peut s'avérer essentiel pour offrir une expérience raisonnable aux visiteurs qui pourraient ne pas être en mesure d'exécuter JavaScript.

Les justifications pour le rendu côté serveur varient d'un projet à l'autre. Certains sites Web doivent fournir du code HTML entièrement fonctionnel, rendu par le serveur, afin de respecter les consignes d'accessibilité. D'autres proposent une expérience sans JavaScript de base afin de garantir de bonnes performances sur les appareils ou les connexions lentes.

Auparavant, il était difficile d'utiliser le 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 fantômes à des éléments DOM qui ont déjà été rendus sans ces racines 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 afficher temporairement un contenu sans style ("FOUC") lors du chargement des feuilles de style de la racine fantôme.

Le Shadow DOM déclaratif (DSD) élimine cette limitation en envoyant 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 avec 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. Le chargement du balisage HTML pur à partir 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 respecte les conventions du panneau "Éléments des outils pour les développeurs Chrome" pour l'affichage du contenu Shadow DOM. Par exemple, le caractère Budget représente le contenu Light DOM inséré.

Cela nous donne les avantages de l'encapsulation et de la projection d'emplacements de Shadow DOM en HTML statique. Aucun code JavaScript n'est nécessaire pour produire l'arborescence entière, 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. Toutefois, il est plus efficace 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éclarative, il est désormais possible pour un élément personnalisé d'avoir une racine fantôme avant sa mise à niveau.

Un élément personnalisé mis à niveau à partir du code HTML incluant une racine fantôme déclarative sera déjà associé à cette racine. Cela signifie qu'une propriété shadowRoot de l'élément est déjà disponible lorsqu'il est instancié, sans que votre code en crée une explicitement. Il est préférable de rechercher this.shadowRoot pour rechercher 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 "null", cela signifie qu'aucune racine fantôme déclarative n'est présente dans le code HTML ou que le navigateur n'est pas compatible avec le Shadow DOM déclarative.

<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: l'appel de la méthode attachShadow() sur un élément avec une racine d'ombre déclarative existante ne générera 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 offre un moyen explicite d'obtenir une référence à la racine d'ombre déclarative existante d'un élément, qu'elle soit ouverte ou fermée. Cela permet de rechercher et d'utiliser n'importe quelle racine fantôme déclarative, tout en ayant recours à attachShadow() si 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 au même endroit que leur élément associé. Cette décision de conception garantit que les racines fantômes sont diffusables comme le reste d'un document HTML. Elle 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 de racines fantômes existantes.

Associer des racines fantômes à leur élément parent a pour contrepartie qu'il n'est pas possible d'initialiser plusieurs éléments à partir de la même <template> de racine fantôme déclarative. Toutefois, il est peu probable que cela ait d'importance 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 (de légères variations au niveau du texte ou des attributs, par exemple). Étant donné que le contenu d'une 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 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 revenir sur 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éclarative permet d'assurer cette possibilité à l'avenir en limitant l'association de racines fantômes à un seul élément.

Le streaming, c'est cool

Associer des 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 associées immédiatement lorsque leur balise d'ouverture <template> est détectée. Le code HTML analysé dans <template> est directement analysé dans la racine fantôme. Il peut donc être diffusé en streaming, c'est-à-dire 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'une racine fantôme déclarative ne sera analysée et associée que pour les balises <template> avec un attribut shadowrootmode présent lors de l'analyse HTML. En d'autres termes, les racines fantômes déclaratives peuvent être créées lors de l'analyse initiale du code HTML:

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

La définition de 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(). La seule façon d'analyser du code HTML en appliquant les racines fantômes déclaratives consiste à transmettre une nouvelle option includeShadowRoots à DOMParser:

<script>
  const html = `
    <div>
      <template shadowrootmode="open"></template>
    </div>
  `;
  const div = document.createElement('div');
  div.innerHTML = html; // No shadow root here
  const fragment = new DOMParser().parseFromString(html, 'text/html', {
    includeShadowRoots: true
  }); // Shadow root here
</script>

Rendu serveur avec style

Les feuilles de style intégrées et externes sont entièrement compatibles avec les racines fantômes déclaratives à l'aide des balises 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 fantômes 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, ce qui élimine la surcharge de la mémoire en double.

Les feuilles de style constructables ne sont pas compatibles avec Declarative Shadow DOM. En effet, il n'existe actuellement aucun moyen de sérialiser des feuilles de style constructibles en HTML et il n'est pas possible de s'y référer pour renseigner adoptedStyleSheets.

Éviter le flash 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 de contenu sans style (FOUC), car le contenu brut est affiché pour les éléments personnalisés qui n'ont pas encore été mis à niveau. Avant l'utilisation du Shadow DOM déclarative, une technique courante pour éviter le FOUC consistait à appliquer une règle de style display:none aux éléments personnalisés qui n'avaient pas encore été chargés, car leur racine fantôme n'était pas associée ni renseignée. 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 ombré 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ê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 Shadow DOM déclaratif soit chargé et converti le modèle de racine fantôme en une véritable racine fantôme.

Heureusement, vous pouvez résoudre ce problème dans 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, sans laisser d'é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 l'erreur FOUC:

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

Au lieu de masquer l'élément personnalisé pas encore défini, la nouvelle règle "FOUC" 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'élément enfant <template shadowrootmode> est supprimé lors de l'analyse du code 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 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 Shadow DOM déclaratif n'est pas encore largement pris en charge par tous les navigateurs. La compatibilité avec les 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 ou les caractéristiques de l'analyseur qui concernent une implémentation de navigateur. Pour polyfill Shadow DOM déclarative, nous pouvons analyser le DOM pour trouver 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 que le document est 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