前言

React 基于浏览器的事件机制自身实现了一套事件机制,包括事件注册、事件的合成、事件冒泡、事件派发等,这套事件机制被称之为合成事件。

目的是为了实现全浏览器的一致性,抹平不同浏览器之间的差异性,比如原生 onclick 事件对应 React 中的 onClick 合成事件。

  • React 事件通过 JSX 方式绑定的事件, 比如 onClick={() => {}}

  • 原生事件使用 addEventListener

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const ref = useRef()
const onClick = useCallback(() => {
}. []);

useEffect(() => {
// 绑定原生事件
ref.current.addEventListener('click', event => {});
}, []);

return (
<div
ref={ref}
onClick={onClick} // React 事件
/>
);

实现机制

React 底层,主要对合成事件做了两件事:事件委派自动绑定

事件流

事件流包括三个部分:事件捕获、目标阶段和事件冒泡。

img

如上图,我们可以看出,当我们点击一个 DOM 事件时所经历的过程。

  • 事件捕获:是从外到里,对应图中的红色箭头标注部分 window -> document -> html ... -> target
  • 目标阶段:是事件真正发生并处理的阶段。
  • 事件冒泡:是从里到外,对应图中的 target -> ... -> html -> document -> window

React 合成事件便是利用了这个机制,通过事件委托将所有的事件绑定在 ducument 或者 root 根节点上。

  • React 17 以前,事件委托在 document 上。
  • React 及以后,事件委托在 root 根上的。

事件委派

React 并不会把事件处理函数直接绑定到真实的节点上,而是把所有事件绑定到结构的最外层,使用统一的事件监听器(事件池)来管理,这个事件池上维持了一个映射来保存所有组件内部的事件监听和处理函数。

当组件挂载或卸载时,只是在事件池上插入或删除一些对象,不会频繁的创建和销毁事件对象。

当事件发生时,会通过事件冒泡机制触及顶层节点,通过事件池在映射里找到真正的事件处理函数并调用。这样做简化了事件处理和回收机制,效率也有很大提升。

自动绑定

React 组件中,每个方法的上下文都会指向该组件的实例,即自动绑定 this 为当前组件。 而且 React 还会对这种引用进行缓存,以达到 CPU 和内存的最优化。

在使用类组件或者纯函数时,我们需要手动实现 this 的绑定。

执行顺序

前面我们已经知道了事件流的顺序,我们看下 React 合成事件是在哪一环节,我们以 React 18 为例。

img

整个执行顺序:

  • 1. window/document/html/body 原始捕获
    1. react 捕获
    1. target 原始捕获
    1. target 原始冒泡
    1. react 冒泡
    1. window/document/html/body 原始捕获

可以看到,基于 捕获 -> 冒泡 的执行顺序,React 会在 root 节点创建一个虚拟层(SyntheticEvent 合成事件层)来代理监听 DOM 的事件。

这个合成事件的监听其实也是通过原生事件的监听,也就是 addEventListener。只不过监听的处理函数优先级是基于 React 封装的。

唯一有争议的就是 React 合成事件 和 root 原生事件的先后顺序,举个例子 🌰:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
import { useEffect, useRef } from 'react';

const HomePage: React.FC = () => {
const refParent = useRef<HTMLDivElement>(null);
const refChild = useRef<HTMLDivElement>(null);

useEffect(() => {
let root = document.getElementById('root');
if (root) {
root?.addEventListener('click', () => console.log('捕获:root 原生'), true);
root?.addEventListener('click', () => console.log('冒泡:root 原生'), false);
}

document.addEventListener('click', () => console.log('捕获:document 原生'), true);
document.addEventListener('click', () => console.log('冒泡:document 原生'), false);

refParent.current?.addEventListener('click', () =>console.log('捕获:父 原生'), true);
refParent.current?.addEventListener('click', () =>console.log('冒泡:父 原生'), false);

refChild.current?.addEventListener('click', () =>console.log('捕获:子 原生'), true);
refChild.current?.addEventListener('click', () =>console.log('冒泡:子 原生'), false);

}, []);

const perantClick = () => console.log('冒泡:父 React 合成');
const perantCaptureClick = () => console.log('捕获:父 React 合成');

const childClick = () => console.log('冒泡:子 React 合成');
const childCaptureClick = () => console.log('捕获:子 React 合成');

return (
<div
ref={refParent}
onClick={perantClick}
onClickCapture={perantCaptureClick}
>
<div
ref={refChild}
onClick={childClick}
onClickCapture={childCaptureClick}
>
点击
</div>
</div>
);
};

export default HomePage;

结果输出:

img

从例子的结果来看,基本满足了我们对于事件流的理解,但是可以注意到 捕获: root 原生 是在 捕获: React 合成 之后,按照我们的理解应该是在 捕获: React 合成 之前。

事件捕获会优先调用结构树最外层的元素上绑定的事件监听器,然后依次向内调用,一直调用到目标元素上的事件监听器为止。可以在将 e.addEventListener() 的第三个参数设置为 true 时,为元素 e 注册捕获事件处理程序,并且在事件传播的第一个阶段调用。 此外,事件捕获并不是一个通用的技术,在低于 IE9 版本的浏览器中无法使用。而事件冒泡则与事件捕获的表现相反,它会从目标元素向外传播事件,由内而外直到最外层。

可以看出,事件捕获在程序开发中的意义并不大,更致命的是它的兼容性问题。

可能基于这个原因,React 的合成事件更多的考虑冒泡阶段,所以存在上诉捕获的顺序问题?

欢迎有经验的人指出我的错误。

对比合成事件与原生事件

绑定方式

受到 DOM 标准的影响,绑定浏览器原生事件的方式也有很多种。

  • 直接在 DOM 元素中绑定:<button onclick="alert(1);">Test</button>
  • 为元素事件属性赋值方式:el.onclick = e => { console.log(e) }
  • 事件监听函数实现绑定:el.addEventListener('click', () => {}, false)

React 合成事件的绑定方式:

1
<button onClick={this.handleClick}>Test</button>

阻止事件冒泡

阻止事件冒泡:e.stopPropagation();

这里有个注意点:对于同一个 DOM 分别绑定原生事件、合成事件,在合成事件中阻止事件冒泡并不会阻止原生事件的冒泡执行,但是在原生事件中阻止事件冒泡,会阻止合成事件的执行。

  • 在原生执行: 会阻止原生和合成的事件冒泡。
  • 在合成执行: 之后阻止合成事件冒泡,并不会阻止原生事件冒泡。

为什么?

答:合成事件是事件委托的一种实现,主要利用事件冒泡机制将所有事件在 document/root 上统一处理,根据事件流,事件执行顺序为 捕获阶段、目标阶段、冒泡阶段,当我们在原生事件上阻止事件冒泡,那么事件就无法冒泡到 document/root,那么合成事件自然无法执行。

问题答疑

  • 问题一:合成事件与原生事件的执行顺序是什么样的。

    答:见执行顺序

  • 问题二:对于同一个 DOM 分别绑定原生事件、合成事件,在原生事件中阻止事件冒泡为什么会阻止合成事件的执行?

    答:见阻止事件冒泡

  • 问题三:为什么 React 合成事件都委托在 document 或者 root 根节点 上?

    答:见事件委派

    • 减少频繁的事件注册,减少内存消耗,提高性能。
    • 统一处理,并提供合成事件对象,抹平浏览器的兼容性差异。
  • 问题四:为什么 React 17 以前是委托在 document17 以后是委托在 root 根节点

    答:性能提升、作用域影响、第三方库的影响

    • 性能提升:将事件处理附加到 document 上会导致每个事件都在整个文档范围内冒泡,增加了浏览器的事件捕获和处理的开销。尤其是在应用比较大、包含很多 DOM 节点时,这种做法会影响性能。而将事件处理绑定到 React 渲染树的根容器上,可以减少不必要的事件传播,提高性能。
    • 作用域:如果页面上有多个 React 版本(如微前端),事件都会被附加在 document 上,这时嵌套的 React 树调用 e.stopPropagation() 停止了事件冒泡, 外部的树仍会接收到该事件(因为只是阻止了 React 事件的冒泡), 这就使嵌套不同版本的 React 难以实现。
    • 第三方库:与作用域同理。