前言
React
有着独特的事件机制-合成事件,React
的初学者肯定碰到过这种问题,使用event.stopPropagation();
,却还是无法禁止当前组件的事件冒泡,这就是React
的事件机制的原因,它并不与DOM
事件相同。
本文将从源码的角度来解析,React
的事件系统到底是如何实现的?
DOM事件流
DOM
事件流属于比较基础的知识点,本文不会详细的再叙述,只列出需要一些关键点。W3C标准约定了一个事件的传播过程要经过以下 3 个阶段:
- 1.事件捕获阶段
- 2.目标阶段
- 3.事件冒泡阶段
通过DOM
事件流,我们经常会用到一直常见的性能优化思路:事件委托。
React
事件系统也是基于事件委托这个特性实现的。
React事件系统
在React
中,除了一些不可冒泡的事件外,其它的事件都不会被绑定在具体的元素上,而是统一被绑定到document
上(17版本之后修改为绑定到React
的根DOM组件上),当事件在具体的DOM
节点上被触发后,最终都会冒泡到document
上,React
根组件上所绑定的统一事件处理程序会将事件分发到具体的组件实例。
在分发事件之前,React
首先会对事件进行包装,把原生DOM
事件包装成合成事件。
React合成事件
合成事件是React
自定义的事件对象,它在底层抹平了不同浏览器的差异,在上层面向开发者暴露统一的、稳定的、与DOM
原生事件相同的事件接口。
虽然合成事件并不是原生DOM
事件,但它保存了原生DOM
事件的引用。当你需要访问原生DOM
事件对象时,可以通过合成事件对象的e.nativeEvent
属性获取到原生DOM
事件。
React事件的绑定
事件的绑定是在组件的首次渲染链路的completeWork
方法中完成的。关于首次渲染的流程,可以看我之前的文章
写给自己看的React源码解析(一):你的React代码是怎么渲染成DOM的?
completeWork
主要做了三件事情:
- 创建 DOM 节点(createInstance)
- 将 DOM 节点插入到 DOM 树中(appendAllChildren)
- 为 DOM 节点设置属性(finalizeInitialChildren)。
再finalizeInitialChildren
方法中,会遍历节点的props
。当遍历到事件相关的props
时,就会触发事件的注册链路。
本文基于16.13版本的React
源码。注意:17版本的事件系统在源码上的改动比较大,源码链路上跟本文并不一致。
在ensureListeningTo
中,会获取当前DOM
中的document
对象,然后通过调用legacyListenToEvent
,将统一的事件监听函数注册到document
上面。
在legacyListenToEvent
中,实际上是通过调用legacyListenToTopLevelEvent
来处理事件和document
之间的关系的。 legacyListenToTopLevelEvent
直译过来是“监听顶层的事件”,这里的“顶层”就可以理解为事件委托的最上层,也就是document
节点。
注意:在17版本中,流程中不会存在ensureListeningTo
和legacyListenToEvent
方法,React
会在finalizeInitialChildren
方法下的setInitialProperties
根据节点的tag
类型,传入不同的参数并调用listenToNonDelegatedEvent
方法。在这个方法里,会直接调用addTrappedEventListener
添加事件到React
的根组件DOM
元素上。
listenToNonDelegatedEvent源码地址
我们接着来看,最终注册到document
上的并不是某一个DOM
节点上对应的具体回调逻辑,而是一个统一的事件分发函数listener
,它的本体是一个dispatchEvent
。
React事件的触发
事件的触发其实就是对于dispatchEvent
函数的调用。
我们根据下面的这个demo来走流程
import React from 'react';
import { useState } from 'react'
function App() {
const [state, setState] = useState(0);
return (
<div className="App">
<div
className="container"
onClickCapture={() => console.log('捕获经过 div')}
onClick={() => console.log('冒泡经过 div')}
>
<p>{state}</p>
<button onClick={() => { setState(state + 1) }}>点击+1</button>
</div>
</div>
);
}
export default App;
这个demo的功能很简单,每次点击按钮都会给state
加1。并给container
这个div上添加了两个点击事件,一个捕获事件,一个冒泡事件。下图是这个demo的fiber
树结构。
收集的逻辑过程在traverseTwoPhase
函数
function traverseTwoPhase(inst, fn, arg) {
// 定义一个 path 数组
var path = [];
while (inst) {
// 将当前节点收集进 path 数组
path.push(inst);
// 向上收集 tag===HostComponent 的父节点
inst = getParent(inst);
}
var i;
// 从后往前,收集 path 数组中会参与捕获过程的节点与对应回调
for (i = path.length; i-- > 0;) {
fn(path[i], 'captured', arg);
}
// 从前往后,收集 path 数组中会参与冒泡过程的节点与对应回调
for (i = 0; i < path.length; i++) {
fn(path[i], 'bubbled', arg);
}
}
traverseTwoPhase
函数做了以下三件事情。
- 循环收集符合条件的父节点,存进 path 数组中
- 模拟事件在捕获阶段的传播顺序,收集捕获阶段相关的节点实例与回调函数
- 模拟事件在冒泡阶段的传播顺序,收集冒泡阶段相关的节点实例与回调函数
收集父节点
traverseTwoPhase
会以触发事件的目标节点为起点,通过getParent
方法,不断向上寻找tag===HostComponent
的父节点,并将这些节点按顺序收集进path
数组中。tag===HostComponent
的节点是DOM
元素对应的的fiber
节点类型,也就是说只收集DOM元素对应的节点。
按照demo中的fiber
树来说,最后收集到的节点为div#container
、div.App
及button
节点。
模拟捕获顺序,收集节点实例与回调函数
for (i = path.length; i-- > 0;) {
fn(path[i], 'captured', arg);
}
path
数组是从子节点出发,向上收集得来的。所以,模拟事件的捕获顺序,需要从后往前遍历path
数组。在遍历的过程中,fn
函数检测每个节点的事件回调,若该节点上对应当前事件的捕获回调不为空,那么节点fiber
实例会被收集到合成事件的SyntheticEvent._dispatchInstances
中,事件回调则会被收集到合成事件的SyntheticEvent._dispatchListeners
属性。
模拟冒泡顺序,收集节点实例与回调函数
for (i = 0; i < path.length; i++) {
fn(path[i], 'bubbled', arg);
}
这里功能跟上一步一致,区别只是从前往后来遍历path
数组。
最后,我们来看下SyntheticEvent
对象上的_dispatchInstances
和_dispatchListeners
。
我们只要按顺序调用执行回调函数,就能够模拟出DOM
事件流,也就是 “捕获-目标-冒泡”这三个阶段。
react17中对于事件系统的更新
上文的源码解析是基于16.13.x
版本的,17版本之后的事件系统,有了挺大的区别。
- 1.事件系统改为挂载到
React
的根组件dom上 - 2.
onScroll
事件不再冒泡 - 3.
onFocus
和onBlur
事件已在底层切换为原生的focusin
和focusout
事件 - 4.
onClickCapture
现在使用的是实际浏览器中的捕获监听器(合成事件只会存在listenToNonDelegatedEvent
添加的冒泡事件) - 5.事件池
SyntheticEvent
不再复用,在点击事件中使用异步方法也将可以获取到点击事件。不需要再使用e.persist()
方法
感谢
如果本文对你有所帮助,请帮忙点个赞,感谢!
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!