blog.takurinton.dev

addEventListener の第3引数

2021-10-13

こんにちは

どうも、僕です。
addEventListener の第3引数って知ってますか。僕は知りませんでした。いや、厳密に言うと、存在は知ってたけど理解してませんでした。
理解するために、DOMのイベントフローとともに見ていきたいと思います。

イベントフローとは

まずはイベントフローとは何かについて考えます。
イベントフローとは、DOM に対するイベントの委任(伝播)のことで、例えば以下のようなコードが動くのはイベントフローによるものです。
これは、parent をクリックすると当然 parent clicked と表示されますが、child の方をクリックしても同じように parent clicked と表示されます。

<div id="parent">
  parent here
  <div id="child">
    child here
  </div>
</div>

<script>
  const parent = document.getElementById("parent");

  parent.addEventListener("click", function() {
    console.log("parent clicked");
  });
</script>

では、逆の場合はどうでしょう?
クリックイベントを parent から child にしました。これで parent をクリックしても何も表示されません。当然だと思う人もいるかもしれませんが、これはイベントキャプチャと呼ばれるフローによるもので、このフローや実行について制御することができるのが、addEventListener の第3引数ということです。

<div id="parent">
  parent here
  <div id="child">
    child here
  </div>
</div>

<script>
  const child = document.getElementById("child");

  child.addEventListener("click", function() {
    console.log("child clicked");
  });
</script>

イベントフェーズ

まず、イベントにはキャプチャとバブリングというものがあります。
簡単にいうと、キャプチャは上から下への伝播、バブリングは下から上への伝播です。
イベントは、キャプチャをしてからバブリングをしてDOM全体に浸透します。伝播中のイベントはEvent.stopPropagation() で中断することも可能です。

また、上であげたイベントフローについてですが、以下の3つのフェーズを経て伝播されます。

  • キャプチャフェーズ
  • ターゲットフェーズ
  • バブルフェーズ

キャプチャフェーズはキャプチャを、バブルフェーズはバブリングをすることがわかります。ターゲットフェーズはトリガーとなる要素のことで、これらをつなぎ合わせてくれる役目を持っています。
それぞれについて詳しく見ていきます。

キャプチャフェーズ

キャプチャフェーズは最初の段階です。
Global object(window) から、ドキュメントツリーをたどって、ターゲットノードまでイベントが伝播する段階です。
このフェーズで登録されたイベントリスナー(一番下の要素までなぞってない場合)は、ターゲットまで到達する前に処理されます。さっきの例だと、parent はターゲットではないですが、イベントが処理されて下の要素にまで伝播されたので、ここでいうキャプチャフェーズで捕捉されたイベントに該当します。

ターゲットフェーズ

ターゲットフェーズは、イベントがターゲットに到達した時に処理されるイベントのことです。ターゲットに到達すると、トリガーとなってイベントが発火します。
例えば、以下のような DOM があるとします。

<html>
  <body>
    <div>
      <p>hello world</p>
    </div>
  </body>
</html>

このような時、イベントは html -> body -> div -> p の順でなぞられます。p はターゲットです。eventListener はターゲットに到達するとイベントが発火します。

バブルフェーズ

バブルフェーズでは、イベントがバブリングするフェーズです。ターゲットフェーズでは、p タグに到達するまででしたが、その後 p -> div -> body -> html といったように下から上になぞっていく状態です。
ほとんどのイベントはこのバブリングが行われますが、以下のイベントについてはバブリングをしません。
バブルしないイベントについては、ターゲットフェーズで処理が止まります。

  • blur
  • focus
  • load
  • unload

イベントフローの制御と addEventListener

DOM Level 2 のイベントについては、addEventListener で登録することができます。
余談ですが、addEventListener は React で state を絡めて使用する際には注意が必要です。おそらく、useEffect で eventListener を登録すると思うのですが、それだと初期ロード時点で登録されたイベントになってしまい、state が最新の値に保たれません。そのような時は useRef を使うか、useEffect の第二引数で依存を適切に渡すことで mount されるたびにイベントの登録をし直すことが必要になります。

話が逸れましたが、 addEventListener についてです。
シンタックスについては

target.addEventListener(type, listener [, options]);
target.addEventListener(type, listener [, useCapture]);

となっています。

それぞれの引数について見ていきます。

options

options については、以下の3つの値を持たせることができ、listener の制御を行うことができます。(非推奨については省略)

  • capture
  • once
  • passive

capture

capture は、DOM ツリーでターゲットまで配信される前に、登録された listener に配信されます。
つまり、本来下から上にバブリングされるイベントを、キャプチャの時点で受け取り、実行してくれます。
useCapture を true にするのと同じです。

once

once は、そのイベントが1回きりしか呼ばれないようにします。
例えば、以下のようにすると、最初の1回は console に hoge が出力されますが、2回目以降は何も表示されなくなります。

<div id="hoge">
  hoge
</div>

<script>
const hoge = document.getElementById('hoge');

hoge.addEventListener('click', () => {
    console.log('hoge')
}, { once: true })
</script>

passive

passive は、listener が指定された関数が preventDefault() を呼ばないことを明示します。
呼び出された listener が preventDefault を呼び出しても、何も表示されません。warning が出ます。

useCapture

useCaputure については、boolean の値で、DOM ツリー内の下のイベントターゲットに配信される前に、登録された listener に配信されるかどうかを示します。
デフォルトでは false に設定されていて、これを true にするとバブリングの際に捕捉するイベントを先に捕捉するようになります。例えば、以下のようなコードがある時、piyo をクリックすると下から上にバブリングするようにイベントが拾われ、console には piyo、fuga、hoge の順で表示されます。

<div id="hoge">
  hoge
  <div id="fuga">
    fuga
    <div id="piyo">
      piyo
    </div>
  </div>
</div>

<script>
const hoge = document.getElementById('hoge');
const fuga = document.getElementById('fuga');
const piyo = document.getElementById('piyo');

hoge.addEventListener('click', () => {
    console.log('hoge')
})

fuga.addEventListener('click', () => {
    console.log('fuga')
})

piyo.addEventListener('click', () => {
    console.log('piyo')
})
</script>
// piyo をクリックした時の出力
piyo
fuga
hoge

では、これの fuga の部分の addEventListener に true を設定するとどうでしょうか?
この状態で piyo をクリックすると fuga、piyo、hoge の順で出力されます。true にした要素が先に捕捉されるようになりました。

<div id="hoge">
  hoge
  <div id="fuga">
    fuga
    <div id="piyo">
      piyo
    </div>
  </div>
</div>

<script>
const hoge = document.getElementById('hoge');
const fuga = document.getElementById('fuga');
const piyo = document.getElementById('piyo');

hoge.addEventListener('click', () => {
    console.log('hoge')
})

fuga.addEventListener('click', () => {
    console.log('fuga')
}, true)

piyo.addEventListener('click', () => {
    console.log('piyo')
})
</script>
// piyo をクリックした時の出力
fuga
piyo
hoge

まとめ

addEventListener の第3引数についてこれまであまり考えたことがなかったのでいい学びになりました。また、DOM とまだまだ仲良くなれないなと感じました。
最近だと命令的にイベントを宣言する機会も減って、根本理解をする機会が自分自身の中で減ってる気がするので、どこまでもネイティブを追い求められるように頑張っていきたいなと思います。