JavaScript実践 #2 イベントハンドリングと委譲

#1 DOM操作の基本 でエレメントを扱うツールを見ました。今回はそれらでユーザーインタラクションを処理する場面です。

イベント登録 — addEventListener #

基本のイベント登録
const btn = document.querySelector('#submit');

btn.addEventListener('click', (e) => {
  console.log('クリックされた');
});

addEventListener('タイプ', ハンドラ) — これがモダンなJavaScriptの標準です。

古いコードには btn.onclick = () => {...} のように直接代入する方式も見かけますが、一つのエレメントに一つのハンドラだけ登録できる制限があります。addEventListener は同じイベントに複数のハンドラを登録できます。

よく使うイベントタイプ #

よく使うイベント
click          マウスクリック
input          入力フィールドの値変更 (リアルタイム)
change         値変更 (フォーカスを失う時)
submit         フォーム送信
keydown/keyup  キー入力
focus/blur     フォーカス取得/喪失
mouseenter     マウス進入
mouseleave     マウス離脱
scroll         スクロール
load           ロード完了
DOMContentLoaded  HTML パース完了

イベントオブジェクト — e #

ハンドラが受け取る引数はイベントオブジェクト。よく使うプロパティ。

イベントオブジェクト
btn.addEventListener('click', (e) => {
  e.target;           // イベントが発生したエレメント
  e.currentTarget;    // ハンドラが登録されたエレメント
  e.type;             // 'click'

  e.preventDefault();    // デフォルト動作を防ぐ
  e.stopPropagation();   // バブリングを止める
});

e.target vs e.currentTarget #

最も混乱する場面です。

HTML
<button class="card">
  <span>クリックしてください</span>
</button>
target vs currentTarget
btn.addEventListener('click', (e) => {
  console.log(e.target);         // <span> (実際にクリックされたエレメント)
  console.log(e.currentTarget);  // <button> (ハンドラ登録エレメント)
});
  • target — イベントが発生したエレメント (最も内側のクリック対象)
  • currentTarget — ハンドラが登録されたエレメント

イベント委譲パターンで二つの違いが重要です。少し後で見ます。

イベントの流れ — キャプチャリングとバブリング #

イベントが発生すると二段階で伝播します。

イベント伝播の流れ
1. キャプチャリング (capturing) — document → イベント発生位置まで 上→下
2. ターゲット (target) — 発生位置に到達
3. バブリング (bubbling) — 発生位置 → document まで 下→上

デフォルトではバブリング段階でハンドラが実行されます。

ネストされた構造
<div id="outer">
  <div id="inner">
    <button id="btn">クリック</button>
  </div>
</div>
バブリング確認
document.querySelector('#outer').addEventListener('click', () => console.log('outer'));
document.querySelector('#inner').addEventListener('click', () => console.log('inner'));
document.querySelector('#btn').addEventListener('click', () => console.log('button'));

// ボタンクリック時の出力:
// button
// inner
// outer

イベントがボタン → inner → outer へと上に流れていき、それぞれのハンドラが順番に実行されます。これがバブリングです。

キャプチャリングで聴く #

キャプチャ段階
document.querySelector('#outer').addEventListener(
  'click',
  () => console.log('outer (キャプチャ)'),
  { capture: true }
);

3番目の引数に { capture: true } を渡すとキャプチャ段階でハンドラが実行されます。ほぼ使いませんが、「上側が先に処理しなければならない場面」があるときにたまに役立ちます。

stopPropagationpreventDefault #

stopPropagation — 伝播を止める #

バブリングを止める
button.addEventListener('click', (e) => {
  e.stopPropagation();   // これ以上上に上がらない
});

上のハンドラで stopPropagation を呼ぶと — outer/innerハンドラは呼び出されません。

preventDefault — デフォルト動作を防ぐ #

デフォルト動作を防ぐ
form.addEventListener('submit', (e) => {
  e.preventDefault();   // フォームがページをリロードしない
  // ... 直接処理
});

link.addEventListener('click', (e) => {
  e.preventDefault();   // リンクが遷移しない
});

ブラウザのデフォルト動作を防ぎます。フォーム送信、リンクの遷移、右クリックメニューなどでよく出会います。

二つのメソッドは目的が違いますstopPropagation はJavaScriptハンドラの伝播を、preventDefault はブラウザのデフォルト動作を、それぞれ制御します。混同しないように別々に置きましょう。

イベント委譲 — 効率的なパターン #

リスト内に100個の項目があり、それぞれにクリックハンドラが必要なら。

良くないパターン — 項目ごとにハンドラ #

非効率
document.querySelectorAll('.item').forEach((item) => {
  item.addEventListener('click', (e) => {
    console.log('クリック:', item.dataset.id);
  });
});

100個のハンドラがメモリに登録されます。リストに動的に項目が追加されると、新しい項目にはハンドラがありません。

良いパターン — 親に一つのハンドラ #

イベント委譲
const list = document.querySelector('.list');

list.addEventListener('click', (e) => {
  const item = e.target.closest('.item');
  if (!item || !list.contains(item)) return;

  console.log('クリック:', item.dataset.id);
});

ハンドラは一つだけ。クリックが起きるとバブリングで親まで上がってきて、そこで closest でどの項目がクリックされたかを確認します。

委譲の利点 #

  1. メモリ節約 — ハンドラが一つ
  2. 動的項目の自動処理 — 後から追加された項目も同じハンドラで動作
  3. 簡潔さ — リストが変わるたびにハンドラを登録/解除しなくて済む

リスト、テーブル、カードグリッドのような場面でほぼ標準パターンです。

ハンドラの削除 — removeEventListener #

削除
function onClick() {
  console.log('クリック');
}

btn.addEventListener('click', onClick);

// 後で
btn.removeEventListener('click', onClick);

ハンドラ関数の参照が同じでなければ削除できません。アロー関数を即席で登録すると削除できません。

削除できない
btn.addEventListener('click', () => console.log('クリック'));
btn.removeEventListener('click', () => console.log('クリック'));   // ✗ 別の関数

AbortController — モダンなハンドラ片付け #

中級 #6 fetch API で見たAbortControllerがイベントの片付けにも使えます。

AbortController でハンドラを片付ける
const controller = new AbortController();

btn.addEventListener('click', onClick, { signal: controller.signal });
input.addEventListener('input', onInput, { signal: controller.signal });
window.addEventListener('resize', onResize, { signal: controller.signal });

// 一度にすべて削除
controller.abort();

複数のハンドラを一度に片付けられます。コンポーネントのcleanupの場面に非常に向いています。

一度だけ実行 — { once: true } #

once オプション
btn.addEventListener('click', () => {
  console.log('最初で最後');
}, { once: true });

自分自身を自動で削除します。一回限りのハンドラに非常にすっきりします。

Passive イベント — スクロールパフォーマンス #

scrolltouchmove のようなイベントは passive で登録するのが推奨されます。

passive オプション
window.addEventListener('scroll', onScroll, { passive: true });

passiveハンドラは preventDefault を呼ばないという約束です。その約束があれば、ブラウザはスクロールをより速く処理できます。

フォーム/入力でよく使うパターン #

input vs change #

リアルタイム vs 変更完了
input.addEventListener('input', (e) => {
  // 各キー入力ごとに
  console.log(e.target.value);
});

input.addEventListener('change', (e) => {
  // 入力が終わってフォーカスを失う時 (または select 変更時)
  console.log(e.target.value);
});

検索のオートコンプリートのようなリアルタイム処理は input。検証/保存のような重い作業は change またはデバウンスされた input

キーボードイベント #

key 検査
input.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') submit();
  if (e.key === 'Escape') close();
  if (e.ctrlKey && e.key === 's') {
    e.preventDefault();
    save();
  }
});

e.key がモダンな標準。古い e.keyCode はdeprecatedです。

カスタムイベント #

自分だけのイベントを作ってdispatchできます。

カスタムイベント
const event = new CustomEvent('user-login', {
  detail: { id: 'u1', name: 'カーティス' },
});

document.dispatchEvent(event);

// 別の場所で
document.addEventListener('user-login', (e) => {
  console.log(e.detail);   // { id: 'u1', name: 'カーティス' }
});

大きなアプリでモジュール間の通信にたまに活用します。ただし使いすぎると流れの追跡が難しくなるので慎重に。

まとめ #

今回の記事で整理した内容。

  • addEventListener がモダンな標準
  • e.target (実際の発生) vs e.currentTarget (ハンドラ登録位置)
  • イベントの流れ: キャプチャリング → ターゲット → バブリング (デフォルトはバブリング)
  • stopPropagation は伝播、preventDefault はデフォルト動作
  • イベント委譲 — 親に一度だけ登録、closest で位置確認
  • removeEventListener は同じ関数参照が必要
  • AbortController で複数のハンドラを一度に片付け
  • { once: true }{ passive: true } オプション
  • input はリアルタイム、change は入力完了時点
  • e.key がキーボードイベントのモダンな標準

次回の記事 (#3 fetch と非同期 UI) では、fetchでデータを受け取ってDOMに反映するパターン — ローディング/エラー状態の表示、デバウンス、AbortControllerまで束ねて扱います。

X