小前端读源码 - React16.7.0(合成事件)

2022-09-26 10:39:43 浏览数 (1)

。在上一篇文章中,我们说到了setState的过程。但是在阅读的过程中,发现setState的很多东西是依赖着合成事件去对我们的事件做一个分发处理和批量更新的。所以这篇文章就是以搞懂合成事件为目的对源码进行阅读的。

Lam:小前端读源码 - React16.7.0(深入了解setState)

官方对合成事件的一些说明:

SyntheticEvent – Reacthttps://reactjs.org/docs/handling-events.html

Handling Events – React

Handling Events – React


首先我们要知道合成事件的出现是为了解决什么问题的?

在我们面试的时候,有时候会被问道一些前端的优化问题,那么事件委派就是其中一个性能优化的方法。例如一个列表有1000个原生,每一个元素都都需绑定点击事件,那么就需要绑定1000个事件。这样对性能和内存都是非常大的开销,那么解决方式就是通过事件委派的方式,将事件都绑定在他们共同的父级元素上,由事件冒泡到父级元素去触发事件,并在父级元素触发事件的时候去确认触发事件的原始元素是什么,从而执行不同的行为。其实合成事件也是如此!

合成事件会将所有我们在jsx中编写的事件进行拦截,并进行一些封装变成一个React的事件,最终只会绑定一个事件到document元素中,通过事件冒泡的方式传递到绑定到document的统一事件进行分发。

下面我们将分成两打章节进行阅读:

  1. JSX的事件如何绑定到React的事件系统?
  2. 合成事件如何触发?

整篇文章都会基于以下DEMO:

代码语言:javascript复制
class App extends React.Component {
    constructor() {
        super();
        this.state = {
            data: 1
        }
    }
    render() {
        console.log('---render App---')
        return (
            <div>
                <p>text</p>
                <button onClick={() => {this.setState({data: 2})}}>setState</button>
            </div>
        )
    }
}

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

JSX的事件如何绑定到React的事件系统?

在一开始我们就知道React会将组件中的onClick这一类的事件都绑定在了document上,但是是什么时候进行查询这一些对应的事件参数并将他们绑定到document上的?

同代码我们追寻到合成事件的绑定是从completeWork函数中开始的。在completeWork中有执行一个函数finalizeInitialChildren,在finalizeInitialChildren中会执行一些函数。重点是从setInitialDOMProperties函数开始一步一进行绑定,下面列出一些比较重要的函数以及做了什么。

  1. setInitialDOMProperties:循环当前组件的props属性,根据循环到的props属性进行不同的操作(dangerouslySetInnerHTML,children,style等等)。在循环最后的判断当前属性是不是一个事件。

2. ensureListeningTo:当如果判断到props中的属性是一个合法事件,将会进入ensureListeningTo函数中,ensureListeningTo函数的作用就是找到document对象。

3. listenTo:检查document中是否有绑定过同类事件。如果没有将会进入trapBubbledEvent函数进行绑定,否则跳过。

isListening就是当前document没有生成过绑定事件的记录对象,如果没有将会创建一个。并且会将绑定过的事件都存在alreadyListeningTo的一个全局对象中。

最后绑定完成之后就会将对应的事件的值改为true,防止重复绑定。

4. trapBubbledEvent:提取dispatchEvent函数,将document、dispatchEvent和事件名称传入addEventBubbleListener进行绑定事件。

需要注意的是,绑定事件之前会通过isInteractiveTopLevelEventType函数检测当前事件类型是否React支持的事件类型,如果当前的事件并不是React配置中所处理的事件,那么将会直接绑定dispatchEvent,否则绑定的将会是dispatchInteractiveEvent。区别在于dispatchEvent不会异步setStatedispatchInteractiveEvent会异步setState。

5. addEventBubbleListener:绑定事件到document中。listener就是dispatchEvent

到这里就是组件初始化的时候绑定每个组件中的事件到document中。当然我们现在用的button元素,如果我们使用其他元素,例如select,input之类的,那么将会有不一样的绑定方式,具体可以去看看listenTo函数。

但是我们发现整个绑定事件中,并没有把事件的回调函数保存起来,只是单单把所有用到的事件类型都绑定到document中,并且都是调用将所有事件的触发都会调用dispatchEvent函数。带着疑问去查阅了一些文章,发现其他文章中有提到的一段代码:

代码语言:javascript复制
......
listenTo(registrationName, doc);
// 绑定事件回调函数
transaction.getReactMountReady().enqueue(putListener, {
  inst: inst,
  registrationName: registrationName,
  listener: listener
});

但是怎么也没有找到这样的一段代码,listenTo函数之后就没有东西了。反复看了几遍执行的过程并没有发现。

大大的黑人疑问表情!!!

那么就可以确定应该是源码有所修改,难道是因为使用了Fiber架构后,对于合成事件的绑定也做了修改吗?带着疑问继续阅读合成事件的触发流程以及是如何找到对应的事件回调函数的。


合成事件触发流程

从上面的DEMO中,我们在渲染的button元素上,绑定了onClick属性。在渲染的时候将对应的事件绑定到了document元素上,做了一个事件委派。但是并没有将回调函数绑定上去,而是仅仅将触发的事件类型和dispatchEvent绑定到了document元素上而已。接下来我们尝试点击button按钮,尝试触发onClick,看看React的dispatchEvent是怎么帮我们找到对应的事件回调函数的。

dispatchEvent/dispatchInteractiveEvent

在点击的时候会触发dispatchEvent或者dispatchInteractiveEvent这个函数,我们看看里面到底执行了什么东西:

如果执行的是dispatchInteractiveEvent会额外调用多几个函数:

  • dispatchInteractiveEvent
  • interactiveUpdates
  • interactiveUpdates$1

之后执行

  • dispatchEvent
  • getEventTarget:获取事件源对象。
  • getClosestInstanceFromNode:寻找当前元素的Fiber节点。
  • getTopLevelCallbackBookKeeping:组装了一个bookKeeping变量(包含事件类型,顶级元素document,事件源对象Fiber节点)。
  • batchedUpdates:批处理更新

getClosestInstanceFromNode

getClosestInstanceFromNode函数中不得不提的就是查找事件源对象的Fiber节点是如何实现的。在React开始执行的时候,会注册两个变量。

代码语言:javascript复制
var randomKey = Math.random().toString(36).slice(2);
var internalInstanceKey = '__reactInternalInstance$'   randomKey;
var internalEventHandlersKey = '__reactEventHandlers$'   randomKey;

而在React的commit阶段的时候,会在元素对象上添加了两个属性,分别是__reactInternalInstance<id>和__reactEventHandlers

__reactInternalInstance$<id>设置时机

  • completeWork
  • createInstance/createTextInstance
  • precacheFiberNode

__reactEventHandlers$<id>设置时机

  • completeWork
  • createInstance/createTextInstance
  • updateFiberProps

接着我们继续看看之后执行了什么。在batchedUpdates函数中最终执行了batchedUpdates$1函数。还记得上一篇文章说过setState为什么异步吗?小前端读源码 - React16.7.0(深入了解setState)中有提到一个scheduleWork函数,在scheduleWork中会调用requestWork。而requestWork相当重要,是决定setState是否异步的一个函数,其中判断是否异步就是用过React内部的一个全局变量isBatchingUpdates是否为true。

batchedUpdates$1函数内就是将isBatchingUpdates设置为true了。如果这次setState并不经过dispatchEvent的话,将不会把isBatchingUpdates设置为true。

修改完isBatchingUpdates后会把之前生成的bookKeeping对象传入handleTopLevel函数中,继续下一步的操作。

之后的调用顺序如下:

  • handleTopLevel
  • runExtractedEventsInBatch
  • extractEvents:extractEvents是一个全局函数
  • extractEvents:是基于以下几个事件类(ChangeEventPlugin,EnterLeaveEventPlugin,SelectEventPlugin,SimpleEventPlugin)中的extractEvents函数。
  • getPooledEvent: 基于事件的类型实例化一个event对象。

当前DEMO中,在使用的事件类是SimpleEventPlugin,在getPooledEvent函数中,使用的类是SyntheticMouseEvent实例化一个event对象。

然后将event对象传入accumulateTwoPhaseDispatches函数中。进入下一个调用栈。

  • accumulateTwoPhaseDispatches
  • forEachAccumulated
  • accumulateTwoPhaseDispatchesSingle
  • traverseTwoPhase:会检查你的组件当中是否存在在捕获阶段触发的事件。
  • accumulateDirectionalDispatches
  • getListener:获取当前Fiber节点传入的props中,是否存在事件(根据在traverseTwoPhase函数中检查是捕获阶段还是冒泡阶段的不一样,获取的事件字段也会不同。)

经过上面的调用流程,将会获取到listener事件。listener事件其实就是当前Fiber节点中对应现在触发的事件名称的props属性,因为现在DEMO使用的onClick事件,那么将会获取当前button组件的onClick的回调函数,如果父级组件也有onClick事件,那么也会获取得到。最终会将listener赋值到event对象中的_dispatchListeners和_dispatchInstances。

代码语言:javascript复制
function accumulateDirectionalDispatches(inst, phase, event) {
  {
    !inst ? warningWithoutStack$1(false, 'Dispatching inst must not be null') : void 0;
  }
  var listener = listenerAtPhase(inst, event, phase);
  if (listener) {
    event._dispatchListeners = accumulateInto(event._dispatchListeners, listener);
    event._dispatchInstances = accumulateInto(event._dispatchInstances, inst);
  }
}

像刚刚说的,如果父级组件都有同样的事件回调函数,那么返回的将会是一个数组否则将会是一个对象。

那么就是说明React会将源对象对应的Fiber节点以及该节点的父级所有的同样事件名的函数都提取出来。

调用栈将会回到runExtractedEventsInBatch函数中,这个时候已经获取到events对象,并且已经获取到

代码语言:javascript复制
function runExtractedEventsInBatch(topLevelType, targetInst, nativeEvent, nativeEventTarget) {
  var events = extractEvents(topLevelType, targetInst, nativeEvent, nativeEventTarget);
  runEventsInBatch(events);
}

我们继续看调用栈

  • runEventsInBatch
  • forEachAccumulated
  • executeDispatchesAndReleaseTopLevel
  • executeDispatchesAndRelease
  • executeDispatchesInOrder:判断到传入event的_dispatchListeners如果是数组将会循环执行executeDispatch,否则直接调用executeDispatch传入event、_dispatchListeners和_dispatchInstances。
  • executeDispatch
  • invokeGuardedCallbackAndCatchFirstError
  • invokeGuardedCallback
  • invokeGuardedCallbackDev

在invokeGuardedCallbackDev就是触发事件callback的重点了。我们来详细看一下里面到底执行了什么。

传入了几个重要参数:

  • name:事件名称。
  • func:回调函数。
  • context:上下文对象
  • event:合成的event对象。

1.首先创建了一个react元素

代码语言:javascript复制
var fakeNode = document.createElement('react');

2.创建一个事件对象

代码语言:javascript复制
var evt = document.createEvent('Event');

3.声明了一个callCallback函数

代码语言:javascript复制
function callCallback() {
    // 解绑事件
    fakeNode.removeEventListener(evtType, callCallback, false);
    // 触发func回调函数
    func.apply(context, funcArgs);  
}

4.绑定事件

代码语言:javascript复制
fakeNode.addEventListener(evtType, callCallback, false);

5.初始化事件并触发该事件

代码语言:javascript复制
evt.initEvent(evtType, false, false);
fakeNode.dispatchEvent(evt);

6.进入绑定事件的callCallback函数

7.解绑事件

代码语言:javascript复制
fakeNode.removeEventListener(evtType, callCallback, false);

8.触发func,传入的funcArgs包含event对象。

代码语言:javascript复制
func.apply(context, funcArgs);

9.进入到onClick中的回调函数,就是DEMO中的setState。

在第9步可以去看关于setState的源码阅读。

因为通过合成事件触发,所以会在合成事件中修改了isBatchingUpdates为true。所以setState会是异步。

最后回到interactiveUpdates$1函数中,performSyncWork函数进行渲染。之前一篇关于setState的文章,可以补充回触发func后发生的事情。进过setState后,对应的App的Fiber节点的updateQueue对象中,存在了新的state属性,然后进过一下调用栈:

  1. performSyncWork
  2. performWork
  3. performWorkOnRoot
  4. renderRoot
  5. workLoop
  6. performUnitOfWork
  7. beginWork
  8. updateClassComponent
  9. .........

具体可以参考渲染总篇的内容。

Lam:小前端读源码 - React16.7.0(渲染总结篇)

当然这次是setState,所以render阶段会的处理会有所不同,但是本文不涉及更新以及diff算法,以后会详细去说。

所以基本上整个合成事件从调用到performSyncWork后,其实就结束了,剩下就是交由react渲染去判断是否有更新的事件队列存在,从而判断后续执行怎样的操作了。

总结

  • 组件中声明的事件并不会保存起来,不像Fiber架构之前会存起来,而仅仅是将事件类型以及dispatchEvent/dispatchInteractiveEvent函数绑定到document元素上,实现事件委派。
  • 在触发阶段,通过事件的触发dispatchEvent/dispatchInteractiveEvent(前者不会异步setState),找到事件源对象上的对应事件的回调函数,并组合成一个"react-事件名"的自定义事件,通过创建一个react元素,通过element.dispatchEvent函数自主触发事件。
  • 在触发阶段,如果父级元素绑定了同样事件名的函数,那么会冒泡一层一层触发。

附上决定是否异步setState的事件类型。

代码语言:javascript复制
[
    "blur",
    "cancel",
    "click",
    "close",
    "contextmenu",
    "copy",
    "cut",
    "auxclick",
    "dblclick",
    "dragend",
    "dragstart",
    "drop",
    "focus",
    "input",
    "invalid",
    "keydown",
    "keypress",
    "keyup",
    "mousedown",
    "mouseup",
    "paste",
    "pause",
    "play",
    "pointercancel",
    "pointerdown",
    "pointerup",
    "ratechange",
    "reset",
    "seeked",
    "submit",
    "touchcancel",
    "touchend",
    "touchstart",
    "volumechange",
    "abort",
    "animationend",
    "animationiteration",
    "animationstart",
    "canplay",
    "canplaythrough",
    "drag",
    "dragenter",
    "dragexit",
    "dragleave",
    "dragover", 
    "durationchange", 
    "emptied", 
    "encrypted", 
    "ended", 
    "error", 
    "gotpointercapture", 
    "load", 
    "loadeddata", 
    "loadedmetadata", 
    "loadstart", 
    "lostpointercapture", 
    "mousemove", 
    "mouseout", 
    "mouseover", 
    "playing", 
    "pointermove", 
    "pointerout", 
    "pointerover", 
    "progress", 
    "scroll", 
    "seeking", 
    "stalled", 
    "suspend", 
    "timeupdate", 
    "toggle", 
    "touchmove", 
    "transitionend", 
    "waiting", 
    "wheel"
]

0 人点赞