LoginSignup
8
7

More than 1 year has passed since last update.

Reactにおけるイベント伝播・イベントデリゲーションの挙動を整理した

Last updated at Posted at 2022-02-05

はじめに

Reactでのイベントの特徴としてイベントデリゲーションを使っている点がありますが、この理解をするために手を動かしながら整理しました。

イベントデリゲーションについて

ReactではonClickなどで各要素にイベントリスナを設定することができますが、実際はその対象のDOMノードにイベントハンドラを設定していません。ReactはそれをReactルート要素にイベントデリゲーションして内部的にイベントの処理をしているようです。

この処理はReact 17で一部変更があった部分で、React 17のリリースブログに画像つきで解説されています。(イベントデリゲーションに関する変更)

ブログで紹介されているこちらの記事によると、要素が追加・削除されるごとに各要素のイベントリスナを設定し直す必要がなくなるというメリットがあるようです。

DOMイベントフローについて

もう一つイベントの処理で知っておく必要があるのは、DOMイベントフローについてです。

クリックなどのイベントが発火すると、キャプチャリング、バブリングフェーズというフェーズに分かれてイベントが伝播します。

W3Cの3.1. Event dispatch and DOM event flowにわかりやすい画像があるので、そちらを引用します。

ReactでonClickなどでイベントリスナを設定するのはバブリングフェーズで、キャプチャリングフェーズに設定する場合はonClickCaptureなどを使用します。
addEventListenerでキャプチャリングフェーズにイベントリスナを設定するには、第3引数に{ capture: true }を設定します。

これ以降はこの2つの図をイメージしながら読んでもらえたらと思います。

2つの図

3.1. Event dispatch and DOM event flow イベントデリゲーションに関する変更

実装

準備

今回は以下のようなdiv2つに囲まれたbuttonをクリックするときのイベントの挙動を見てみます。

export const Event = () => {
  return (
    <div id='grandParendDiv'>
      <div id='parendDiv'>
        <button id='currentButton'>
          Click here
        </button>
      </div>
    </div>
  )
}

各要素のキャプチャリングフェーズ、バブリングフェーズのイベントリスナにログを出力するコールバックをイベントハンドラとして設定していきます。

import { MouseEvent } from 'react'

const loggingCapturingCurrentTargetId = (e: MouseEvent<HTMLElement>) => {
  console.log(`Capturing callback ${e.currentTarget.getAttribute('id')}`)
}
const handleGrandParentCaptureEvent = loggingCapturingCurrentTargetId
const handleParentCaptureEvent = loggingCapturingCurrentTargetId
const handleTargetButtonCaptureEvent = loggingCapturingCurrentTargetId

const loggingBubblingCurrentTargetId = (e: MouseEvent<HTMLElement>) => {
  console.log(`Bubbling callback ${e.currentTarget.getAttribute('id')}`)
}
const handleTargetButtonEvent = loggingBubblingCurrentTargetId
const handleParentEvent = loggingBubblingCurrentTargetId
const handleGrandParentEvent = loggingBubblingCurrentTargetId

export const Event = () => {
  return (
    <div
      id='grandParendDiv'
      onClickCapture={handleGrandParentCaptureEvent}
      onClick={handleGrandParentEvent}
    >
      <div
        id='parendDiv'
        onClickCapture={handleParentCaptureEvent}
        onClick={handleParentEvent}
      >
        <button
          id='currentButton'
          onClickCapture={handleTargetButtonCaptureEvent}
          onClick={handleTargetButtonEvent}
        >
          Click here
        </button>
      </div>
    </div>
  )
}

次に、おそらくあまり普段は使わないですが今回は挙動を確かめる目的として、addEventListenerを使ってdocumentとそれぞれの要素にログを仕込んでいきます。

export const Event = () => {
  useEffect(() => {
    document.addEventListener('click',() => {
      console.log('Capturing addEventListener document')
    }, { capture: true })
    document.getElementById('grandParendDiv')?.addEventListener('click', () => {
      console.log('Capturing addEventListener grandParendDiv')
    }, { capture: true })
    document.getElementById('parendDiv')?.addEventListener('click', () => {
      console.log('Capturing addEventListener parendDiv')
    }, { capture: true })
    document.getElementById('currentButton')?.addEventListener('click', () => {
      console.log('Capturing addEventListener currentButton')
    }, { capture: true })

    document.getElementById('currentButton')?.addEventListener('click', () => {
      console.log('Bubbling addEventListener currentButton')
    })
    document.getElementById('parendDiv')?.addEventListener('click', () => {
      console.log('Bubbling addEventListener parendDiv')
    })
    document.getElementById('grandParendDiv')?.addEventListener('click', () => {
      console.log('Bubbling addEventListener grandParendDiv')
    })
    document.addEventListener('click', () => {
      console.log('Bubbling addEventListener document')
    })
  }, [])

  return (
...
  )
}

ボタンを押してみる

これで準備は完了です。

ボタンをクリックすると、以下のようなログが出力されました。

image.png

2つの図にあるように、まずキャプチャリングフェーズのイベントハンドラが順次呼ばれますが、onClickCaptureで設定されたイベントハンドラはReactルート要素にイベントリスナが設定されているので、addEventListenerで設定されたイベントハンドラよりも早いタイミングで呼ばれています。逆にバブリングフェーズでは各要素に直接設定されたイベントリスナが先に発火しています。

このように、同じ要素にイベントリスナを設定しようとしても、addEventListenerを使ってしまうと順番が割り込まれてしまいます。

注意したほうがよさそうなこと

このイベントの発火のタイミングを理解しておかないと、stopPropagationをしたときに意図しない挙動につながる可能性があります。

試しにボタンのonClickstopPropagationを仕込んでみます。

-const handleTargetButtonEvent = loggingBubblingCurrentTargetId
+const handleTargetButtonEvent = (e: MouseEvent<HTMLButtonElement>) => {
+  loggingBubblingCurrentTargetId(e)
+  e.stopPropagation()
+}
const handleParentEvent = loggingBubblingCurrentTargetId
const handleGrandParentEvent = loggingBubblingCurrentTargetId

バブリングフェーズだけピックアップすると、ボタンにstopPropagationを仕込んでも、addEventListenerで直接divに仕込んだイベントハンドラが先に発火してしまいます。このように、addEventListenerでReactルート以下の要素にコールバックを設定しておくとイベントの伝播を止める前に呼ばれてしまうことがあります。

image.png

そのため、Reactルート要素以下にaddEventListenerを設定することは注意が必要で、必要がないときは使わないほうが良さそうです。

最終的なコード
import { MouseEvent, useEffect } from 'react'

const loggingCapturingCurrentTargetId = (e: MouseEvent<HTMLElement>) => {
  console.log(`Capturing callback ${e.currentTarget.getAttribute('id')}`)
}
const loggingBubblingCurrentTargetId = (e: MouseEvent<HTMLElement>) => {
  console.log(`Bubbling callback ${e.currentTarget.getAttribute('id')}`)
}

const handleGrandParentCaptureEvent = loggingCapturingCurrentTargetId
const handleParentCaptureEvent = loggingCapturingCurrentTargetId
const handleTargetButtonCaptureEvent = loggingCapturingCurrentTargetId

const handleTargetButtonEvent = loggingBubblingCurrentTargetId
const handleParentEvent = loggingBubblingCurrentTargetId
const handleGrandParentEvent = loggingBubblingCurrentTargetId

export const Event = () => {
  useEffect(() => {
    document.addEventListener('click',() => {
      console.log('Capturing addEventListener document')
    }, { capture: true })
    document.getElementById('grandParendDiv')?.addEventListener('click', () => {
      console.log('Capturing addEventListener grandParendDiv')
    }, { capture: true })
    document.getElementById('parendDiv')?.addEventListener('click', () => {
      console.log('Capturing addEventListener parendDiv')
    }, { capture: true })
    document.getElementById('currentButton')?.addEventListener('click', () => {
      console.log('Capturing addEventListener currentButton')
    }, { capture: true })
    document.getElementById('currentButton')?.addEventListener('click', () => {
      console.log('Bubbling addEventListener currentButton')
    })
    document.getElementById('parendDiv')?.addEventListener('click', () => {
      console.log('Bubbling addEventListener parendDiv')
    })
    document.getElementById('grandParendDiv')?.addEventListener('click', () => {
      console.log('Bubbling addEventListener grandParendDiv')
    })
    document.addEventListener('click', () => {
      console.log('Bubbling addEventListener document')
    })
  }, [])

  return (
    <div
      id='grandParendDiv'
      onClickCapture={handleGrandParentCaptureEvent}
      onClick={handleGrandParentEvent}
    >
      <div
        id='parendDiv'
        onClickCapture={handleParentCaptureEvent}
        onClick={handleParentEvent}
      >
        <button
          id='currentButton'
          onClickCapture={handleTargetButtonCaptureEvent}
          onClick={handleTargetButtonEvent}
        >
          Click here
        </button>
      </div>
    </div>
  )
}

参考

8
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
7