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

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

まとめ

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

Shadow DOM は、CSS と DOM を修正します。ウェブ プラットフォームにスコープスタイルを導入します。ツールや命名規則がなくても、CSS をマークアップにバンドルし、実装の詳細を非表示にして、Vanilla JavaScript で自己完結型のコンポーネントを作成できます。

はじめに

Shadow DOM は、HTML テンプレートShadow DOMカスタム要素の 3 つのウェブ コンポーネント標準の一つです。HTML インポートは、リストに含まれていましたが、現在はサポートが終了しています。

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

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 の優れた点は、これがページのライブ表現であることです。Google が作成する静的 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 ですが、1)作成方法と使用方法、2)ページの他の部分との関係でどのように動作するかの 2 点が異なります。通常は、DOM ノードを作成して、別の要素の子として追加します。Shadow DOM では、要素に関連付けられているが、実際の子からは分離されている、スコープが設定された DOM ツリーを作成します。このスコープ設定されたサブツリーはシャドウツリーと呼ばれます。それがアタッチされている要素はシャドウホストです。シャドウに追加したものはすべて、<style> を含め、ホスティング要素に対してローカルになります。これが Shadow DOM が CSS スタイルスコープを実現する仕組みです

Shadow DOM の作成

シャドウルートは、「host」要素に付加されるドキュメント フラグメントです。Shadow ルートをアタッチすることで、要素が 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 を使用して Shadow ルートを埋めていますが、他の DOM API を使用することもできます。これがウェブです。選択の余地がある。

この仕様では、Shadow ツリーをホストできない要素のリストを定義しています。要素がこのリストに含まれる理由はいくつかあります。

  • ブラウザがすでに、その要素に関する独自の内部 Shadow DOM(<textarea><input>)をホストしています。
  • 要素が 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>
    `;
    }
    ...
});

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

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

コンポジションは、あまり知られていない Shadow DOM の機能の一つですが、間違いなく最も重要なものです。

ウェブ開発の世界では、コンポジションとは、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 つ以上のスロットを定義することで、外部マークアップをコンポーネントの Shadow DOM にレンダリングするよう招待します。基本的には、「ユーザーのマークアップをここでレンダリングする」と言うことになります。

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

コンポーネントは、その Shadow 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>

名前付きスロットを作成することもできます。名前付きスロットは、ユーザーが名前で参照する Shadow 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/クラス名を再び使用できます。CSS セレクタをシンプルにすることは Shadow DOM 内での ベスト プラクティスです。また パフォーマンスにも優れています

- Shadow ルートで定義されたスタイルがローカルである

#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 ツリーです。

#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 は Shadow ルートのコンテキスト内でのみ機能するため、Shadow 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) は、<fancy-tabs>.darktheme の子孫である場合、次のようにスタイルを設定します。

: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> はユーザーの Light 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 には、「クローズド」モードと呼ばれる別のフレーバーがあります。クローズド Shadow ツリーを作成すると、外部の JavaScript はコンポーネントの内部 DOM にアクセスできなくなります。これは、<video> などのネイティブ要素の仕組みに似ています。 JavaScript は <video> の Shadow DOM にアクセスできません。これは、ブラウザがクローズド モードの Shadow ルートを使用して実装しているためです。

- クローズド シャドウツリーの作成:

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 を返します。
  • Shadow DOM 内の要素に関連付けられたイベントの Event.composedPath() は、[] を返します。

{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 でのスロットの操作

Shadow DOM API は、スロットと分散ノードを使用するためのユーティリティを提供します。これらは、カスタム要素を作成する際に便利です。

スロット変更イベント

slotchange イベントは、スロットの分散ノードが変更されると起動されます。たとえば、ユーザーが Light DOM から子を追加または削除した場合です。

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

Light 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 が提供するカプセル化を維持するために、イベントのターゲットが調整されます。つまり、イベントのターゲットが再設定され、Shadow DOM 内の内部要素ではなく、コンポーネントから発生したイベントであるかのように扱われます。一部のイベントは Shadow DOM の外に伝播しません。

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

  • 重点イベント: blurfocusfocusinfocusout
  • マウスイベント: clickdblclickmousedownmouseentermousemove など
  • ホイール イベント: wheel
  • 入力イベント: beforeinputinput
  • キーボード イベント: keydownkeyup キー
  • 楽曲イベント: compositionstartcompositionupdatecompositionend
  • DragEvent: dragstartdragdragenddrop など

ヒント

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

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

Shadow ツリー内の内部ノードで呼び出されるカスタム DOM イベントは、composed: true フラグを使用してイベントが作成されない限り、Shadow 境界の外には出てきません。

// 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(デフォルト)の場合、コンシューマは Shadow ルートの外部でイベントをリッスンできません。

<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 内で呼び出されるイベントは、ホスティング要素から発生したイベントのように調整されます。たとえば、Shadow ルート内の <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 を見つけるために Shadow ルートを再帰的にドリルする必要があります。

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

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

  • Shadow 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: 実際の動作。

上記は、<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 で、「クリック可能な Shadow DOM テキスト」がクリックされた場合(または、要素の 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>

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

ページで使用されているカスタム要素を見つけると役に立つ場合があります。そのためには、ページで使用されているすべての要素の Shadow 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 を使用して Shadow ルートにデータを入力する代わりに、宣言型の <template> を使用できます。テンプレートは、ウェブ コンポーネントの構造を宣言するための理想的なプレースホルダです。

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

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

ここ数年、ウェブ コンポーネントをご利用いただいている方ならご存じかもしれませんが、Chrome 35 以降/Opera にはしばらく前から古いバージョンの Shadow DOM が付属しています。Blink はしばらくの間、両方のバージョンを同時にサポートします。v0 仕様では、シャドウルートを作成するための別のメソッド(v1 の element.attachShadow ではなく element.createShadowRoot)が提供されていました。古いメソッドを呼び出しても、引き続き v0 セマンティクスを持つシャドウルートが作成されるため、既存の v0 コードは破損しません。

古い v0 の仕様については、html5rocks の記事(123)をご覧ください。また、Shadow 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!
}

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

おわりに

今回初めて、適切な CSS スコープと DOM スコープを設定し、真のコンポジションを持つ API プリミティブが導入されました。カスタム要素などの他のウェブ コンポーネント API と Shadow DOM を組み合わせることで、ハックや <iframe> などの古いバゲージを使用せずに、真にカプセル化されたコンポーネントを作成できます。

誤解しないでください。Shadow DOM は確かに複雑なわく星です。学ぶ価値はあります少し時間をかけてみます。使い方を学んで質問してください。

関連情報

よくある質問

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

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

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

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

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

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

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

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