これは知っておきたいJavaScript講座 Vol.1~イベントの伝播とか~
チェックボックスをクリックしてもチェックが付かないお話
システムツー・ワンのテクニカルシステムエンジニアの「じょん(日本人)」です。Windowsのデスクトップアプリの開発に携わることが多いですが、Webやスマホアプリ、簡単なインフラなど割と広い領域を担当しています。食べ物は海鮮が好きなのと、レザークラフトなどでちょっとしたものを手作りしたりするのが好きです。
さて、JavaScript講座の第1回目となる本日は、JavaScriptのあるある話とそれにまつわる技術のお話。
どういうこと?
JSを書いていてこんな現象を見たことはあるだろうか。
表の行をクリックする
チェックボックスにチェックが付く
document.querySelectorAll('tbody tr').forEach(function(rowElement) {
const checkbox = rowElement.querySelector('input[type=checkbox]');
rowElement.addEventListener('click', function() {
checkbox.checked = !checkbox.checked;
});
});
という処理をJSで記載した際に起こる現象である。チェックボックスの外をクリックした際には意図通りチェックが付くが、肝心のチェックボックスをクリックした際にはチェックが付かない。これは様々な人が通ったことのある道ではないだろうか。
何がいけないのか
これはイベントの伝播(Event Propagation)を考えられていないことが原因である。イベントの伝播を理解していれば、原因も対処法もぱっと思いつくはずである。(書き方を忘れた人はついでに覚えて帰ってもらえると嬉しいです。)
イベントの伝播とは
そもそも伝播とは何かが伝わり広がることである。イベントの伝播において「何か」はイベントである。では「どこを」伝わるのだろうか。それはHTMLの要素(<div>とか<td>とか<input>とか)である。もっと言えば、HTMLの親子関係を伝わっていく。
HTMLの親子関係?
下記のようなHTMLを考える。
<tr>
<td>
<input type="checkbox"/>
</td>
<td>
</td>
</tr>
ここで、trの子は2つのtdであり、inputの親はtdである。入れ子構造がそのまま親子関係となる。
起きたこと(チェックボックスの外をクリックしたとき)
trにクリックイベントリスナを追加しているので、trのイベントが最初に起こった、と考えがちである。だが、実際には先にtdをクリックしたというイベントが発火している。(厳密には違うがそれは後述する。)
tdのクリックイベントが発火
trのクリックイベントが発火
trのクリックイベントに反応して、イベントリスナが処理される
無事チェックボックスのチェックが切り替わる
起きたこと(チェックボックスをクリックしたとき)
ではチェックボックス(<input/>)をクリックした際にはどうなっているのか。inputをクリックしているので、勿論inputのクリックイベントが発火する。その後イベントが伝播してしまい、親要素であるtrのクリックイベントまで発火してしまっている。
チェックボックスをクリックしたことによりチェックが付く
inputのクリックイベントが発火
tdのクリックイベントが発火
trのクリックイベントが発火
trのクリックイベントに反応して、イベントリスナが処理される
つまり、1でチェックが付いたのに、5でチェックが外されてしまっている。
どうすれば良いのか
やろうと思えば対応方法は幾つかあると思うが、シンプルな考え方は「チェックボックスをクリックしてもtrのクリックイベントが発火しなければ良い」という発想である。そこで少しだけ手を加えたコードを用意する。
document.querySelectorAll('tbody tr').forEach(function(rowElement) {
const checkbox = rowElement.querySelector('input[type=checkbox]');
rowElement.addEventListener('click', function() {
checkbox.checked = !checkbox.checked;
});
checkbox.addEventListener('click', function(event) {
event.stopPropagation();
})
});
input(checkbox)にもクリックイベントリスナを追加し、event.stopPropagation();を呼ぶようにした。これは「次の要素にイベントの伝播をしないでね。」という指示である。inputのクリックイベントが発火した時点で、次の要素(tdより先)にイベントの伝播を行わないようにしたため、trのクリックイベントが発火しなくなる。
これにより作りたい挙動をするようになった。
もう少し詳細に
下記のようなコードを書いて動きを見てみる。
document.querySelectorAll('tbody tr').forEach(function(element) {
element.addEventListener('click', function() {
console.log('tr1-1');
});
element.addEventListener('click', function() {
console.log('tr1-2');
});
element.addEventListener('click', function() {
console.log('tr2');
}, true);
});
document.querySelectorAll('tbody tr td').forEach(function(element) {
element.addEventListener('click', function() {
console.log('td1-1');
});
element.addEventListener('click', function() {
console.log('td1-2');
});
element.addEventListener('click', function() {
console.log('td2');
}, true);
});
document.querySelectorAll('tbody tr td input').forEach(function(element) {
element.addEventListener('click', function() {
console.log('input1-1');
});
element.addEventListener('click', function() {
console.log('input1-2');
});
element.addEventListener('click', function() {
console.log('input2');
}, true);
});
addEventListenerの第3引数はuseCaptureの設定値である。useCaptureを入れていないイベントリスナでは1-1, 1-2を出力。useCaptureをtrueにしたイベントリスナでは2を出力する。これでそれぞれの要素をクリックするとどうなるのか。
チェックボックスをクリックした際に、
と出力されている。この結果から、useCaptureにtrueを設定したイベントリスナは親要素(tr)から、useCaptureに設定をしていないイベントリスナは子要素(input)から登録した順番に呼び出されたことが分かる。
キャプチャリングフェーズとバブリングフェーズ
イベントの伝播にはキャプチャリングフェーズとバブリングフェーズがある。(厳密にはこれらの他にターゲットフェーズがありますが今回は説明を端折ります。)
キャプチャリングフェーズ:親の要素から、イベント発火の起点となる要素(今回はinput)までイベントが伝播するフェーズ。
バブリングフェーズ:イベント発火の起点となる要素から親の要素までイベントが伝播するフェーズ。
useCaptureにtrueを入れたイベントリスナは、キャプチャリングフェーズのイベントにより処理が実行されたため、親要素から順番に実行されることとなった。多くの場合はバブリングフェーズのみを使用し、キャプチャリングフェーズを使用することは無い。しかし、子要素より先に実行して欲しい親要素のイベントリスナがもしあるのであれば、使うことを検討する。
最後に
今回はイベントの伝播をよくあるミスから深掘りしていった。この辺りの動きに関わるものとして、stopPropagationやstopImmediatePropagation、preventDefaultなどもあるが、それはまたの機会に。