2019年7月15日

Shadow DOMとイベント

シャドウツリーの背後にある考え方は、コンポーネントの内部実装の詳細をカプセル化することです。

例えば、<user-card>コンポーネントのShadow DOM内でクリックイベントが発生したとします。しかし、メインドキュメントのスクリプトは、特にコンポーネントがサードパーティライブラリから提供されている場合、Shadow DOMの内部については何も知りません。

そのため、詳細をカプセル化するために、ブラウザはイベントを *再ターゲティング* します。

Shadow DOMで発生するイベントは、コンポーネントの外側からキャッチされた場合、ホスト要素をターゲットとして持ちます。

簡単な例を次に示します。

<user-card></user-card>

<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `<p>
      <button>Click me</button>
    </p>`;
    this.shadowRoot.firstElementChild.onclick =
      e => alert("Inner target: " + e.target.tagName);
  }
});

document.onclick =
  e => alert("Outer target: " + e.target.tagName);
</script>

ボタンをクリックすると、メッセージが表示されます。

  1. 内部ターゲット: BUTTON - 内部イベントハンドラは、Shadow DOM内の要素である正しいターゲットを取得します。
  2. 外部ターゲット: USER-CARD - ドキュメントイベントハンドラは、シャドウホストをターゲットとして取得します。

イベントの再ターゲティングは、外部ドキュメントがコンポーネントの内部を知る必要がないため、非常に便利です。外部ドキュメントの観点からは、イベントは<user-card>で発生しました。

スロットされた要素(物理的にライトDOMに存在する要素)でイベントが発生した場合、再ターゲティングは発生しません。

例えば、下の例で<span slot="username">をクリックした場合、シャドウとライトの両方のハンドラでイベントターゲットはまさにこのspan要素になります。

<user-card id="userCard">
  <span slot="username">John Smith</span>
</user-card>

<script>
customElements.define('user-card', class extends HTMLElement {
  connectedCallback() {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.innerHTML = `<div>
      <b>Name:</b> <slot name="username"></slot>
    </div>`;

    this.shadowRoot.firstElementChild.onclick =
      e => alert("Inner target: " + e.target.tagName);
  }
});

userCard.onclick = e => alert(`Outer target: ${e.target.tagName}`);
</script>

「John Smith」をクリックした場合、内部と外部の両方のハンドラでターゲットは<span slot="username">になります。これはライトDOMの要素であるため、再ターゲティングは行われません。

一方、Shadow DOMに由来する要素(例えば、<b>Name</b>)をクリックした場合、Shadow DOMからバブルアップする際に、そのevent.target<user-card>にリセットされます。

バブリング、event.composedPath()

イベントのバブリングの目的では、フラット化されたDOMが使用されます。

したがって、スロットされた要素があり、その内部でイベントが発生した場合、そのイベントは<slot>まで、そして上方にバブルアップします。

すべてのシャドウ要素を含む元のイベントターゲットへの完全なパスは、event.composedPath()を使用して取得できます。メソッドの名前からもわかるように、そのパスは合成後に取得されます。

上記の例では、フラット化されたDOMは次のようになります。

<user-card id="userCard">
  #shadow-root
    <div>
      <b>Name:</b>
      <slot name="username">
        <span slot="username">John Smith</span>
      </slot>
    </div>
</user-card>

そのため、<span slot="username">をクリックした場合、event.composedPath()の呼び出しは、[span, slot, div, shadow-root, user-card, body, html, document, window]という配列を返します。これは、合成後のフラット化されたDOMにおけるターゲット要素からの親チェーンと完全に一致します。

シャドウツリーの詳細は、{mode:'open'}ツリーに対してのみ提供されます。

シャドウツリーが{mode: 'closed'}で作成された場合、合成されたパスはホスト: user-cardから上方に始まります。

これは、Shadow DOMで動作する他のメソッドと同様の原理です。クローズドツリーの内部は完全に隠されています。

event.composed

ほとんどのイベントは、Shadow DOMの境界を正常にバブルアップします。しかし、そうではないイベントもいくつかあります。

これは、composedイベントオブジェクトのプロパティによって制御されます。これがtrueの場合、イベントは境界を越えます。それ以外の場合は、Shadow DOMの内側からしかキャッチできません。

UI Events仕様を見ると、ほとんどのイベントはcomposed: trueになっています。

  • blurfocusfocusinfocusout
  • clickdblclick
  • mousedownmouseupmousemovemouseoutmouseover
  • wheel,
  • beforeinputinputkeydownkeyup

すべてのタッチイベントとポインターイベントにもcomposed: trueが設定されています。

ただし、composed: falseのイベントもあります。

  • mouseentermouseleave(まったくバブルアップしません)、
  • loadunloadaborterror
  • select,
  • slotchange.

これらのイベントは、イベントターゲットが存在する同じDOM内の要素でのみキャッチできます。

カスタムイベント

カスタムイベントをディスパッチする場合は、バブルアップしてコンポーネントの外に出るように、bubblescomposedプロパティの両方をtrueに設定する必要があります。

例えば、ここではdiv#outerのShadow DOMにdiv#innerを作成し、その上で2つのイベントをトリガーします。composed: trueが設定されているものだけが、ドキュメントの外側に到達します。

<div id="outer"></div>

<script>
outer.attachShadow({mode: 'open'});

let inner = document.createElement('div');
outer.shadowRoot.append(inner);

/*
div(id=outer)
  #shadow-dom
    div(id=inner)
*/

document.addEventListener('test', event => alert(event.detail));

inner.dispatchEvent(new CustomEvent('test', {
  bubbles: true,
  composed: true,
  detail: "composed"
}));

inner.dispatchEvent(new CustomEvent('test', {
  bubbles: true,
  composed: false,
  detail: "not composed"
}));
</script>

まとめ

イベントは、composedフラグがtrueに設定されている場合にのみ、Shadow DOMの境界を越えます。

組み込みイベントのほとんどは、関連する仕様で説明されているように、composed: trueになっています。

composed: falseの組み込みイベントの一部

  • mouseentermouseleave(バブルアップもしません)、
  • loadunloadaborterror
  • select,
  • slotchange.

これらのイベントは、同じDOM内の要素でのみキャッチできます。

CustomEventをディスパッチする場合は、明示的にcomposed: trueを設定する必要があります。

入れ子になったコンポーネントの場合、1つのShadow DOMが別のShadow DOMに入れ子になる可能性があることに注意してください。その場合、合成されたイベントはすべてのShadow DOM境界をバブルアップします。したがって、イベントが直近の包含コンポーネントのみに向けられている場合は、シャドウホストでディスパッチし、composed: falseを設定することもできます。そうすると、コンポーネントのShadow DOMの外に出ますが、上位レベルのDOMにはバブルアップしません。

チュートリアルマップ

コメント

コメントする前にこれを読んでください…
  • 改善点の提案がある場合は、コメントする代わりにGitHub issueまたはプルリクエストを送信してください。
  • 記事の内容が理解できない場合は、詳しく説明してください。
  • 数語のコードを挿入するには<code>タグを使用し、複数行の場合は<pre>タグで囲み、10行を超える場合はサンドボックス(plnkrjsbincodepen…)を使用してください。