DOM shadow dichiarativo

Un nuovo modo per implementare e utilizzare Shadow DOM direttamente nel codice HTML.

Lo shadow DOM dichiarativo è una funzionalità standard della piattaforma web, supportata in Chrome a partire dalla versione 90. Tieni presente che la specifica per questa funzionalità è cambiata nel 2023 (inclusa la ridenominazione di shadowroot in shadowrootmode) e che le versioni standardizzate più aggiornate di tutte le parti della funzionalità sono disponibili nella versione 124 di Chrome.

Shadow DOM è uno dei tre standard dei componenti web, completato da modelli HTML ed elementi personalizzati. Shadow DOM consente di definire l'ambito degli stili CSS in uno specifico sottoalbero del DOM e isolare questo sottoalbero dal resto del documento. L'elemento <slot> consente di controllare dove inserire gli elementi secondari di un elemento personalizzato all'interno del suo albero ombra. La combinazione di queste funzionalità consente a un sistema di creare componenti autonomi e riutilizzabili che si integrano perfettamente nelle applicazioni esistenti proprio come un elemento HTML integrato.

Finora, l'unico modo per utilizzare Shadow DOM era creare una radice shadow utilizzando JavaScript:

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

Un'API imperativa come questa funziona bene per il rendering lato client: gli stessi moduli JavaScript che definiscono i nostri elementi personalizzati creano anch'essi le radici shadow e ne impostano i contenuti. Tuttavia, molte applicazioni web devono eseguire il rendering dei contenuti lato server o in formato HTML statico in fase di creazione. Questo può essere un aspetto importante per offrire un'esperienza ragionevole ai visitatori che potrebbero non essere in grado di eseguire JavaScript.

Le giustificazioni per il rendering lato server (SSR) variano in base al progetto. Alcuni siti web devono fornire codice HTML sottoposto a rendering dal server per rispettare le linee guida sull'accessibilità, altri scelgono di offrire un'esperienza senza JavaScript di base come metodo per garantire buone prestazioni su connessioni o dispositivi lenti.

Storicamente, è stato difficile utilizzare Shadow DOM in combinazione con il rendering lato server, perché non esisteva un modo integrato per esprimere le radici shadow nell'HTML generato dal server. Ci sono anche implicazioni in termini di prestazioni quando si collegano le root shadow agli elementi DOM che sono già stati sottoposti a rendering senza questi elementi. Questo può causare una variazione del layout dopo il caricamento della pagina o mostrare temporaneamente un lampo di contenuti senza stile ("FOUC") durante il caricamento dei fogli di stile della radice shadow.

Il Declarative Shadow DOM (DSD) rimuove questa limitazione, portando Shadow DOM al server.

Creare una radice ombra dichiarativa

Una radice ombra dichiarativa è un elemento <template> con un attributo shadowrootmode:

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

Un elemento del modello con l'attributo shadowrootmode viene rilevato dall'analizzatore sintattico HTML e applicato immediatamente come radice ombra dell'elemento principale. Il caricamento del markup HTML puro dagli esempi di esempio precedenti genera la seguente struttura DOM:

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

Questo esempio di codice segue le convenzioni del riquadro Elementi di Chrome DevTools per la visualizzazione dei contenuti DOM shadow. Ad esempio, il carattere ꛭ rappresenta i contenuti DOM Light DOM slot.

Questo ci offre i vantaggi dell'incapsulamento e della proiezione degli slot di Shadow DOM in HTML statico. Non serve JavaScript per produrre l'intero albero, inclusa la radice ombra.

Idratazione dei componenti

Lo Shadow DOM dichiarativo può essere utilizzato da solo per incapsulare gli stili o personalizzare il posizionamento secondario, ma è più efficace se utilizzato con elementi personalizzati. L'upgrade dei componenti creati utilizzando Elementi personalizzati viene eseguito automaticamente dal codice HTML statico. Con l'introduzione del DOM dichiarativo di shadow, ora un elemento personalizzato può avere una radice ombra prima dell'upgrade.

Un elemento personalizzato in fase di upgrade da HTML che include una radice shadow dichiarativa avrà già questa radice shadow collegata. Ciò significa che l'elemento avrà una proprietà shadowRoot già disponibile quando viene creata un'istanza, senza che il codice ne crei esplicitamente una. È meglio controllare this.shadowRoot per verificare la presenza di eventuali root shadow esistenti nel costruttore dell'elemento. Se esiste già un valore, il codice HTML di questo componente include una radice ombra dichiarativa. Se il valore è nullo, nell'HTML non era presente alcuna radice shadow dichiarativa oppure il browser non supporta il DOM shadow dichiarativo.

<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>

Gli elementi personalizzati sono in uso da un po' di tempo e fino ad ora non c'era motivo di verificare la presenza di una radice shadow esistente prima di crearne una utilizzando attachShadow(). Lo Shadow DOM dichiarativo include una piccola modifica che consente il funzionamento dei componenti esistenti nonostante: la chiamata al metodo attachShadow() su un elemento con una radice shadow dichiarativa esistente non genera un errore. La radice ombra dichiarativa viene invece svuotata e restituita. In questo modo, i componenti meno recenti non creati per il DOM dichiarativo di Shadow possono continuare a funzionare, poiché le radici dichiarative vengono conservate fino a quando non viene creata una sostituzione imperativa.

Per gli elementi personalizzati appena creati, una nuova proprietà ElementInternals.shadowRoot fornisce un modo esplicito per ottenere un riferimento alla radice ombra dichiarativa esistente di un elemento, sia aperta che chiusa. Questa può essere utilizzata per verificare e utilizzare qualsiasi radice ombra dichiarativa, continuando a utilizzare attachShadow() nei casi in cui non ne sia stata fornita una.

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);

Un'ombra per radice

Una radice ombra dichiarativa è associata soltanto al suo elemento principale. Ciò significa che le radici shadow sono sempre collocate con l'elemento associato. Questa decisione di progettazione garantisce che le radici shadow possano essere trasmesse in streaming come il resto di un documento HTML. È utile anche per la creazione e la generazione, poiché l'aggiunta di una radice shadow a un elemento non richiede la gestione di un registro di root shadow esistenti.

Il compromesso derivante dall'associazione delle radici ombra all'elemento principale è che non è possibile inizializzare più elementi dalla stessa radice ombra dichiarativa <template>. Tuttavia, è improbabile che questo sia importante nella maggior parte dei casi in cui viene utilizzato il DOM shadow dichiarativo, poiché i contenuti di ogni radice shadow sono raramente identici. Sebbene l'HTML visualizzato dal server spesso contenga strutture di elementi ripetute, generalmente i suoi contenuti sono diversi, ad esempio leggere variazioni nel testo o attributi. Poiché i contenuti di una radice ombra dichiarativa serializzata sono completamente statici, l'upgrade di più elementi da una singola radice ombra dichiarativa funzionerebbe solo se gli elementi si verificassero essere identici. Infine, l'impatto di root shadow simili ripetute sulle dimensioni di trasferimento di rete è relativamente ridotto a causa degli effetti della compressione.

In futuro potrebbe essere possibile rivedere le radici shadow condivise. Se il DOM ottiene il supporto per i modelli integrati, le radici shadow dichiarative potrebbero essere trattate come modelli di cui viene creata un'istanza al fine di creare la radice ombra per un determinato elemento. L'attuale progettazione con Shadow DOM dichiarativo consente questa possibilità che esista in futuro limitando l'associazione della radice shadow a un singolo elemento.

Lo streaming è fantastico

L'associazione delle radici shadow dichiarative direttamente all'elemento padre semplifica il processo di upgrade e il loro collegamento all'elemento. Le radici ombra dichiarative vengono rilevate durante l'analisi dell'HTML e collegate immediatamente quando viene rilevato il tag <template> di apertura. L'HTML analizzato all'interno di <template> viene analizzato direttamente nella radice shadow, in modo che possa essere "in streaming": viene visualizzato non appena viene ricevuto.

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

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

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

Solo parser

Lo Shadow DOM dichiarativo è una funzionalità dell'analizzatore sintattico HTML. Ciò significa che una radice ombra dichiarativa verrà analizzata e collegata solo per i tag <template> con un attributo shadowrootmode presente durante l'analisi HTML. In altre parole, le radici delle ombre dichiarative possono essere create durante l'analisi HTML iniziale:

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

L'impostazione dell'attributo shadowrootmode di un elemento <template> non ha alcun effetto, mentre il modello rimane un normale elemento del modello:

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

Per evitare importanti considerazioni sulla sicurezza, non è inoltre possibile creare le directory shadow dichiarative utilizzando API di analisi dei frammenti come innerHTML o insertAdjacentHTML(). L'unico modo per analizzare l'HTML con le radici ombra dichiarative applicate è utilizzare setHTMLUnsafe() o 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>

Rendering del server con stile

I fogli di stile in linea ed esterni sono completamente supportati all'interno delle directory shadow dichiarative utilizzando i tag <style> e <link> standard:

<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>

Anche gli stili specificati in questo modo sono altamente ottimizzati: se lo stesso foglio di stile è presente in più radici ombra dichiarative, viene caricato e analizzato una sola volta. Il browser utilizza un singolo CSSStyleSheet di backup condiviso da tutte le directory radice shadow, eliminando l'overhead della memoria duplicata.

I fogli di stile costruibili non sono supportati nel DOM dichiarativo shadow. Questo perché al momento non è possibile serializzare i fogli di stile costruibili in HTML e non è possibile farvi riferimento durante la compilazione di adoptedStyleSheets.

Evitare il flash di contenuti senza stile

Un potenziale problema nei browser che non supportano ancora il DOM dichiarativo è quello di evitare il "lampo di contenuti non stilizzati" (FOUC), in cui vengono mostrati contenuti non elaborati per gli elementi personalizzati di cui non è stato ancora eseguito l'upgrade. Prima dello Shadow DOM dichiarativo, una tecnica comune per evitare FOUC era applicare una regola di stile display:none agli elementi personalizzati che non erano ancora stati caricati, poiché non avevano la radice shadow collegata e compilata. In questo modo, i contenuti non vengono visualizzati finché non sono "pronti":

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

Con l'introduzione dello shadow DOM dichiarativo, è possibile creare elementi personalizzati o eseguirne il rendering in HTML in modo che i contenuti shadow siano presenti e pronti prima del caricamento dell'implementazione del componente lato client:

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

In questo caso, la regola "FOUC" display:none impedirebbe la visualizzazione dei contenuti della radice shadow dichiarativa. Tuttavia, se rimuovi questa regola, i browser che non supportano lo Shadow DOM dichiarativo potrebbero mostrare contenuti errati o senza stile finché il polyfill del DOM dichiarativo non viene caricato e converte il modello di root shadow in una radice shadow reale.

Fortunatamente, questo problema può essere risolto in CSS modificando la regola di stile FOUC. Nei browser che supportano Shadow DOM dichiarativo, l'elemento <template shadowrootmode> viene immediatamente convertito in una radice shadow, senza lasciare nessun elemento <template> nella struttura DOM. I browser che non supportano il DOM dichiarativo shadow conservano l'elemento <template>, che possiamo utilizzare per evitare FOUC:

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

Anziché nascondere l'elemento personalizzato non ancora definito, la regola "FOUC" rivista nasconde i relativi elementi secondari quando seguono un elemento <template shadowrootmode>. Una volta definito l'elemento personalizzato, la regola non corrisponde più. La regola viene ignorata nei browser che supportano il DOM dichiarativo shadow perché il publisher secondario <template shadowrootmode> viene rimosso durante l'analisi HTML.

Rilevamento delle funzionalità e supporto del browser

Lo Shadow DOM dichiarativo è disponibile da Chrome 90 ed Edge 91, ma utilizzava un attributo non standard meno recente chiamato shadowroot anziché l'attributo shadowrootmode standardizzato. L'attributo shadowrootmode e il comportamento di streaming più recenti sono disponibili in Chrome 111 ed Edge 111.

Essendo una nuova API della piattaforma web, il dichiarativo Shadow DOM non dispone ancora di un supporto diffuso in tutti i browser. Il supporto dei browser può essere rilevato controllando l'esistenza di una proprietà shadowRootMode sul prototipo di HTMLTemplateElement:

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

Polyfill

Creare un polyfill semplificato per Shadow DOM dichiarativo è relativamente semplice, dal momento che un polyfill non deve replicare perfettamente la semantica dei tempi o le caratteristiche solo dell'analizzatore sintattico che interessano l'implementazione del browser. Per eseguire il polyfill del DOM dichiarativo, possiamo eseguire la scansione del DOM per trovare tutti gli elementi <template shadowrootmode>, quindi convertirli in root shadow collegate sull'elemento principale. Questo processo può essere eseguito una volta che il documento è pronto o attivato da eventi più specifici come i cicli di vita degli elementi personalizzati.

(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);

Per approfondire