React 合成事件和 DOM 原生事件混用

3,804 阅读4分钟

昨天面试被问到一个元素同时绑定 React 合成事件和 DOM 原生事件的问题。首先来说,我在项目中真的没遇到过这样的场景,我也没想到要用的那些场景中。

React 合成事件

先来说说 React 合成事件是怎么回事。

React 合成事件是如何工作的

React 的事件系统沿袭了事件委托的思想。在 React 中,除了少数特殊的不可冒泡的事件(比如媒体类型的事件)无法被事件系统处理外,绝大部分的事件都不会被绑定在具体的元素上,而是统一被绑定在页面的 document 上。当事件在具体的 DOM 节点上被触发后,最终都会冒泡到 document 上,document 上所绑定的统一事件处理程序会将事件分发到具体的组件实例。在分发事件之前,React 首先会对事件进行包装,把原生 DOM 事件包装成合成事件。

为什么 React 要自己定义合成事件

首先一定要说的,也是 React 官方说明过的一点是:合成事件符合W3C规范,在底层抹平了不同浏览器的差异,在上层面向开发者暴露统一的、稳定的、与 DOM 原生事件相同的事件接口。开发者们由此便不必再关注烦琐的底层兼容问题,可以专注于业务逻辑的开发。

此外,自研事件系统使 React 牢牢把握住了事件处理的主动权。

在 React 中使用原生事件

其实和原生没什么区别,就是获取 DOM 的方式可能不太一样。

import React, { useEffect, useRef } from 'react'

function Demo() {
  const dome = useRef(null)

  useEffect(() => {
    dome.current.addEventListener('click', clickDOMButton, false)
  }, [])

  function clickDOMButton() {
    console.log('DOM event')
  }

  return (
    <div>
      <button ref={dome}>
        按钮
      </button>
    </div>
  )
}

export default Demo

或者可以自己写一个工具函数,抹平浏览器间的差异,然后在组件中直接调用。

export const addEventListener = (
  target,
  type,
  listener,
  useCapture = false
) => {
  if (target.addEventListener) {
    target.addEventListener(type, listener, useCapture)
  } else if (target.attachEvent) {
    target.attachEvent(`on${type}`, listener)
  } else {
    target[`on${type}`] = listener
  }
}

二者混合使用

执行顺序

import React, { useEffect, useRef } from 'react'

function Demo() {
  const dome = useRef(null)

  useEffect(() => {
    addEventListener(dome.current, 'click', clickDOMButton, false)
  }, [])

  function clickDOMButton() {
    console.log('DOM event')
  }

  function clickReactButton() {
    console.log('React event')
  }

  return (
    <div>
      <button ref={dome} onClick={clickReactButton}>
        按钮
      </button>
    </div>
  )
}

export default Demo

上边代码对 button 元素分别绑定了两种事件,当点击时打印的结果为:

DOM event
React event

原因也很简单,当点击 button 时,原生事件直接就触发了,而合成事件要冒泡至 document 之后,才会去触发。

阻止冒泡

Note:

As of v0.14, returning false from an event handler will no longer stop event propagation. Instead, e.stopPropagation() or e.preventDefault() should be triggered manually, as appropriate.

React 官网有这样一句话,意思是从事件处理程序返回 false 将不再停止事件传播,而是应适当地手动触发e.stopPropagation()e.preventDefault()

那我们就来试一试吧,先在合成事件中阻止冒泡。

import React, { useEffect, useRef } from 'react'

function Demo() {
  const wrapper = useRef(null)
  const dome = useRef(null)

  useEffect(() => {
    addEventListener(wrapper.current, 'click', clickDOMWrapper, false)
    addEventListener(dome.current, 'click', clickDOMButton, false)
  }, [])

  function clickDOMWrapper() {
    console.log('wrapper DOM event')
  }

  function clickDOMButton() {
    console.log('button DOM event')
  }

  function clickReactWrapper() {
    console.log('wrapper React event')
  }

  function clickReactButton(e) {
    e.stopPropagation()
    console.log('button React event')
  }
  
  return (
    <div ref={wrapper} onClick={clickReactWrapper}>
      <button ref={dome} onClick={clickReactButton}>
        按钮
      </button>
    </div>
  )
}

export default Demo

结果打印了这些:

button DOM event
wrapper DOM event
button React event

这证明了合成事件不会影响到原生事件。因为 React 给合成事件封装的 stopPropagation 函数在调用时给自己加了个 isPropagationStopped 的标记位来确定后续监听器是否执行。

那如果在原生事件中阻止冒泡呢?上边的例子改为:

function clickDOMWrapper() {
  console.log('wrapper DOM event')
}

function clickDOMButton(e) {
  e.stopPropagation()
  console.log('button DOM event')
}

function clickReactWrapper() {
  console.log('wrapper React event')
}

function clickReactButton() {
  console.log('button React event')
}

结果只打印了 button DOM event ,那就意味着在原生事件中使用 e.stopPropagation() 会阻止合成事件的执行。因为原生事件中使用 e.stopPropagation() 后,事件不会冒泡的 document,所以也就触发不了 document 上绑定的合成事件了。

nativeEvent

这里还有一个问题,就是当你需要访问原生事件对象时,可以通过合成事件对象的 e.nativeEvent 属性获取到它。但他可能和我们想象的不太一样。

当我们使用 e.nativeEvent.stopPropagation() 试图去阻止冒泡时,不但不能阻止原生事件的冒泡,连合成事件的冒泡也不能阻止了。执行这段代码的时候,原生事件早就执行完了,而又没有去阻止合成事件的冒泡,也不知道应该在什么情况下使用。

结论

  • React 合成事件和 DOM 原生事件混用,先执行原生事件,再去执行合成事件
  • 原生事件中使用 e.stopPropagation() 会阻止合成事件的执行,但在合成事件中使用 e.stopPropagation() 却不会阻止原生事件的执行。