Shadow DOM v1 - 自己完結型のウェブ コンポーネント

Shadow DOM を使用すると、ウェブ デベロッパーはウェブ コンポーネント用に分割された DOM と CSS を作成できます。

概要

Shadow DOM を使用すると、ウェブアプリの脆弱性を解消できます。脆弱性は、HTML、CSS、JS のグローバルな性質に起因しています。Google では長年にわたり、この問題を回避するための膨大なツールを開発してきました。たとえば、新しい HTML id またはクラスを使用する場合、ページで使用されている既存の名前と競合するかどうかはわかりません。微妙なバグが潜伏し、CSS 限定性が大きな問題になります(!important すべての要素が対象になります)。スタイル セレクタが制御不能になり、パフォーマンスが低下する可能性があります。他にもさまざまな機能があります。

Shadow DOM は CSS と DOM を修正します。これにより、ウェブ プラットフォームにスコープ設定されたスタイルが導入されます。ツールや命名規則を使用せずに、CSS をマークアップとバンドルし、実装の詳細を非表示にできます。また、JavaScript で自己完結型のコンポーネントを作成することもできます。

はじめに

Shadow DOM は、HTML テンプレートShadow DOMカスタム要素の 3 つの Web Components 標準の 1 つです。HTML インポートは以前はリストに含まれていましたが、現在は非推奨と見なされています。

Shadow DOM を使用するウェブ コンポーネントを作成する必要はありません。ただし、その場合は、そのメリット(CSS スコープ、DOM カプセル化、コンポジション)を活用して、再利用可能なカスタム要素を構築します。これらの要素は、復元力があり、高度に構成可能で、非常に再利用可能です。カスタム要素が新しい HTML を作成する方法(JS API を使用)である場合、Shadow DOM は HTML と CSS を提供する方法です。2 つの API を組み合わせて、自己完結型の HTML、CSS、JavaScript を含むコンポーネントを作成します。

Shadow DOM は、コンポーネントベースのアプリを構築するためのツールとして設計されています。そのため、ウェブ開発の一般的な問題の解決策が提供されます。

  • 分離された DOM: コンポーネントの DOM は自己完結型です(例: document.querySelector() はコンポーネントの Shadow DOM 内のノードを返しません)。
  • スコープ設定された CSS: Shadow DOM 内で定義された CSS は、そのスコープに制限されます。スタイルルールが漏洩せず、ページスタイルが漏洩しません。
  • コンポジション: コンポーネント用の宣言型のマークアップベースの API を設計します。
  • CSS を簡素化 - スコープ設定された DOM を使用すると、シンプルな CSS セレクタやより汎用的な ID/クラス名を使用でき、名前の競合を心配する必要がなくなります。
  • 生産性 - 1 つの大きな(グローバル)ページではなく、DOM のチャンクでアプリを検討します。

fancy-tabs デモ

この記事では、デモ コンポーネント(<fancy-tabs>)とそのコード スニペットについて説明します。ブラウザが API に対応している場合は、そのライブデモが下部に表示されます。そうでない場合は、GitHub で完全なソースを確認してください。

GitHub でソースを見る

Shadow DOM とは

DOM の背景

HTML は扱いやすいため、ウェブの基盤となっています。いくつかのタグを宣言するだけで、表示と構造の両方を備えたページを数秒で作成できます。ただし、HTML 自体はそれほど有用ではありません。人間はテキストベースの言語を簡単に理解できますが、機械にはそれ以上のものが必要です。ドキュメント オブジェクト モデル(DOM)を入力します。

ブラウザがウェブページを読み込むと、さまざまな処理が行われます。たとえば、作成者の HTML をライブドキュメントに変換します。基本的に、ブラウザはページの構造を理解するために、HTML(静的なテキスト文字列)を解析してデータモデル(オブジェクト/ノード)に変換します。ブラウザは、これらのノードのツリー(DOM)を作成することで、HTML の階層を保持します。DOM の優れた点は、ページをリアルタイムで表していることです。作成した静的 HTML とは異なり、ブラウザが生成したノードにはプロパティとメソッドが含まれ、何よりプログラムで操作できます。そのため、JavaScript を使用して DOM 要素を直接作成できます。

const header = document.createElement('header');
const h1 = document.createElement('h1');
h1.textContent = 'Hello DOM';
header.appendChild(h1);
document.body.appendChild(header);

次の HTML マークアップが生成されます。

<body>
    <header>
    <h1>Hello DOM</h1>
    </header>
</body>

すべて順調です。では、Shadow DOM とは何でしょうか?

DOM… 影に隠れて

Shadow DOM は通常の DOM と 2 つの点で異なります。1 つは作成方法と使用方法、もう 1 つはページの他の部分に対する動作です。通常は、DOM ノードを作成して、別の要素の子として追加します。Shadow DOM を使用すると、要素に接続されているが、実際の子とは分離されたスコープ付き DOM ツリーを作成できます。このスコープ付きサブツリーはシャドウ ツリーと呼ばれます。接続されている要素はシャドーホストです。シャドウに追加したものはすべて、<style> を含め、ホスト要素にローカルになります。これが、Shadow DOM が CSS スタイルのスコープ設定を実現する方法です。

Shadow DOM の作成

シャドールートは、「ホスト」要素に接続されるドキュメント フラグメントです。シャドウルートを適用することで、要素は Shadow DOM を取得します。要素の Shadow DOM を作成するには、element.attachShadow() を呼び出します。

const header = document.createElement('header');
const shadowRoot = header.attachShadow({mode: 'open'});
shadowRoot.innerHTML = '<h1>Hello Shadow DOM</h1>'; // Could also use appendChild().

// header.shadowRoot === shadowRoot
// shadowRoot.host === header

ここでは .innerHTML を使用してシャドウルートを埋めていますが、他の DOM API を使用することもできます。これがウェブです。選択肢があります。

仕様では、シャドー ツリーをホストできない要素のリストが定義されています。要素がリストに表示される理由はいくつかあります。

  • ブラウザは、要素(<textarea><input>)に独自の内部 Shadow DOM をすでにホストしています。
  • 要素が Shadow DOM(<img>)をホストすることは意味がありません。

たとえば、次の場合は機能しません。

    document.createElement('input').attachShadow({mode: 'open'});
    // Error. `<input>` cannot host shadow dom.

カスタム要素の Shadow DOM を作成する

Shadow DOM は、カスタム要素を作成する場合に特に便利です。Shadow DOM を使用して要素の HTML、CSS、JS を分割し、「ウェブ コンポーネント」を作成します。

- カスタム要素が Shadow DOM を自身に接続し、DOM/CSS をカプセル化します。

// Use custom elements API v1 to register a new HTML tag and define its JS behavior
// using an ES6 class. Every instance of <fancy-tab> will have this same prototype.
customElements.define('fancy-tabs', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    // Attach a shadow root to <fancy-tabs>.
    const shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `
        <style>#tabs { ... }</style> <!-- styles are scoped to fancy-tabs! -->
        <div id="tabs">...</div>
        <div id="panels">...</div>
    `;
    }
    ...
});

ここで興味深い点がいくつかあります。1 つ目は、<fancy-tabs> のインスタンスが作成されると、カスタム要素が独自の Shadow DOM を作成します。これは constructor() で行います。次に、シャドールートを作成するため、<style> 内の CSS ルールのスコープは <fancy-tabs> になります。

コンポジションとスロット

合成は Shadow DOM で最も理解されていない機能の 1 つですが、最も重要な機能の 1 つとも言えます。

ウェブ開発の世界では、コンポジションは HTML から宣言的にアプリを作成する方法です。さまざまなビルディング ブロック(<div><header><form><input>)が組み合わされてアプリを形成します。これらのタグの中には、相互に連携するものもあります。<select><details><form><video> などのネイティブ要素が非常に柔軟なのは、コンポジションによるものです。これらのタグはそれぞれ、特定の HTML を子として受け入れ、特別な処理を行います。たとえば、<select><option><optgroup> をプルダウン ウィジェットとマルチ選択ウィジェットにレンダリングする方法を知っています。<details> 要素は、<summary> を展開可能な矢印としてレンダリングします。<video> でも、特定の子要素を処理する方法が認識されています。<source> 要素はレンダリングされませんが、動画の動作に影響します。魔法のようですね。

用語: Light DOM と Shadow DOM

Shadow DOM の合成により、ウェブ開発の基礎が大きく変わります。詳細に入る前に、用語を標準化して、同じ用語を使用できるようにしましょう。

Light DOM

コンポーネントのユーザーが記述するマークアップ。この DOM は、コンポーネントの Shadow DOM の外部にあります。要素の実際の子要素です。

<better-button>
    <!-- the image and span are better-button's light DOM -->
    <img src="gear.svg" slot="icon">
    <span>Settings</span>
</better-button>

Shadow DOM

コンポーネント作成者が記述する DOM。Shadow DOM はコンポーネントにローカルであり、内部構造、スコープ付き CSS を定義し、実装の詳細をカプセル化します。また、コンポーネントのコンシューマが作成したマークアップのレンダリング方法を定義することもできます。

#shadow-root
    <style>...</style>
    <slot name="icon"></slot>
    <span id="wrapper">
    <slot>Button</slot>
    </span>

フラット化された DOM ツリー

ブラウザがユーザーの Light DOM を Shadow DOM に分散し、最終的なプロダクトをレンダリングした結果。フラット化されたツリーは、最終的に DevTools に表示され、ページにレンダリングされるものです。

<better-button>
    #shadow-root
    <style>...</style>
    <slot name="icon">
        <img src="gear.svg" slot="icon">
    </slot>
    <span id="wrapper">
        <slot>
        <span>Settings</span>
        </slot>
    </span>
</better-button>

<slot> 要素

Shadow DOM は、<slot> 要素を使用してさまざまな DOM ツリーを合成します。スロットは、ユーザーが独自のマークアップで入力できるコンポーネント内のプレースホルダです。1 つ以上のスロットを定義すると、外部マークアップを招待してコンポーネントのシャドウ DOM にレンダリングできます。基本的には、「ユーザーのマーカーアップをここにレンダリング」と指示していることになります。

<slot> が要素を招待すると、要素は Shadow DOM の境界を「越えて」移動できます。これらの要素は分散ノードと呼ばれます。分散ノードは概念的には奇妙に見えることがあります。スロットは DOM を物理的に移動しません。Shadow DOM 内の別の場所にレンダリングします。

コンポーネントは、シャドー DOM に 0 個以上のスロットを定義できます。スロットは空にすることも、フォールバック コンテンツを提供することもできます。ユーザーが Light DOM コンテンツを提供しない場合、スロットはフォールバック コンテンツをレンダリングします。

<!-- Default slot. If there's more than one default slot, the first is used. -->
<slot></slot>

<slot>fallback content</slot> <!-- default slot with fallback content -->

<slot> <!-- default slot entire DOM tree as fallback -->
    <h2>Title</h2>
    <summary>Description text</summary>
</slot>

名前付きスロットを作成することもできます。名前付きスロットは、ユーザーが名前で参照するシャドー DOM の特定の穴です。

- <fancy-tabs> の Shadow DOM 内のスロット:

#shadow-root
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot> <!-- named slot -->
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>

コンポーネント ユーザーは、次のように <fancy-tabs> を宣言します。

<fancy-tabs>
    <button slot="title">Title</button>
    <button slot="title" selected>Title 2</button>
    <button slot="title">Title 3</button>
    <section>content panel 1</section>
    <section>content panel 2</section>
    <section>content panel 3</section>
</fancy-tabs>

<!-- Using <h2>'s and changing the ordering would also work! -->
<fancy-tabs>
    <h2 slot="title">Title</h2>
    <section>content panel 1</section>
    <h2 slot="title" selected>Title 2</h2>
    <section>content panel 2</section>
    <h2 slot="title">Title 3</h2>
    <section>content panel 3</section>
</fancy-tabs>

フラット化されたツリーは次のようになります。

<fancy-tabs>
    #shadow-root
    <div id="tabs">
        <slot id="tabsSlot" name="title">
        <button slot="title">Title</button>
        <button slot="title" selected>Title 2</button>
        <button slot="title">Title 3</button>
        </slot>
    </div>
    <div id="panels">
        <slot id="panelsSlot">
        <section>content panel 1</section>
        <section>content panel 2</section>
        <section>content panel 3</section>
        </slot>
    </div>
</fancy-tabs>

コンポーネントはさまざまな構成に対応できますが、フラット化された DOM ツリーは同じままです。<button> から <h2> に切り替えることもできます。このコンポーネントは、<select> と同様に、さまざまなタイプの子を処理するように作成されています。

スタイル設定

ウェブ コンポーネントのスタイル設定には、多くのオプションがあります。Shadow DOM を使用するコンポーネントは、メインページでスタイル設定したり、独自のスタイルを定義したり、ユーザーがデフォルトをオーバーライドするためのフック(CSS カスタム プロパティの形式)を提供したりできます。

コンポーネント定義のスタイル

Shadow DOM の最も便利な機能は、スコープ付き CSS です。

  • 外側のページの CSS セレクタはコンポーネント内に適用されません。
  • 内部で定義されたスタイルは、外部に漏れません。ホスト要素にスコープが設定されます。

Shadow DOM 内で使用される CSS セレクタは、コンポーネントにローカルで適用されます。つまり、ページの他の場所との競合を気にすることなく、共通の ID/クラス名を再び使用できます。Shadow DOM 内では、シンプルな CSS セレクタを使用することをおすすめします。パフォーマンスの向上にもつながります。

- シャドールートで定義されたスタイルはローカルです

#shadow-root
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        ...
    }
    #tabs {
        display: inline-flex;
        ...
    }
    </style>
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

スタイルシートもシャドウ ツリーにスコープされます。

#shadow-root
    <link rel="stylesheet" href="styles.css">
    <div id="tabs">
    ...
    </div>
    <div id="panels">
    ...
    </div>

multiple 属性を追加すると、<select> 要素がプルダウンではなく複数選択ウィジェットをレンダリングする仕組みを疑問に思ったことはありませんか?

<select multiple>
  <option>Do</option>
  <option selected>Re</option>
  <option>Mi</option>
  <option>Fa</option>
  <option>So</option>
</select>

<select> は、宣言した属性に応じて自身のスタイルを変更できます。ウェブ コンポーネントは、:host セレクタを使用して自身をスタイル設定することもできます。

- コンポーネント自体のスタイル設定

<style>
:host {
    display: block; /* by default, custom elements are display: inline */
    contain: content; /* CSS containment FTW. */
}
</style>

:host の注意点の 1 つは、親ページのルールは、要素で定義された :host ルールよりも特定性が高くなることです。つまり、外部スタイルが優先されます。これにより、ユーザーは外部から最上位のスタイル設定をオーバーライドできます。また、:host はシャドールートのコンテキストでのみ機能するため、シャドー DOM の外部では使用できません。

:host(<selector>) の関数形式を使用すると、<selector> と一致する場合にホストをターゲットにできます。これは、ホストに基づいてユーザー操作、状態、内部ノードのスタイルに反応する動作をコンポーネントでカプセル化するのに適しています。

<style>
:host {
    opacity: 0.4;
    will-change: opacity;
    transition: opacity 300ms ease-in-out;
}
:host(:hover) {
    opacity: 1;
}
:host([disabled]) { /* style when host has disabled attribute. */
    background: grey;
    pointer-events: none;
    opacity: 0.4;
}
:host(.blue) {
    color: blue; /* color host when it has class="blue" */
}
:host(.pink) > #tabs {
    color: pink; /* color internal #tabs node when host has class="pink". */
}
</style>

コンテキストに基づくスタイル設定

:host-context(<selector>) は、コンポーネントまたはその祖先のいずれかが <selector> と一致する場合に、コンポーネントと一致します。一般的な用途は、コンポーネントの周囲に基づくテーマ設定です。たとえば、多くのユーザーは、<html> または <body> にクラスを適用してテーマ設定を行っています。

<body class="darktheme">
    <fancy-tabs>
    ...
    </fancy-tabs>
</body>

:host-context(.darktheme) は、.darktheme の子孫である場合に <fancy-tabs> にスタイルを適用します。

:host-context(.darktheme) {
    color: white;
    background: black;
}

:host-context() はテーマ設定に役立ちますが、CSS カスタム プロパティを使用してスタイルフックを作成する方法のほうがさらに優れています。

分散ノードのスタイル設定

::slotted(<compound-selector>) は、<slot> に分散されたノードに一致します。

たとえば、名札コンポーネントを作成したとします。

<name-badge>
    <h2>Eric Bidelman</h2>
    <span class="title">
    Digital Jedi, <span class="company">Google</span>
    </span>
</name-badge>

コンポーネントの Shadow DOM は、ユーザーの <h2>.title のスタイルを設定できます。

<style>
::slotted(h2) {
    margin: 0;
    font-weight: 300;
    color: red;
}
::slotted(.title) {
    color: orange;
}
/* DOESN'T WORK (can only select top-level nodes).
::slotted(.company),
::slotted(.title .company) {
    text-transform: uppercase;
}
*/
</style>
<slot></slot>

前述のとおり、<slot> はユーザーのライト DOM を移動しません。ノードが <slot> に分散されると、<slot> は DOM をレンダリングしますが、ノードは物理的に移動しません。配信前に適用されたスタイルは、配信後も引き続き適用されます。ただし、Light DOM が分散されると、追加のスタイル(Shadow DOM で定義されたスタイル)を適用できます

<fancy-tabs> のより詳細な例を次に示します。

const shadowRoot = this.attachShadow({mode: 'open'});
shadowRoot.innerHTML = `
    <style>
    #panels {
        box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
        background: white;
        border-radius: 3px;
        padding: 16px;
        height: 250px;
        overflow: auto;
    }
    #tabs {
        display: inline-flex;
        -webkit-user-select: none;
        user-select: none;
    }
    #tabsSlot::slotted(*) {
        font: 400 16px/22px 'Roboto';
        padding: 16px 8px;
        ...
    }
    #tabsSlot::slotted([aria-selected="true"]) {
        font-weight: 600;
        background: white;
        box-shadow: none;
    }
    #panelsSlot::slotted([aria-hidden="true"]) {
        display: none;
    }
    </style>
    <div id="tabs">
    <slot id="tabsSlot" name="title"></slot>
    </div>
    <div id="panels">
    <slot id="panelsSlot"></slot>
    </div>
`;

この例では、タブタイトル用の名前付きスロットと、タブパネル コンテンツ用のスロットの 2 つのスロットがあります。ユーザーがタブを選択すると、選択したタブが太字になり、そのパネルが表示されます。これを行うには、selected 属性を持つ分散ノードを選択します。カスタム要素の JS(ここには示していません)が、その属性を適切なタイミングで追加します。

外部からコンポーネントのスタイルを設定する

コンポーネントに外部からスタイルを適用する方法はいくつかあります。最も簡単な方法は、タグ名をセレクタとして使用することです。

fancy-tabs {
    width: 500px;
    color: red; /* Note: inheritable CSS properties pierce the shadow DOM boundary. */
}
fancy-tabs:hover {
    box-shadow: 0 3px 3px #ccc;
}

外部スタイルは、Shadow DOM で定義されたスタイルよりも常に優先されます。たとえば、ユーザーがセレクタ fancy-tabs { width: 500px; } を記述すると、コンポーネントのルール :host { width: 650px;} よりも優先されます。

コンポーネント自体のスタイル設定だけでは、ただし、コンポーネントの内部にスタイルを適用したい場合はどうすればよいでしょうか。そのためには、CSS カスタム プロパティが必要です。

CSS カスタム プロパティを使用したスタイルフックの作成

コンポーネントの作成者が CSS カスタム プロパティを使用してスタイル設定フックを提供している場合、ユーザーは内部スタイルを微調整できます。コンセプト的には <slot> に似ています。ユーザーがオーバーライドできる「スタイル プレースホルダ」を作成します。

- <fancy-tabs> を使用すると、ユーザーは背景色をオーバーライドできます。

<!-- main page -->
<style>
    fancy-tabs {
    margin-bottom: 32px;
    --fancy-tabs-bg: black;
    }
</style>
<fancy-tabs background>...</fancy-tabs>

Shadow DOM 内:

:host([background]) {
    background: var(--fancy-tabs-bg, #9E9E9E);
    border-radius: 10px;
    padding: 10px;
}

この場合、ユーザーが指定したため、コンポーネントは black を背景値として使用します。それ以外の場合は、デフォルトで #9E9E9E になります。

高度なトピック

閉じたシャドウルートを作成する(避けるべき)

Shadow DOM には、「クローズド」モードという別の種類もあります。閉じたシャドー ツリーを作成すると、外部 JavaScript はコンポーネントの内部 DOM にアクセスできなくなります。これは、<video> などのネイティブ要素の仕組みに似ています。ブラウザはクローズドモードのシャドウルートを使用して <video> を実装するため、JavaScript は <video> のシャドウ DOM にアクセスできません。

- 閉じたシャドウ ツリーを作成する:

const div = document.createElement('div');
const shadowRoot = div.attachShadow({mode: 'closed'}); // close shadow tree
// div.shadowRoot === null
// shadowRoot.host === div

クローズドモードは他の API にも影響します。

  • Element.assignedSlot / TextNode.assignedSlotnull を返します。
  • Event.composedPath(): シャドー DOM 内の要素に関連付けられたイベントの場合、[] を返します。

{mode: 'closed'} を使用してウェブ コンポーネントを作成しないでください。その理由をまとめると次のとおりです。

  1. セキュリティに対する虚偽の安心感。攻撃者が Element.prototype.attachShadow を不正使用するのを防ぐ方法はありません。

  2. クローズド モードでは、カスタム要素コードが独自の Shadow DOM にアクセスできないようにします。完全な失敗です。代わりに、querySelector() などのものを使用したい場合は、後で参照をスタッシュする必要があります。これは、クローズド モードの本来の目的に完全に反しています。

        customElements.define('x-element', class extends HTMLElement {
        constructor() {
        super(); // always call super() first in the constructor.
        this._shadowRoot = this.attachShadow({mode: 'closed'});
        this._shadowRoot.innerHTML = '<div class="wrapper"></div>';
        }
        connectedCallback() {
        // When creating closed shadow trees, you'll need to stash the shadow root
        // for later if you want to use it again. Kinda pointless.
        const wrapper = this._shadowRoot.querySelector('.wrapper');
        }
        ...
    });
    
  3. クローズド モードでは、エンドユーザーがコンポーネントを柔軟に使用できなくなります。ウェブ コンポーネントを構築する際に、機能を追加し忘れることがあります。構成オプション。ユーザーが希望するユースケース。一般的な例としては、内部ノードに適切なスタイル設定フックを追加し忘れることなどがあります。クローズド モードでは、ユーザーがデフォルトをオーバーライドしてスタイルを調整することはできません。コンポーネントの内部にアクセスできることは非常に便利です。最終的には、ユーザーがコンポーネントをフォークするか、別のコンポーネントを見つけるか、自分のコンポーネントを作成します(ユーザーの要望に沿わない場合)。

JS でスロットを操作する

シャドー DOM API には、スロットと分散ノードを使用するユーティリティが用意されています。これらは、カスタム要素を作成する際に便利です。

slotchange イベント

スロットの分散ノードが変更されると、slotchange イベントが発生します。たとえば、ユーザーがライト DOM に子を追加または削除した場合などです。

const slot = this.shadowRoot.querySelector('#slot');
slot.addEventListener('slotchange', e => {
    console.log('light dom children changed!');
});

ライト DOM の他のタイプの変更をモニタリングするには、要素のコンストラクタで MutationObserver を設定します。

スロットでレンダリングされている要素

スロットに関連付けられている要素を確認すると便利な場合があります。slot.assignedNodes() を呼び出して、スロットがレンダリングしている要素を確認します。{flatten: true} オプションは、ノードが配布されていない場合、スロットのフォールバック コンテンツも返します。

たとえば、Shadow DOM が次のようになっているとします。

<slot><b>fallback content</b></slot>
用途電話結果
<my-component>コンポーネント テキスト</my-component> slot.assignedNodes(); [component text]
<my-component></my-component> slot.assignedNodes(); []
<my-component></my-component> slot.assignedNodes({flatten: true}); [<b>fallback content</b>]

要素はどのスロットに割り当てられますか?

逆の質問に答えることもできます。element.assignedSlot は、要素が割り当てられているコンポーネント スロットを示します。

Shadow DOM イベントモデル

イベントが Shadow DOM からバブルアップすると、Shadow DOM が提供するカプセル化を維持するためにターゲットが調整されます。つまり、イベントは、シャドー DOM 内の内部要素ではなく、コンポーネントから送信されたように見えるように再ターゲティングされます。一部のイベントはシャドー DOM の外部に伝播されません。

シャドウ境界を越えるイベントは次のとおりです。

  • フォーカス イベント: blurfocusfocusinfocusout
  • マウス イベント: clickdblclickmousedownmouseentermousemove など
  • ホイール イベント: wheel
  • 入力イベント: beforeinputinput
  • キーボード イベント: keydownkeyup
  • コンポジション イベント: compositionstartcompositionupdatecompositionend
  • DragEvent: dragstartdragdragenddrop など

ヒント

シャドウ ツリーが開いている場合、event.composedPath() を呼び出すと、イベントが通過したノードの配列が返されます。

カスタム イベントを使用する

シャドー ツリーの内部ノードで発生するカスタム DOM イベントは、composed: true フラグを使用してイベントが作成されていない限り、シャドー境界からバブルアップしません。

// Inside <fancy-tab> custom element class definition:
selectTab() {
    const tabs = this.shadowRoot.querySelector('#tabs');
    tabs.dispatchEvent(new Event('tab-select', {bubbles: true, composed: true}));
}

composed: false(デフォルト)の場合、コンシューマはシャドウルートの外部でイベントをリッスンできません。

<fancy-tabs></fancy-tabs>
<script>
    const tabs = document.querySelector('fancy-tabs');
    tabs.addEventListener('tab-select', e => {
    // won't fire if `tab-select` wasn't created with `composed: true`.
    });
</script>

フォーカスの処理

Shadow DOM のイベントモデルを思い出すと、Shadow DOM 内で発生したイベントは、ホスト要素から発生したように調整されます。たとえば、シャドールート内の <input> をクリックするとします。

<x-focus>
    #shadow-root
    <input type="text" placeholder="Input inside shadow dom">

focus イベントは、<input> ではなく <x-focus> から送信されたように見えます。同様に、document.activeElement<x-focus> になります。シャドウルートを mode:'open' で作成した場合(クローズド モードを参照)、フォーカスを獲得した内部ノードにもアクセスできます。

document.activeElement.shadowRoot.activeElement // only works with open mode.

複数レベルの Shadow DOM が使用されている場合(別のカスタム要素内のカスタム要素など)、シャドウルートを再帰的にドリルダウンして activeElement を見つける必要があります。

function deepActiveElement() {
    let a = document.activeElement;
    while (a && a.shadowRoot && a.shadowRoot.activeElement) {
    a = a.shadowRoot.activeElement;
    }
    return a;
}

フォーカスのもう 1 つのオプションは delegatesFocus: true オプションです。これは、シャドウ ツリー内の要素のフォーカス動作を拡張します。

  • シャドー DOM 内のノードをクリックしても、そのノードがフォーカス可能な領域でない場合、最初のフォーカス可能な領域がフォーカスされます。
  • Shadow DOM 内のノードがフォーカスを取得すると、:focus はフォーカスされた要素に加えてホストにも適用されます。

- delegatesFocus: true がフォーカス動作を変更する方法

<style>
    :focus {
    outline: 2px solid red;
    }
</style>

<x-focus></x-focus>

<script>
customElements.define('x-focus', class extends HTMLElement {
    constructor() {
    super(); // always call super() first in the constructor.

    const root = this.attachShadow({mode: 'open', delegatesFocus: true});
    root.innerHTML = `
        <style>
        :host {
            display: flex;
            border: 1px dotted black;
            padding: 16px;
        }
        :focus {
            outline: 2px solid blue;
        }
        </style>
        <div>Clickable Shadow DOM text</div>
        <input type="text" placeholder="Input inside shadow dom">`;

    // Know the focused element inside shadow DOM:
    this.addEventListener('focus', function(e) {
        console.log('Active element (inside shadow dom):',
                    this.shadowRoot.activeElement);
    });
    }
});
</script>

結果

delegatesFocus: true の動作。

上記は、<x-focus> がフォーカスされた場合(ユーザーがクリック、タブ移動、focus() など)の結果です。「クリック可能な Shadow DOM テキスト」がクリックされたか、内部の <input>autofocus を含む)がフォーカスされています。

delegatesFocus: false を設定すると、次のように表示されます。

delegatesFocus: false で、内部入力がフォーカスされています。
delegatesFocus: false と内部の <input> がフォーカスされています。
delegatesFocus: false で、x-focus がフォーカスを取得します(tabindex=&#39;0&#39; など)。
delegatesFocus: false<x-focus> がフォーカスを取得します(例: tabindex="0" があります)。
delegatesFocus: false で、[Clickable Shadow DOM text] がクリックされた(または要素の Shadow DOM 内の他の空白領域がクリックされた)。
delegatesFocus: false と「クリック可能な Shadow DOM テキスト」がクリックされます(または、要素の Shadow DOM 内の他の空白領域がクリックされます)。

ヒントとアドバイス

長年にわたり、ウェブ コンポーネントの作成について学んできました。これらのヒントは、コンポーネントの作成や Shadow DOM のデバッグに役立つと思います。

CSS の制限を使用する

通常、ウェブ コンポーネントのレイアウト、スタイル、ペイントは、かなり自己完結的です。パフォーマンスの向上のために、:hostCSS 制限を使用します。

<style>
:host {
    display: block;
    contain: content; /* Boom. CSS containment FTW. */
}
</style>

継承可能なスタイルのリセット

継承可能なスタイル(backgroundcolorfontline-height など)は、Shadow DOM で引き続き継承されます。つまり、デフォルトでは Shadow DOM の境界を貫通します。最初からやり直す場合は、all: initial; を使用して、シャドウ境界を越えたときに継承可能なスタイルを初期値にリセットします。

<style>
    div {
    padding: 10px;
    background: red;
    font-size: 25px;
    text-transform: uppercase;
    color: white;
    }
</style>

<div>
    <p>I'm outside the element (big/white)</p>
    <my-element>Light DOM content is also affected.</my-element>
    <p>I'm outside the element (big/white)</p>
</div>

<script>
const el = document.querySelector('my-element');
el.attachShadow({mode: 'open'}).innerHTML = `
    <style>
    :host {
        all: initial; /* 1st rule so subsequent properties are reset. */
        display: block;
        background: white;
    }
    </style>
    <p>my-element: all CSS properties are reset to their
        initial value using <code>all: initial</code>.</p>
    <slot></slot>
`;
</script>

ページで使用されているすべてのカスタム要素を見つける

ページで使用されているカスタム要素を確認すると便利な場合があります。これを行うには、ページで使用されているすべての要素のシャドウ DOM を再帰的に走査する必要があります。

const allCustomElements = [];

function isCustomElement(el) {
    const isAttr = el.getAttribute('is');
    // Check for <super-button> and <button is="super-button">.
    return el.localName.includes('-') || isAttr && isAttr.includes('-');
}

function findAllCustomElements(nodes) {
    for (let i = 0, el; el = nodes[i]; ++i) {
    if (isCustomElement(el)) {
        allCustomElements.push(el);
    }
    // If the element has shadow DOM, dig deeper.
    if (el.shadowRoot) {
        findAllCustomElements(el.shadowRoot.querySelectorAll('*'));
    }
    }
}

findAllCustomElements(document.querySelectorAll('*'));

<template> から要素を作成する

.innerHTML を使用してシャドウルートを入力する代わりに、宣言型の <template> を使用できます。テンプレートは、ウェブ コンポーネントの構造を宣言するのに最適なプレースホルダです。

「カスタム要素: 再利用可能なウェブ コンポーネントの構築」の例をご覧ください。

履歴とブラウザのサポート

過去数年間ウェブ コンポーネントをフォローしてきた方なら、Chrome 35 以降と Opera で古いバージョンのシャドー DOM がリリースされていることはご存じでしょう。Blink は、しばらくの間、両方のバージョンを並行してサポートします。v0 仕様では、シャドールートを作成する別のメソッド(v1 の element.attachShadow ではなく element.createShadowRoot)が提供されています。古いメソッドを呼び出すと、引き続き v0 セマンティクスでシャドールートが作成されるため、既存の v0 コードが破損することはありません。

古い v0 仕様に興味がある場合は、html5rocks の記事(123)をご覧ください。また、シャドー DOM v0 と v1 の違いの比較もご覧ください。

ブラウザ サポート

Shadow DOM v1 は、Chrome 53(ステータス)、Opera 40、Safari 10、Firefox 63 でリリースされています。Edge の開発が開始されました

Shadow DOM を機能検出するには、attachShadow の存在を確認します。

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

ポリフィル

ブラウザのサポートが広く利用可能になるまでは、shadydomshadycss ポリフィルにより v1 機能を利用できます。Shady DOM は、Shadow DOM の DOM スコープを模倣し、shadycss ポリフィルは CSS カスタム プロパティと、ネイティブ API が提供するスタイル スコープを模倣します。

ポリフィルをインストールします。

bower install --save webcomponents/shadydom
bower install --save webcomponents/shadycss

ポリフィルを使用します。

function loadScript(src) {
    return new Promise(function(resolve, reject) {
    const script = document.createElement('script');
    script.async = true;
    script.src = src;
    script.onload = resolve;
    script.onerror = reject;
    document.head.appendChild(script);
    });
}

// Lazy load the polyfill if necessary.
if (!supportsShadowDOMV1) {
    loadScript('/bower_components/shadydom/shadydom.min.js')
    .then(e => loadScript('/bower_components/shadycss/shadycss.min.js'))
    .then(e => {
        // Polyfills loaded.
    });
} else {
    // Native shadow dom v1 support. Go to go!
}

スタイルをシムまたはスコープする方法については、https://github.com/webcomponents/shadycss#usage をご覧ください。

まとめ

適切な CSS スコープと DOM スコープを実行し、真の合成を備えた API プリミティブが初めて登場しました。カスタム要素などの他の Web コンポーネント API と組み合わせることで、Shadow DOM は、ハックや <iframe> などの古いバガジを使用することなく、完全にカプセル化されたコンポーネントを作成できる方法を提供します。

誤解しないでください。Shadow DOM は確かに複雑なものです。しかし、学ぶ価値のある機能です。しばらくお待ちください。学習して質問する

関連情報

よくある質問

Shadow DOM v1 は現在使用できますか?

ポリフィルを使用すると可能です。ブラウザのサポートをご覧ください。

Shadow DOM にはどのようなセキュリティ機能がありますか?

Shadow DOM はセキュリティ機能ではありません。CSS をスコープし、コンポーネント内の DOM ツリーを非表示にする軽量なツールです。真のセキュリティ境界が必要な場合は、<iframe> を使用します。

ウェブ コンポーネントは Shadow DOM を使用する必要がありますか?

いいえ。シャドー DOM を使用するウェブ コンポーネントを作成する必要はありません。ただし、Shadow DOM を使用するカスタム要素を作成すると、CSS スコープ、DOM カプセル化、コンポジションなどの機能を利用できます。

オープン シャドールートとクローズド シャドールートの違いは何ですか?

クローズド シャドウルートをご覧ください。