- バックエンド / リーダー候補
- PdM
- Webエンジニア(シニア)
- Other occupations (18)
- Development
- Business
JavaScript: 所望のイベントリスナの発火を妨げているイベントリスナを特定する
Photo by Alberto Bianchini on Unsplash
Webアプリケーションでは、DOMの要素にイベントリスナ(イベントハンドラ)を取り付けることで、ユーザーによる様々な操作 (クリックなど) に応じて処理を行うことができます。
しかし、イベントリスナを登録しても、他のイベントリスナとの干渉によって意図した通りに発火しないことがあります。ここではその調査方法を紹介します。
前提知識: イベントバブリング
イベントについては筆者の過去記事でも解説しましたが、あらためてここでも説明します。イベントバブリングを理解することが、イベントデバッグの近道だからです。
DOMにおいて、要素はネストすることによって木構造を形成します。ある要素(ターゲット要素)がクリックされるなどしてイベントが発生したとき、イベントはその要素自体だけではなく、その祖先要素にも送られます。これをイベントバブリングといいます。
イベントバブリングは2つの段階に分けられます。
- Captureフェーズでは、イベントは文書の最上位要素にまず送られます。そこから順に子孫に進んでいきます。ターゲット要素まで来たら、Bubbleフェーズに移ります。
- Bubbleフェーズでは、Captureフェーズとは逆順に、ターゲット要素から最上位要素に進みます。
慣習として、特に理由がなければBubbleフェーズでイベントを処理します。addEventListenerのデフォルトはBubbleです。
前提知識: イベントリスナの順番とスキップ
イベントリスナは、要素ごとに複数登録することができます。この場合、同じ要素内のイベントリスナは登録順に呼ばれます。また、Captureフェーズ用のイベントリスナとBubbleフェーズ用のイベントリスナは独立に管理されます。
イベントバブリングの終了後、ブラウザによる既定の挙動が実行されます。たとえば、リンクであればリンク先に遷移し、フォームボタンであればフォームの提出を行います。マウスホイールのイベントであれば、スクロール位置を動かします。
この順番に影響を与えるのが preventDefault, stopPropagation, stopImmediatePropagation の3つのメソッドです。
preventDefault
イベントバブリングの途中でpreventDefaultが呼ばれた場合、ブラウザの既定の挙動はキャンセルされます。preventDefaultを呼ぶだけでは、後続のイベントリスナはキャンセルされません。
また、規格の正式な規定には含まれませんが、イベントリスナがfalseを返した場合はpreventDefaultが呼ばれたものとみなす慣行があります。
stopPropagation
イベントバブリングの途中でstopPropagationが呼ばれた場合、現在処理中の要素までは処理を続行します。ただし、Captureフェーズの要素とBubbleフェーズの要素は別として扱います。
現在処理中の要素の処理が終わったら、イベントバブリングをキャンセルします。後続のイベントリスナは実行されません。CaptureフェーズでstopPropagationが呼ばれた場合は、Bubbleフェーズも含めたバブリング処理全体がキャンセルされます。
stopPropagationを呼ぶだけでは、ブラウザの既定の処理はキャンセルされません。また、同じ要素内の残りのイベントリスナもキャンセルされません。
stopImmediatePropagation
stopImmediatePropagationはstopPropagationと似ていますが、同じ要素内の後続するイベントリスナもキャンセルします。
イベントリスナを列挙する
さて、イベントバブリングの基本がわかったところで、イベントバブリングのデバッグ方法を説明します。以降はGoogle Chromeを想定します。
イベントが「発火しない」というバグのデバッグが難しいのは、それが別のイベントリスナーに起因しているからです。デバッグしようとしている箇所とはほとんど関係のないような箇所に仕込まれたイベントリスナーが問題を抱えている可能性があり、コードだけ追っていてもなかなか関連性のある箇所を特定するのは難しいかもしれません。
そこで、実際にコードを動かしながら、悪さをしているイベントリスナがどこで定義されているのかを動的に発見するという手段を考えます。
getEventListeners
DOM APIには、要素に付随するイベントリスナを列挙する方法は用意されていません。これはおそらく意図的な設計です。
しかしそれではデバッグで不便なため、ChromeのDevToolsには開発専用APIとして getEventListeners というAPIが用意されています。使い方は簡単で、コンソールで以下のようにします。
getEventListeners(document); // <html> のイベントリスナの一覧を返す。
getEventListenersの型は以下のような感じです。
function getEventListeners(object: Element): {
[eventName: string]: {
type: string;
listener: (this: Element, e: Event) => void | boolean,
once: boolean;
passive: boolean;
useCapture: boolean;
}[];
};
getEventListenersをラップする
getEventListenersをそのまま使っても、イベントバブリング全体のデバッグはできません。そこで、以下のようなユーティリティー関数を作ります。
中で getEventListeners を使っているため、この関数はDev Toolsのコンソール内で定義する必要があります。
function getEventListenerChain(type, object) {
function enhance(element, entry) {
return {
element,
...entry,
remove: () => element.removeEventListener(entry.type, entry.listener, entry.useCapture)
};
}
let element = object;
let chain = [];
while (element) {
const entries = (getEventListeners(element)[type] ?? []).map((entry) => enhance(element, entry));
chain = [
...entries.filter((entry) => entry.useCapture),
...chain,
...entries.filter((entry) => !entry.useCapture),
];
element = element.parentNode;
}
return chain;
}
この関数を使うと、特定の要素のイベントバブリングに出現する全てのリスナーを列挙できます。
// DevTools上で選択中の要素 ($0) の関連するイベントリスナーを全て列挙する
const listeners = getEventListenerChain("click", $0);
listeners
listenersの中身をコンソール上で展開することで、関数の出所を調べることもできます。DevTools上で関数を展開すると [[FunctionLocation]] という内部スロットが露出されており、ここで関数の定義位置を調査できます。もしこの関数が別の関数のラッパーであるなどの場合は、 [[Scopes]] 内部スロットを展開することでクロージャのキャプチャ時点の環境を確認することができ、これを使うことで真の定義箇所を特定することが可能です。
また、上記のユーティリティー関数では、 remove() というユーティリティーメソッドを定義しています。これにより、リスナを一部削除することが可能です。
// 4〜6番目のリスナを削除
listeners.slice(3, 6).forEach((l) => l.remove());
もし問題のリスナ (stopPropagationを呼んでいるリスナ) がこの中に含まれていれば、それを削除したことによって所望のリスナが呼ばれるようになるはずです。目的のリスナのほうにログを仕込んでおけば、そのことは簡単に観測できます。これを繰り返すことで、定義箇所を全て確認しなくても問題の範囲をある程度絞り込むことができます。
事例
最後に、実際にこのようなデバッグが役に立った事例を紹介します。 (社員向けリンク)
問題が起きていたのは比較的古いフロントエンド実装で、rails-ujsというライブラリを使っている箇所でした。そして、このrails-ujsの機能が特定箇所でうまく動かないという問題があり、それを解決する必要がありました。
data-methodとイベント移譲
rails-ujsの機能のひとつに data-method というものがあります。これは、リンクに data-method という属性を指定しておくと、クリック時にGET以外のメソッドで遷移してくれるというものです。
これは、リンクのクリックイベントを乗っ取り、かわりに隠しフォーム要素を作ってナビゲーションするという形で実装されています。つまり、素朴には以下のような形になります。
しかし、対象であるリンク要素に直接イベントリスナーを実装すると、対象となるリンク要素が増えるたびにイベントリスナーを追加してまわる必要があります。そのためにはMutationObserverなど高度な処理が必要で、パフォーマンス上の懸念もあります。
そこで、対象であるリンク要素自体ではなくより祖先に近い要素にイベントリスナーを登録しておき、バブリングされたイベントを受け取るという方法が使われています。これはイベント移譲と呼ばれているテクニックです。
今回はこのイベント移譲されたはずのものがうまく発火していないという問題が起きていました。
モーダルが悪さをしていた
本稿で紹介したようなテクニックを使ってデバッグした結果、悪さをしていたのはモーダルダイアログのUI中の処理であることがわかりました。
ここで使われていたモーダルダイアログは自前の実装でした。モーダルではしばしば、モーダルの領域外をクリックしたときにモーダルを閉じるという挙動が実装されています。今回問題になった箇所ではこれを以下のように実装していました。
- モーダルがクリックされたら、モーダルを閉じる。
- ただし、モーダル内部がクリックされていたときは、イベントバブリングを止めることでモーダルを閉じないようにする。
この処理は本来は止めてはいけないイベントバブリングまで止めてしまうため、rails-ujsの挙動を阻害していたというのが真相でした。図にすると以下のようになります。
モーダル内部がクリックされたときにstopPropagationでバブリングを止めています。その意図は祖先要素で起きる「モーダルを閉じる」というイベントをキャンセルするためにありますが、その余波としてdocumentで待ち構えている移譲イベントハンドラまでスキップしてしまいます。このイベントは実際には子孫である <a> のためのイベントであるにもかかわらず、実際には実行されないという結果になります。
実のところ、モーダルの枠外クリックをわざわざこのように実装する必要はなく、DOMの構成を見直して枠外のクリックのみ反応するようにするなり、モーダル枠外のクリックイベントで枠内の場合の分岐を書くなりで事足ります。今回は後者で対応することができました。
まとめ
- イベントリスナーは後続するイベントの処理をキャンセルすることができるが、これが思わぬ干渉につながることがある。
- こういった事象のデバッグにはイベントバブリングの知識が有益である。
- また、Chrome DevToolsにはイベントリスナを列挙する機能があり、これを有効活用することも問題特定への近道である。