宣言型の Shadow DOM

宣言型 Shadow DOM は、標準のウェブ プラットフォーム機能であり、Chrome バージョン 90 以降でサポートされています。この機能の仕様は 2023 年に変更されました(shadowrootshadowrootmode への名前変更など)。この機能のすべての部分の最新の標準化されたバージョンは Chrome バージョン 124 でリリースされています。

対応ブラウザ

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

ソース

Shadow DOM は、HTML テンプレートカスタム要素とともに、3 つのウェブ コンポーネント標準の 1 つです。Shadow DOM を使用すると、CSS スタイルを特定の DOM サブツリーにスコープ設定し、そのサブツリーをドキュメントの他の部分から分離できます。<slot> 要素を使用すると、カスタム要素の子をシャドウ ツリー内に挿入する場所を制御できます。これらの機能を組み合わせることで、組み込みの HTML 要素のように既存のアプリケーションにシームレスに統合される、自己完結型の再利用可能なコンポーネントを構築するシステムを実現できます。

これまで、Shadow DOM を使用する唯一の方法は、JavaScript を使用してシャドウルートを作成することでした。

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

このような命令型 API は、クライアントサイド レンダリングに適しています。カスタム要素を定義する JavaScript モジュールがシャドウルートを作成し、コンテンツを設定します。ただし、多くのウェブ アプリケーションでは、コンテンツをサーバーサイドでレンダリングするか、ビルド時に静的 HTML にレンダリングする必要があります。これは、JavaScript を実行できない可能性のあるユーザーに適切なエクスペリエンスを提供するうえで重要な要素となります。

サーバーサイド レンダリング(SSR)の正当性は、プロジェクトによって異なります。一部のウェブサイトは、ユーザー補助ガイドラインを満たすために、完全に機能するサーバー レンダリング HTML を提供する必要があります。また、低速な接続やデバイスで優れたパフォーマンスを確保するために、JavaScript を使用しないベースライン エクスペリエンスを提供するウェブサイトもあります。

これまで、サーバーサイド レンダリングと Shadow DOM を組み合わせて使用することは困難でした。これは、サーバー生成の HTML で Shadow ルートを表現する組み込みの方法がなかったためです。また、Shadow ルートなしですでにレンダリングされている DOM 要素に Shadow ルートを追加すると、パフォーマンスに影響する可能性があります。これにより、ページの読み込み後にレイアウトがずれたり、シャドウルートのスタイルシートの読み込み中にスタイル設定されていないコンテンツがフラッシュ表示されたりする(「FOUC」)可能性があります。

宣言型 Shadow DOM(DSD)では、この制限が解除され、Shadow DOM がサーバーに移行されます。

宣言型シャドウルートの作成方法

宣言型シャドウルートは、shadowrootmode 属性を持つ <template> 要素です。

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

shadowrootmode 属性を持つテンプレート要素は HTML パーサーによって検出され、親要素の Shadow ルートとしてすぐに適用されます。上記のサンプルから純粋な HTML マークアップを読み込むと、次の DOM ツリーが生成されます。

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

このコードサンプルは、Shadow DOM コンテンツを表示するための Chrome DevTools の [Elements] パネルの規則に従っています。たとえば、 文字はスロット化された Light DOM コンテンツを表します。

これにより、静的 HTML で Shadow DOM のカプセル化とスロット投影のメリットが得られます。シャドウルートを含むツリー全体を生成するために JavaScript は必要ありません。

コンポーネントのハイドレーション

宣言型 Shadow DOM は、スタイルをカプセル化したり、子要素の配置をカスタマイズしたりする方法として単独で使用できますが、カスタム要素と組み合わせて使用すると最も強力です。カスタム要素を使用して作成されたコンポーネントは、静的 HTML から自動的にアップグレードされます。宣言型 Shadow DOM の導入により、カスタム要素をアップグレードする前にシャドウルートを設定できるようになりました。

宣言型シャドウルートを含む HTML からアップグレードされたカスタム要素には、そのシャドウルートがすでに接続されています。つまり、要素がインスタンス化されたときに、コードで明示的に作成しなくても、shadowRoot プロパティがすでに使用可能になります。要素のコンストラクタで、既存のシャドールートがあるかどうか this.shadowRoot で確認することをおすすめします。値がすでにある場合、このコンポーネントの HTML には宣言型シャドウルートがあります。値が null の場合、HTML に宣言型シャドウルートがなかったか、ブラウザが宣言型シャドウ DOM をサポートしていません。

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

カスタム要素は以前から存在していましたが、これまでは attachShadow() を使用してシャドウルートを作成する前に、既存のシャドウルートを確認する必要はありませんでした。宣言型 Shadow DOM には、既存のコンポーネントが機能できるようにするための小さな変更が含まれています。既存の宣言型 Shadow Root を持つ要素で attachShadow() メソッドを呼び出しても、エラーはスローされません。代わりに、宣言型シャドウルートが空にされて返されます。これにより、宣言型 Shadow DOM 用にビルドされていない古いコンポーネントも引き続き動作します。これは、命令型の置換が作成されるまで宣言型ルートが保持されるためです。

新しく作成されたカスタム要素の場合、新しい ElementInternals.shadowRoot プロパティにより、要素の既存の宣言型シャドウルートの参照を明示的に取得できます(開いている場合と閉じている場合の両方)。これにより、宣言型シャドウルートをチェックして使用できます。また、宣言型シャドウルートが指定されていない場合は、attachShadow() にフォールバックします。

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

ルートごとに 1 つのシャドウ

宣言型シャドウルートはその親要素にのみ関連付けられます。つまり、シャドウルートは常に関連付けられた要素と同じ場所に配置されます。この設計上の決定により、シャドウルートは HTML ドキュメントの他の部分と同様にストリーミング可能になります。また、要素にシャドウルートを追加する際に、既存のシャドウルートのレジストリを維持する必要がないため、作成と生成にも便利です。

シャドウルートを親要素に関連付けるトレードオフは、同じ宣言型シャドウルート <template> から複数の要素を初期化できないことです。ただし、各 Shadow ルートの内容が同じになることはほとんどないため、宣言型 Shadow DOM を使用するほとんどのケースでは問題にならないでしょう。サーバー レンダリングされた HTML には、要素構造が繰り返されることがありますが、通常、コンテンツは異なります(テキストや属性がわずかに異なるなど)。シリアル化された宣言型シャドウルートのコンテンツは完全に静的であるため、1 つの宣言型シャドウルートから複数の要素をアップグレードできるのは、要素が偶然同じである場合のみです。最後に、圧縮の影響により、類似のシャドールートを繰り返してもネットワーク転送サイズへの影響は比較的小さくなります。

今後、共有シャドウルートを再検討する可能性があります。DOM が組み込みテンプレートをサポートできるようになれば、宣言型 Shadow ルートは、特定の要素の Shadow ルートを構築するためにインスタンス化されるテンプレートとして扱うことができます。現在の宣言型 Shadow DOM の設計では、シャドウルートの関連付けを単一の要素に限定することで、将来的にこの可能性を実現できます。

ストリーミングは便利

宣言型 Shadow ルートは親要素に直接関連付けることで、アップグレードしてその要素に適用するプロセスを簡素化できます。宣言型シャドウルートは HTML 解析中に検出され、開始 <template> タグが検出されるとすぐにアタッチされます。<template> 内で解析された HTML は Shadow ルートに直接解析されるため、「ストリーミング」できます。つまり、受信された順にレンダリングされます。

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

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

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

パーサーのみ

宣言型 Shadow DOM は HTML パーサーの機能です。つまり、宣言型 Shadow Root は、HTML 解析中に存在する shadowrootmode 属性を持つ <template> タグに対してのみ解析され、アタッチされます。つまり、宣言型シャドウルートを HTML の最初の解析中に作成できます。

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

<template> 要素の shadowrootmode 属性を設定しても何も起こらず、テンプレートは通常のテンプレート要素のままです。

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

重要なセキュリティ上の考慮事項を回避するため、宣言型シャドウルートを innerHTMLinsertAdjacentHTML() などのフラグメント解析 API を使用して作成することもできません。宣言型シャドウルートを適用した HTML を解析する唯一の方法は、setHTMLUnsafe() または 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>

スタイルを使用したサーバー レンダリング

インライン スタイルシートと外部スタイルシートは、標準の <style> タグと <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>

この方法で指定されたスタイルは高度に最適化されています。同じスタイルシートが複数の宣言型シャドウルートに存在する場合、そのスタイルシートは 1 回だけ読み込まれ、解析されます。ブラウザは、すべてのシャドウルートによって共有される単一のバッキング CSSStyleSheet を使用するため、重複するメモリ オーバーヘッドがなくなります。

コンストラクタブル スタイルシートは、宣言型 Shadow DOM ではサポートされていません。これは、現在のところ、コンストラクタブル スタイルシートを HTML でシリアル化する方法がなく、adoptedStyleSheets の入力時に参照する方法がないためです。

スタイル設定されていないコンテンツのフラッシュを回避する方法

宣言型シャドー DOM をまだサポートしていないブラウザで発生する可能性がある問題の一つは、「スタイル設定されていないコンテンツのフラッシュ」(FOUC) を回避することです。これは、まだアップグレードされていないカスタム要素に対して未加工のコンテンツが表示される現象です。宣言型 Shadow DOM の登場以前、FOUC を回避するための一般的な手法の 1 つは、まだ読み込まれていないカスタム要素に display:none スタイルルールを適用することでした。これらの要素はシャドウルートがアタッチされていません。これにより、コンテンツが「準備完了」になるまで表示されなくなります。

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

宣言型 Shadow DOM の導入により、カスタム要素を HTML でレンダリングまたは作成できるため、クライアントサイド コンポーネントの実装が読み込まれる前に、シャドウ コンテンツが配置され、準備が整います。

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

この場合、display:none「FOUC」ルールにより、宣言型シャドウルートのコンテンツは表示されなくなります。ただし、このルールを削除すると、宣言型 Shadow DOM をサポートしていないブラウザでは、宣言型 Shadow DOM の ポリフィルが読み込まれ、シャドウルート テンプレートを実際のシャドウルートに変換するまで、誤ったコンテンツやスタイル設定されていないコンテンツが表示されます。

幸い、FOUC スタイル ルールを変更することで、CSS でこの問題を解決できます。宣言型 Shadow DOM をサポートするブラウザでは、<template shadowrootmode> 要素はすぐにシャドウルートに変換され、DOM ツリーに <template> 要素が残りません。宣言型 Shadow DOM をサポートしていないブラウザでは、<template> 要素が保持されます。この要素を使用して FOUC を防ぐことができます。

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

改訂された「FOUC」ルールでは、まだ定義されていないカスタム要素を非表示にする代わりに、<template shadowrootmode> 要素の後に続く子要素を非表示にします。カスタム要素が定義されると、ルールは一致しなくなります。<template shadowrootmode> 子要素は HTML 解析中に削除されるため、宣言型 Shadow DOM をサポートするブラウザではこのルールは無視されます。

機能の検出とブラウザのサポート

宣言型 Shadow DOM は Chrome 90 と Edge 91 から利用可能でしたが、標準化された shadowrootmode 属性ではなく、古い非標準の shadowroot 属性を使用していました。新しい shadowrootmode 属性とストリーミング動作は、Chrome 111 と Edge 111 で利用できます。

宣言型 Shadow DOM は新しいウェブ プラットフォーム API であるため、まだすべてのブラウザで広くサポートされていません。ブラウザのサポートは、HTMLTemplateElement のプロトタイプに shadowRootMode プロパティが存在するかどうかをチェックすることで検出できます。

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

ポリフィル

ポリフィルは、ブラウザ実装が懸念するタイミングのセマンティクスやパーサー専用特性を完全に再現する必要がないため、宣言型 Shadow DOM 用の簡素なポリフィルを構築するのは比較的簡単です。宣言型 Shadow DOM をポリフィルするには、DOM をスキャンしてすべての <template shadowrootmode> 要素を見つけ、親要素にアタッチされた Shadow ルートに変換します。このプロセスは、ドキュメントの準備が整ったら行うことができます。また、カスタム エレメントのライフサイクルなど、より具体的なイベントによってトリガーすることもできます。

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

関連情報