JavaScriptのイベントの伝搬(バブリングとキャプチャリング)とターゲット要素について実験をしながら理解を深めていきましょう. コーディングで楽をするためにVueを使いますが、シュガーシンタックスを使うだけなので生のJavaScriptでも同じ結果になります.
HTMLの階層構造
HTMLは階層構造になっていて、全ての要素は最終的にWindowのdocument要素を祖先に持ちます. そんなことを知らなくても、HTMLを書いていれば自然と階層構造を持つことになるでしょう. 例えば、以下のように3世代すべてdiv要素であるHTMLがあるとします.
<!-- index1.html --> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <div id="app"> <div class="first"> First <div class="second"> Second <div class="third"> Third </div> </div> </div> </div> <style> .first{ background-color: blue; padding: 30px; font-size: 30px; cursor: pointer; } .second{ background-color: rgb(111, 111, 255); padding: 30px; } .third{ padding: 30px; background-color: rgb(182, 182, 255); } </style>
この階層をWindowから図式すると以下のようになります.
この階層構造はあくまでDOMツリー上の階層構造のことであり、表示上の階層構造とは関係ありません.
イベントの伝搬
この時、親子孫要素をクリックすると挙動にどのような変化があるでしょうか. 実はJavaScriptではイベントは自然と伝搬するようにできています. このイベントの伝搬には向きがあり、親→子→孫(降順)と孫→子→親(昇順)のものがあります. 降順でのイベントの伝搬をキャプチャリング、昇順でのイベントの伝搬をバブリング といいます.
キャプチャリングとバブリングは共通して以下のようなポイントがあります
- イベントを受け取った要素で折り返すようにイベントは伝搬する
- 伝搬の道中で対応したイベントリスナが登録されていれば実行される
- 伝搬の道中でイベントリスナが実行されてもイベントの伝搬は自動的には止まらない
- どの要素のイベントリスナであろうと、e.targetは折り返しになった要素になる
例えば、index1.htmlで孫要素をクリックすると、次のようにイベントの伝搬が起こります.
親要素をクリックした場合、伝搬の折り返し地点とe.targetが指す要素が次のようになります.
イベントリスナ
次にイベントリスナが登録されていた場合のイベントの伝搬を見てみましょう. 次のhtmlとjsのように、クリックイベントリスナを登録して親子孫要素をクリックしたときの出力の違いを見てみます.
<!-- index2.html --> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <div id="app"> <div class="first" @click="onClickFirst($event)" > First <div class="second" @click="onClickSecond($event)" > Second <div class="third" @click="onClickThird($event)" > Third </div> </div> </div> </div> <script type="module" src="js/main1.js"> </script> <style> .first{ background-color: blue; padding: 30px; font-size: 30px; cursor: pointer; } .second{ background-color: rgb(111, 111, 255); padding: 30px; } .third{ padding: 30px; background-color: rgb(182, 182, 255); } </style>
// main1.js const { createApp } = Vue createApp({ data() { return { message: 'Hello Vue!', } }, methods:{ onClickFirst(e){ console.log('First div clicked') console.log(e.target) }, onClickSecond(e){ console.log('Second div clicked') console.log(e.target) }, onClickThird(e){ console.log('Third div clicked') console.log(e.target) }, } }).mount('#app')
注目したいのは、折り返しの要素が深いとその祖先のイベントリスナも実行されていることと、クリックイベント単位で見るとe.targetが指す要素が常に折り返しの要素であるということですね. この結果を見るだけでイベントの伝搬の仕組みとe.targetが指す要素が理解が深まりますね. ちなみに、コンソールに出力される順番からも分かるように、Vueの@click="Mufunc"というシュガーシンタックスはバブリング時のイベントリスナを登録します.
さて、e.targetが常に折り返しの要素を表すというのではかゆいところに手が届かない感がありますね. おそらく、呼び出されたイベントリスナをもつ要素を取得したいことが多いのではないでしょうか. そんなときのためにJavaScriptには Event.currentTarget が用意されています. これはイベント伝搬中の現在の要素を表します. 先ほどのスクリプトの中のe.targetをe.currentTargetにすると以下のような出力になります.
無事イベントリスナごとの要素を得ることが出来ました. これの亜種のようなものとして一部のクラスは relatedTarget というインスタンスを持ちます. これはクラスに応じた副ターゲットを表します. 例えば、MouseEventやこれを継承したDragEventの場合、副ターゲットとは前回のターゲットを表します. 副ターゲットを調べることでLeaveイベントリスナを想定通りの動きにすることが出来たりします. 使い道は限られますが、だからこそ重要な場面で使われるイメージです. ターゲット要素についてまとめると以下のようになります.
ターゲット要素 | すべてのイベントが持つか | 説明 |
---|---|---|
Event.target | Yes | イベント単位で常に同じものを指す |
Event.currentTarget | Yes | イベント伝搬中の現在の要素を指す |
SomeEvent.relatedTarget | No | クラスに応じた副ターゲットを指す |
キャプチャリングとバブリング
普段あまり意識することはないと思いますが、降順にイベントが伝搬していくフェーズをキャプチャリングといいます. 反対に、昇順にイベントが伝搬していくいわゆるなフェーズをバブリングと言います. 次のような特徴があります.
- Vueでは @click.capture="MyFunc" のように記述することでキャプチャリング時のイベントリスナを登録する
- Vueでは @click="MyFunc" のように記述することでバブリング時のイベントリスナを登録する
- キャプチャリング時のイベントリスナであっても、 Event.targetはイベント伝搬の折り返しの要素を指す
- イベントの伝搬を止めたい場合 @click.capture.stop="MyFuncOnlyParent" や @click.stop="MyFuncOnlyChild" のように記述する
- キャプチャリング時のイベントの伝搬を止めた場合バブリング時のイベント伝搬も止まる
以下のようなhtmlとjsで孫要素をクリックすると得られる出力を見ればこれらの特徴を確認することが出来ます.
<!-- index2.html --> <script src="https://unpkg.com/vue@3/dist/vue.global.js"></script> <div id="app"> <div class="first" @click="onClickFirst($event)" @click.capture="onClickFirst($event)" > First <div class="second" @click="onClickSecond($event)" @click.capture.stop="onClickSecond($event)" > Second <div class="third" @click="onClickThird($event)" @click.capture="onClickThird($event)" > Third </div> </div> </div> </div> <script type="module" src="js/main2.js"> </script> <style> .first{ background-color: blue; padding: 30px; font-size: 30px; cursor: pointer; } .second{ background-color: rgb(111, 111, 255); padding: 30px; } .third{ padding: 30px; background-color: rgb(182, 182, 255); } </style>
// main2.js const { createApp } = Vue createApp({ data() { return { message: 'Hello Vue!', } }, methods:{ onClickFirst(e){ console.log('First div clicked') console.log(e.target) }, onClickSecond(e){ console.log('Second div clicked') console.log(e.target) }, onClickThird(e){ console.log('Third div clicked') console.log(e.target) }, } }).mount('#app')