前言
大家好这里是阳九,一个文科转码的野路子码农,热衷于研究和手写前端工具.
我的宗旨就是 万物皆可手写
今天我们来看一看React的合成事件模型,并尝试自己手写一个合成事件系统
新手创作不易,有问题欢迎指出和轻喷,谢谢
React合成事件
我们知道,react中的事件都是合成事件,什么是合成事件呢? 我们来看一段代码
<button onClick={(e) => {
console.log('React合成事件', e);
}}>合成事件</button>
点击按钮后,我们获取到的事件对象e,是被react重新封装后的SyntheticBaseEvent
,
而其中的nativeEvent
则是我们的原生事件对象event
事件冒泡和捕获
我们知道,在浏览器中的事件会经过两个阶段 捕获阶段-冒泡阶段
- 捕获阶段: 从document层层向下找到触发事件的Dom节点(button),获取到event对象
- 冒泡阶段: event对象会层层上传,如果某一层的dom上监听了click事件,则会触发事件回调,传入event作为参数
所以如果我们有如下代码
<div id="div2">
<div id="div1">
<button id="btn"></button>
</div>
</div>
const div1 = document.getElementById('div1')
const div2 = document.getElementById('div1')
const btn = document.getElementById('btn')
div1.addEventListener('click',()=>{ console.log('点击div1', e);})
div2.addEventListener('click',()=>{ console.log('点击div1', e);})
btn.addEventListener('click',()=>{ console.log('点击btn', e);})
// 点击后执行结果
// 点击button
// 点击div2
// 点击div1
// 如果我们给addEventListener设置了capture属性,那么会直接在捕获阶段执行回调
div1.addEventListener('click',()=>{ console.log('点击div1', e);},true)
div2.addEventListener('click',()=>{ console.log('点击div1', e);},true)
btn.addEventListener('click',()=>{ console.log('点击btn', e);},true)
// 点击后执行结果
// 点击div2
// 点击div1
// 点击button
事件代理
为了获得更加优异的性能,我们有时候会将button上的事件直接代理到上层dom节点处执行。
而React的逻辑就是,在顶层document处监听所有的事件,将事件代理到document上,然后手动模拟冒泡执行和捕获执行的逻辑
这样做的好处是除了document节点,底层的dom都不需要添加监听器了,那么就可以大大提高事件性能(跟事件代理一样的逻辑)。
React事件代理模型
- 如果我们要实现上述代码逻辑,需要给btn,div1,div2都设置一个事件监听器,并给出onClick回调
- react会直接在document上监听click事件,如果事件上浮到document时进行捕获
- 捕获到e后,通过e找到触发的dom节点(button),并按照路径将三个onClick回调收集起来。
- 按照冒泡顺序1-2-3执行回调, (如果是onClickCapture事件,则会按照捕获顺序3-2-1执行回调)
有的小伙伴可能会问如何收集dom上的事件呢?
我们知道,react会形成一个虚拟dom树(fiber树), 而每个fiber.return都保存了当前节点对应的父节点,我们即可通过这个属性沿着dom上浮路径访问到所有的fiber节点
而当我们写下<button onClick={fn}></button>
时,就会给对应的fiber节点的props上添加onCilck属性,保存了回调fn
监听所有原生事件并包装为合成事件
-
我们在React.render(createRoot)的时候,在document上监听所有的原生事件,将所有事件代理起来。
-
捕获到event后,收集沿途的回调函数,判断isCapture(如果事件为onClickCapture),
-
包装为react的
SyntheticBaseEvent
后即可开始按顺序执行收集来的回调函数,并将其作为参数传入
源码导读解析
合成事件的源码不算复杂,这里使用调用栈的方式记录合成事件 大家可以打开react18的源码,一步步查找下去:
- 入口
ReactDom.createRoot()
ReactDom.createRoot()
// 创建时执行,监听所有原生事件
listenToAllSupportedEvents(container);
- 监听
listenToAllSupportedEvents
export function listenToAllSupportedEvents(
rootContainerElement: EventTarget
) {
// 保证事件只注册一次
if (!(rootContainerElement: any)[listeningMarker]) {
(rootContainerElement: any)[listeningMarker] = true;
// set中记录所有原生事件名称
allNativeEvents.forEach(domEventName => {
if (domEventName !== 'selectionchange') {
// 特殊事件不需要委托根节点:如 'cancel'、'load'、'scroll' 等
if (!nonDelegatedEvents.has(domEventName)) {
// 在根节点上绑定对此事件的冒泡阶段的事件委托
listenToNativeEvent(domEventName, false, rootContainerElement);
}
// 在根节点上绑定对此事件的捕获阶段的事件委托
listenToNativeEvent(domEventName, true, rootContainerElement);
}
});
}
}
- 绑定事件 (委托阶段+冒泡阶段)
listenToNativeEvent
addTrappedEventListener
export function listenToNativeEvent(
// 原生事件名
domEventName: DOMEventName,
// true 捕获阶段,false,冒泡阶段
isCapturePhaseListener: boolean,
// 添加事件委托的节点
target: EventTarget,
): void {
// ... 最终调用
addTrappedEventListener(...args);
}
function addTrappedEventListener(...args) {
// 创建事件委托的回调函数
let listener = createEventListenerWrapperWithPriority();
let unsubscribeListener;
// 根据捕获阶段还是冒泡阶段,会调用两个不同的函数
if (isCapturePhaseListener) {
unsubscribeListener = addEventCaptureListener(true,...args);
} else {
unsubscribeListener = addEventBubbleListener(false,...args);
}
}
- 创建事件,并给出优先级
createEventListenerWrapperWithPriority
export function createEventListenerWrapperWithPriority(...args): Function {
// 根据事件名获取优先级,一般的事件如 'click'、'input' 都是 DiscreteEventPriority 级别的
const eventPriority = getEventPriority(domEventName);
let listenerWrapper;
switch (eventPriority) {
case DiscreteEventPriority:
listenerWrapper = dispatchDiscreteEvent;
break;
case ContinuousEventPriority:
listenerWrapper = dispatchContinuousEvent;
break;
case DefaultEventPriority:
default:
listenerWrapper = dispatchEvent;
break;
}
// 这个返回值就是我们的回调函数 listener
// listenerWrapper 其实接受四个参数,我们当前只绑定了前三个,
// 第四个就是在触发事件的时候,调用回调传入的,
// 也就是 DOM 的 Event 对象。
return listenerWrapper.bind(
null,
domEventName,
eventSystemFlags,
targetContainer,
);
}
- 绑定dom事件
// 就是addEventListener 三号参数为true 捕获执行
export function addEventCaptureListener(): Function {
target.addEventListener(eventType, listener, true);
return listener;
}
// addEventListener 三号参数为false 冒泡执行
export function addEventBubbleListener(): Function {
target.addEventListener(eventType, listener, false);
return listener;
}
6.执行事件-1 dispatchEvent
上面5步绑定好事件后, 我们click点击事件触发后会执行
dispatchDiscreteEvent()
dispatchContinuousEvent()
dispatchEvent() // 最终执行
// 下层调用
attemptToDispatchEvent()
// 下层调用
dispatchEventForPluginEventSystem()
// 下层调用
dispatchEventsForPlugins()
7.执行事件-2 dispatchEventsForPlugins
这里做了两件事
- 收集所有的事件(按照冒泡/捕获顺序排序)
冒泡顺序:从底层dom到顶层dom 捕获顺序:从顶层dom到底层dom
- 依次触发事件
function dispatchEventsForPlugins(...args): void {
// 拿到当前点击的 DOM 节点
const nativeEventTarget = getEventTarget(nativeEvent);
// 创建调用函数路径
const dispatchQueue: DispatchQueue = [];
// 从当前 Fiber 节点出发,收集整个链路上的事件
extractEvents();
// 依次触发收集到的事件
processDispatchQueue(dispatchQueue, eventSystemFlags);
}
- 收集事件
- fiber节点中有一个属性return,可以找到它对应的父dom所在的fiber
- 可以从触发事件的fiber处,往上收集所有的eventListener
function extractEvents(...args): void {
// 从当前 Fiber 节点出发,根据事件名 沿着fiber树向上收集事件listener
const listeners = accumulateSinglePhaseListeners();
![image.png](https://p9-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/5e577f1c00ca4b7b9b47500675032210~tplv-k3u1fbpfcp-watermark.image?)
if (listeners.length > 0) {
// 创建合成事件
const event = new SyntheticEventCtor( );
// 加入到派发队列中
dispatchQueue.push({ event, listeners });
}
}
- 执行事件
- 如果是冒泡, 从底层dom到顶层dom的顺序执行
- 如果是捕获 从顶层到底层...
export function processDispatchQueue(
dispatchQueue: DispatchQueue, // 我们前面收集的事件数组
eventSystemFlags: EventSystemFlags, // 包含了是否是捕获阶段的信息
): void {
// true 的话是捕获阶段,这会影响后续事件的执行顺序
const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
for (let i = 0; i < dispatchQueue.length; i++) {
const {event, listeners} = dispatchQueue[i];
processDispatchQueueItemsInOrder(event, listeners, inCapturePhase);// 真实执行事件 .call
}
}
后记
通过以上模型,想必大家也可以写出一个简易的合成事件系统。
本人也是开发了一个事件代理包,用在了自己的框架中。
想看一下具体的代码实现可以查看我的git仓库。