事件机制

WARNING

React 事件机制初步研究,机制流程部分写得较为笼统,后续还要继续研究。

React 基于浏览器的事件机制,自身实现了一套事件机制,称为合成事件。包括事件注册、事件合成、事件冒泡、事件分发等。 它根据 W3C 标准定义事件,兼容所有浏览器,拥有与原生事件相同的接口。

为什么要另外再实现一套事件机制呢?

  • 磨平各浏览器之间的差异
  • 方便事件统一管理

事件机制流程

React 事件机制主要可划分为两个部分:事件注册、事件分发。

  • 事件注册:事件并没有注册到具体的 DOM 节点上,而是注册到 document 根节点上(React v17 开始注册到 root 容器节点上),由统一的事件处理函数 dispatchEvent 来执行事件分发。(利用事件冒泡原理,任何节点触发的事件都能冒泡到最外层元素)
  • 事件分发:首先生成合成事件,同一种事件类型只能生成一个合成事件。以 onClick 事件为例,所有通过 JSX 绑定的 onClick 事件函数都会按序(冒泡或捕获)放到 Event._dispatchListeners 这个数组里,后面依次执行它。

react_event_mechanism_02

react_event_mechanism_01

原生事件 & 合成事件

主要区别

  • 对于事件名称命名方式,原生事件为全小写,合成事件采用小驼峰
  • 对于事件函数处理语法,原生事件使用引号,合成事件使用大括号
  • 对于阻止浏览器默认行为,原生事件使用 return false,合成事件采用 event.prenventDefault()
// 原生事件
<button onclick="return false">按钮</button>

// 合成事件
<button onClick={e => e.prenventDefault()}>按钮</button>

执行顺序

原生和合成事件的执行顺序在 React v16v17 版本有较大变化。

  • v16 版本,原生事件执行顺序恒早于合成事件。
  • v17 版本,捕获模式,合成事件执行顺序早于原生事件。冒泡模式,反之。

举个栗子:

import React, { createRef } from "react"

export default class App extends React.Component {
  parentRef = createRef()
  childRef = createRef()

  componentDidMount() {
    // 原生事件捕获监听
    this.parentRef.current.addEventListener('click', () => console.log("原生事件,捕获阶段,父元素事件监听!"), true)
    this.childRef.current.addEventListener('click', () => console.log("原生事件,捕获阶段,子元素事件监听!"), true)
    document.addEventListener('click', () => console.log("原生事件,捕获阶段,document 事件监听!"), true)

    // 原生事件冒泡监听
    this.parentRef.current.addEventListener('click', () => console.log("原生事件,冒泡阶段,父元素事件监听!"))
    this.childRef.current.addEventListener('click', () => console.log("原生事件,冒泡阶段,子元素事件监听!"))
    document.addEventListener('click', () => console.log("原生事件,冒泡阶段,document 事件监听!"))
  }

  // 合成事件捕获监听
  handleParentCaptureClick = () => console.log("合成事件,捕获阶段,父元素事件监听!")
  handleChildCaptureClick = () => console.log("合成事件,捕获阶段,子元素事件监听!")

  // 合成事件冒泡监听
  handleParentBubbleClick = () => console.log("合成事件,冒泡阶段,父元素事件监听!")
  handleChildBubbleClick = () => console.log("合成事件,冒泡阶段,子元素事件监听!")

  render() {
    return (
      <div ref={this.parentRef} onClick={this.handleParentBubbleClick} onClickCapture={this.handleParentCaptureClick}>
        <div ref={this.childRef} onClick={this.handleChildBubbleClick} onClickCapture={this.handleChildCaptureClick}>
          点击查看原生与合成事件执行顺序
        </div>
      </div>
    )
  }
}

React v16 版本输出结果如下:

原生事件,捕获阶段,document 事件监听! 
原生事件,捕获阶段,父元素事件监听! 
原生事件,捕获阶段,子元素事件监听! 
原生事件,冒泡阶段,子元素事件监听! 
原生事件,冒泡阶段,父元素事件监听! 
合成事件,捕获阶段,父元素事件监听! 
合成事件,捕获阶段,子元素事件监听! 
合成事件,冒泡阶段,子元素事件监听! 
合成事件,冒泡阶段,父元素事件监听! 
原生事件,冒泡阶段,document 事件监听!

React v17 版本输出结果如下:

原生事件,捕获阶段,document 事件监听! 
合成事件,捕获阶段,父元素事件监听! 
合成事件,捕获阶段,子元素事件监听! 
原生事件,捕获阶段,父元素事件监听! 
原生事件,捕获阶段,子元素事件监听! 
原生事件,冒泡阶段,子元素事件监听! 
原生事件,冒泡阶段,父元素事件监听! 
合成事件,冒泡阶段,子元素事件监听! 
合成事件,冒泡阶段,父元素事件监听! 
原生事件,冒泡阶段,document 事件监听! 

为什么 v16v17 版本执行顺序有这么大变化呢?

  • v16 版本的捕获模式仅是模拟。实质是当事件冒泡到 document 时,遍历元素节点,模拟捕获和冒泡模式的处理方式,获取对应模式下的事件函数,然后调用它们。
  • v17 之后,加入了原生捕获模式的支持,对齐了浏览器原生标准。

TIP

v16 版本中,当原生和合成事件同时绑定在 document 上的时候,由于合成事件是先注册的,因而会先触发。

// 按照注册顺序依次执行
document.addEventListener('click', 合成事件)
document.addEventListener('click', 原生事件)

阻止冒泡

阻止原生事件冒泡,会影响合成事件冒泡

import React, { createRef } from "react"

export default class App extends React.Component {
  parentRef = createRef()
  childRef = createRef()

  componentDidMount() {
    // 原生事件冒泡监听
    this.parentRef.current.addEventListener('click', () => console.log("原生事件,冒泡阶段,父元素事件监听!"))
    this.childRef.current.addEventListener('click', e => {
      console.log("原生事件,冒泡阶段,子元素事件监听!")

      e.stopPropagation()
      console.log("阻止原生事件冒泡!")
    })
    document.addEventListener('click', () => console.log("原生事件,冒泡阶段,document 事件监听!"))
  }

  // 合成事件冒泡监听
  handleParentClick = () => console.log("合成事件,冒泡阶段,父元素事件监听!")
  handleChildClick = () => console.log("合成事件,冒泡阶段,子元素事件监听!")

  render() {
    return (
      <div ref={this.parentRef} onClick={this.handleParentClick}>
        <div ref={this.childRef} onClick={this.handleChildClick}>
          点击查看原生与合成事件执行顺序
        </div>
      </div>
    )
  }
}

// React v16 输出
原生事件,冒泡阶段,子元素事件监听! 
阻止原生事件冒泡!

// React v17 输出
原生事件,冒泡阶段,子元素事件监听! 
阻止原生事件冒泡!

阻止合成事件冒泡,不会影响原生事件冒泡(v17 版本会影响)

import React, { createRef } from "react"

export default class App extends React.Component {
  parentRef = createRef()
  childRef = createRef()

  componentDidMount() {
    // 原生事件冒泡监听
    this.parentRef.current.addEventListener('click', () => console.log("原生事件,冒泡阶段,父元素事件监听!"))
    this.childRef.current.addEventListener('click', () => console.log("原生事件,冒泡阶段,子元素事件监听!"))
    document.addEventListener('click', () => console.log("原生事件,冒泡阶段,document 事件监听!"))
  }

  // 合成事件冒泡监听
  handleParentClick = () => console.log("合成事件,冒泡阶段,父元素事件监听!")
  handleChildClick = e => {
    console.log("合成事件,冒泡阶段,子元素事件监听!")

    e.stopPropagation()
    console.log("阻止合成事件冒泡!")

    // 阻止 documet 上其他事件监听函数的执行
    // e.nativeEvent.stopImmediatePropagation()
  }

  render() {
    return (
      <div ref={this.parentRef} onClick={this.handleParentClick}>
        <div ref={this.childRef} onClick={this.handleChildClick}>
          点击查看原生与合成事件执行顺序
        </div>
      </div>
    )
  }
}

// React v16 输出
原生事件,冒泡阶段,子元素事件监听! 
原生事件,冒泡阶段,父元素事件监听! 
合成事件,冒泡阶段,子元素事件监听! 
阻止合成事件冒泡!
原生事件,冒泡阶段,document 事件监听! 

// React v17 输出
原生事件,冒泡阶段,子元素事件监听! 
原生事件,冒泡阶段,父元素事件监听! 
合成事件,冒泡阶段,子元素事件监听! 
阻止合成事件冒泡!

TIP

  • v16 版本中,所有事件将会被集中注册到 document 节点上,等到事件冒泡到 document 上时才会触发合成事件。当在合成事件上阻止冒泡时,原生事件已经冒泡完了,因而无法阻止原生事件冒泡。
  • v17 版本中,所有事件将会被集中注册到 root 节点上,等到事件冒泡到 root 上时才会触发合成事件。当在合成事件上阻止冒泡时,原生事件还没有冒泡完成(根节点 root 和最外层文档节点 document之间还有一段冒泡距离),这段距离内是可以阻止原生事件冒泡的。

参考

Last Updated:
Contributors: Vsnoy