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 つのウェブ コンポーネント標準の 1 つです。HTML Imports は以前はリストに含まれていましたが、現在は非推奨と見なされています。

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

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

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

fancy-tabs デモ

この記事では、デモ コンポーネント(<fancy-tabs>)を参照し、そのコード スニペットを参照します。ブラウザが API をサポートしている場合は、すぐ下にライブデモが表示されます。それ以外の場合は、GitHub の完全なソースをご覧ください。

GitHub 上のソースを見る

シャドウ DOM とは

DOM の背景

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

ブラウザはウェブページを読み込む際に、さまざまな処理を行います。その 1 つは、作成者の 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… in the shadows

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

シャドー DOM の作成

シャドウルートは、「ホスト」要素にアタッチされるドキュメント フラグメントです。シャドウルートをアタッチする行為は、要素がシャドウ 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.

カスタム要素のシャドウ DOM を作成する

シャドウ 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> になります。

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

コンポジションは、シャドウ DOM の機能の中で最も理解されていない機能の一つですが、間違いなく最も重要な機能です。

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

用語: Light DOM と Shadow DOM

Shadow DOM の構成により、ウェブ開発に多くの新しい基本要素が導入されます。詳細に入る前に、用語を標準化して、同じ用語を使用するようにしましょう。

ライト 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>

シャドウ DOM

コンポーネントの作成者が記述する DOM。シャドウ 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 を物理的に移動するのではなく、シャドウ 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> と同様に、さまざまなタイプの子を処理するように作成されています。

スタイル設定

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

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

Shadow DOM の最も便利な機能は、スコープ設定された CSS です。

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

シャドウ 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 の注意点として、親ページのルールは、要素で定義された :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 になります。

高度なトピック

クローズド シャドウルートの作成(避けるべき)

シャドウ DOM には、「クローズド」モードと呼ばれる別の種類があります。クローズド シャドウツリーを作成すると、外部の JavaScript はコンポーネントの内部 DOM にアクセスできなくなります。これは、<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.assignedSlot は null を返します。
  • Event.composedPath(): シャドウ DOM 内の要素に関連付けられたイベントの場合、[] を返します。

{mode: 'closed'} でウェブ コンポーネントを作成すべきでない理由の概要は次のとおりです。

  1. セキュリティに対する誤った認識。攻撃者が Element.prototype.attachShadow を乗っ取ることを防ぐ方法はありません。

  2. クローズド モードでは、カスタム要素コードが独自のシャドウ 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 でスロットを操作する

Shadow 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} オプションは、スロットの代替コンテンツも返します(ノードが分散されていない場合)。

たとえば、シャドウ 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 から伝播しません。

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

  • フォーカス イベント: blur、focus、focusin、focusout
  • マウスイベント: click、dblclick、mousedown、mouseenter、mousemove など
  • ホイール イベント: wheel
  • 入力イベント: beforeinput、input
  • キーボード イベント: keydown、keyup
  • コンポジション イベント: compositionstart、compositionupdate、compositionend
  • DragEvent: dragstart、drag、dragend、drop など

ヒント

シャドウツリーが開いている場合、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>

フォーカスの処理

シャドウ DOM のイベントモデルを思い出してください。シャドウ 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() など)、[Clickable Shadow DOM text] がクリックされたとき、または内部の <input> がフォーカスされたとき(autofocus を含む)の結果です。

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

delegatesFocus: false で、内部入力にフォーカスが当たっている。
delegatesFocus: false と内部の <input> にフォーカスが当たっている。
delegatesFocus: false で、x-focus がフォーカスを取得します(例: tabindex='0' が設定されている)。
delegatesFocus: false と <x-focus> がフォーカスを取得します(tabindex="0" が設定されている場合など)。
delegatesFocus: false で、「Clickable Shadow DOM text」がクリックされた(または要素の Shadow DOM 内の他の空の領域がクリックされた)。
delegatesFocus: false と「クリック可能な Shadow DOM テキスト」がクリックされます(または、要素の Shadow DOM 内の他の空の領域がクリックされます)。

ヒントとアドバイス

私は長年にわたり、ウェブ コンポーネントの作成についていくつかのことを学びました。これらのヒントの一部は、コンポーネントの作成やシャドウ DOM のデバッグに役立つと思います。

CSS コンテインメントを使用する

通常、ウェブ コンポーネントのレイアウト、スタイル、ペイントは、かなり自己完結しています。パフォーマンスを向上させるには、:host で CSS コンテインメントを使用します。

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

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

継承可能なスタイル(background、color、font、line-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 がしばらくの間、古いバージョンの Shadow DOM を提供していたことをご存じでしょう。Blink は、しばらくの間、両方のバージョンを並行してサポートします。v0 仕様では、シャドウルートを作成する別のメソッド(v1 の element.attachShadow ではなく element.createShadowRoot)が提供されていました。古いメソッドを呼び出すと、v0 セマンティクスでシャドウルートが作成されるため、既存の v0 コードは破損しません。

古い v0 仕様に関心がある場合は、html5rocks の記事(1、2、3)をご覧ください。シャドウ DOM v0 と v1 の違いを比較した優れた記事もあります。

ブラウザ サポート

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

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

const supportsShadowDOMV1 = !!HTMLElement.prototype.attachShadow;

ポリフィル

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

polyfill をインストールします。

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

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

まとめ

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

誤解しないでください。Shadow DOM は確かに複雑なものです。しかし、学ぶ価値のあるものです。時間をかけて、ぜひ学んで、質問してください。

関連情報

よくある質問

Shadow DOM v1 は今すぐ使用できますか?

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

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

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

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

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

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

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