#初めに
今回はイベントリスナーを設定した時のイベントの伝播方法について、詳しく見ていきます。突然ですが、下記のようなHTMLがあったとします。
<html>
<body>
<div onclick="console.log('clicked!')">
<button style="width: 100px; height: 50px">button</button>
</div>
</html>
</body>
onclickイベントを設定しているのは親のdiv要素ですが、ボタンをクリックしてもイベントが発火して「clicked!」が表示されました。考えてみれば不思議ですよね。なぜ実際のクリックがボタンに対してなされてもdiv上のハンドラが実行されるのでしょうか。それは、イベントプロパゲーションという仕組みが背後で作用しているためなのです。
#イベントプロパゲーション
イベントプロパゲーションとは、イベントがどのようにDOMツリーを伝搬してターゲットに到達し、戻ってくるかを定義するメカニズムです。
イベントプロパゲーションのコンセプトを理解するためには、まずDOMが階層構造を有していることを理解する必要があります。例えば、上記のHTMLの例を図にして詳しく見てみると以下のようになります。
DOMツリーの最上位にWindowオブジェクトが配置されています。その下にDocumentオブジェクトが配置され、HTMLの階層と同様の階層構造がDocumentの下に配置されます。では階層構造の一番下に位置するボタンエレメントでクリックイベントが発生した際のイベント伝播の流れを見ていきましょう。
イベントプロパゲーションは「キャプチャリング」、「ターゲット」、「バブリング」の3つのフェーズに大きく分けられます。
キャプチャリングフェーズ && ターゲットフェーズ
HTMLページの中のbuttonがクリックされると、まずDOMツリーの一番最上位であるWindowオブジェクトでClickイベントが発生します。そしてDocumentオブジェクト、htmlエレメント、bodyエレメント、divエレメントとDomツリーを下に向かって実際にイベントが発生したターゲットエレメントを特定します。この過程をキャプチャリングフェーズと呼び、イベントがターゲット要素に到達した段階をターゲットフェーズと呼びます。
バブリングフェーズ
バブリングフェーズでは、キャプチャリングフェーズと正反対のことが起きます。ターゲットエレメントからWindowオブジェクトまで、イベントが水底で生まれたバブル(泡)のように上がっていきます。要素内でイベントが起きると、最初にその要素のハンドラが実行され、次にその親のハンドラが実行され、さらにその親...というようにハンドラが駆け上がっていきます。イベントの発火は通常バブリングフェーズで実行されます。
本稿の最初に見た例に戻ると、button要素で検知したイベントがバグリングフェーズでdiv要素を経由するため、div要素でもイベントが検知され、div要素のハンドラが実行されたと考えることができます。
実験
では、実際にイベントプロパゲーションの流れを確認するために簡単な実験をしてみます。
まずは、ハンドラの実行順番についてのテストです。
<html>
<body>
<div>
<button style="width: 100px; height: 50px">button</button>
</div>
</body>
</html>
<script>
document
.querySelector("html")
.addEventListener("click", () => console.log("html clicked!"));
document
.querySelector("body")
.addEventListener("click", () => console.log("body clicked!"));
document
.querySelector("div")
.addEventListener("click", () => console.log("div clicked!"));
document
.querySelector("button")
.addEventListener("click", () => console.log("button clicked!"));
</script>
html, body, div, button全てにイベントリスナーを設定しました。上記のHTMLのbutton要素をクリックした場合、ハンドラはどの順番で起きるでしょうか?
正解は、button要素からhtml要素まで、駆け上がっていくようにハンドラが実行されていきます。これはイベントのハンドラは通常バブリングフェーズで実行されるためです。
...では、キャプチャリングフェーズで実行することはできないのか、という疑問も当然生じてきますよね。はい、できますとも。addEventListnerの第三引数をtrueに指定することで可能です。
<html>
<body>
<div>
<button style="width: 100px; height: 50px">button</button>
</div>
</body>
</html>
<script>
document
.querySelector("html")
.addEventListener("click", () => console.log("html clicked!"), true);
document
.querySelector("body")
.addEventListener("click", () => console.log("body clicked!"), true);
document
.querySelector("div")
.addEventListener("click", () => console.log("div clicked!"), true);
document
.querySelector("button")
.addEventListener("click", () => console.log("button clicked!"), true);
</script>
順番が逆になりましたね。一番親のhtml要素でハンドラが実行され、徐々に子要素へとイベントが伝播していっているのがわかります。
なお、今回はテストのためわざわざ順序を逆にしましたが、実際の開発でキャプチャリングフェーズでハンドラを実行させることはほとんどないと思われます。因みにaddEventListnerの第三引数はdefaultでfalseとなっているため、何も指定しなければイベントのハンドラはバブリングフェーズで実行されます。
ユースケース
ではイベントプロパゲーションの仕組みを理解しておくとどんな時に役に立つのでしょうか。色々ありますが、例えばEvent Delegation(イベント移譲)と呼ばれるイベントハンドリングのパターンを実装する際なんかに役に立ちます。
イベント移譲とは、「似たようなイベントリスナーを多くの要素に対して設定する際、一つ一つにハンドラを割り当てる代わりに共通の祖先に一つハンドラを置く」という実装方法のパターンを指します。
具体的な例を見てみましょう。あるサイトのトップページのnavバーにボタンが三つあったと仮定します。そのボタンにはそれぞれページ内の要素が紐づけられており、クリックされるとその要素位置までスクロールされます。もしイベント委譲を使わずに書こうとするとボタンのエレメントをquerySelectorAllで全て取得してforEachでループ処理をするようになります。
<nav class="nav__items">
<ul>
<li class="nav__item">
<a href="#section--1">セクション1</a>
</li>
<li class="nav__item">
<a href="#section--2">セクション2</a>
</li>
<li class="nav__item">
<a href="#section--3">セクション3</a>
</li>
</ul>
</nav>
<script>
// .nav__itemを持つエレメントを全て取得してforEachでイテレート
document.querySelectorAll('.nav__item').forEach(function (el) {
el.addEventListener('click', function (e) {
const id = this.getAttribute('href');
document.querySelector(id).scrollIntoView({ behavior: 'smooth' });
});
});
</script>
しかし、このやり方では各ボタンを全てquerySelectorAllで取得して、全てに対してevent listenerをつけることになります。今回のようにボタンが3つだけの場合ではほとんど問題はないのですが、もしボタンが百個、千個を超えるようでしたらどうでしょうか。パフォーマンスに影響が出そうですよね。
そこで役に立つのがイベント委譲(Event delegation)の考え方です。共通の親要素に対してeventListenerをたった一つ付けることによって、同じことを実現します。
例を見ていきましょう。
<script>
document.querySelector('.nav__items').addEventListener('click', function (e) {
// クリックされたターゲットまでスクロール
const id = e.target.getAttribute('href');
const target = document.querySelector(id);
target.scrollIntoView({
behavior: 'smooth',
});
});
</script>
上記のようにnav要素の親要素である にイベントリスナーを設定し、クリックされたターゲットをe.targetで取得します。こうすることによって、子要素がどれほど増えようとイベントリスナーの数を少なく保つことができ、パフォーマンスの改善にも役立つことができます。