SyntheticEvent
SyntheticEvent
在 React 中,有一套自己的事件系统,对于 React DOM 宿主环境来说,事件系统由合成事件对象和模拟实现事件传播机制两部分组成。
合成事件对象
SyntheticEvent(合成事件对象)是对浏览器原生事件对象的一层封装,兼容了主流的浏览器,同时拥有和浏览器原生事件相同的 API,例如 stopPropagation 和 preventDefault,其目的就是为了消除不同浏览器在事件对象上面的一个差异。
模拟传播机制
利用事件委托原理,React 基于 FiberTree 来实现事件的捕获、目标及冒泡的过程,并且在这一套事件传播机制中还加入了许多新的特性:
- 不同的事件对应了不同的优先级。
- 定制事件名。如统一采用
onXXX的驼峰写法来绑定事件。 - 定制事件行为。如
onChange的默认行为与原生的oninput相同。
const jsx = (
<div onClick={(e) => console.log("click div")}>
<h3>Hello</h3>
<button
onClick={(e) => {
// e.stopPropagation();
console.log("click button");
}}
>
Click Me
</button>
</div>
);
在上面的代码中,外层的 div 以及内部的 button 都绑定了点击事件,默认情况下,点击 button 会打印出 click button、click div,如果打开 e.stopPropagation(),那么就会阻止事件冒泡,只打印出 click button。
可以看出,React 内部的事件系统实现了模拟实现事件传播机制。
原理
事件对象
SyntheticEvent 指的是合成事件对象,在 React 中的 SyntheticEvent 会包含很多的属性和方法,合成事件对象需要提供一个和原生 DOM 同名的方法。
class SyntheticEvent {
constructor(e) {
this.nativeEvent = e
}
stopPropagation() {
this._stopPropagation = true
if (this.nativeEvent.stopPropagation) {
this.nativeEvent.stopPropagation()
}
}
// ...
}
传播机制
对于可以冒泡的事件,整个事件的传播机制实现步骤为:
- 在根元素绑定事件类型对应的事件回调,所有子孙元素触发该类事件时最终会委托给根元素的事件回调函数来进行处理。
- 寻找触发事件的 DOM 元素,找到对应的 FiberNode。
- 收集从当前的 FiberNode 到 HostRootFiber 之间所有注册了该事件的回调函数。
- 反向遍历并执行一遍收集的所有的回调函数(模拟捕获阶段的实现)。
- 正向遍历并执行一遍收集的所有的回调函数(模拟冒泡阶段的实现)。
通过 addEvent 给根元素绑定事件,目的是使用事件委托:
/**
* 给根元素绑定事件
* @param {*} container 根元素
* @param {*} type 事件类型
*/
export const addEvent = (container, type) => {
container.addEventListener(type, (e) => {
dispatchEvent(e, type.toUpperCase());
});
};
在入口中通过调用 addEvent 来绑定事件:
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(jsx);
// 进行根元素的事件绑定, 使用自定义的事件系统
addEvent(document.getElementById("root"), "click");
在 addEvent 里面,调用 dispatchEvent 做事件的派发。
dispatchEvent 方法对应有如下的步骤:
- 实例化一个合成事件对象。
- 找到对应的 FiberNode。
- 收集从当前的 FiberNode 一直往上所有的该事件类型的回调函数。
- 模拟捕获的实现。
- 模拟冒泡的实现。
/**
* @param {*} e 原生的事件对象
* @param {*} type 事件类型,已经全部转为了大写,比如这里传递过来的是 CLICK
*/
const dispatchEvent = (e, type) => {
// 实例化一个合成事件对象
const se = new SyntheticEvent(e);
// 拿到触发事件的元素
const ele = e.target;
let fiber;
// 通过 DOM 元素找到对应的 FiberNode
for (let prop in ele) {
if (prop.toLocaleLowerCase().includes("fiber")) {
fiber = ele[prop];
}
}
// 找到对应的 fiberNode 之后,收集路径中该事件类型所对应的所有的回调函数
const paths = collectPaths(type, fiber);
// 模拟捕获的实现
triggerEventFlow(paths, type + "CAPTURE", se);
// 模拟冒泡的实现
// 首先需要判断是否阻止了冒泡,如果没有,那么只需要将 paths 进行反向再遍历执行一次即可
if(!se._stopPropagation){
triggerEventFlow(paths.reverse(), type, se);
}
};
收集事件
收集的思路是从当前的 FiberNode 一直向上遍历,直到 HostRootFiber,收集遍历过程中 FiberNode.memoizedProps 属性所保存的对应的事件处理函数。
/**
* 收集路径中所有 type 类型的事件回调函数
* @param {*} type 事件类型
* @param {*} begin FiberNode
* @returns
* [{
* CLICK : function(){...}
* },{
* CLICK : function(){...}
* }]
*/
const collectPaths = (type, begin) => {
// 存放收集到所有的事件回调函数
const paths = [];
// 如果不是 HostRootFiber,就一直往上遍历
while (begin.tag !== 3) {
const { memoizedProps, tag } = begin;
// 如果 tag 对应的值为 5,说明是 DOM 元素对应的 FiberNode
if (tag === 5) {
const eventName = "bind" + type; // bindCLICK
// 检测当前的节点是否有绑定事件
if (memoizedProps && Object.keys(memoizedProps).includes(eventName)) {
// 如果进入该 if,说明当前这个节点绑定了对应类型的事件
// 需要进行收集,收集到 paths 数组里面
const pathNode = {};
pathNode[type] = memoizedProps[eventName];
paths.push(pathNode);
}
begin = begin.return;
}
}
return paths;
};
捕获和冒泡
由于收集是从目标元素的 FiberNode 向上遍历的,因此收集到的顺序为:
[
目标元素的事件回调,
某个祖先元素的事件回调,
某个更上层的祖先元素的事件回调
]
因此要模拟捕获阶段的实现,需要从后往前进行遍历并执行:
/**
* @param {*} paths 收集到的事件回调函数的数组
* @param {*} type 事件类型
* @param {*} se 合成事件对象
*/
const triggerEventFlow = (paths, type, se) => {
// 遍历这个数组,执行回调函数即可
for (let i = paths.length; i--; ) {
const pathNode = paths[i];
const callback = pathNode[type];
if (callback) {
// 存在回调函数,执行该回调
callback.call(null, se);
}
if (se._stopPropagation) {
// 说明在当前的事件回调函数中,开发者阻止继续往上冒泡
break;
}
}
};
在执行事件的回调的时候,每一次执行需要检验 _stopPropagation 属性是否为 true,如果为 true,说明当前的事件回调函数中阻止了事件冒泡,因此我们应当停止后续的遍历。
模拟冒泡阶段,只需要将 paths 进行反向再遍历一次并执行即可:
if(!se._stopPropagation){
triggerEventFlow(paths.reverse(), type, se);
}