DOM shadow dichiarativo

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

Il DOM dichiarativo Shadow è una funzionalità della piattaforma web attualmente in fase di standardizzazione. Viene attivata per impostazione predefinita nella versione 111 di Chrome.

Shadow DOM è uno dei tre standard dei componenti web, completato dai modelli HTML e dagli elementi personalizzati. Lo shadow DOM offre un modo per definire l'ambito degli stili CSS in una specifica struttura ad albero del DOM e isolare il sottoalbero dal resto del documento. L'elemento <slot> ci consente di controllare dove devono essere inseriti gli elementi secondari di un elemento personalizzato all'interno del relativo albero delle ombre. La combinazione di queste funzionalità consente di creare un sistema per la creazione di componenti autonomi e riutilizzabili che si integrano perfettamente nelle applicazioni esistenti, 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: anche gli stessi moduli JavaScript che definiscono i nostri elementi personalizzati creano anche le proprie radici shadow e impostano i propri contenuti. Tuttavia, molte applicazioni web devono eseguire il rendering dei contenuti lato server o in HTML statico al momento della creazione. Questa può essere una parte importante dell'offerta di un'esperienza ragionevole ai visitatori che potrebbero non essere in grado di eseguire JavaScript.

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

In passato, è stato difficile utilizzare il DOM shadow 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 associano i valori shadow Root agli elementi DOM di cui è già stato eseguito il rendering. Ciò può causare la 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 di Shadow Root.

Il Declarative Shadow DOM (DSD) rimuove questa limitazione, portando lo 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 dal parser HTML e applicato immediatamente come radice shadow dell'elemento principale. Il caricamento del markup HTML puro dall'esempio riportato sopra risulta nel seguente albero 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 shadow DOM. Ad esempio, il carattere Ұ rappresenta i contenuti Light DOM con slot.

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

Idratazione componenti

Il DOM dichiarativo shadow può essere utilizzato da solo come modo per incapsulare gli stili o personalizzare il posizionamento secondario, ma è più efficace se utilizzato con gli elementi personalizzati. L'upgrade dei componenti creati utilizzando Elementi personalizzati viene eseguito automaticamente da HTML statico. Con l'introduzione del DOM dichiarativo shadow, è ora possibile che un elemento personalizzato abbia una radice shadow prima di eseguire l'upgrade.

Un elemento personalizzato in fase di upgrade da HTML che include una radice shadow dichiarativa sarà già associata a questa radice shadow. Ciò significa che l'elemento avrà una proprietà shadowRoot già disponibile quando viene creata un'istanza, senza che il codice ne crei una in modo esplicito. È meglio controllare se in this.shadowRoot è presente la radice shadow esistente nel costruttore dell'elemento. Se esiste già un valore, il codice HTML per questo componente include una radice ombra dichiarativa. Se il valore è null, nel codice HTML non era presente alcuna radice shadow dichiarativa oppure il browser non supporta il DOM dichiarativo di shadowing.

<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 esistono da un po' di tempo e fino a questo momento non c'era motivo di verificare la presenza di una radice shadow esistente prima di crearne una utilizzando attachShadow(). Il DOM shadow dichiarativo include una piccola modifica che consente il funzionamento dei componenti esistenti nonostante ciò: la chiamata del metodo attachShadow() su un elemento con una radice shadow dichiarativa esistente non genera un errore. Al contrario, la radice ombra dichiarativa viene svuotata e restituita. Ciò consente ai componenti meno recenti non creati per il DOM dichiarativo shadow, di continuare a funzionare, poiché le radici dichiarative vengono conservate fino alla creazione di 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. Può essere usato per verificare e utilizzare qualsiasi radice ombra dichiarativa, continuando però a tornare a attachShadow() nei casi in cui non ne fosse 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 solo al relativo elemento principale. Ciò significa che le radici shadow sono sempre posizionate con l'elemento associato. Questa decisione di progettazione garantisce che le radici shadow siano compatibili con il resto di un documento HTML. È conveniente 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 radici shadow esistenti.

L'associazione delle radici shadow dell'elemento principale comporta che non è possibile inizializzare più elementi dalla stessa radice ombra dichiarativa <template>. Tuttavia, è improbabile che ciò abbia importanza nella maggior parte dei casi in cui viene utilizzato il DOM dichiarativo di shadowing, poiché i contenuti di ogni radice shadow sono raramente identici. Sebbene il codice HTML sottoposto a rendering dal server spesso contenga strutture di elementi ripetute, i loro contenuti generalmente differiscono, ad esempio lievi variazioni nel testo o negli attributi. Poiché i contenuti di una radice shadow dichiarativa serializzata sono interamente statici, l'upgrade di più elementi da una singola radice shadow dichiarativa funziona solo se gli elementi risultano identici. Infine, l'impatto di radici shadow simili ripetute sulle dimensioni di trasferimento della 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 ombra dichiarative potrebbero essere trattate come modelli per la creazione di un'istanza al fine di creare la radice ombra per un determinato elemento. L'attuale design del DOM dichiarativo di shadowing consente che questa possibilità esista in futuro limitando l'associazione della radice shadow a un singolo elemento.

Lo streaming è fantastico

L'associazione diretta delle radici shadow dichiarative al loro elemento principale semplifica il processo di upgrade e il loro collegamento all'elemento. Le radici shadow dichiarative vengono rilevate durante l'analisi HTML e allegate immediatamente quando viene rilevato il tag <template> di apertura. Il codice HTML analizzato all'interno di <template> viene analizzato direttamente nella directory radice shadow, quindi può essere "streaming": visualizzato così come viene ricevuto.

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

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

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

Solo parser

Il DOM Shadow dichiarativo è una funzionalità dell'analizzatore sintattico HTML. Ciò significa che una radice shadow dichiarativa verrà analizzata e allegata solo per i tag <template> con un attributo shadowrootmode presenti durante l'analisi HTML. In altre parole, le radici shadow 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 comporta alcun effetto e il modello rimane un normale elemento:

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 alcune importanti considerazioni sulla sicurezza, anche le radici shadow dichiarative non possono essere create utilizzando le API di analisi dei frammenti come innerHTML o insertAdjacentHTML(). L'unico modo per analizzare il codice HTML con le radici shadow dichiarative applicate è passare una nuova opzione includeShadowRoots a 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>

Rendering del server con stile

I fogli di stile incorporati ed esterni sono completamente supportati all'interno delle radici di ombra dichiarative utilizzando i tag standard <style> e <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>

Gli stili specificati in questo modo sono anch'essi altamente ottimizzati: se lo stesso foglio di stile è presente in più radici di ombra dichiarative, viene caricato e analizzato una sola volta. Il browser utilizza un unico CSSStyleSheet di supporto condiviso da tutte le radici shadow, eliminando l'overhead di memoria duplicato.

I fogli di stile costruibili non sono supportati nel DOM dichiarativo di shadowing. Il motivo è che al momento non è possibile serializzare i fogli di stile costruibili in HTML e non è possibile farvi riferimento durante la compilazione di adoptedStyleSheets.

Evitare lampo di contenuti senza stile

Un possibile problema nei browser che non supportano ancora il Declarative Shadow DOM è evitare il "Flash di contenuti senza stile" (FOUC), in cui vengono mostrati i contenuti non elaborati per gli elementi personalizzati di cui non è stato ancora eseguito l'upgrade. Prima del DOM dichiarativo shadow, una tecnica comune per evitare FOUC era applicare una regola di stile display:none agli elementi personalizzati che non erano ancora stati caricati, poiché questi non avevano la radice shadow associata 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 del DOM dichiarativo shadow, gli elementi personalizzati possono essere visualizzati o creati in HTML in modo che i relativi contenuti shadow siano disponibili 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" di display:none impedirebbe la visualizzazione dei contenuti della radice ombra dichiarativa. Tuttavia, la rimozione di questa regola causerebbe la visualizzazione di contenuti non corretti o senza stile nei browser che non supportano il DOM dichiarativo di shadowing fino a quando il polyfill del DOM dichiarativo shadow non viene caricato e converte il modello radice shadow in una radice shadow reale.

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

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

Invece di 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 shadow dichiarativo perché l'elemento secondario <template shadowrootmode> viene rimosso durante l'analisi HTML.

Rilevamento delle funzionalità e supporto del browser

Il DOM dichiarativo Shadow è disponibile da Chrome 90 ed Edge 91, ma utilizzava un attributo non standard precedente denominato 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 Declarative Shadow DOM non ha ancora un supporto diffuso in tutti i browser. Il supporto dei browser può essere rilevato controllando l'esistenza di una proprietà shadowRootMode nel prototipo di HTMLTemplateElement:

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

Polyfill

La creazione di un polyfill semplificato per il DOM dichiarativo shadow è relativamente semplice, poiché non deve replicare perfettamente la semantica del tempo o le caratteristiche solo parser che interessano l'implementazione del browser. Per eseguire il polyfill del DOM dichiarativo di shadowing, possiamo analizzare il DOM per trovare tutti gli elementi <template shadowrootmode>, quindi convertirli in radici shadow collegate sul relativo 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