🌲

shadowDOM 以下の要素で発生したイベントの発生元要素を特定する

に公開

document レベルでイベント捕捉、target で発生元を識別

HTML/JavaScript でのプログラムの話です。

最近の Web アプリケーションフレームワークを使っていると、あまり出番は薄いのですが、希に、「下位要素からバブリングしてくるイベントを、document レベルでリッスンする」みたいな実装を行なうことがあります。そしてこのようなパターンでは、「どの要素でそのイベントが発生したのか」を、イベント引数の target プロパティを見て判断することがままあります。

// このページで発生したすべての click イベントを捕捉し...
document.addEventListener("click", e => {
    // click イベントの発生元が href 属性付きの a 要素であれば...
    if (e.target.matches('a[href]')) {
        // 既定の動作をキャンセルして...
        e.preventDefault();
        // 代わりに何かする
    }
});

shadowDOM 内の要素でイベント発生しても target はカスタム要素

しかしところで、ここで、以下のようなカスタム要素が定義されていたとしましょう。

class AwesomeAnchor extends HTMLElement {
    constructor() {
        super();
        this.attachShadow({ mode: 'open' });
        this.input = document.createElement('a');
        ...
        this.shadowRoot.appendChild(this.input);
    }
    ...
}
customElements.define('awesome-anchor', AwesomeAnchor);

そしてページ上でこのカスタム要素 <awesome-anchor> が使われていたとします。

<awesome-anchor href="/foo/bar">
    Awesome!
</awesome-anchor>

すると、ブラウザ上で構築される DOM ツリーは以下のような感じになります。

<awesome-anchor href="/foo/bar">
  shadowRoot
    <a href="/foo/bar">
      Awesome!
    </a>    
</awesome-anchor>

さてここで、このカスタム要素 <awesome-anchor> の shadowDOM 内にレンダリングされた anchor 要素 (<a>) をクリックしたとき、先の document オブジェクトに取り付けた click イベントハンドラーは期待どおり機能するでしょうか? 残念ながら答えは「NO」です。この場合のイベント引数の target プロパティには、shadowDOM 配下にレンダリングされた anchor 要素 (<a>) ではなく、カスタム要素 <awesome-anchor> が格納されているのです。

document.addEventListener("click", e => {
    // 👇 ここで e.target には <awesome-anchor> が格納されている
    if (e.target.matches('a[href]')) {
        e.preventDefault();
        ...
    }
});

これはちょっと期待した動作とは違いますね。このシナリオでは、例え shadowDOM の下にあるものであっても、anchor 要素 (<a>) で発生したイベントであれば、すべて拾いたいのです。どうしたらいいでしょうか?

composedPath() メソッドを使えばよさそう

ブラウザの開発者ツールでデバッグしながら試行錯誤していたところ、イベント引数に、composedPath() というメソッドがあるのに気がつきました。MDN で調べてみます。

https://developer.mozilla.org/ja/docs/Web/API/Event/composedPath

"composedPath() は Event インターフェイスのメソッドで、イベントの経路をリスナーが呼び出されるオブジェクトの配列で返します。"

つまり、イベント引数の composedPath() メソッドを呼び出すと、shadowDOM 配下の要素であっても、そのイベント発生元の要素から document に至るまでの経路要素が配列で返ってくるようなのです。

ということで、先のイベントハンドラーを以下のように実装すれば解決できます。

document.addEventListener("click", e => {
    // 👇 composedPath() メソッドを使って、イベント発生元を特定する!
    const target = e.composedPath()[0] || e.target;
    if (target.matches('a[href]')) {
        e.preventDefault();
        ...
    }
});

おめでとうございます🎉

注意: shadowRoot が open のときだけ通用します

残念ながら、この方法は、shadowRoot の mode が open のときだけ通用します。もしも mode が closed を指定してshadowDOM が作られていた場合、イベント引数の composedPath() メソッドが返す要素配列はカスタム要素から始まり、shadowDOM 配下の要素の様子は伺うことができません。この場合はあきらめるしかなさそうです。

Discussion