前言
接触过 React 技术栈的同学相信都有了解,React 团队在源码中实现了一套事件机制来代替原生浏览器事件,其目的是:
- 抹平事件对象在不同浏览器上的差异,如:在不同浏览器下阻止事件冒泡(
SyntheticEvent 合成事件
); - 与底层架构上的任务调度「优先级机制」衔接;
- 基于以上两点,React 需要自己实现一套模拟「捕获」和「冒泡」的事件机制。
这套机制采用了「事件委托」方式,将冒泡和捕获事件统一绑定在 document
上进行事件回调派发(不能冒泡的事件会直接在对应的 dom 节点上直接绑定);
不过在新版 ReactV17 及以后,不再将事件委托到 document 上,而是委托在渲染 React 应用的根 DOM 容器中,即使用 rootNode.addEventListener()
监听事件动作去执行相关事件回调。
React 事件系统实现虽然庞大,但核心都放在这两个模块上:
- SyntheticEvent(合成事件)
- 模拟实现事件的传播机制(冒泡、捕获)
从源码角度看,React 事件机制整体可以概括为:
- 事件绑定(listenToNativeEvent)
- 处理合成事件(extractEvents)
- 收集事件响应链路(extractEvents)
- 触发事件回调函数(processDispatchQueue)
下面,我们将通过 debugger 来分析事件 Call Stack 调用栈去理解 React 事件机制。
一、前置知识
1.1、DOM 事件流
W3C 标准约定了一个事件的传播过程要经过 3 个阶段:事件捕获阶段
、目标阶段
、事件冒泡阶段
。
当事件被触发时,首先经历的是一个捕获过程:事件会从最外层的元素开始“穿梭”,逐层“穿梭”到最内层元素,这个过程会持续到事件抵达它目标的元素(也就是真正触发这个事件的元素)为止;
此时事件流就切换到了“目标阶段” —— 事件被目标元素所接收;
然后事件会被“回弹”,进入到冒泡阶段——它会沿着来时的路“逆流而上”,一层一层再走回去。
1.2、事件委托
在面对事件流机制,事件委托
(也叫事件代理)则是一种重要的性能优化手段。
例如,页面有 10 个 li 元素,通过点击每个元素都能输出它的文本内容,一个比较直观的思路是让每一个 li 元素都去监听一个点击动作,10 个 li 就会被绑定 10 个监听事件,这样开销比较大。
有没有更好方式呢?借助事件冒泡机制,我们将事件处理委托到上级 ul 元素上,让 ul 来帮忙感知这个点击事件。这样我们就可以实现「委托」:
<ul id="list">
<li>海内存知己</li>
<li>天涯若比邻</li>
...
</ul>
const ul = document.getElementById('list')
ul.addEventListener('click', function(e){
console.log(e.target.innerHTML)
})
二、debugger Demo
这里我们提供一个简单的 Demo,进行事件机制代码调试:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>React 事件机制</title>
</head>
<body>
<script crossorigin src="https://unpkg.com/react@17.0.2/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@17.0.2/umd/react-dom.development.js"></script>
<script src="http://static.runoob.com/assets/react/browser.min.js"></script>
<div id="root"></div>
<script type="text/babel">
function App() {
return (
<div className="App" onClick={() => {
debugger;
console.log('click App.');
}}>
Hello World
</div>
)
}
ReactDOM.render(<App />, document.getElementById('root'));
</script>
</body>
</html>
我们打开浏览器控制台,当触发点击事件后,会进行断点,你会看到调用栈信息显示如下:
在这条执行链路上,包含了 React 事件机制的三个部分:
- 处理合成事件(dispatchEventsForPlugins)
- 收集事件响应链路(dispatchEventsForPlugins)
- 触发事件回调函数(processDispatchQueue)
除此之外,还有重要的一部分是事件初始化
过程,会将事件委托到 container 容器节点,下面我们先来看初始化这一部分。
三、初始化事件
初始化事件阶段分为两部分内容进行:
- 注册事件插件,React 对不同类型的事件采用不同插件提供支持;
- 事件委托,为 contianer 挂载容器元素绑定捕获和冒泡事件监听器。
3.1、注册事件插件
浏览器环境下事件种类很多,React 将不同事件(鼠标事件、表单事件等)进行区分定义,它的位置在:react-dom/src/events/DOMPluginEventProperties.js
// 定义不同事件
const discreteEventPairsForSimpleEventPlugin = [
('cancel': DOMEventName), 'cancel',
('click': DOMEventName), 'click',
('close': DOMEventName), 'close',
('contextmenu': DOMEventName), 'contextMenu',
...
]
const otherDiscreteEvents: Array<DOMEventName> = [
'change',
'selectionchange',
'textInput',
'compositionstart',
'compositionend',
'compositionupdate',
];
const userBlockingPairsForSimpleEventPlugin: Array<string | DOMEventName> = [
...
]
onst continuousPairsForSimpleEventPlugin: Array<string | DOMEventName> = [
...
]
在引入加载 react-dom.js 时,会同时执行事件注册逻辑,React 根据事件类型划分为 5 大类:
// react-dom/src/events/DOMPluginEventSystem.js
SimpleEventPlugin.registerEvents();
EnterLeaveEventPlugin.registerEvents();
ChangeEventPlugin.registerEvents();
SelectEventPlugin.registerEvents();
BeforeInputEventPlugin.registerEvents();
通过执行不同插件的 registerEvents
,最终将所有的事件注册并保存在 allNativeEvents
变量集合之中(下面会用到):
// react-dom/src/events/EventRegistry.js
const allNativeEvents: Set<DOMEventName> = new Set();
function registerDirectEvent(registrationName, dependencies) {
...
for (var i = 0; i < dependencies.length; i++) {
allNativeEvents.add(dependencies[i]);
}
}
3.2、container 容器节点事件委托
将事件处理函数委托在 container 容器节点,时机是在调用 ReactDOM.render
进行初渲染时,与创建 FiberRootNode
和 HostRootFiber
在同一阶段:
// react-dom/src/client/ReactDOMRoot.js
function createRootImpl(container, tag, options) {
// 创建 FiberRootNode 和 HostRootFiber
var root = createContainer(container, tag, hydrate);
var rootContainerElement = container.nodeType === COMMENT_NODE ? container.parentNode : container;
// 初始化事件,对 container 容器元素绑定各类事件
listenToAllSupportedEvents(rootContainerElement);
return root;
}
调用 listenToNativeEvent
为 rootContainerElement
注册捕获和冒泡事件处理器:
// react-dom/src/events/DOMPluginEventSystem.js
function listenToAllSupportedEvents(rootContainerElement) {
allNativeEvents.forEach(function (domEventName) {
// 进行支持冒泡事件的注册,如 click
if (!nonDelegatedEvents.has(domEventName)) {
listenToNativeEvent(domEventName, false, rootContainerElement, null);
}
// 捕获事件
listenToNativeEvent(domEventName, true, rootContainerElement, null);
});
}
function listenToNativeEvent(domEventName, isCapturePhaseListener, rootContainerElement, targetElement) {
var eventSystemFlags = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : 0;
var target = rootContainerElement;
...
addTrappedEventListener(target, domEventName, eventSystemFlags, isCapturePhaseListener);
}
function addTrappedEventListener(targetContainer, domEventName, eventSystemFlags, isCapturePhaseListener, isDeferredListenerForLegacyFBSupport) {
// 根据事件名称,创建 dispatchEvent 方法
var listener = createEventListenerWrapperWithPriority(targetContainer, domEventName, eventSystemFlags);
var unsubscribeListener; // When legacyFBSupport is enabled, it's for when we
if (isCapturePhaseListener) { // 捕获事件注册
unsubscribeListener = addEventCaptureListener(targetContainer, domEventName, listener);
} else { // 冒泡事件注册
unsubscribeListener = addEventBubbleListener(targetContainer, domEventName, listener);
}
}
function addEventBubbleListener(target, eventType, listener) {
target.addEventListener(eventType, listener, false);
return listener;
}
function addEventCaptureListener(target, eventType, listener) {
target.addEventListener(eventType, listener, true);
return listener;
}
上面值得注意的一点是创建 listener
,这个函数就是绑定给 contianer 的事件处理函数,会根据用户触发事件类型优先级,使用不同的 dispatchEvent。
如 click 事件会使用 dispatchDiscreteEvent
,正如在 debugger Call Stack 看到的最顶层函数。
// react-dom/src/events/ReactDOMEventListener.js
function createEventListenerWrapperWithPriority(targetContainer, domEventName, eventSystemFlags) {
var eventPriority = getEventPriorityForPluginSystem(domEventName);
var listenerWrapper;
switch (eventPriority) {
case DiscreteEvent:
listenerWrapper = dispatchDiscreteEvent; // click 事件的处理
break;
case UserBlockingEvent:
listenerWrapper = dispatchUserBlockingUpdate;
break;
case ContinuousEvent:
default:
listenerWrapper = dispatchEvent;
break;
}
return listenerWrapper.bind(null, domEventName, eventSystemFlags, targetContainer);
}
其实对于本例,上述事件的初始化可以简单概括为:
container.addEventListener('click', dispatchDiscreteEvent, false);
四、debugger Call Stack 分析
虽然从图中我们看到 Call Stack
函数调用链路很长,不过多数是和并发及优先级调度相关,抛开这些,我们提炼几个重要的函数来看看。
dispatchDiscreteEvent
是注册在 container 容器节点上的事件监听函数,核心是调用 dispatchEvent
:
function dispatchDiscreteEvent(domEventName, eventSystemFlags, container, nativeEvent) {
// 核心是执行 dispatchEvent
dispatchEvent(dispatchEvent, domEventName, eventSystemFlags, container, nativeEvent);
}
function dispatchEvent(domEventName, eventSystemFlags, targetContainer, nativeEvent) {
// 根据事件获取 DOM 元素
const nativeEventTarget = getEventTarget(nativeEvent); // nativeEvent.target
// 根据 DOM 元素获取 Fiber 节点
const targetInst = getClosestInstanceFromNode(nativeEventTarget);
// 核心是执行 dispatchEventsForPlugins
dispatchEventsForPlugins(domEventName, eventSystemFlags, nativeEvent, targetInst);
}
React 需要模拟事件冒泡和捕获机制,就需要收集 DOM 树链路上的所有元素事件回调;
事件回调被存储在 FiberNode 节点上,这里需要先通过 nativeEvent.target
拿到当前 FiberNode,后续将通过它向上查找事件链路。
下面是根据 DOM 获取 FiberNode 逻辑:
const randomKey = Math.random().toString(36).slice(2);
const internalInstanceKey = '__reactFiber$' + randomKey;
function getClosestInstanceFromNode(targetNode) {
// 每个 DOM 节点上会在 internalInstanceKey 属性上记录对应的 Fiber 节点。
let targetInst = targetNode[internalInstanceKey];
if (targetInst) {
return targetInst;
}
...
}
另外扩展一下,DOM 进行 Fiber 属性标记的时机在 render/completeWork
「render 归阶段」创建 DOM 实例时:
function createInstance(type, props, rootContainerInstance, hostContext, internalInstanceHandle) {
var domElement = createElement(type, props, rootContainerInstance, parentNamespace);
precacheFiberNode(internalInstanceHandle, domElement); // internalInstanceHandle --> FiberNode
...
}
function precacheFiberNode(hostInst, node) {
node[internalInstanceKey] = hostInst;
}
有了 FiberNode
,将进入 dispatchEventsForPlugins
处理:合成事件、收集事件传播,以及事件回调的执行,所以后面内容的分析都集中在这里:
// targetInst 是目标元素的 Fiber 节点,后续查找事件链路会使用到 FiberNode
function dispatchEventsForPlugins(domEventName, eventSystemFlags, nativeEvent, targetInst, targetContainer) {
const nativeEventTarget = getEventTarget(nativeEvent);
const dispatchQueue: DispatchQueue = [];
// 事件对象的合成,收集事件到执行路径上
extractEvents(
dispatchQueue,
domEventName, // click
targetInst, // FiberNode
nativeEvent, // Event
nativeEventTarget, // Target
eventSystemFlags,
targetContainer,
);
// 执行收集到的组件中真正的事件
processDispatchQueue(dispatchQueue, eventSystemFlags);
}
五、合成事件
SyntheticEvent
合成事件类似于原生 event
对象,是对浏览器原生事件对象的一层封装。
它抹平了不同浏览器在事件对象间的差异,同时拥有和浏览器原生事件相同的API,如 stopPropagation() 和 preventDefault()。
在 extractEvents
中包含了创建「合成事件」以及收集「事件链路」,我们这里重点看下合成事件的处理。
// react-dom/src/events/plugins/SimpleEventPlugin.js
function extractEvents(dispatchQueue, domEventName, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags, targetContainer) {
const reactName = topLevelEventsToReactNames.get(domEventName); // 获取完整事件名称
// 根据事件名称,获取对应的合成事件构造实例
let SyntheticEventCtor = SyntheticEvent;
switch (domEventName) {
case 'click':
case 'dblclick':
SyntheticEventCtor = SyntheticMouseEvent;
break;
...
}
const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
const accumulateTargetOnly = !inCapturePhase && domEventName === 'scroll';
// 1、收集 事件传播
const listeners = accumulateSinglePhaseListeners(
targetInst,
reactName,
nativeEvent.type,
inCapturePhase,
accumulateTargetOnly,
);
// 2、创建合成事件
if (listeners.length > 0) {
const event = new SyntheticEventCtor(
reactName,
reactEventType,
null,
nativeEvent,
nativeEventTarget,
);
// 3、加入队列
dispatchQueue.push({ event, listeners });
}
}
在这里会根据事件类型,使用不同的合成事件实例,不论是哪一类合成事件,都具备 SyntheticEvent
基础结构,这也是合成事件的核心:
function functionThatReturnsTrue() {
return true;
}
function functionThatReturnsFalse() {
return false;
}
// react-dom/src/events/SyntheticEvent.js
function createSyntheticEvent(Interface: EventInterfaceType) {
function SyntheticBaseEvent(
reactName: string | null,
reactEventType: string,
targetInst: Fiber,
nativeEvent: {[propName: string]: mixed},
nativeEventTarget: null | EventTarget,
) {
this._reactName = reactName;
this._targetInst = targetInst;
this.type = reactEventType;
this.nativeEvent = nativeEvent;
this.target = nativeEventTarget;
this.currentTarget = null;
for (const propName in Interface) {
if (!Interface.hasOwnProperty(propName)) {
continue;
}
const normalize = Interface[propName];
if (normalize) {
this[propName] = normalize(nativeEvent);
} else {
this[propName] = nativeEvent[propName];
}
}
const defaultPrevented =
nativeEvent.defaultPrevented != null
? nativeEvent.defaultPrevented
: nativeEvent.returnValue === false;
if (defaultPrevented) {
this.isDefaultPrevented = functionThatReturnsTrue;
} else {
this.isDefaultPrevented = functionThatReturnsFalse;
}
this.isPropagationStopped = functionThatReturnsFalse;
return this;
}
Object.assign(SyntheticBaseEvent.prototype, {
preventDefault: function() {
this.defaultPrevented = true;
const event = this.nativeEvent;
if (!event) return;
if (event.preventDefault) {
event.preventDefault();
} else if (typeof event.returnValue !== 'unknown') {
event.returnValue = false;
}
this.isDefaultPrevented = functionThatReturnsTrue;
},
stopPropagation: function() {
const event = this.nativeEvent;
if (!event) return;
if (event.stopPropagation) {
event.stopPropagation();
} else if (typeof event.cancelBubble !== 'unknown') {
event.cancelBubble = true;
}
this.isPropagationStopped = functionThatReturnsTrue; // return true
},
persist: function() {},
isPersistent: functionThatReturnsTrue,
});
return SyntheticBaseEvent;
}
在合成事件对象中实现了 preventDefault
和 stopPropagation
两个方法,其内部抹平了不同浏览器环境下的兼容问题。
六、收集事件链路
我们回顾一下浏览器事件冒泡机制:从当前元素开始,依次向上执行每个上级元素对应事件的回调。
React 为了实现这一机制,就需要去收集事件回调,基于底层 Fiber 架构,元素的事件回调都保存在了 FiberNode 中,React 会从当前 FiberNode 到根 FiberNode 收集所有注册该事件的回调函数。
所以在 extractEvents
的另一个工作是收集 FiberNode 上的事件链路回调,将其存储在 dispatchQueue
之中,以此来实现事件传播机制。
// react-dom/src/events/DOMPluginEventSystem.js
export function accumulateSinglePhaseListeners(
targetFiber: Fiber | null,
reactName: string | null,
nativeEventType: string,
inCapturePhase: boolean,
accumulateTargetOnly: boolean,
): Array<DispatchListener> {
const captureName = reactName !== null ? reactName + 'Capture' : null;
const reactEventName = inCapturePhase ? captureName : reactName;
const listeners: Array<DispatchListener> = [];
let instance = targetFiber;
let lastHostComponent = null;
while (instance !== null) {
const { stateNode, tag } = instance;
// 在 DOM Fiber 上收集 onClick 事件
if (tag === HostComponent && stateNode !== null) {
lastHostComponent = stateNode;
if (reactEventName !== null) {
const listener = getListener(instance, reactEventName);
if (listener != null) {
listeners.push(
createDispatchListener(instance, listener, lastHostComponent),
);
}
}
}
if (accumulateTargetOnly) break;
instance = instance.return;
}
return listeners;
}
// react-dom/src/events/getListener.js
export default function getListener(inst: Fiber, registrationName: string): Function | null {
const stateNode = inst.stateNode;
// props 保存在了 DOM 节点上
const props = getFiberCurrentPropsFromNode(stateNode);
const listener = props[registrationName];
return listener;
}
var randomKey = Math.random().toString(36).slice(2);
var internalPropsKey = '__reactProps$' + randomKey;
function getFiberCurrentPropsFromNode(node) {
return node[internalPropsKey] || null;
}
function createDispatchListener(instance, listener, currentTarget) {
return {
instance: instance,
listener: listener,
currentTarget: currentTarget
};
}
七、事件执行
通过 extractEvents
,会将收集到的事件回调 list 存储在 dispatchQueue
之中,接下里会根据区分「捕获」和「冒泡」采用不同方式执行回调:
在本例中使用的 onClick
是冒泡阶段被触发的事件。React 提供了类似于 addEventListener
第三参数,可支持「冒泡」和「捕获」两种方式。
如需注册捕获阶段的事件处理函数,则应为事件名添加 Capture
。例如,处理捕获阶段的点击事件请使用 onClickCapture
,而不是 onClick
。
// 执行事件(包含捕获和冒泡阶段处理)
function processDispatchQueue(dispatchQueue, eventSystemFlags) {
const inCapturePhase = (eventSystemFlags & IS_CAPTURE_PHASE) !== 0;
for (let i = 0; i < dispatchQueue.length; i++) {
const { event, listeners } = dispatchQueue[i];
processDispatchQueueItemsInOrder(event, listeners, inCapturePhase);
}
}
function processDispatchQueueItemsInOrder(event, dispatchListeners, inCapturePhase) {
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;
}
executeDispatch(event, listener, currentTarget);
previousInstance = instance;
}
} else {
// 冒泡事件执行(顺向执行,模拟冒泡)
for (let i = 0; i < dispatchListeners.length; i++) {
const { instance, currentTarget, listener } = dispatchListeners[i];
// 若阻止了冒泡,中断后面事件的执行
if (instance !== previousInstance && event.isPropagationStopped()) {
return;
}
executeDispatch(event, listener, currentTarget);
previousInstance = instance;
}
}
}
function executeDispatch(event, listener, currentTarget) {
var type = event.type || 'unknown-event';
event.currentTarget = currentTarget;
listener(event); // listener 是传给 onClick 的真正事件回调,传入合成事件对象,执行回调
event.currentTarget = null;
}
若我们的 Demo 改造成如下结构,便于你理解上面的逻辑:
function App() {
return (
<div className="App" onClickCapture={() => console.log('div capture')} onClick={() => console.log('div bubble')}>
<span onClickCapture={() => console.log('span capture')} onClick={() => console.log('span bubble')}>点击</span>
</div>
)
}
回到页面触发点击事件,控制台输出如下:
div capture
span capture
span bubble
div bubble
八、总结事件传播机制
整个事件传播机制可以概括为:
- 在根节点绑定事件类型对应的事件回调,所有子孙节点触发该类事件最终都会委托给「根节点的事件回调」处理;
- 寻找触发事件的 DOM 节点,找到其对应的 FiberNode(节点上保存了事件回调);
- 收集从当前 FiberNode 到根 FiberNode 之间所有注册的「该事件对应回调」;
- 对于捕获事件(事件名中存在
Capture
),反向遍历并执行一遍所有收集的回调(模拟捕获阶段的实现); - 对于冒泡事件,正向遍历并执行一遍所有收集的回调(模拟冒泡阶段的实现)。
另外,这里有一篇卡颂的 60行代码实现React的事件系统,文章简明通俗易懂,感兴趣的同学可以阅读查看。
小技巧分享
假如我们现在遇到这样一个场景:
线上环境某个 Button 按钮点击触发事件后没有任何反应,或者没有按照预期逻辑去执行。这时候我们会想到,通过浏览器控制台查看该元素所绑定的 onClick 事件回调,找到相关源码并在事件回调中加入 debugger。
但生产环境下代码都是压缩混淆过的,再加上 React 事件机制的链路很长,很难通过浏览器控制台 - Element - Event Listeners 找到元素绑定的真正事件回调。
那么生产环境下如何找到视图上 DOM 元素真正的事件回调?
其实在上面的分析中我们会发现,真正的事件回调都是由 executeDispatch
触发的,而在这个函数中会有一个默认 type unknown-event
:
function executeDispatch(event, listener, currentTarget) {
var type = event.type || 'unknown-event';
event.currentTarget = currentTarget;
listener(event); // listener 是传给 onClick 的真正事件回调,传入合成事件对象,执行回调
event.currentTarget = null;
}
由于字符串值不会被打包压缩,因此我们只需在 Sources 面板中找到 react-dom
资源打包所在的文件,查找关键字 unknown-event
来定位到 executeDispatch
函数的位置,通过在这里打断点,很方便得便能找到元素真实绑定的事件回调。
最后
阅读完本文,相信读者对 React 事件机制有了更进一步的了解。