宣言型の Shadow DOM

HTML で直接 Shadow DOM を実装して使用する新しい方法です。

宣言型 Shadow DOM は、現在標準化プロセス中のウェブ プラットフォームの機能です。Chrome バージョン 111 ではデフォルトで有効になっています。

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

これまで、Shadow DOM を使用するには、JavaScript を使用して Shadow ルートを構築する必要がありました。

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 Roots を表現する組み込み方法がないためでした。また、すでにレンダリングされていない DOM 要素にシャドウルートをアタッチすると、パフォーマンスにも影響します。このため、ページの読み込み後にレイアウトがずれたり、Shadow Root のスタイルシートの読み込み中にスタイル設定されていないコンテンツが一時的に表示されたりすることがあります(「FOUC」)。

宣言型 Shadow DOM(DSD)はこの制限を取り除き、Shadow DOM をサーバーに導入します。

宣言型 Shadow Root をビルドする

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

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

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

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

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

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

成分の水分補給

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

宣言型のシャドウルートを含む HTML からアップグレードされるカスタム要素には、そのシャドウルートがすでにアタッチされています。つまり、要素のインスタンス化時に shadowRoot プロパティがすでに使用可能になっていて、コードで明示的に作成されていないことになります。要素のコンストラクタに既存の Shadow ルートがないか this.shadowRoot を確認することをおすすめします。すでに値がある場合、このコンポーネントの HTML には宣言型のシャドールートが含まれます。値が null の場合、HTML に宣言型 Shadow ルートが存在しないか、ブラウザが宣言型 Shadow 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 DOM が使用されているほとんどのケースでは、各 Shadow ルートの内容が同一になることはほとんどないため、これが重要になる可能性は低くなります。サーバーでレンダリングされる HTML には繰り返し要素の構造が含まれることがよくありますが、そのコンテンツは通常異なります(テキストや属性がわずかに異なるなど)。シリアル化された宣言型シャドールートの内容は完全に静的であるため、1 つの宣言型シャドールートから複数の要素をアップグレードできるのは、要素が同一である場合だけです。最後に、同様のシャドウルートを繰り返すとネットワーク転送サイズに及ぼす影響は、圧縮による影響から比較的小さくなります。

将来的には、共有シャドウルートに再度アクセスできるようになる可能性があります。DOM で組み込みテンプレートがサポートされた場合、宣言型シャドウルートは、特定の要素のシャドウルートを構築するためにインスタンス化されたテンプレートとして扱うことができます。現在の宣言型 Shadow DOM の設計では、Shadow ルートの関連付けを 1 つの要素に制限することで、この可能性を将来にわたって存在させることができます。

ストリーミング

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

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

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

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

パーサー専用

宣言型 Shadow DOM は、HTML パーサーの機能です。つまり、宣言型シャドールートは、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 を解析する唯一の方法は、新しい 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>

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

インラインと外部スタイルシートは、標準の <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 を入力する際にそれらを参照する方法もないためです。

スタイル設定のないコンテンツのフラッシュを避ける

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

<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 のpolyfillが読み込まれて Shadow ルート テンプレートが実際の Shadow ルートに変換されるまで、宣言型 Shadow DOM をサポートしていないブラウザでは、誤ったコンテンツやスタイル設定されていないコンテンツが表示されます。

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

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

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

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

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

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

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

ポリフィル

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

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

関連情報