1. O que é um evento sintético
No React, se quisermos vincular um evento a um div
rótulo , adicionaremos um onClick
atributo a ele e ele aceitará uma função de retorno de chamada. Quando acionamos um evento de clique, apenas essa função de retorno de chamada é executada. Na verdade, onClick
é isso que fazemos chamar um evento sintético. Seu evento sonoro original correspondente é click
, semelhante a ele, existem onChange
eventos , seus eventos originais correspondentes são blur
, change
, input
, keydown
, e assim keyup
por diante.
Agora que sabemos que os eventos sintéticos do React realmente correspondem a um ou mais eventos nativos, o método de vinculação dos eventos sintéticos é o mesmo que os eventos nativos? Primeiro, veja como o evento nativo está vinculado:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div onclick="handleClick()">click</div>
</body>
</html>
<script>
function handleClick() {
console.log('我被点击了~')
}
</script>
复制代码
div
Vinculamos um click
evento e vemos como ele é refletido no DOM.
Aqui podemos ver que nosso evento está realmente vinculado ao DOM real, vamos ver como ele se reflete no React:
import React from 'react';
function App() {
const handleClick = () => {
console.log("我被点击了~")
}
return (
<div className="App">
<span onClick={handleClick}>click</span>
</div>
);
}
export default App;
复制代码
Aqui nós span
vinculamos um onClick
evento a Finalmente , descobriremos que o manipulador é na verdade uma função vazia, então para onde nosso evento foi? Alunos cuidadosos descobrirão que nossos eventos já estão vinculados a doucument
on , e quando removemos o onClick
evento, doucument
o click
evento também desaparece, para que possamos concluir que os eventos sintéticos do React eventualmente serão vinculados a doucument
.
2. Por que o React faz eventos sintéticos?
Vendo isso, tenho uma dúvida. Como o efeito de eventos sintéticos e eventos nativos é semelhante, ou seja, a função callback pode ser chamada quando o usuário aciona o evento correspondente, então por que o React gasta tanta energia para escrever um novo evento sistema? Acho que são vários motivos:
- Suavize as diferenças entre diferentes navegadores
React 作为一个优秀的前端框架,首先当然需要去兼容不同的浏览器,但是不同的浏览器的事件系统可能会存在一定的差异,比如 event 对象在 IE 和 chrome 是不同的,所以 React 为了能够达到兼容的目的就需要去抹平这一差异,这样对于开发者来说我们只需要使用 React 给我们提供的事件而不需要去担心不同浏览器的兼容问题。
- 避免频繁的对 DOM 进行事件的绑定和解绑
在原生事件当中我们有事件委托
的概念,即通过给父元素绑定事件去避免给每一个子元素都绑定相同的事件,在 React 中也很好的践行了这一理念,只不过他不是在父元素上去绑定事件而是在 doucument 上去绑定,这样我们在 diff 算法中对新增/删除的节点就不需要频繁的去调用 addEventListener
和removeEventListener
,造成性能的浪费,我们只需要在document上做监听,然后根据 event 中的 target 来判断事件触发的结点。
- 避免频繁的创建和销毁事件对象
React 采用了事件池的思想,每次执行事件函数时可以对事件池中的事件对象进行复用,在事件处理函数执行完毕之后会清空事件对象上相应的属性,也就达到避免频繁的创建和销毁事件对象的目的。
- 批量更新
React 批量更新在16和17中采用的是不同的机制,在 Ract16 中采用的是 transction 事件机制,而在 React17 中采用的是 lane 模型,下面以展示的是 React16 的批量更新机制:
batchedEventUpdates(fn,a){
isBatchingEventUpdates = true;
try{
fn(a)
}finally{
isBatchingEventUpdates = false
}
}
复制代码
React 通过变量 isBatchingEventUpdates
来决定当前是否处于批量更新的流程中,而我们的时间函数最终也会在 batchedEventUpdates
中被执行,当我们在事件函数中执行 setState 时就会走批量更新的流程,从而避免多次渲染的问题。这里稍微拓展下,假如我的事件函数长这样,会出现什么问题呢?
class App extends React.Component {
constructor(){
super();
this.state = {
count:0
}
}
render() {
return (
<div onClick={
() => {
this.setState({count:1}) // 0
console.log(this.state.count);
this.setState({count:2}) // 0
console.log(this.state.count);
setTimeout(() => {
this.setState({count:3}) // 3
console.log(this.state.count);
this.setState({count:4}) // 4
console.log(this.state.count);
})
}
}>
click
</div>
)
}
}
复制代码
当我们在 setTimeout 中执行 setState 则不会进入批量更新的流程,因为 transcation 机制是同步的,而 setTimeout 是异步执行的当执行回调函数时已经不在 transcation 的管辖范围之内了,这个问题在 React17 中得到了解决,因为在 React17 中使用了 lane 模型,他里面规定假如两次更新的 lane 值相同那么就会将这两个更新合并成一次,那么什么情况下 lane 会相同呢?当这些更新都在同一个事件函数中被触发时他们的 lane 是相同的,所以上面输出应该是 0 0 2 2,这只是一种情况,后面会出一篇关于 lane 模型的文章。
“当这些更新都在同一个事件函数中被触发时他们的 lane 是相同的”这句话说得不是很准确,因为不同的事件函数中的更新的 lane 也可能相同,比如我再添加一个 setTimeout,里面的更新也会和前一个 setTineout 的更新合并,这里要深入的话就得和时间切片联系起来,这里就不展开了。
三、事件绑定
以下源码来自 react-16.13.1
1、前置知识
在讲事件绑定之前我们首先要知道我们传给 span 的事件函数最终去哪了,先看我们的 App 组件:
function App() {
const handleClick = () => {
console.log("我被点击了~")
}
return (
<div className="App">
<span onClick={handleClick}>click</span>
</div>
);
}
复制代码
这里是我们的 JSX 代码,这些代码经过 babel
编译之后会变成什么,来看看结果:
经过babel
转换后会变成React.createElement
形式,这是 React16 的转换方式,在 React17 中引入了 JSX 运行时,也就是说我们写组件时不需要手动引入 React 了,React.createElement
的运行结果就是对应的 JSX 对象,如下图所示: 我们的事件函数就保存在这个对象的 props 中,我们知道在 React16 中引入了 fiber 的概念,每一个节点对应一个 fiber,而这些 fiber 节点就是根据 React.createElement
生成的 JSX 对象创建而来的(diff 算法中的也是将 fiber 树和 JSX 对象进行对比),所以 props 上的数据最终会被保存进 fiber 节点,如图所示: 现在我们知道事件函数最终会被保存进 pendingProps
,那么我么可以想一想在赋值的时候我们能不能去检查一下 props 中的事件,如果是合成事件我们就可以直接给 document 去注册事件,没错 React 就是这么干的,这件事就发生在 React 的 completeWork
流程中,然后调用 legacyListenToEvent
方法去注册事件。
completeWork 是什么?
completeWork 是一个函数,他会在 render 阶段被执行,它的作用我们简单了解下就行:
1、为 fiber 节点生成对应的 DOM 节点,即 stateNode。
2、将他的子孙节点的 DOM 插入刚生成的 DOM 中。
3、处理 props 中的属性。
我们可以简单的看一下他们的调用栈
2、legacyListenToEvent 注册事件监听器
// registrationName -> onClick 事件
// mountAt -> document
function legacyListenToEvent(registrationName, mountAt) {
// 已经注册的事件 map 表
var listenerMap = getListenerMapForElement(mountAt);
// 根据 onClick 获取 onClick 依赖的事件数组 [ 'click' ],对于 onChange事件来说他的
// 依赖数组为[`blur`、`change`、`input`、`keydown`、`keyup`]。
var dependencies = registrationNameDependencies[registrationName];
// 将每一个事件都注册到 document 上,如果已经注册了的就不用再注册了
for (var i = 0; i < dependencies.length; i++) {
var dependency = dependencies[i];
legacyListenToTopLevelEvent(dependency, mountAt, listenerMap);
}
}
复制代码
在 legacyListenToEvent
函数中,先找到 React
合成事件对应的原生事件集合,比如 onClick -> ['click'] , onChange -> ['blur' , 'change' , 'input' , 'keydown' , 'keyup'],然后遍历依赖项的数组,绑定事件。
3、legacyListenToTopLevelEvent
function legacyListenToTopLevelEvent(topLevelType, mountAt, listenerMap) {
if (!listenerMap.has(topLevelType)) {
switch (topLevelType) {
case TOP_SCROLL:
// 绑定捕获事件
trapCapturedEvent(TOP_SCROLL, mountAt);
break;
//.....省略
default:
var isMediaEvent = mediaEventTypes.indexOf(topLevelType) !== -1;
if (!isMediaEvent) {
// 绑定冒泡事件
trapBubbledEvent(topLevelType, mountAt);
}
break;
}
listenerMap.set(topLevelType, null);
}
}
复制代码
在 legacyListenToTopLevelEvent
函数中会根据 topLevelType
区分是捕获事件还是冒泡事件,像 click 这样的就属于冒泡事件,scroll、focus、blur等都属于捕获事件。
4、trapBubbledEvent / trapCapturedEvent
function trapBubbledEvent(topLevelType, element) {
trapEventForPluginEventSystem(element, topLevelType, false);
}
function trapCapturedEvent(topLevelType, element) {
trapEventForPluginEventSystem(element, topLevelType, true);
}
function trapEventForPluginEventSystem(container, topLevelType, capture) {
var listener;
// 根据事件的优先级调用不同的 dispatch 函数,但是最终都会去执行 dispatchEvent。
switch (getEventPriorityForPluginSystem(topLevelType)) {
case DiscreteEvent:
listener = dispatchDiscreteEvent.bind(null, topLevelType, PLUGIN_EVENT_SYSTEM, container);
break;
case UserBlockingEvent:
listener = dispatchUserBlockingUpdate.bind(null, topLevelType, PLUGIN_EVENT_SYSTEM, container);
break;
case ContinuousEvent:
default:
listener = dispatchEvent.bind(null, topLevelType, PLUGIN_EVENT_SYSTEM, container);
break;
}
var rawEventName = getRawEventName(topLevelType);
// 如果是捕获事件
if (capture) {
addEventCaptureListener(container, rawEventName, listener);
} else {
addEventBubbleListener(container, rawEventName, listener);
}
}
复制代码
从这里我们知道 trapBubbledEvent
和 trapCapturedEvent
最终都会调用 trapEventForPluginEventSystem
函数,在 trapEventForPluginEventSystem
中做了两件事,一是根据事件的优先级去生成不同的监听器,二是将监听器作为捕获或者冒泡事件绑定在 document
上,这样我们就完成了我们的事件绑定流程。
四、事件触发
1、前置知识
namesToPlugins
const namesToPlugins = {
SimpleEventPlugin,
EnterLeaveEventPlugin,
ChangeEventPlugin,
SelectEventPlugin,
BeforeInputEventPlugin,
}
复制代码
这些插件的作用就是为他对应的事件生成一个合成的事件对象,比如 click 事件就对应 SimpleEventPlugin
。
plugins
plugins 就是 namesToPlugins
的数组表示方式,方便后面调用。
const plugins = [LegacySimpleEventPlugin, LegacyEnterLeaveEventPlugin, ...];
复制代码
registrationNameModules
registrationNameModules
记录了React合成的事件对应的事件插件的关系。
{
onBlur: SimpleEventPlugin,
onClick: SimpleEventPlugin,
onClickCapture: SimpleEventPlugin,
onChange: ChangeEventPlugin,
onChangeCapture: ChangeEventPlugin,
onMouseEnter: EnterLeaveEventPlugin,
...
}
复制代码
上面已经介绍完事件的绑定流程,那当我们去点击触发 onClick 事件会发生什么呢?还是老规矩我们先看一下调用栈,了解整个执行流程:
2、dispatchEvent
当我们触发 onClick
事件时,click
事件将会最终冒泡至document,并触发我们监听在document 上的监听器 dispatchEvent
,dispatchEvent
的前三个参数我们已经通过 bind 传进去了,此时我们只需要将第四个参数 nativeEvent
串进去就行,这个参数就是事件源的事件对象。
当前的 target 就是 span 元素。
3、attemptToDispatchEvent
function attemptToDispatchEvent(topLevelType, eventSystemFlags, container, nativeEvent) {
// 从原生事件对象中获取 target
var nativeEventTarget = getEventTarget(nativeEvent);
// 获取 target 对应的 fiber 节点
var targetInst = getClosestInstanceFromNode(nativeEventTarget);
// .... 一些兼容处理
dispatchEventForLegacyPluginEventSystem(topLevelType, eventSystemFlags, nativeEvent, targetInst);
}
复制代码
这个函数主要做了三件事:
- 从原生事件对象中获取对应的 target。
- 根据 target 获取该元素对应的 fiber 节点。
- 进入批量更新流程,也就是
batchedEventUpdates
。
4、runExtractedEventsInBatch
进入 batchedEventUpdates
流程后首先会调用他的回调函数, 即handleTopLevel
函数,在 handleTopLevel
函数中会调用 runExtractedPluginEventsInBatch
,这是React事件处理最重要的函数,下面看看他做什么?
function runExtractedPluginEventsInBatch(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
// 模拟捕获冒泡收集需要执行的回调函数
var events = extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);
// 遍历回调函数模拟捕获冒泡
runEventsInBatch(events);
}
复制代码
这里主要介绍下 extractPluginEvents
函数的作用,在这个函数中主要他会调用我们的 plugin 生成被触发的事件的事件对象,然后按照冒泡的方式去收集对应的回调函数,比如说 SimpleEventPlugin
这个插件,他就会沿着 fiber 链向上遍历,遇到 onClick
、onClickCapture
等事件就会将他们的回调函数收集起来存放到事件对象中,即extractedEvents
,最终将这些事件对象合并到 events 中。
function extractPluginEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags) {
var events = null;
// 遍历所有的 plugin
for (var i = 0; i < plugins.length; i++) {
var possiblePlugin = plugins[i];
if (possiblePlugin) {
// 调用 plugin 上的 extractEvents 方法生成对应的事件对象,并按照冒泡的形式也就是从当前 fiber 节点向上遍历去收集所属这个插件的同时会被触发的事件的回调函数
var extractedEvents = possiblePlugin.extractEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget, eventSystemFlags);
if (extractedEvents) {
// 将生成的事件对象合并到 events 中
events = accumulateInto(events, extractedEvents);
}
}
}
return events;
}
复制代码
下面举一个例子:
import React from 'react';
function App() {
const handleClick1 = () => {
debugger;
console.log("我被点击了1")
}
const handleClick2 = () => {
debugger;
console.log("我被点击了2")
}
const handleClick3 = () => {
debugger;
console.log("我被点击了3")
}
const handleClick4 = () => {
debugger;
console.log("我被点击了4")
}
const handleClick5 = () => {
debugger;
console.log("我被点击了5")
}
const handleClick6 = () => {
debugger;
console.log("我被点击了6")
}
return (
<div className="App" onClick={handleClick6} onClickCapture={handleClick1}>
<div onClick={handleClick5} onClickCapture={handleClick2}>
<span onClick={handleClick4} onClickCapture={handleClick3}>click</span>
</div>
</div>
);
}
export default App;
复制代码
当我们点击 span 标签后生成的 events 如下:
最主要的就是这两个变量,他们存储了当前 fiber(span) 节点及父节点、父父级节点、...的所有的回调函数,最终就可以模拟捕获和冒泡过程。
5、executeDispatchesInOrder
function executeDispatchesInOrder(event) {
var dispatchListeners = event._dispatchListeners;
var dispatchInstances = event._dispatchInstances;
if (Array.isArray(dispatchListeners)) {
// 从前往后遍历,先执行捕获的回调函数,在执行冒泡的回调函数
for (var i = 0; i < dispatchListeners.length; i++) {
// 如果阻止冒泡则直接退出
if (event.isPropagationStopped()) {
break;
}
// 执行回调函数
executeDispatch(event, dispatchListeners[i], dispatchInstances[i]);
}
} else if (dispatchListeners) {
executeDispatch(event, dispatchListeners, dispatchInstances);
}
event._dispatchListeners = null;
event._dispatchInstances = null;
}
复制代码
这个函数会遍历我们的队列,先执行捕获的回调函数再执行冒泡的回调函数,同时 React 对于阻止冒泡就是通过 isPropagationStopped来判断的,如果在某一个函数中调用e.stopPropagation()
,就会赋值给isPropagationStopped=()=>true
,当再执行 e.isPropagationStopped()
就会返回 true
,接下来事件处理函数,就不会执行了。
五、React17 事件系统
React17 针对 React16 种存在的问题进行了修复,主要的改动点如下:
1、将事件绑定在 container 上
React17 将事件统一绑定 container 上,这个 container 指的就是 render 函数的第二个参数。
这样好处是有利于微前端的,微前端一个前端系统中可能有多个应用,如果继续采取全部绑定在document
上,那么可能多应用下会出现问题,比如比较流行的 qiankun 框架,它里面使用了shadow-dom 对子应用进行隔离,他会对子应用的事件源进行重定向,下面是官网的解释。
2、取消事件池
React 17
取消事件池复用,因为 react16 中的事件池在一些情况下会出现一些问题,比如下面这个例子:
const handleClick = (e) => {
console.log(e);
setTimeout(() => {
console.log(e);
});
}
复制代码
打印出的结果如下:
很明显这两个输出是不一样的,因为 setTimeout
执行时事件池中的事件对象已经被清理了。