【ハマログ】親要素に設定したmouseover / mouseoutのイベントリスナーが子要素で発生する件
表題の件でハマってしまい、解決に苦労したのでハマログとして残しておきます。
事象
言葉では説明しづらいので、キャプチャーを撮りました。
ざっくりいうと、GoogleChromeで、親要素(container)に設定していた mouseover
、mouseout
のイベントリスナーが子要素(btn)を連続でクリックしまくった時にも発動して、表示に不具合が出るときがあるというものです。
FirefoxやEdge、IE11では発生しませんでした。
原因
Chromeでしか発生していないためChrome特有のバグの可能性があり、根本原因と言い切れませんが、クリックイベントのバブリングと、mouseover
/ mouseout
のイベントが意図している要素以外に波及することで不必要なイベントリスナーが実行され不具合が出ているようでした。
イベントハンドリングを細かく行うことで解決ができたので、その過程をまとめておきたいと思います。
最初のコード
html
<div class="container"> <div class="btn is-none"></div> </div>
css
.is-none { display: none !important; } .container { // 略 border: 1px solid #000; .btn { // 略 background-color: tomato; &.is-active { background-color: skyblue; } } }
js
document.addEventListener('DOMContentLoaded', function() { const container = document.querySelector('.container'); const btn = container.querySelector('.btn'); container.addEventListener('mouseover', function() { console.log('container mouseover'); btn.classList.remove('is-none'); }); container.addEventListener('mouseout', function() { console.log('container mouseout'); btn.classList.add('is-none'); }); btn.addEventListener('mouseover', function() { console.log('btn mouseover'); btn.classList.add('is-active'); }); btn.addEventListener('mouseout', function() { console.log('btn mouseout'); btn.classList.remove('is-active'); }); // ログ出してるだけ btn.addEventListener('click', function() { console.log('click'); }); });
非常にシンプルな作りになっています。
.container
にマウスオンすると、.btn
に付いている非表示用のクラス is-none
を外して .btn
を表示させて、マウスアウトすると is-none
を付けて .btn
が非表示になります。
.btn が表示されているときに、.btn
にマウスオン/マウスアウトすると色が変化するという挙動です。
イメージとしては、下記のような、amazonトップ画面にあるカルセールスライダーの左右のボタンのイメージです(カルーセルにマウスオン/マウスアウトでボタンが表示/非表示になる)。
怪しいところを探る
冒頭のキャプチャーで見る通り、各イベントリスナーには console.log
を仕込んでいます。
そのlogを見ると、.btn
を mouseover
した際に、
container mouseout btn mouseover container mouseover
と、親要素である .container
に設定した console.log
までもが実行されているのが分かります。
ボタンの非表示は、.container
を mouseout
時に is-none
クラスを .btn
に付与することで実現しているので、上記の container mouseout
のログが出ているところで発生していることが予測できます。
親要素に設定したmouseover / mouseoutのイベントリスナーが子要素で発生しないようにする
というわけで、.btn
をmouseover
時に .container
に設定したイベントが発生しないようにすれば解決できそうです。
なんとなく .btn
の リスナー内で event.stopPropagation()
を実行すれば .container
のイベントは発生せずにうまいこと行きそうですが、実際に試してみると不具合が直りません。
先ほどの console.log
の結果をよく見ると、btn mouseover
の前に container mouseout
が発生しています。
なのでまずはこちらのイベントを発生させないようにする必要があります。
そのためには mouseover
と mouseout
の挙動を理解しなくてはなりません。
マウスイベントを扱う場合は要素の領域はその直下の部分になる
これが意外と分かりにくいのですが、今回のケースでいうと、.container
の領域はその直下のみとなり、.btn
は .container
の領域と見なされません。
そのため、.btn
にオンマウスすると .container
の領域から出るということで mouseout
イベントが発生します。
その反対も然りで、.btn
からマウスアウトすると .container
の領域に入ることになるので mouseover
イベントが発生します。
今起きているイベントと対になるイベントが起こった要素を示すrelatedTarget プロパティ
mouseover
/ mouseout
イベントは、どこからどこへマウスを動かそうと最終的にはバブリングにより document
へ伝わり、2つのイベントは本質的に同じイベントといえます。
この2つのイベントをまとめて扱うために、mouseover
/ mouseout
のイベントオブジェクトには relatedTarget
というプロパティが存在します。
event.ralatedTarget
プロパティは、今起きているイベントと対になるイベントが起こった要素を示します。
mouseover
を例に考えると、mouseover
が発生した要素は event.target
で取得でき、反対に mouseout
が発生した要素は event.relatedTarget
で取得できます。
MDNに分かりやすいサンプルがありましたのでそちらを載せておきます。
See the Pen relatedTarget by KDE (@KDE_SPACE) on CodePen.
以上の挙動を踏まえて、下記の対応をしていきます。
先ほどの
console.log
の結果をよく見ると、btn mouseover
の前にcontainer mouseout
が発生しています。
なのでまずはこちらのイベントを発生させないようにする必要があります。
.container
内の要素からマウスアウトした時だけ発生させたいので、下記でいけそうな気がします。
container.addEventListener('mouseout', function(e) { if (e.target !== container) return; // 略 });
しかし先述したように、
.btn
にオンマウスすると.container
の領域から出るということでmouseout
イベントが発生します。
となるので、上記の条件分岐では対応できません。
そこで、mouseout
時に mouseover
された要素で考えてみます。
.btn
の領域に入ったときに mouseover
される要素は .btn
です。
.container
の外の領域に入ったときに mouseover
される要素は body
あるいは html
です。
つまりこの mouseover される要素
が .container
内の要素でなければ、mouseout
のイベントリスナーを実行すれば良さそうです。
mouseout
のイベントリスナー内で、mouseover される要素
を取得するには event.relatedTarget
を使えば可能で、その要素が特定の要素内に含まれるかを判断するには、Node.contains
メソッドを使えば可能です。
Node.contains メソッドは指定ノードの子孫ノードに特定の子ノード(※自身も含む)が含まれるかどうかを示す真偽値を返します。
(Node.contains | MDN より)
イベントリスナー内を下記のように変更します。
container.addEventListener('mouseout', function(e) { // .container内の要素にオンマウス時は処理続行しない if (container.contains(e.relatedTarget)) return; console.log('container mouseout'); btn.classList.add('is-none'); });
これで実際に .btn
にオンマウスしてみると下記のようにログが出ます。
btn mouseover container mouseover
container mouseover
も余計なのでこちらも制御します。
こちらは反対に mouseover
時に mouseout
された要素で考えてみます。
.container
の領域に入ったときに mouseout
される要素は body
あるいは html
です。
.btn
の領域に入ったときに mouseout
される要素は .container
です。
つまりこの mouseout される要素
が .container
内の要素でなければ、mouseon
のイベントリスナーを実行すれば良さそうです。
.container
の mouseover
時のイベントリスナーは下記のようにします。
container.addEventListener('mouseover', function(e) { if (container.contains(e.relatedTarget)) return; console.log('container mouseover'); btn.classList.remove('is-none'); });
これで一応、無駄なイベントは発生しないようなりました。
ここまでのコードで確認できます。
See the Pen mouseover, mouseout, ralatedTarget debug_01 by KDE (@KDE_SPACE) on CodePen.
e.relatedTarget が null になる
実はまだ解決できていません。
.btn
を連続クリックすると、ボタンが一瞬非表示になるときがまだあります。
.container
のイベントリスナーに console.log
を仕込んで event.target
と event.relatedTarget
の状態を確認してみみます。
非表示になった瞬間のログです。
container mouseout target <div class="container">…</div> relatedTarget null container mouseover
ralatedTargetが null
になったことで、.container
の mouseout
/ mouseover
が発生してしまっているのが原因のようです。
そこでイベントリスナーを下記のように修正し null
の時に処理がされないようにします。
container.addEventListener('mouseover', function(e) { if (container.contains(e.relatedTarget) || e.relatedTarget === null) return; // 略 }); container.addEventListener('mouseout', function(e) { if (container.contains(e.relatedTarget) || e.relatedTarget === null) return; // 略 });
しかし、これだと、ウィンドウ外から素早く.container
にオンマウスした時に、.btn
が表示されないという新たな問題に遭遇します(今回の例だと上部からが発生しやすい)。
これを解決するために下記のように修正します。
container.addEventListener('mouseover', function(e) { if (container.contains(e.relatedTarget) || (e.relatedTarget === null && e.target === btn)) return; // 略 }); container.addEventListener('mouseout', function(e) { if (container.contains(e.relatedTarget) || (e.relatedTarget === null && e.target === btn)) return; // 略 });
これで再度連続クリックしてみると、非表示になることはなくなりましたが、ボタンの色が一瞬、青から赤へ変わる事象が確認できます。
この瞬間のログを確認してみます。
下記は青から赤になった瞬間です。
btn clicked container clicked btn mouseout
そして赤から青になった瞬間です。
btn mouseover btn clicked container clicked
.btn
に対して mouseout
/ mouseover
が発生していることが原因のようです。
.btn の mouseout、mouseoverを制御する
まずはイベントが発生している要素の状態を確認します。
btn.addEventListener('mouseover', function(e) { console.log('btn mouseover'); console.log('target', e.target); console.log('relatedTarget', e.relatedTarget); btn.classList.add('is-active'); }); btn.addEventListener('mouseover', function(e) { console.log('btn mouseover'); console.log('target', e.target); console.log('relatedTarget', e.relatedTarget); btn.classList.add('is-active'); });
これで確認してみると mouseover
/ mouseout
ともに、事象が発生した時に event.relatedTaget
が null
になっていることが分かったので、条件を追加します。
btn.addEventListener('mouseover', function(e) { if (e.relatedTarget === null) return; // 略 }); btn.addEventListener('mouseover', function(e) { if (e.relatedTarget === null) return; // 略 });
これで確認してみると、挙動としては問題なさそうです。
See the Pen mouseover, mouseout, ralatedTarget debug_02 by KDE (@KDE_SPACE) on CodePen.
リファクタリング
概ね問題ないのですが、少し気になるところとしては、.btn
を mouseover
/ mouseout
時にイベントがバブリングして .container
の mouseover
/ mouseout
のイベントリスナーが実行されています。
今は特に問題ありませんが、機能が追加されたりしたときにバグになる可能性があるのでこれを制御しておきます。
btn.addEventListener('mouseover', function(e) { e.stopPropagation(); // 略 }); btn.addEventListener('mouseout', function(e) { e.stopPropagation(); // 略 });
e.stopPropagation()
は、イベントの伝播をストップするメソッドです。
これにより .container
の .mouseover
/ mouseout
のイベントリスナーが実行されなくなります。
最終的なコードは下記になります。
'use strict'; document.addEventListener('DOMContentLoaded', function () { const container = document.querySelector('.container'); const btn = container.querySelector('.btn'); container.addEventListener('mouseover', function (e) { if (container.contains(e.relatedTarget) || (e.relatedTarget === null && e.target === btn)) return; btn.classList.remove('is-none'); }); container.addEventListener('mouseout', function (e) { if (container.contains(e.relatedTarget) || (e.relatedTarget === null && e.target === btn)) return; btn.classList.add('is-none'); }); btn.addEventListener('click', function (e) { console.log('btn clicked'); }); btn.addEventListener('mouseover', function (e) { e.stopPropagation(); if (e.relatedTarget === null) return; btn.classList.add('is-active'); }); btn.addEventListener('mouseout', function (e) { e.stopPropagation(); if (e.relatedTarget === null) return; btn.classList.remove('is-active'); }); });
See the Pen mouseover, mouseout, ralatedTarget debug_03_FIX by KDE (@KDE_SPACE) on CodePen.
これで解決できました!
なかなか大変でしたが、改めてイベントフローの理解が深まった一件となりました。
イベントについての詳細は下記の参考記事を参考ください。
参考
- 三章第三回 イベントバブリング — JavaScript初級者から中級者になろう — uhyohyo.net
- 三章第四回 イベントキャプチャリング — JavaScript初級者から中級者になろう — uhyohyo.net
- 三章第五回 イベントオブジェクト — JavaScript初級者から中級者になろう — uhyohyo.net
- 三章第六回 mouseoverとmouseout — JavaScript初級者から中級者になろう — uhyohyo.net
- [javascript] 親絶対divの子要素をホバリングするときのonmouseoutの禁止jQueryなし [css] [javascript-events] | CODE Q&A 問題解決 [日本語]
- MouseEvent.relatedTarget | MDN