代表的な面接トピック

フロントエンド面接:イベント伝播の仕組みとイベント委譲の実装

フロントエンド普通
Offer.cc 編集チーム公開日 更新日

質問

動的なタスクリストにおいて、行の追加・削除・ネストが頻繁に行われます。各行にはネストされたアイコンを持つ編集・削除・トグルボタンが含まれています。DOMイベントのキャプチャ、ターゲット、バブリングの各フェーズを説明した上で、親リスナー1つによる信頼性の高いイベント委譲を実装してください。target と currentTarget を正しく区別し、委譲の境界を制限し、stopPropagation、バブリングしないイベント、Shadow DOM、クリーンアップ、テストについても網羅してください。

プロンプトと適用可能なコンテキスト

タスクリストには数千行が表示されることがあります。ページネーションの結果、リアルタイム更新、ユーザー操作によって、行が動的に挿入または削除されます。各行には編集、削除、ステータストグルのボタンがあり、ボタン内にはアイコンやテキスト要素が含まれる場合があります。また、リスト内には独立して管理されるネストされたサブタスクリストが含まれることもあります。外側のリストに登録する click リスナーは1つのみとし、コントロールの data-action とその行の data-task-id を使用してアクションをディスパッチしてください。

回答では、イベントが祖先からターゲットへ、そして再び祖先へと伝わる流れを説明し、event.targetevent.currentTarget を区別し、外側のリストがネストされたリストのアクションを処理しないように制御する必要があります。また、通常のバブリング委譲が使用できないイベント、子孫要素が伝播を停止した場合の影響、Shadow DOM による可視パスの変化、コンポーネントがアンマウントされた際のリスナー解除についても説明しなければなりません。

この質問は、ミドルレベルのフロントエンド、Webプラットフォーム、フルスタックの面接に適しています。コアとなるスキルはブラウザのDOMイベントディスパッチとUIの所有権境界であり、フロントエンド領域に属します。ユーザーランドのPub/Subコンテナや、タスク、マイクロタスク、レンダリングのスケジューリングをテストするものではありません。

面接官が評価しているポイント

優れた回答は正確なモデルから始まります。ディスパッチは、キャプチャフェーズ、ターゲットフェーズ、そしてイベントがバブリング可能な場合はバブリングフェーズというイベントパスをたどります。addEventListener はデフォルトでバブリングリスナーを登録します。capture: true を指定すると、イベントがターゲットに向かって伝わる間に実行されます。「イベントは上にバブリングする」とだけ答えても、キャプチャリスナー、バブリングしないイベント、Shadow DOM の境界を説明することはできません。

実装の品質はルーティングロジックに現れます。target はイベントがディスパッチされたターゲットを特定し、currentTarget はリスナーが現在実行されているオブジェクトを特定します。親リストのハンドラーでは、前者がボタン内のアイコンであり、後者がリストそのものになることがあります。タグ名の単純な比較では、ネストされたマークアップを見落とします。堅牢な回答では、target から開始し、closest() を使ってアクションコントロールを検索した上で、そのコントロールがアクティブな委譲ルートに属していることを検証します。

所有権の境界設定は、基本的な回答とプロダクション対応の回答を分けるポイントです。contains() はコンテナ外のマッチを拒否できますが、コンテナ自体の中に独立して委譲された別のルートが含まれている場合があります。最も近い委譲ルートが現在のリストと一致することを要求することで所有権が明示され、外側とネストされたリストの双方が1つのアクションを実行してしまう事態を防げます。また、アクション名はDOMの文字列から任意の関数名に変換するのではなく、アローリストを介して処理すべきです。

検証計画では、観察可能な障害ケース(ネストされたマークアップ、動的に追加された行、無効化されたボタン、ネストされたルート、伝播の停止、フォーカスイベント、Shadow DOM、非同期コールバック、アンマウント時のクリーンアップ)を対象にする必要があります。委譲はリスナー数を削減し、動的な子孫要素を自然にカバーしますが、小規模で安定したインターフェースであれば直接リスナーを設定する方が明快な場合もあります。委譲は設計上の選択肢であり、必須の最適化ではありません。

回答前に確認すべき明確化のための質問

  • どのイベントを委譲するのか? click は通常バブリングするため、この要件に適しています。focusblurmouseenter、または mouseleave を委譲する場合は、キャプチャ、バブリングする代替イベント、または直接のリスナーが必要になり、実装とセマンティクスの両方が変わります。
  • リストにネストされたコンポーネントや Shadow DOM が含まれる可能性はあるか? フラットなリストであれば contains() だけで十分な場合が多いです。ネストされた所有権には独立したルートマーカーが必要であり、Shadow DOM には公開コンポーネントイベントの規約が必要です。
  • ボタンのマークアップは安定しているか? ターゲットがアイコンやテキストノードになる可能性がある場合、ハンドラーには closest() が必要です。matches() で済むのは、ターゲットが確実にアクション要素である場合に限られます。
  • 誰が stopPropagation() を呼び出せるのか? 子コンポーネントによる伝播の停止が許可されている場合、祖先のバブリングリスナーはイベントの受信を保証できません。ビジネスアクションを無断でキャプチャフェーズに移動させるのではなく、所有権を明確にしてください。
  • disabled(無効)状態はどのように表現されるか? ネイティブボタンは disabled を使用します。カスタムコントロールにはアクセシビリティやキーボード操作への対応も必要であり、ハンドラーにとってCSSクラス単体は信頼できる規約ではありません。
  • リスナーのライフタイムは誰が管理するのか? ページレベルのルートであれば永続的である場合があります。マウント可能なコンポーネントは、解除可能な関数参照を保持するか AbortSignal を使用する必要があります。そうしないと、再マウント時に1回のクリックが複数回処理されてしまいます。
  • アクションはデフォルト動作をキャンセルする必要があるか? ナビゲーション、フォーム送信、その他のデフォルト動作がプロダクトの意図と競合する場合にのみ preventDefault() を呼び出します。デフォルト動作のキャンセルと伝播制御は独立した判断です。

30秒の回答フレームワーク

「イベントはパスに沿ってキャプチャされ、ターゲットで処理され、bubbles が true の場合は祖先を通ってバブリングします。親のデフォルトリスナーはバブリングフェーズで実行されるため、1つのリスナーで動的な子孫要素をカバーできます。ハンドラー内では、まず target が Element であることを検証し、closest('[data-action]') を呼び出します。contains を確認し、最も近い委譲ルートが現在のリストであることを必須とすることで、ネストされたリストからアクションが外側に漏れないようにします。currentTarget はリスナーが設定されたリストであり、ハンドラーの実行中のみ信頼できます。アクションのアローリストを通じてディスパッチを行い、AbortController でクリーンアップします。バブリングしないイベント、stopPropagation、Shadow DOM については、境界に応じてキャプチャ、バブリングする代替イベント、直接リスナー、コンポーネントレベルのカスタムイベントを選択し、ネストされたターゲット、動的な行、アンマウント/再マウントのテストを実施します。」

ステップごとの詳細解説

まずはイベントパスから始めます。ボタン内のアイコン上でクリックが発生したと仮定します。パスには、document、外側のリスト、タスク行、ボタン、アイコンが含まれます。キャプチャリスナーは、パスの外側からターゲットに向かって実行されます。適用可能なターゲットリスナーは、イベントがターゲットに到達したときに実行されます。bubbles が true の場合、祖先のバブリングリスナーが内側から外側に向かって実行されます。ディスパッチアルゴリズムがこのパスを構築し、DOM構造と Shadow DOM の境界の双方がこれに影響を与えます。

targetcurrentTarget は異なる問いに答えるものです。target はこのインタラクションがどこでディスパッチされたかを示し、通常のDOMバブリング中は原則として不変です。currentTarget は現在どのオブジェクトのリスナーが実行中であるかを示します。これはハンドラー間で変化し、ハンドラーの復帰後は null になります。必要なリスト参照やデータは同期的に保存してください。event.currentTarget の後で await に依存してはいけません。

以下の実装では、マッチング、所有権、状態、アクションディスパッチを分離しています。リストルートは data-delegation-root を持ち、各行は data-task-id を持ち、アクションには data-action を持つネイティブボタンを使用すると仮定します。openEditordeleteTasktoggleTask は既存のプロダクト関数を表します。

javascript
const list = document.querySelector("#task-list");

if (!(list instanceof HTMLElement)) {
  throw new Error("task list was not found");
}

const handlers = new Map([
  ["edit", (taskId) => openEditor(taskId)],
  ["delete", (taskId) => deleteTask(taskId)],
  ["toggle", (taskId) => toggleTask(taskId)],
]);

const controller = new AbortController();

list.addEventListener(
  "click",
  (event) => {
    const target = event.target;
    if (!(target instanceof Element)) return;

    const action = target.closest("[data-action]");
    if (!(action instanceof HTMLButtonElement)) return;
    if (!list.contains(action)) return;
    if (action.closest("[data-delegation-root]") !== list) return;
    if (action.disabled) return;

    const row = action.closest("[data-task-id]");
    if (!(row instanceof HTMLElement) || !list.contains(row)) return;

    const actionName = action.dataset.action;
    const taskId = row.dataset.taskId;
    if (!actionName || !taskId) return;

    const handler = handlers.get(actionName);
    if (!handler) return;

    handler(taskId);
  },
  { signal: controller.signal },
);

function cleanupTaskListDelegation() {
  controller.abort();
}

target.closest() は実際のアイコンからボタンへとツリーを遡るため、内部マークアップが変更されてもルーティングが壊れません。list.contains(action) はマッチした要素がリスト内にあることを保証します。最も近いルートのチェックにより、ネストされたリストが所有するボタンが除外されます。Map は既知の3つのアクションのみを許可し、未知の data-action の場合は安全に終了します。明示的な disabled チェックによってハンドラーの規約が明確になるため、将来コードがプログラム的なディスパッチを使用したりマークアップを変更したりしても、無効なアクションは確実に拒否されます。

伝播制御には個別の説明が必要です。stopPropagation() は、キャプチャパスまたはバブルパスに沿ったそれ以上の伝播を停止します。デフォルトアクションや同一要素上の他のリスナーは停止しません。後者を停止するには stopImmediatePropagation() が必要です。preventDefault() はキャンセル可能なイベントのデフォルトアクションをキャンセルしますが、伝播は停止しません。これら3つのAPIを同一視すると、リンクが意図せず遷移してしまったり、祖先要素がイベントを見逃したり、同一ノードのハンドラーが予期せず実行され続けたりする原因になります。

バブリングしないイベントごとに適切な戦略を選択します。祖先要素はキャプチャ中に focusblur を監視するか、バブリングする focusin および focusout イベントを使用できます。mouseentermouseleave は直接バインド可能です。これらをバブリングする mouseovermouseout に置き換える場合は、子孫間の移動によって余計なイベントが発生するため relatedTarget によるフィルタリングも必要になります。スクロール可能な要素には、直接リスナーを設定する方が最も分かりやすい場合が多いです。似たイベント名であっても同一のセマンティクスが保証されるわけではありません。

Shadow DOM はコンポーネントの境界です。ドキュメントレベルの委譲がコンポーネント内部のセレクタに依存すべきではありません。click などのユーザーエージェントUIイベントは通常シャドウ境界を越えて composed されますが、外側のリスナーからはリターゲットされたホストが target として見えることがあります。composedPath() は open なシャドウルート内のパスを公開できますが、closed なルート内のノードは公開しません。アクションを公開する必要がある Web Component は、内部の処理を自身で完結させ、バブリングと境界越えが許可された文書化されたコンポーネントイベントを発行すべきです。利用側は安定した公開インターフェースに依存することになります。

単にリスナーの数を数えるのではなく、実際の動作を検証してください。キャプチャ、ターゲット、バブリングの順序をログ出力する、ボタンのテキストとアイコンの双方をクリックする、行を追加して再バインドなしでクリックする、行を削除して古いノードが機能しないことを確認する、ネストされたリストをクリックしてその所有者のみが処理することを確認する、disabled や未知のアクションをテストする、子が伝播を停止したときに祖先でイベントが欠落することを観察する、フォーカス戦略や Shadow DOM をカバーする、cleanupTaskListDelegation() を呼び出してクリックが停止することを証明し、その後1度再マウントして1回のクリックが正確に1度だけ処理されることを確認します。

質の高い模範解答

「まず、バブリングする click イベントを委譲していることを確認します。ブラウザはイベントパスに沿ってターゲットまでキャプチャし、ターゲットで処理した後、バブリングが許可されていれば祖先を通って戻ります。親のデフォルトリスナーはバブリング中に実行されます。event.target はディスパッチのターゲットであり、ボタン内のアイコンである可能性があります。event.currentTarget はこのハンドラーを持つリストであり、ハンドラーの実行中のみ有効です。

私は target から closest('[data-action]') で探索を開始し、結果が現在のリスト内のボタンであることを検証します。このリストには別の委譲されたコンポーネントが含まれる可能性があるため、アクションに最も近い data-delegation-root がこのリストであることも要求します。該当する data-task-id の行を取得し、Map によるアローリストを使用して edit、delete、toggle を既存の関数にディスパッチします。新しく挿入された行は同じ祖先リスナーによって自然にカバーされ、アンマウント時には AbortController がリスナーを解除します。

すべてのイベントを無理にバブリング委譲に適合させることはしません。フォーカスにはキャプチャまたは focusin を利用でき、mouseenter は mouseover とセマンティクスが異なります。子が stopPropagation を呼び出すと、祖先のバブルハンドラーはイベントを受け取れなくなります。Shadow DOM を越える場合、target はリターゲットされる可能性があり、closed なルートは内部パスを公開しないため、コンポーネントはページを内部セレクタに依存させるのではなく、安定した外部イベントを公開すべきです。

テストでは、内部アイコン、動的な行、ネストされたルート、disabled や未知のアクション、伝播の停止、Shadow DOM、アンマウント/再マウントをカバーします。関連性のない動作を持つ少数の安定したボタンのみであれば、直接リスナーを使用します。ここでの委譲の主な価値は、明確な所有権境界、動的な子孫要素のサポート、単一のライフサイクル管理にあり、文脈を無視した単なるパフォーマンス向上ではありません。」

よくある間違い

  • event.target.matches('button') のみをチェックする → アイコンをクリックすると target がアイコンになる → closest() でアクション要素まで辿る。
  • 最初に見つかった closest() のマッチを実行する → 別のコンテナやネストされたルートに属している可能性がある → contains() と最も近い所有権ルートの双方をチェックする。
  • currentTarget をクリックされた要素として扱う → 親の委譲においては実行中のハンドラーを持つリストになる → ソースには target を、ハンドリング境界には currentTarget を使用する。
  • event.currentTarget の後で await を読み取る → ハンドラーが復帰しているため値は null になる → 必要な要素参照やデータは同期的に保存する。
  • preventDefault() がバブリングを停止すると誤認する → 祖先が実行され続ける一方でデフォルト動作のみキャンセルされる場合がある → デフォルトのキャンセルと伝播制御は別々に判断する。
  • すべてのイベントに同一の委譲テンプレートを適用する → focus や mouseenter は click のようにはバブリングしない → イベントのセマンティクスに応じてキャプチャ、代替イベント、または直接バインドを選択する。
  • ドキュメントルートから Shadow DOM 内部をインスペクトする → target はリターゲットされ、closed ルートは内部パスを隠蔽する → コンポーネントに内部を管理させ、安定したイベントを公開させる。
  • クリーンアップなしでマウントごとに無名リスナーを追加する → 古い状態のまま1回のクリックが繰り返し実行される → 解除可能な参照を保持するか、AbortSignal でライフタイムを管理する。
  • 委譲が常に高速であると主張する → 小規模で安定したインターフェースではルーティングやフィルタリングの複雑さが増す → ノードの動特性、所有権、メンテナンスコストから選択する。

フォローアップ質問と回答

フォローアップ1: 子ボタンが stopPropagation を呼び出していますが、外側の委譲を機能させ続ける必要があります。どう対応しますか?

まず、クリックのセマンティクスを誰が所有しているかを判断します。子要素にイベントをブロックする正当な理由がない場合は、その呼び出しを削除してコンポーネントの規約を修正します。キャプチャリスナーを使用すればボタンに到達する前にイベントを監視できますが、キャプチャ中に削除などのビジネスアクションを実行すると、ターゲットのハンドラーやデフォルト動作よりも前に実行されるため、順序やキャンセルの機会が変わってしまいます。キャプチャによる横取りは、プロダクトがそれを明示的に要求している場合にのみ使用し、順序、キャンセル、重複アクションのテストを伴うべきです。

フォローアップ2: フォームで一元化されたフォーカス処理が必要です。どのように委譲しますか?

focus はバブリングしませんが、祖先要素は capture: true でこれを監視できます。あるいは、バブリングする focusin イベントを使用します。フィールドヘルプハンドラーを1つ用意することが目的であれば、focusin モデルの方が動作を把握しやすいことが多いです。キーボード、ポインター、プログラムによるフォーカスをテストし、複合ウィジェット内での移動のたびにウィジェット全体から離脱したと判定されないように注意します。

フォローアップ3: アクションボタンが closed な shadow root の内部にあります。ページレベルのリストはこれをどのように特定しますか?

ページが closed なルート内のボタンを特定すべきではありません。コンポーネントが内部でクリックを処理し、ホストからセマンティックなイベントを発行します。そのイベントデータには安定したアクションとタスク識別子のみを含め、バブリングとシャドウ境界越えを許可する明示的な規約を設けます。ページはそのコンポーネントイベントを委譲処理します。これによりカプセル化が維持され、ページが composedPath() でも取得できない内部ノードに依存することを防げます。

フォローアップ4: ハンドラーが確認を await しています。DOM変更後に誤った行に対してアクションが実行されるのを防ぐにはどうしますか?

非同期処理を開始する前に、不変の taskIdactionName を同期的に読み取って検証します。確認が完了した後は、古い行への参照を保持して接続されたままだと想定するのではなく、taskId によって現在のビジネス状態を検索します。重複が問題になる場合は、タスクやアクションを pending 状態としてマークするか、リクエストにべき等性キーを使用します。

フォローアップ5: 委譲をやめるべきケースはどのような場合ですか?

ノードが少なく安定している場合、コントロール同士に関連のない動作がある場合、イベントがバブリングしない場合、またはコンポーネント境界でローカルな処理が必要な場合は、直接リスナーの方が監査しやすくなります。高頻度なイベントにおいて、1つのルートが無関係な多数のイベントに対して複雑なセレクタを繰り返し実行している場合は、ルートの範囲を狭めるか直接バインドします。パフォーマンス向上のために構造を変更する前に、実際のパフォーマンスパスを計測してください。

公開情報ソース

関連する質問