跳到主要内容

认识

2024年01月25日
柏拉文
越努力,越幸运

一、认识


React.js 16.x 顶层容器为 Document。因此, React 执行大多数事件都会调用 document.addEventListener()。触发事件后, 事件流为: Document 捕获 -> DOM 原生捕获 -> DOM 原生冒泡 -> React 捕获 -> React 冒泡 -> Document 冒泡

React.js 16.x 捕获阶段和冒泡阶段的事件都是模拟的,本质上都是在冒泡阶段执行的。一定程度上,不符合事件流的执行时机。

二、对比


2.1 事件绑定位置

原生 DOM 将事件绑定在当前元素上

React 将事件函数存储到当前元素对应的 Fiber 中的 memoizedPropspendingProps

2.2 事件执行顺序

React.js 16.x 把事件委托到Document对象上。触发事件之后, 事件流为: Document 捕获 -> DOM 原生捕获 -> DOM 原生冒泡 -> React 捕获 -> React 冒泡 -> Document 冒泡

import React from "react";
import ReactDOM from "react-dom";

class App extends React.Component {
parentRef = React.createRef();
childRef = React.createRef();
componentDidMount() {
this.parentRef.current.addEventListener(
"click",
() => {
console.log("父元素原生捕获");
},
true
);
this.parentRef.current.addEventListener(
"click",
() => {
console.log("父元素原生冒泡");
},
false
);
this.childRef.current.addEventListener(
"click",
() => {
console.log("子元素原生捕获");
},
true
);
this.childRef.current.addEventListener(
"click",
() => {
console.log("子元素原生冒泡");
},
false
);
document.addEventListener(
"click",
() => {
console.log("document 元素捕获");
},
true
);
document.addEventListener(
"click",
() => {
console.log("document 元素冒泡");
},
false
);
}
parentBubble = () => {
console.log("父元素 React 事件冒泡");
};
parentCapture = () => {
console.log("父元素 React 事件捕获");
};
childBubble = () => {
console.log("子元素 React 事件冒泡");
};
childCapture = () => {
console.log("子元素 React 事件捕获");
};
render() {
return (
<div
ref={this.parentRef}
onClick={this.parentBubble}
onClickCapture={this.parentCapture}
>
<div
ref={this.childRef}
onClick={this.childBubble}
onClickCapture={this.childCapture}
>
事件执行顺序
</div>
</div>
);
}
}

ReactDOM.render(<App />, document.getElementById("root"));

点击按钮,执行结果如下:

document 捕获
父元素原生捕获
子元素原生捕获
子元素原生冒泡
父元素原生冒泡
父元素React事件捕获
子元素React事件捕获
子元素React事件冒泡
父元素React事件冒泡
document 冒泡

由上可以看出: React注册的捕获/冒泡事件,不是在冒泡/捕获阶段执行的, 给元素绑定的事件,不是真正的事件处理函数

2.3 阻止默认事件

React 中,我们的所有事件都可以说是虚拟的,并不是原生的事件。我们在 React 中拿到的事件源(e) 也并非是真正的事件e,而是经过React单独处理的e

  1. 原生事件: 可以通过e.preventDefault()return false来阻止默认事件

  2. 合成事件: 通过e.preventDefault()阻止默认事件, 由于 React 注册的事件处理函数不是真正的处理函数, 所以不可以通过 return false 来组织默认事件。特别注意, 原生事件和合成事件的 e.preventDefault() 并非是同一个函数,React的事件源 e 是单独创立的。

2.3 阻止事件冒泡

阻止合成事件间的冒泡: e.stopPropagation()

阻止合成事件与最外层 document 上的事件间的冒泡: e.nativeEvent.stopImmediatePropagation()

阻止合成事件与除最外层 document 上的原生事件上的冒泡: 通过判断e.target来避免

document.body.addEventListener('click', e => {   
if (e.target && e.target.matches('div.code')) {
return;
}
this.setState({ active: false, }); });
}

三、事件池


React.js 16.x 中采取了一个事件池的概念,每次我们用的事件源对象,在事件函数执行之后,可以通过 releaseTopLevelCallbackBookKeeping 等方法将事件源对象释放到事件池中,这样的好处每次我们不必再创建事件源对象,可以从事件池中取出一个事件源对象进行复用,在事件处理函数执行完毕后,会释放事件源到事件池中,清空属性。

测试用例如下:

handerClick = (e) => {
console.log(e.target) // button
setTimeout(()=>{
console.log(e.target) // null
},0)
}

这就是 setTimeout 中打印为什么是 null 的原因了。

四、绑定事件


4.1 存储事件

React.js 中, 通过 JSX 转换, 元素注册的 onClickonChange 事件会存储到对应元素 fibermemoizedPropspendingProps 属性上。

4.2 判断事件

React 处理 props 时, 会判断当前的 propKey 是否为 React 合成事件。 判断方式就是通过判断 propKey 是否在 registrationNameModules 中。判断逻辑如下:

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

registrationNameModules 记录了 React 事件(比如 onBlur )和与之对应的处理插件的映射。

4.3 注册事件

接着, 如果判断是合成事件, React 通过 legacyListenToEvent 传入合成事件名称, 向顶层容器 document 注册事件。注册逻辑如下:

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];
legacyTrapBubbledEvent(dependency, mountAt);
}
}

registrationNameDependencies 对象保存了 React 事件和原生事件对应关系。取出合成事件对应的原生事件数组, 遍历事件数组, 调用 legacyListenToTopLevelEvent 判断事件是否进行冒泡处理。React.js 16.x 大部分事件都按照冒泡处理,少数事件会按照捕获逻辑处理(比如 scrollfocusblur 事件)。

function legacyTrapBubbledEvent(topLevelType,element){
addTrappedEventListener(element,topLevelType,PLUGIN_EVENT_SYSTEM,false)
}

4.4 绑定容器

React 是如何绑定事件到 document ? 事件处理函数函数又是什么?问题都指向了上述的 addTrappedEventListener

function addTrappedEventListener(targetContainer,topLevelType,eventSystemFlags,capture){
const listener = dispatchEvent.bind(null,topLevelType,eventSystemFlags,targetContainer)
if(capture){
// 事件捕获阶段处理函数。
}else{
targetContainer.addEventListener(topLevelType,listener,false) // document.addEventListener('click',listener,false)
}
}

如上所示, 所有事件都会绑定到 dispatchEvent 对象上。最后, dispatchEvent 会作为真正的事件处理函数。

五、触发事件


5.1 调度事件

React.js 16.x 事件注册时候, 将事件统一绑定到 dispatchEvent。也就是当我们触发事件后,首先执行的是 dispatchEvent 函数。执行 attemptToDispatchEvent 尝试调度事件。根据真实的事件源对象, 找到 e.target 真实的 DOM 元素。然后根据 DOM 元素找到与它对应的 Fiber。 (React 在初始化真实 dom 的时候,用一个随机的 key internalInstanceKey 指针指向了当前 dom 对应的 fiber 对象,fiber 对象用 stateNode 指向了当前的 dom 元素。)。然后正式进去 legacy 模式的事件处理系统,

function dispatchEvent(topLevelType,eventSystemFlags,targetContainer,nativeEvent){
/* 尝试调度事件 */
const blockedOn = attemptToDispatchEvent( topLevelType,eventSystemFlags, targetContainer, nativeEvent);
}

function attemptToDispatchEvent(topLevelType,eventSystemFlags,targetContainer,nativeEvent){
/* 获取原生事件 e.target */
const nativeEventTarget = getEventTarget(nativeEvent)
/* 获取当前事件,最近的dom类型fiber ,我们 demo中 button 按钮对应的 fiber */
let targetInst = getClosestInstanceFromNode(nativeEventTarget);
/* 重要:进入legacy模式的事件处理系统 */
dispatchEventForLegacyPluginEventSystem(topLevelType,eventSystemFlags,nativeEvent,targetInst,);
return null;
}

5.2 批量更新

legacy 模式下, 从 React 事件池中找到对应属性, 赋予给事件, 随后开启批量更新, 执行真正的事件处理函数 handleTopLevel, 最后释放事件池。

/* topLevelType - click事件 | eventSystemFlags = 1 | nativeEvent = 事件源对象  | targetInst = 元素对应的fiber对象  */
function dispatchEventForLegacyPluginEventSystem(topLevelType,eventSystemFlags,nativeEvent,targetInst){
/* 从React 事件池中取出一个,将 topLevelType ,targetInst 等属性赋予给事件 */
const bookKeeping = getTopLevelCallbackBookKeeping(topLevelType,nativeEvent,targetInst,eventSystemFlags);
try { /* 执行批量更新 handleTopLevel 为事件处理的主要函数 */
batchedEventUpdates(handleTopLevel, bookKeeping);
} finally {
/* 释放事件池 */
releaseTopLevelCallbackBookKeeping(bookKeeping);
}
}
export function batchedEventUpdates(fn,a){
isBatchingEventUpdates = true;
try{
fn(a) // handleTopLevel(bookKeeping)
}finally{
isBatchingEventUpdates = false
}
}

由上可以看出, 触发事件后, 最终会走到 batchedEventUpdates 函数,进而开启批量更新, 因此 React 合成事件 具有批量更新的功能。示例如下:

state={number:0}
handerClick = () =>{
this.setState({number: this.state.number + 1 })
console.log(this.state.number) //0
this.setState({number: this.state.number + 1 })
console.log(this.state.number) //0
setTimeout(()=>{
this.setState({number: this.state.number + 1 })
console.log(this.state.number) //2
this.setState({number: this.state.number + 1 })
console.log(this.state.number)// 3
})
}

如上述所示,第一个 setState 和第二个setState在批量更新条件之内执行,所以打印不会是最新的值,但是如果是发生在setTimeout中,由于eventLoop放在了下一次事件循环中执行,此时 batchedEventUpdates 中已经执行完isBatchingEventUpdates = false,所以批量更新被打破,我们就可以直接访问到最新变化的值了。

5.3 执行事件插件函数

batchedEventUpdates 中的 fn(a) 调用的是 handleTopLevel

function handleTopLevel(bookKeeping){
const { topLevelType,targetInst,nativeEvent,eventTarget, eventSystemFlags} = bookKeeping
for(let i=0; i < plugins.length;i++ ){
const possiblePlugin = plugins[i];
/* 找到对应的事件插件,形成对应的合成event,形成事件执行队列 */
const extractedEvents = possiblePlugin.extractEvents(topLevelType,targetInst,nativeEvent,eventTarget,eventSystemFlags)
}
if (extractedEvents) {
events = accumulateInto(events, extractedEvents);
}
/* 执行事件处理函数 */
runEventsInBatch(events);
}

handleTopLevel 最后的处理逻辑就是执行我们说的事件处理插件(SimpleEventPlugin)中的处理函数extractEventsextractEvents 可以作为整个事件系统核心函, 作用如下:

  1. 生成事件源对象, 保存了整个事件的信息。将作为参数传递给真正的事件处理函数

  2. 从事件源开始逐渐向上,查找元素类型为 HostComponent 对应的 fiber ,收集上面的 React 合成事件, 直到顶层容器。在事件捕获阶段, 将事件执行函数 unshift 添加到队列的最前面, 模拟事件捕获阶段。在事件冒泡阶段, 将事件执行函数 push 添加到队列的最后面, 模拟事件冒泡阶段。

  3. 最后将事件执行队列,保存到 React 事件源对象上。等待执行。

5.4 执行事件队列

依次执行执行队列, 如果发现阻止冒泡,那么 break 跳出循环,最后重置事件源,放回到事件池中,完成整个流程。