Skip to main content

事件原理

info

问:React 为什么要有一套自己的事件系统呢?

  1. 为了解决浏览器的兼容性问题
  2. v17 之前,React 的事件都是绑定在 document 上的;v17 之后 React 把事件绑定在应用对应的容器 container 上

阻止冒泡

e.stopPropagation()

阻止默认事件

e.preventDefault()

在 React 中不能通过 return false; 来阻止默认事件,原因是 React 中给元素的事件并不是真正的事件处理函数

事件合成

React 事件合成的概念:React 应用中,元素绑定的事件并不是原生事件,而是 React 合成的事件,比如 onClick 是由 click 合成,onChange 是由 blur ,change ,focus 等多个事件合成

事件插件机制

React 有一种事件插件机制,比如上述 onClick 和 onChange ,会有不同的事件插件 SimpleEventPlugin ,ChangeEventPlugin 处理,有两个重要的对象。

第一个 registrationNameModules

const registrationNameModules = {
onBlur: SimpleEventPlugin,
onClick: SimpleEventPlugin,
onClickCapture: SimpleEventPlugin,
onChange: ChangeEventPlugin,
onChangeCapture: ChangeEventPlugin,
onMouseEnter: EnterLeaveEventPlugin,
onMouseLeave: EnterLeaveEventPlugin,
...
}

registrationNameModules 记录了 React 事件(比如 onBlur )和与之对应的处理插件的映射,应用于事件触发阶段,根据不同事件使用不同的插件。

第二个 registrationNameDependencies

{
onBlur: ['blur'],
onClick: ['click'],
onClickCapture: ['click'],
onChange: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange'],
onMouseEnter: ['mouseout', 'mouseover'],
onMouseLeave: ['mouseout', 'mouseover'],
...
}

这个对象保存了 React 事件和原生事件对应关系,这就解释了为什么上述只写了一个 onChange ,会有很多原生事件绑定在 document 上。在事件绑定阶段,如果发现有 React 事件,比如 onChange ,就会找到对应的原生事件数组,逐一绑定。

事件绑定

事件绑定,就是在 React 处理 props 时候,如果遇到事件比如 onClick ,就会通过 addEventListener 注册原生事件

export default function Index() {
const handleClick = () => console.log("点击事件");
const handleChange = () => console.log("change事件");
return (
<div>
<input onChange={handleChange} />
<button onClick={handleClick}>点击</button>
</div>
);
}

对于如上结构,最后 onChange 和 onClick 会保存在对应 DOM 元素类型 fiber 对象( hostComponent )的 memoizedProps 属性上,如上结构会变成这样。

1

接下来就是 React 根据事件注册事件监听器。

react-dom/src/client/ReactDOMComponent.js

function diffProperties(){
/* 判断当前的 propKey 是不是 React合成事件 */
if(registrationNameModules.hasOwnProperty(propKey)){
/* 这里多个函数简化了,如果是合成事件, 传入成事件名称 onClick ,向document注册事件 */
legacyListenToEvent(registrationName, document;
}
}

diffProperties 函数在 diff props 如果发现是合成事件( onClick ) 就会调用 legacyListenToEvent 函数。注册事件监听器。

react-dom/src/events/DOMLegacyEventPluginSystem.js

function legacyListenToEvent(registrationName,mountAt){
const dependencies = registrationNameDependencies[registrationName]; // 根据 onClick 获取 onClick 依赖的事件数组 [ 'click' ]。
for (let i = 0; i < dependencies.length; i++) {
const dependency = dependencies[i];
// addEventListener 绑定事件监听器
...
}
}

这个就是应用上述 registrationNameDependencies 对 React 合成事件,分别绑定原生事件的事件监听器。比如发现是 onChange ,那么取出 ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange'] 遍历绑定。

那么绑定在 document 的事件处理函数是如上写的 handleChange,handleClick 吗?

答案是否定的,绑定在 document 的事件,是 React 统一的事件处理函数 dispatchEvent ,React 需要一个统一流程去代理事件逻辑,包括 React 批量更新等逻辑。

只要是 React 事件触发,首先执行的就是 dispatchEvent ,那么 dispatchEvent 是如何知道是什么事件触发的呢?实际在注册的时候,就已经通过 bind ,把参数绑定给 dispatchEvent 了。

比如绑定 click 事件

const listener = dispatchEvent.bind(null, "click", eventSystemFlags, document);
/* TODO: 重要, 这里进行真正的事件绑定。*/
document.addEventListener("click", listener, false);

事件触发

一次点击事件

export default function Index() {
const handleClick1 = () => console.log(1);
const handleClick2 = () => console.log(2);
const handleClick3 = () => console.log(3);
const handleClick4 = () => console.log(4);
return (
<div onClick={handleClick3} onClickCapture={handleClick4}>
<button onClick={handleClick1} onClickCapture={handleClick2}>
点击
</button>
</div>
);
}

如果上述点击按钮,触发点击事件,那么在 React 系统中,整个流程会是这个样子的:

第一步:批量更新

首先上面讲到执行 dispatchEvent ,dispatchEvent 执行会传入真实的事件源 button 元素本身。通过元素可以找到 button 对应的 fiber ,fiber 和原生 DOM 之间是如何建立起联系的呢?

React 在初始化真实 DOM 的时候,用一个随机的 key internalInstanceKey 指针指向了当前 DOM 对应的 fiber 对象,fiber 对象用 stateNode 指向了当前的 DOM 元素。

2

react-dom/src/events/ReactDOMUpdateBatching.js
export function batchedEventUpdates(fn, a) {
isBatchingEventUpdates = true; //打开批量更新开关
try {
fn(a); // 事件在这里执行
} finally {
isBatchingEventUpdates = false; //关闭批量更新开关
}
}

第二步:合成事件源

接下来会通过 onClick 找到对应的处理插件 SimpleEventPlugin ,合成新的事件源 e ,里面包含了 preventDefault 和 stopPropagation 等方法。

第三步:形成事件执行队列

在第一步通过原生 DOM 获取到对应的 fiber ,接着会从这个 fiber 向上遍历,遇到元素类型 fiber ,就会收集事件,用一个数组收集事件

  • 如果遇到捕获阶段事件 onClickCapture ,就会 unshift 放在数组前面。以此模拟事件捕获阶段。

  • 如果遇到冒泡阶段事件 onClick ,就会 push 到数组后面,模拟事件冒泡阶段。

  • 一直收集到最顶端 app ,形成执行队列,在接下来阶段,依次执行队列里面的函数。

while (instance !== null) {
const { stateNode, tag } = instance;
if (tag === HostComponent && stateNode !== null) {
/* DOM 元素 */
const currentTarget = stateNode;
if (captured !== null) {
/* 事件捕获 */
/* 在事件捕获阶段,真正的事件处理函数 */
const captureListener = getListener(instance, captured); // onClickCapture
if (captureListener != null) {
/* 对应发生在事件捕获阶段的处理函数,逻辑是将执行函数unshift添加到队列的最前面 */
dispatchListeners.unshift(captureListener);
}
}
if (bubbled !== null) {
/* 事件冒泡 */
/* 事件冒泡阶段,真正的事件处理函数,逻辑是将执行函数push到执行队列的最后面 */
const bubbleListener = getListener(instance, bubbled); //
if (bubbleListener != null) {
dispatchListeners.push(bubbleListener); // onClick
}
}
}
instance = instance.return;
}

那么如上点击一次按钮,4 个事件执行顺序是这样的:

  • 首先第一次收集是在 button 上,handleClick1 冒泡事件 push 处理,handleClick2 捕获事件 unshift 处理。形成结构 [ handleClick2 , handleClick1 ]

  • 然后接着向上收集,遇到父级,收集父级 div 上的事件,handleClick3 冒泡事件 push 处理,handleClick4 捕获事件 unshift 处理。[handleClick4, handleClick2 , handleClick1,handleClick3 ]

  • 依次执行数组里面的事件,所以打印 4 2 1 3。

3

React 如何模拟阻止事件冒泡

看一下事件队列是怎么执行的。

legacy-events/EventBatching.js
function runEventsInBatch() {
const dispatchListeners = event._dispatchListeners;
if (Array.isArray(dispatchListeners)) {
for (let i = 0; i < dispatchListeners.length; i++) {
if (event.isPropagationStopped()) {
/* 判断是否已经阻止事件冒泡 */
break;
}
dispatchListeners[i](event); /* 执行真正的处理函数 及handleClick1... */
}
}
}