React的事件机制已经是老生常谈的问题了,我们都知道React自己实现了一套事件机制,那么,React为什么要自己实现一套呢?它是如何实现的呢?
实践出真知,那就一起看看源码吧!源码调试
为什么React要实现一套事件机制
主要有一下几个原因:
- 提供合成事件对象 SyntheticEvent,抹平浏览器的兼容性差异
- 对事件进行归类,可以在事件产生的任务上包含不同的优先级
- **提高性能:所有合成事件统一绑定到了document上,简化了DOM事件处理逻辑【React17绑定在容器节点】
React16+采用了fiber架构,由于fiber机制的特点,生成一个fiber节点时,它对应的dom节点有可能还未挂载,onClick这样的事件处理函数作为fiber节点的prop,也就不能直接被绑定到真实的DOM节点上。 为此,React提供了一种“顶层注册,事件收集,统一触发”的事件机制。
所谓“顶层注册”,其实是在root元素上绑定一个统一的事件处理函数。
“事件收集”指的是事件触发时(实际上是root上的事件处理函数被执行),构造合成事件对象,按照冒泡或捕获的路径去组件中收集真正的事件处理函数。
“统一触发”发生在收集过程之后,对所收集的事件逐一执行,并共享同一个合成事件对象。
Demo1
我们先来看下合成事件和原生事件的区别:
import * as React from "react";
class EventDemo extends React.Component {
componentDidMount() {
document
.getElementById("btn-reactandnative")
.addEventListener("click", e => {
console.log("Events=====>原生事件",e);
});
}
handleNativeAndReact = e => {
console.log("Events=====>合成事件",);
};
handleClick = e => {
console.log("Events=====>合成事件",e);
};
render() {
return (
<div>
<button id="btn-confirm" onClick={this.handleClick}>
React合成事件
</button>
<button id="btn-reactandnative" onClick={this.handleNativeAndReact}>原生事件</button>
</div>
);
}
}
export default EventDemo;
代码中【原生事件】的button同时绑定了合成事件onClick和原生事件addEventListener,当我们点击【原生事件】按钮的时候,会发现,先执行原生事件,然后执行合成事件。
原生事件的执行是在目标阶段,然而合成事件的执行是在冒泡阶段,所以原生事件会先合成事件执行,然后再往父节点冒泡
同时我们发现:
- 当同一个节点同时绑定了原生事件和合成事件的时候,原生事件先执行
- 合成事件内部通过e.nativeEvent包装了原生事件
- 合成事件被绑定到document上(React17绑定到root上),原生事件被绑定到元素(这里是button)上
区别: event.stopPropagation():停止向上移动,但是当前元素上的其他处理程序都会继续运行 event.stopImmediatePropagation():如果一个元素在一个事件上有多个处理程序,即使其中一个停止冒泡,其他处理程序仍会执行。并阻止当前元素上的处理程序运行。使用该方法之后,其他处理程序就不会被执行。
Demo2
具体看下阻止冒泡的demo:
import * as React from "react";
class EventDemo extends React.Component {
componentDidMount() {
window.addEventListener("click", (e) => {
console.log("Events=====>Window原生事件",);
});
document.addEventListener("click", (e) => {
console.log("Events=====>Document原生事件",);
});
document.getElementById("div-root").addEventListener("click", e => {
console.log("Events=====>div-root-原生事件");
});
document.getElementById("target-button").addEventListener("click", e => {
console.log("Events=====>target-button-原生事件");
// e.stopPropagation();
});
}
handleClick = (e) => {
console.log("Events=====>target-button点击-合成事件");
};
handleRootClick = (e) => {
console.log("Events=====>div-root-合成事件");
};
render() {
return (
<div id="div-root" onClick={this.handleRootClick}>
<button id="target-button" onClick={this.handleClick}>
react 事件
</button>
</div>
);
}
}
export default EventDemo;
我们在button同时绑定了原生事件和合成事件,也绑定了window和document的监听事件
点击【react 事件】按钮,依次打印:
很好,冒泡的顺序按照预期
总体趋势是原生先于合成事件触发
下面我们想阻止冒泡,
handleClick = (e) => {
console.log("Events=====>target-button点击-合成事件");
e.stopPropagation();// 阻止合成事件冒泡
};
可以看到,点击的目标元素阻止了后面的事件触发,符合预期
接下来改动
document.getElementById("div-root").addEventListener("click", e => {
console.log("Events=====>div-root-原生事件");
// e.stopPropagation();
});
可以发现,在原生事件中,阻止冒泡,后续的合成事件都被阻止了。(因为原生事件执行先于合成事件)
那我们可以轻松的得出结论:
- 阻止合成事件间的冒泡,用e.stopPropagation();
- 原生事件阻止冒泡会阻止合成事件,合成事件的阻止冒泡不会影响原生事件
- 阻止合成事件与除最外层document上的原生事件上的冒泡,通过判断e.target来避免
Demo3
React17中新增了onClickCapture 用来捕捉捕获阶段
class EventDemo extends React.Component {
state = { count: 0 };
onDemoClick = () => {
console.log("Events=====>counter的点击事件被触发了");
this.setState({
count: this.state.count + 1,
});
};
onParentClick = () => {
console.log("Events=====>父级元素的点击事件被触发了");
};
onClickParentCapture = () => {
console.log("Events=====>父级元素的点击事件onClickParentCapture被触发了");
};
render() {
const { count } = this.state;
return (
<div
className={"counter-parent"}
onClick={this.onParentClick}
onClickCapture={this.onClickParentCapture}
>
<div onClick={this.onDemoClick} className={"counter"}>
{count}
</div>
</div>
);
}
}
export default EventDemo;
点击,看下打印
可以看到基本是先捕捉了捕获阶段 然后触发了冒泡阶段
源码探索
从v17.0.0开始, React 不会再将事件处理添加到 document 上, 而是将事件处理添加到渲染 React 树的根 DOM 容器中.
引入官方提供的图片:
图中清晰的展示了v17.0.0的改动, 无论是在document还是根 DOM 容器上监听事件, 都可以归为事件委托(代理)(mdn).
注意: react的事件体系, 不是全部都通过事件委托来实现的. 有一些特殊情况, 是直接绑定到对应 DOM 元素上的(如:scroll, load), 它们都通过listenToNonDelegatedEvent函数进行绑定.
React提供了一种“顶层注册,事件收集,统一触发”的事件机制。
所谓“顶层注册”,其实是在root元素上绑定一个统一的事件处理函数。
“事件收集”指的是事件触发时(实际上是root上的事件处理函数被执行),构造合成事件对象,按照冒泡或捕获的路径去组件中收集真正的事件处理函数。
“统一触发”发生在收集过程之后,对所收集的事件逐一执行,并共享同一个合成事件对象。
下面我们以React17来进行源码探索
顶层注册
与之前版本不同,React17的事件是注册到root上而非document,这主要是为了渐进升级,避免多版本的React共存的场景中事件系统发生冲突。
<div onClick={() => {/*do something*/}}>React</div>
这个div节点最终要对应一个fiber节点,onClick则作为它的prop。当这个fiber节点进入render阶段的complete阶段时,名称为onClick的prop会被识别为事件进行处理。
通过之前的源码分析,我们知道,React在启动的时候会创建全局对象,legacyCreateRootFromDOMContainer中
function legacyCreateRootFromDOMContainer(
container: Container,
forceHydrate: boolean,
): FiberRoot {
//省略 ...
const rootContainerElement =
container.nodeType === COMMENT_NODE ? container.parentNode : container;
listenToAllSupportedEvents(rootContainerElement);
return root;
}
listenToAllSupportedEvents函数完成了事件代理
沿着这条线我们往下走,
export function listenToAllSupportedEvents(rootContainerElement: EventTarget) {
if (!(rootContainerElement: any)[listeningMarker]) {
// 1. 节流优化, 保证全局注册只被调用一次
(rootContainerElement: any)[listeningMarker] = true;
// 2. 遍历allNativeEvents 监听冒泡和捕获阶段的事件
allNativeEvents.forEach(domEventName => {
if (domEventName !== 'selectionchange') {
if (!nonDelegatedEvents.has(domEventName)) {
// 冒泡阶段监听
listenToNativeEvent(domEventName, false, rootContainerElement);
}
// 捕获阶段监听
listenToNativeEvent(domEventName, true, rootContainerElement);
}
});
// ...
}
}
listenToNativeEvent => addTrappedEventListener
function addTrappedEventListener(
targetContainer: EventTarget,
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
isCapturePhaseListener: boolean,
isDeferredListenerForLegacyFBSupport?: boolean,
) {
// 1. 构造listener
let listener = createEventListenerWrapperWithPriority(
targetContainer,
domEventName,
eventSystemFlags,
);
let isPassiveListener = undefined;
if (passiveBrowserEventsSupported) {
if (
domEventName === 'touchstart' ||
domEventName === 'touchmove' ||
domEventName === 'wheel'
) {
isPassiveListener = true;
}
}
targetContainer =
enableLegacyFBSupport && isDeferredListenerForLegacyFBSupport
? (targetContainer: any).ownerDocument
: targetContainer;
let unsubscribeListener;
if (enableLegacyFBSupport && isDeferredListenerForLegacyFBSupport) {
const originalListener = listener;
listener = function(...p) {
removeEventListener(
targetContainer,
domEventName,
unsubscribeListener,
isCapturePhaseListener,
);
return originalListener.apply(this, p);
};
}
// 2. 注册事件监听
if (isCapturePhaseListener) {
if (isPassiveListener !== undefined) {
unsubscribeListener = addEventCaptureListenerWithPassiveFlag(
targetContainer,
domEventName,
listener,
isPassiveListener,
);
} else {
unsubscribeListener = addEventCaptureListener(
targetContainer,
domEventName,
listener,
);
}
} else {
if (isPassiveListener !== undefined) {
unsubscribeListener = addEventBubbleListenerWithPassiveFlag(
targetContainer,
domEventName,
listener,
isPassiveListener,
);
} else {
unsubscribeListener = addEventBubbleListener(
targetContainer,
domEventName,
listener,
);
}
}
}
// 注册原生事件 冒泡
export function addEventBubbleListener(
target: EventTarget,
eventType: string,
listener: Function,
): Function {
target.addEventListener(eventType, listener, false);
return listener;
}
// 注册原生事件 捕获
export function addEventCaptureListener(
target: EventTarget,
eventType: string,
listener: Function,
): Function {
target.addEventListener(eventType, listener, true);
return listener;
}
看第9行 生成listener, 这个很关键,它实现了把原生事件派发到React体系之内。(例如将DOM点击的原生事件,派发到React的onClick中)
export function createEventListenerWrapperWithPriority(
targetContainer: EventTarget,
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
): Function {
// 1. 根据优先级设置 listenerWrapper
const eventPriority = getEventPriority(domEventName);
let listenerWrapper;
switch (eventPriority) {
case DiscreteEventPriority:
listenerWrapper = dispatchDiscreteEvent;
break;
case ContinuousEventPriority:
listenerWrapper = dispatchContinuousEvent;
break;
case DefaultEventPriority:
default:
listenerWrapper = dispatchEvent;
break;
}
// 2. 返回 listenerWrapper
return listenerWrapper.bind(
null,
domEventName,
eventSystemFlags,
targetContainer,
);
}
可以看到, 不同的domEventName调用getEventPriority后返回不同的优先级, 最终会有 3 种情况:
- DiscreteEvent: 优先级最高, 包括click, keyDown, input等事件, 源码
- 对应的listener是dispatchDiscreteEvent
- UserBlockingEvent: 优先级适中, 包括drag, scroll等事件, 源码
- 对应的listener是dispatchUserBlockingUpdate
- ContinuousEvent: 优先级最低,包括animation, load等事件, 源码
- 对应的listener是dispatchEvent
这 3 种listener实际上都是对dispatchEvent的包装:
// ...省略无关代码
export function dispatchEvent(
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
targetContainer: EventTarget,
nativeEvent: AnyNativeEvent,
): void {
if (!_enabled) {
return;
}
const blockedOn = attemptToDispatchEvent(
domEventName,
eventSystemFlags,
targetContainer,
nativeEvent,
);
}
划重点: 优先级和事件监听的包装器
这些包装器是真正绑定到root上的事件监听器listener,它们持有各自的优先级,当对应的事件触发时,调用的其实是这个包含优先级的事件监听。
到这里很抽象,我们先看下demo吧,打开chrome,可以看到所有listener都注册到root上
小结
- 事件处理函数不是绑定到组件的元素上的,而是绑定到root上,这和fiber树的结构特点有关,即事件处理函数只能作为fiber的prop。
- 绑定到root上的事件监听不是我们在组件里写的事件处理函数,而是一个持有事件优先级,并能传递事件执行阶段标志的监听器。
事件收集
绑定到root上的事件监听listener只是相当于一个传令官,它按照事件的优先级去安排接下来的工作:事件对象的合成、将事件处理函数收集到执行路径、 事件执行,这样在后面的调度过程中,scheduler才能获知当前任务的优先级,然后展开调度。
接着上面的代码继续往下捋顺,createEventListenerWrapperWithPriority=>dispatchEvent=>attemptToDispatchEvent=> dispatchEventsForPlugins=>dispatchEventsForPlugins
export function attemptToDispatchEvent(
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
targetContainer: EventTarget,
nativeEvent: AnyNativeEvent,
): null | Container | SuspenseInstance {
// TODO: Warn if _enabled is false.
// 1. 定位原生DOM节点
const nativeEventTarget = getEventTarget(nativeEvent);
// 2. 获取与DOM节点对应的fiber节点
let targetInst = getClosestInstanceFromNode(nativeEventTarget);
if (targetInst !== null) {
const nearestMounted = getNearestMountedFiber(targetInst);
if (nearestMounted === null) {
targetInst = null;
} else {
const tag = nearestMounted.tag;
if (tag === SuspenseComponent) {
const instance = getSuspenseInstanceFromFiber(nearestMounted);
if (instance !== null) {
return instance;
}
targetInst = null;
} else if (tag === HostRoot) {
const root: FiberRoot = nearestMounted.stateNode;
if
return getContainerFromFiber(nearestMounted);
}
targetInst = null;
} else if (nearestMounted !== targetInst) {
targetInst = null;
}
}
}
// 3. 通过插件系统, 派发事件
dispatchEventForPluginEventSystem(
domEventName,
eventSystemFlags,
nativeEvent,
targetInst,
targetContainer,
);
// We're not blocked on anything.
return null;
}
export function dispatchEventForPluginEventSystem(
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
nativeEvent: AnyNativeEvent,
targetInst: null | Fiber,
targetContainer: EventTarget,
): void {
let ancestorInst = targetInst;
//...
batchedUpdates(() =>
dispatchEventsForPlugins(
domEventName,
eventSystemFlags,
nativeEvent,
ancestorInst,
targetContainer,
),
);
}
batchedUpdates感觉似曾相识啊!批量更新?
先不管,看下dispatchEventsForPlugins
function dispatchEventsForPlugins(
domEventName: DOMEventName,
eventSystemFlags: EventSystemFlags,
nativeEvent: AnyNativeEvent,
targetInst: null | Fiber,
targetContainer: EventTarget,
): void {
const nativeEventTarget = getEventTarget(nativeEvent);
const dispatchQueue: DispatchQueue = [];
// 事件对象的合成,收集事件到执行路径上
extractEvents(
dispatchQueue,
domEventName,
targetInst,
nativeEvent,
nativeEventTarget,
eventSystemFlags,
targetContainer,
);
// 执行收集到的组件中真正的事件
processDispatchQueue(dispatchQueue, eventSystemFlags);
}
在这里,进行事件对象的合成,并
dispatchEventsForPlugins函数中事件的流转有一个重要的载体:dispatchQueue,它承载了本次合成的事件对象和收集到事件执行路径上的事件处理函数。
class EventDemo extends React.Component{
state = { count: 0 }
onDemoClick = () => {
console.log('counter的点击事件被触发了');
this.setState({
count: this.state.count + 1
})
}
onParentClick = () => {
console.log('父级元素的点击事件被触发了');
}
render() {
const { count } = this.state
return <div
className={'counter-parent'}
onClick={this.onParentClick}
>
<div
onClick={this.onDemoClick}
className={'counter'}
>
{count}
</div>
</div>
}
}
看demo,点击之后打印
event是合成事件对象,listeners是事件执行路径
事件对象合成
继续看extractEvents。19行开始构造合成事件对象
function extractEvents(
dispatchQueue: DispatchQueue,
domEventName: DOMEventName,
targetInst: null | Fiber,
nativeEvent: AnyNativeEvent,
nativeEventTarget: null | EventTarget,
eventSystemFlags: EventSystemFlags,
targetContainer: EventTarget,
): void {
const reactName = topLevelEventsToReactNames.get(domEventName);
if (reactName === undefined) {
return;
}
let EventInterface;
switch (domEventName) {
// 赋值EventInterface(接口)
}
// 构造合成事件对象
const event = new SyntheticEvent(
reactName,
null,
nativeEvent,
nativeEventTarget,
EventInterface,
);
const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
if (/*...*/) {
...
} else {
// scroll事件不冒泡
const accumulateTargetOnly =
!inCapturePhase &&
domEventName === 'scroll';
// 事件对象分发 & 收集事件
accumulateSinglePhaseListeners(
targetInst,
dispatchQueue,
event,
inCapturePhase,
accumulateTargetOnly,
);
}
return event;
}
收集事件到执行路径
这个过程是将组件中真正的事件处理函数收集到数组中,等待下一步的批量执行。
由demo2中我们可以看到,父子元素同时绑定click事件,点击子元素会先执行子元素的点击事件,然后执行父元素的点击事件的。
实际上这是将事件以冒泡的顺序收集到执行路径之后导致的。收集的过程由accumulateSinglePhaseListeners完成。
export function accumulateSinglePhaseListeners(
targetFiber: Fiber | null,
dispatchQueue: DispatchQueue,
event: ReactSyntheticEvent,
inCapturePhase: boolean,
accumulateTargetOnly: boolean,
): void {
// 根据事件名来识别是冒泡阶段的事件还是捕获阶段的事件
const bubbled = event._reactName;
const captured = bubbled !== null ? bubbled + 'Capture' : null;
// 声明存放事件监听的数组
const listeners: Array<DispatchListener> = [];
// 找到目标元素
let instance = targetFiber;
// 从目标元素开始一直到root,累加所有的fiber对象和事件监听。
while (instance !== null) {
const {stateNode, tag} = instance;
if (tag === HostComponent && stateNode !== null) {
const currentTarget = stateNode;
// 事件捕获
if (captured !== null && inCapturePhase) {
// 从fiber中获取事件处理函数
const captureListener = getListener(instance, captured);
if (captureListener != null) {
listeners.push(
createDispatchListener(instance, captureListener, currentTarget),
);
}
}
// 事件冒泡
if (bubbled !== null && !inCapturePhase) {
// 从fiber中获取事件处理函数
const bubbleListener = getListener(instance, bubbled);
if (bubbleListener != null) {
listeners.push(
createDispatchListener(instance, bubbleListener, currentTarget),
);
}
}
}
instance = instance.return;
}
// 收集事件对象
if (listeners.length !== 0) {
dispatchQueue.push(createDispatchEntry(event, listeners));
}
}
函数内部最重要的操作无疑是收集事件到执行路径
从触发事件的元素开始,依据fiber树的层级结构向上查找,累加上级元素中所有相同类型的事件,最终形成一个具有所有相同类型事件的数组,这个数组就是事件执行路径。通过这个路径,React自己模拟了一套事件捕获与冒泡的机制。
同时,沿途路过fiber节点时,根据事件名,从props中获取我们真正写在组件中的事件处理函数,push到路径中,等待下一步的批量执行。
统一触发
经过事件和事件对象收集的过程,得到了一条完整的事件执行路径,还有一个被共享的事件对象,之后进入到事件执行过程,从头到尾循环该路径,依次调用每一项中的监听函数。这个过程的重点在于事件冒泡和捕获的模拟,以及合成事件对象的应用,如下是从dispatchQueue中提取出事件对象和时间执行路径的过程。
dispatchEventsForPlugins=>processDispatchQueue
export function processDispatchQueue(
dispatchQueue: DispatchQueue,
eventSystemFlags: EventSystemFlags,
): void {
const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
for (let i = 0; i < dispatchQueue.length; i++) {
// 从dispatchQueue中取出事件对象和事件监听数组
const {event, listeners} = dispatchQueue[i];
// 将事件监听交由processDispatchQueueItemsInOrder去触发,同时传入事件对象供事件监听使用
processDispatchQueueItemsInOrder(event, listeners, inCapturePhase);
}
// 捕获错误
rethrowCaughtError();
}
模拟冒泡和捕获
在收集事件的时候,无论是冒泡还是捕获,事件都是直接push到路径里的。那么执行顺序的差异是如何体现的呢?
我们打印dispatchQueue的时候可以看到,触发事件的目标元素的事件处理函数排在第一个,上层组件的事件处理函数依次往后排。
那么冒泡和捕获的处理就很清晰了
从左往右循环的时候,目标元素的事件先触发,父元素事件依次执行,这与冒泡的顺序一样,那捕获的顺序自然是从右往左循环了。模拟冒泡和捕获执行事件的代码如下:
function processDispatchQueueItemsInOrder(
event: ReactSyntheticEvent,
dispatchListeners: Array<DispatchListener>,
inCapturePhase: boolean,
): void {
let previousInstance;
if (inCapturePhase) {
// 事件捕获倒序循环
for (let i = dispatchListeners.length - 1; i >= 0; i--) {
const {instance, currentTarget, listener} = dispatchListeners[i];
if (instance !== previousInstance && event.isPropagationStopped()) {
return;
}
// 执行事件,传入event对象,和currentTarget
executeDispatch(event, listener, currentTarget);
previousInstance = instance;
}
} else {
// 事件冒泡正序循环
for (let i = 0; i < dispatchListeners.length; i++) {
const {instance, currentTarget, listener} = dispatchListeners[i];
// 如果事件对象阻止了冒泡,则return掉循环过程
if (instance !== previousInstance && event.isPropagationStopped()) {
return;
}
executeDispatch(event, listener, currentTarget);
previousInstance = instance;
}
}
}
至此,组件中的事件处理函数就被执行掉了,合成事件对象在这个过程中充当了一个公共角色,每个事件执行时,都会检查合成事件对象,有没有调用阻止冒泡的方法,另外会将当前挂载事件监听的元素作为currentTarget挂载到事件对象上,最终传入事件处理函数,我们得以获取到这个事件对象。
React17 事件机制
- 事件系统改为挂载到
**React**
的根组件dom上 onScroll
事件不再冒泡onFocus
和onBlur
事件已在底层切换为原生的focusin
和focusout
事件onClickCapture
现在使用的是实际浏览器中的捕获监听器(合成事件只会存在listenToNonDelegatedEvent
添加的冒泡事件)- 事件池
**SyntheticEvent**
不再复用,在点击事件中使用异步方法也将可以获取到点击事件。不需要再使用e.persist()
方法
这些更改会使 React 与浏览器行为更接近,并提高了互操作性。
总结
React 事件注册过程
a. 事件注册 - 组件挂载阶段,根据组件内的声明的事件类型-onClick,onChange 等,给 document(root) 上添加事件(被包装的事件 具有优先级) -addEventListener并指定统一的事件处理程序 dispatchEvent(做了浏览器事件的兼容)。
b. 事件存储 - 从事件的元素开始向上找,累加这个路径上所有相同类型的事件,最终形成了一个具有相同类型事件数组-执行路径listeners。 等待批量执行
React 事件触发过程
事件被触发的时候,会向上冒泡到document(root), 由document统一进行事件执行(冒泡:左=>右 捕获:右=>左)
References
https://juejin.cn/post/6909271104440205326?utm_source=gold_browser_extension
https://juejin.cn/post/6844903700423507976#heading-7
https://juejin.cn/post/6844903700423524366
https://mp.weixin.qq.com/s/Qs3PKtKcu4yYXHz6_GcGzg
https://github.com/7kms/react-illustration-series/blob/master/docs/main/synthetic-event.md
https://github.com/neroneroffy/react-source-code-debug/blob/master/docs/%E4%BA%8B%E4%BB%B6%E7%B3%BB%E7%BB%9F/%E6%A6%82%E8%A7%88.md