开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情
写在前面
这是关于React合成事件的第二篇,如果你之前对于合成事件不太了解,或是对React了解较少这里强烈建议看一下第一篇(传送门)。这篇主要是对React源码的梳理,讲解部分去掉了部分无关代码。
为什么使用合成事件
这里我们稍稍回顾一下前情,让这篇看起来完整一些。
我们之前说的只是React的触发方式,实际上对于合成事件中合成并没有解释,这个也是打算是放在下一个篇章中结合源码进行讲解,我们可以把使用合成事件归结成下面两个目的
- 抹平浏览器差异,这里主要是针对不同浏览器对于事件的api的兼容,主要是万恶的IE的兼容,好在微软官方已经放弃它了。
- 使用事件委托,监听都在根节点进行,减少了内存开销。
插件系统
React通过一个插件系统来控制整个合成事件的过程,从注册事件、事件映射、监听事件、分发事件函数,有条不紊的进行工作,来保证事件系统的稳定。
准备阶段
我们在上个篇章中提到,React使用了事件委托,把所有的事件绑定在div#root上,由它统一去处理。那我们在监听事件之前需要做哪些准备呢?
注册事件
我们需要拿到原生DOM支持的事件,一一注册在React上面。React这里使用了一个Set,通过插件,将所有事件保存,这里以SimpleEventPlugin注册简单事件为例
// src/react-dom-bindings/src/events/EventRegistry.js
export const allNativeEvents = new Set(); // 用来保存所有的原生事件
// src/react-dom-bindings/src/events/DOMEventProperties.js
// 这里事件比较多,我们用省略号省略了一些事件,保留了一些眼熟的事件
const simpleEventPluginEvents = [
'abort',
// ...,
'click',
// ...,
'mouseDown',
'mouseMove',
'mouseOut',
'mouseOver',
'mouseUp',
// ...,
'wheel',
];
function registerSimpleEvent(domEventName, reactName) {
registerTwoPhaseEvent(reactName, [domEventName]); // 注册两个阶段的事件
}
/**
* 注册简单事件
*/
export function registerSimpleEvents() {
for (let i = 0; i < simpleEventPluginEvent.length; i++) {
const eventName = simpleEventPluginEvent[i]; // click
const domEventName = eventName.toLowerCase(); // click
const capitalizeEvent = eventName[0].toUpperCase() + eventName.slice(1); // Click
registerSimpleEvent(domEventName, `on${capitalizeEvent}`);
}
}
// src/react-dom-bindings/src/events/plugins/SimpleEventPlugin.js
import { registerSimpleEvents } from '../DOMEventProperties';
export { registerSimpleEvents as registerEvents };
// src/react-dom-bindings/src/events/DOMPluginEventSystem.js
import * as SimpleEventPlugin from './plugins/SimpleEventPlugin';
// 为我们allNativeEvents赋值,注册事件
SimpleEventPlugin.registerEvents();
源码在这里写的比较麻烦,但是实际上做的事情并不是很多,这里我简单的进行一下概括:
- 首先我们有一个包括所有原生事件的数组
simpleEventPluginEvents; - 我们创建了一个Set
allNativeEvents来保存原生事件。 - 通过
SimpleEventPlugin插件调用registerEvents方法为allNativeEvents赋值。
这样我们allNativeEvents就包括所有的事件了。
事件映射
事实上我们在使用React绑定事件的时候都是使用比如onClick的方式,但是我们如果想让DOM实现监听的话,那么我们就必须使用它能“听”懂的click,这里就需要我们做一个映射了,把onClick和click关联起来。
React使用的方式是维护了名叫topLevelEventsToReactNames的Map,在注册事件的事件,把ReactName和nativeName做一个关联:
// src/react-dom-bindings/src/events/DOMEventProperties.js
export const topLevelEventsToReactNames = new Map();
export function registerSimpleEvents() {
for (let i = 0; i < simpleEventPluginEvent.length; i++) {
const eventName = simpleEventPluginEvent[i];
const domEventName = eventName.toLowerCase();
const capitalizeEvent = eventName[0].toUpperCase() + eventName.slice(1);
registerSimpleEvent(domEventName, `on${capitalizeEvent}`); // 这里完成了reactName的拼接
}
}
function registerSimpleEvent(domEventName, reactName) {
topLevelEventsToReactNames.set(domEventName, reactName); // 保存domEventName和reactName的映射
registerTwoPhaseEvent(reactName, [domEventName]);
}
小结
到这里我们准备阶段的工作就完成了,最后的结果就是拿到了一个了包括所有事件的SetallNativeEvents和一个关于React事件和原生事件的映射topLevelEventsToReactNames。下面我们将进行真正的监听。
事件监听
注册捕获和冒泡事件
当我们完成准备条件之后,就可以在我们div#root进行事件的监听:
// src/react-dom-bindings/src/events/DOMPluginEventSystem.js
// 随机的唯一标识,我们只进行一次事件监听,避免重复监听
const listeningMarker = `_reactListening` + Math.random().toString(36).slice(2);
/**
* 监听根容器,即div#root
* @param {*} rootContainerElement div#root
*/
export function listenToAllSupportedEvents(rootContainerElement) {
// 监听根容器,为其添加listeningMarker属性,以达到只监听一次的目的
if (!rootContainerElement[listeningMarker]) {
rootContainerElement[listeningMarker] = true;
}
// 遍历所有原生事件比如click,进行监听
allNativeEvents.forEach((domEventName) => {
listenToNativeEvent(domEventName, true, rootContainerElement); // 捕获事件
listenToNativeEvent(domEventName, false, rootContainerElement); // 冒泡事件
});
}
/**
* 注册原生事件
* @param {*} domEventName 原生事件,click
* @param {*} isCapurePhaseListener 是否是捕获阶段
* @param {*} target 目标DOM节点,div#root 容器节点
*/
export function listenToNativeEvent(
domEventName,
isCapurePhaseListener,
target,
) {
let eventSystemFlags = 0; // 默认是0 指的是冒泡 4是捕获
if (isCapurePhaseListener) {
eventSystemFlags |= IS_CAPTURE_PHASE;
}
// 将要进行的原生监听
addTrappedEventListener(
target,
domEventName,
eventSystemFlags,
isCapurePhaseListener,
);
}
// src/react-dom-bindings/src/events/EventSystemFlags.js
export const IS_CAPTURE_PHASE = 1 << 2; // 4
这个阶段完成的工作主要包括:
- 遍历所有的事件,为每个事件都注册了
捕获和冒泡两个阶段的监听。这里需要注意的是,我们只进行一个的监听,方法是通过产生一个随机listeningMarker标记div#root,来证明他已经被监听过,避免在之后rerender阶段重复监听。 - 添加标志
捕获的flagseventSystemFlags,其中0表示冒泡,4表示捕获
获取监听事件
存事件函数
这里是我们这个篇章的重点,完成的也是比较复杂的,下面我们借助示例来理清React在这里的处理思想(下面的示例均以点击事件为例)。
<div onClick={() => console.log('click')}>click me</div>
我们通常会采用上面的写法来进行事件绑定,绑定的函数是写在虚拟DOM上面的,页面渲染时,React会把虚拟DOM转换为Fiber,然后通过Fiber协调,生成真实DOM,虚拟DOM、Fiber、真实DOM都是一一对应的。
我们的点击函数会随着虚拟DOM被添加在Fiber的属性中,但是我们在真实DOM中,无法观察到函数的存在,那React又是怎么获取这个点击函数的呢?
我们在创建真实DOM的时候就把Fiber里面属性绑定在DOM上了
// src/react-dom-bindings/src/client/ReactDOMHostConfig.js
/**
* 创建实例节点
* @param {*} type 节点类型
* @param {*} props 属性
* @param {*} internalInstanceHandle fiber
* @returns dom实例
*/
export function createInstance(type, props, internalInstanceHandle) {
const domElement = document.createElement(type);
updateFiberNode(domElement, props);
return domElement;
}
// src/react-dom-bindings/src/client/ReactDOMComponentTree.js
const randomKey = Math.random().toString(36).slice(2);
const internalPropsKey = '__reactProps$' + randomKey;
export function updateFiberNode(node, props) {
node[internalPropsKey] = props;
}
我们在创建DOM实例的时候,会产生一个随机的internalPropsKey做为真实DOM的一个属性,这样我们就可以通过真实DOM拿到我们事件函数了。
取事件函数
通过上面的方式,我们在真实DOM中可以通过属性访问的方式拿到我们的事件函数。
// src/react-dom-bindings/src/events/getListener.js
export default function getListener(inst, registrationName) {
const { stateNode } = inst;
if (stateNode === null) {
return null;
}
const props = getFiberCurrentPropsFromNode(stateNode);
if (props === null) {
return null;
}
const listener = props[registrationName];
return listener;
}
// src/react-dom-bindings/src/client/ReactDOMComponentTree.js
export function getFiberCurrentPropsFromNode(node) {
return node[internalPropsKey] || null;
}
但是这里依旧的问题是,我们是通过事件委托的方式,由根节点统一监听。当点击事件发生时,我们不仅要当前目标绑定的事件,还需要冒泡到根节点,获取其中所有绑定的点击函数,根据不同阶段依次触发。
计算单阶段所有事件
不论是冒泡阶段还是捕获阶段,都需要我们获取当前事件源到根节点的所有事件,而这种不断遍历的事情,我们通过真实DOM又很难做到,最好的方式就是通过Fiber,遍历Fiber获取所有的事件。
React在这也是通过和真实DOM绑定的方式,通过事件源的真实DOM获取到其对应的Fiber,然后遍历Fiber获取所有的事件。
// src/react-dom-bindings/src/client/ReactDOMHostConfig.js
/**
* 创建实例节点
* @param {*} type 节点类型
* @param {*} props 属性
* @param {*} internalInstanceHandle fiber
* @returns dom实例
*/
export function createInstance(type, props, internalInstanceHandle) {
const domElement = document.createElement(type);
precacheFiberNode(internalInstanceHandle, domElement);
updateFiberNode(domElement, props);
return domElement;
}
// src/react-dom-bindings/src/client/ReactDOMComponentTree.js
/**
* 提前在dom上面缓存对应的fiber
* @param {*} hostInst
* @param {*} node
*/
const randomKey = Math.random().toString(36).slice(2);
const internalInstanceKey = '__reactFiber$' + randomKey;
export function precacheFiberNode(hostInst, node) {
node[internalInstanceKey] = hostInst;
}
这里也是和存事件函数使用一样的方法,在创建真实DOM的时候,为其绑定随机的internalInstanceKey属性,这个属性指向真实DOM对应的Fiber,这样我们就可以通过真实DOM获取到其对应的Fiber。
/**
* 计算单阶段的事件
* @param {*} targetFiber
* @param {*} reactName
* @param {*} nativeEventType
* @param {*} isCapturePhase
*/
export function accumulateSinglePhaseListeners(
targetFiber,
reactName,
nativeEventType,
isCapturePhase,
) {
const captureName = reactName + 'Capture';
const reactEventName = isCapturePhase ? captureName : reactName;
const listeners = []; // 事件数组
let instance = targetFiber; // 要处理的Fiber
while (instance !== null) {
const { stateNode, tag } = instance;
if (tag === HostComponent && stateNode !== null) {
const listener = getListener(instance, reactEventName); // 获取该fiber上面的事件函数
if (listener) {
listeners.push(listener);
}
}
instance = instance.return; // 进行下一次迭代
}
return listeners;
}
我们可以看看上面的处理手法,从事件源的fiber开始,如果是原生组件(div、sapn)这些对应真实DOM的fiber,取到其对应的事件函数,保存到数组中,如果该Fiber存在父Fiber则继续遍历,直至没有父Fiber。这样我们数组就可以保存到所有“途径”的事件函数。
小结
这一小节我们进行了事件监听,在div#root上面,对allNativeEvents里面所有的事件都进行了冒泡和捕获阶段的监听;着重介绍了通过为真实DOM添加新属性获取事件函数的方法;获取事件源到根结点所有事件函数的方法。
合成事件
当我们点击事件发生时,浏览器会将当前事件的事件源传递给addEventListener的监听函数中,React在原生事件的基础上进行了一些属性添加和覆盖,来进行对浏览器的兼容,新的事件对象被称为是合成事件。React把这个合成事件作为事件对象传递给触发函数。
// src/react-dom-bindings/src/events/SyntheticEvent.js
function functionThatReturnTrue() {
return true;
}
function functionThatReturnFalse() {
return false;
}
// 鼠标事件,这里其实有很多,这里做了一些删减
const MouseEventInterface = {
clientX: 0,
clientY: 0,
// ...
};
function createSyntheticEvent(inter) {
/**
* 合成事件的基类
* @param {*} reactName react事件名 onClick
* @param {*} reactEventType click
* @param {*} targetInst 事件源对应的fiber实例
* @param {*} nativeEvent 原生事件对象
* @param {*} nativeEventTarget 原生事件源,span 事件源对用的那个真实DOM
*/
function SyntheticBaseEvent(
reactName,
reactEventType,
targetInst,
nativeEvent,
nativeEventTarget,
) {
debugger;
this._reactName = reactName;
this.type = reactEventType;
this._targetInst = targetInst;
this.nativeEvent = nativeEvent;
this.target = nativeEventTarget;
// 把接口上面对应属性从原生事件上拷贝到合成事件实例上
for (const propName in inter) {
if (!inter.hasOwnProperty(propName)) {
continue;
}
this[propName] = nativeEvent[propName];
}
// 是否已经阻止默认事件
this.isDefaultPrevented = functionThatReturnFalse;
// 是否已经阻止继续传播
this.isPropagationStopped = functionThatReturnFalse;
return this;
}
// 重写preventDefault和stopPropagation,主要是兼容浏览器
Object.assign(SyntheticBaseEvent.prototype, {
preventDefault() {
const event = this.nativeEvent;
if (event.preventDefault) {
event.preventDefault();
} else {
event.returnValue = false;
}
this.isDefaultPrevented = functionThatReturnTrue;
},
stopPropagation() {
const event = this.nativeEvent;
if (event.stopPropagation) {
event.stopPropagation();
} else {
event.cancelBubble = true;
}
this.isPropagationStopped = functionThatReturnTrue;
},
});
return SyntheticBaseEvent;
}
export const SyntheticMouseEvent = createSyntheticEvent(MouseEventInterface);
这里主要是看看我们对preventDefault,stopPropagation的兼容。React对两个方法方法进行了兼容和覆盖,这里其实主要是对IE浏览器的兼容,抹平浏览器之间的差异。
最后
本篇我们从源码上分析React是如何实现合成事件,着重讲解了事件的获取和绑定关系的确立;如何进行事件绑定,监听两个阶段的事件;如何创建合成事件,重写事件对象的属性,实现浏览器的兼容。
这个是我们React合成事件的第二篇,第一篇中主要是对为何要要使用合成函数的思考,而本篇中则是侧重于在源码上实现各处细节。