KDE BLOG

バイブス

【ハマログ】親要素に設定したmouseover / mouseoutのイベントリスナーが子要素で発生する件

表題の件でハマってしまい、解決に苦労したのでハマログとして残しておきます。

事象

言葉では説明しづらいので、キャプチャーを撮りました。

f:id:jinseirestart:20181206012545g:plain

ざっくりいうと、GoogleChromeで、親要素(container)に設定していた mouseovermouseout のイベントリスナーが子要素(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トップ画面にあるカルセールスライダーの左右のボタンのイメージです(カルーセルにマウスオン/マウスアウトでボタンが表示/非表示になる)。

f:id:jinseirestart:20181206020653g:plain

怪しいところを探る

冒頭のキャプチャーで見る通り、各イベントリスナーには console.log を仕込んでいます。
そのlogを見ると、.btnmouseover した際に、

container mouseout
btn mouseover
container mouseover

と、親要素である .container に設定した console.log までもが実行されているのが分かります。

ボタンの非表示は、.containermouseout 時に is-none クラスを .btn に付与することで実現しているので、上記の container mouseout のログが出ているところで発生していることが予測できます。

親要素に設定したmouseover / mouseoutのイベントリスナーが子要素で発生しないようにする

というわけで、.btnmouseover 時に .container に設定したイベントが発生しないようにすれば解決できそうです。

なんとなく .btn の リスナー内で event.stopPropagation() を実行すれば .container のイベントは発生せずにうまいこと行きそうですが、実際に試してみると不具合が直りません。

先ほどの console.log の結果をよく見ると、btn mouseover の前に container mouseout が発生しています。
なのでまずはこちらのイベントを発生させないようにする必要があります。

そのためには mouseovermouseout の挙動を理解しなくてはなりません。


マウスイベントを扱う場合は要素の領域はその直下の部分になる

これが意外と分かりにくいのですが、今回のケースでいうと、.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 のイベントリスナーを実行すれば良さそうです。

.containermouseover 時のイベントリスナーは下記のようにします。

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.targetevent.relatedTarget の状態を確認してみみます。

f:id:jinseirestart:20181209001220g:plain

非表示になった瞬間のログです。

container mouseout
target <div class=​"container">​…​</div>​
relatedTarget null
container mouseover

ralatedTargetが null になったことで、.containermouseout / 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;
  // 略
});

これで再度連続クリックしてみると、非表示になることはなくなりましたが、ボタンの色が一瞬、青から赤へ変わる事象が確認できます。

f:id:jinseirestart:20181209004024g:plain

この瞬間のログを確認してみます。
下記は青から赤になった瞬間です。

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.relatedTagetnull になっていることが分かったので、条件を追加します。

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.

リファクタリング

概ね問題ないのですが、少し気になるところとしては、.btnmouseover / mouseout 時にイベントがバブリングして .containermouseover / 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.

これで解決できました!
なかなか大変でしたが、改めてイベントフローの理解が深まった一件となりました。
イベントについての詳細は下記の参考記事を参考ください。

参考