抱歉在掘金页内链接失效,后续会改正
TL;DR
本文通过对React事件系统和源码进行浅析,回答“为什么React需要自己实现一套事件系统?”和“React的事件系统是怎么运作起来的?”两个问题。React为了性能和复用,采用了事件代理,池,批量更新,跨浏览器和跨平台兼容等思想,将事件监听挂载在document上,构造合成事件,并且在内部模拟了一套捕获和冒泡并触发回调函数的机制,实现了自己的一套事件系统。
- 如果你只有几分钟,建议你直接看动画部分。
- 如果你有半个小时,你可以按顺序往下阅读,忽略源码部分。
- 如果你对React事件系统有较大的兴趣,那么推荐你clone一份React的源码(本文列举的源码来自v16.5.0),然后按照顺序依次往下阅读。
开始
最近在使用React对项目前端进行重构的时候,自己和同事遇到了一些奇怪的问题。所以花了一些时间对React源码进行了研究,此篇的主题为React事件系统,尽量剔除复杂的技术细节,希望能以简单直观的方式回答两个问题,分别是**“为什么React需要自己实现一套事件系统?”和“React的事件系统是怎么运作起来的?”**。
Stuff can sometimes get surprisingly messy if you don’t know how it works…
两个简单的例子
例子一
-
根据下面代码,点击按钮之后,输出结果会是什么?(ABCD排序)
-
如果我把
innerClick
中的e.stopPropagation()
加上,输出结果又会是什么?(ABCD排序)
class App extends React.Component {
innerClick = e => {
console.log("A: react inner click.");
// e.stopPropagation();
};
outerClick = () => {
console.log("B: react outer click.");
};
componentDidMount() {
document
.getElementById("outer")
.addEventListener("click", () => console.log("C: native outer click"));
window.addEventListener("click", () =>
console.log("D: native window click")
);
}
render() {
return (
<div id="outer" onClick={this.outerClick}>
<button id="inner" onClick={this.innerClick}>
BUTTON
</button>
</div>
);
}
}
正确答案是(防止你们偷看,请向左滑动 <—— ):
1.
C: native outer click
A: react inner click.
B: react outer click.
D: native window click
2.
C: native outer click
A: react inner click.
例子二
一个表单,预期为需要点击按钮edit之后才可以进行编辑,并且此时Edit按钮变为submit按钮,点击submit按钮提交表单。代码如下
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
editable: false
};
}
handleClick = () => {
console.log("edit button click!!");
this.setState({ editable: true });
};
handleSubmit = e => {
console.log("submit event!!");
e.preventDefault(); //避免页面刷新
};
render() {
return (
<form onSubmit={this.handleSubmit}>
{this.state.editable ? (
<button type="submit">submit</button>
) : (
<button type="button" onClick={this.handleClick}>edit</button>
)}
</form>
);
}
}
但实际上我们发现,点击edit按钮的时候就已经触发form的submit事件了。为什么我们点击了一个type="button"
的按钮会触发submit
事件呢?
带着对这两个例子的思考,我们进入到本文的主题。我只想直接看答案?
React为什么要自己实现一个事件系统?
我认为这个问题主要是为了性能和复用两个方面来考虑。
首先对于性能来说,React作为一套View层面的框架,通过渲染得到vDOM,再由diff算法决定DOM树那些结点需要新增、替换或修改,假如直接在DOM结点插入原生事件监听,则会导致频繁的调用addEventListener
和removeEventListener
,造成性能的浪费。所以React采用了事件代理的方法,对于大部分事件而言都在document上做监听,然后根据Event中的target来判断事件触发的结点。(除了少数不会冒泡到document的事件,例如video等。)
其次React合成的SyntheticEvent
采用了池的思想,从而达到节约内存,避免频繁的创建和销毁事件对象的目的。这也是如果我们需要异步使用一个syntheticEvent
,需要执行event.persist()
才能防止事件对象被释放的原因。
最后在React源码中随处可见batch做批量更新,基本上凡是可以批量处理的事情(最普遍的setState
)React都会将中间过程保存起来,留到最后面才flush掉。就如浏览器对DOM树进行Style,Layout,Paint一样,都不会在操作ele.style.color='red';
之后马上执行,只会将这些操作打包起来并最终在需要渲染的时候再做渲染。
ele.style.color='red';
ele.style.color='blue';
ele.style.color='red';
浏览器只会渲染一次
而对于复用来说,React看到在不同的浏览器和平台上,用户界面上的事件其实非常相似,例如普通的click
,change
等等。React希望通过封装一层事件系统,将不同平台的原生事件都封装成SyntheticEvent
。
- 使得不同平台只需要通过加入EventEmitter以及对应的Renderer就能使用相同的一个事件系统,WEB平台上加入
ReactBrowserEventEmitter
,Native上加入ReactNativeEventEmitter
。如下图,对于不同平台,React只需要替换掉左边部分,而右边EventPluginHub
部分可以保持复用。 - 而对于不同的浏览器而言,React帮我们统一了事件,做了浏览器的兼容,例如对于
transitionEnd
,webkitTransitionEnd
,MozTransitionEnd
和oTransitionEnd
, React都会集合成topAnimationEnd
,所以我们只用处理这一个标准的事件即可。
简单而言,就与jQuery帮助我们解决了不同浏览器之间的兼容问一样,React更进一步,还帮我们统一了不同平台的兼容,使我们在开发的时候只需要考虑标准化的事件即可。
React的事件系统是怎么运作起来的?
事件绑定
我们来看一下我们在JSX中写的onClick
handler是怎么被记录到DOM结点上,并且在document
上做监听的。
React对于大部分事件的绑定都是使用trapBubbledEvent
和trapCapturedEvent
这两个函数来注册的。如上图所示,当我们执行了render
或者setState
之后,React的Fiber调度系统会在最后commit到DOM树之前执行trapBubbledEven
或trapCapturedEvent
,
通过执行addEventListener
在document结点上绑定对应的dispatch
作为handler负责监听类型为topLevelType
的事件。
这里面的dispatchInteractiveEvent
和dispatchEvent
两个回调函数的区别为,React16开始换掉了原本Stack Reconciliation成Fiber希望实现异步渲染(目前仍未默认打开,仍需使用unstable_
开头的api,此特性与例子2有关,将在文章最后配图解释),所以异步渲染的情况下加入我点了两次按钮,那么第二次按钮响应的时候,可能第一次按钮的handlerA中调用的setState
还未最终被commit到DOM树上,这时需要把第一次按钮的结果先给flush掉并commit到DOM树,才能够保持一致性。这个时候就会用到dispatchInteractiveEvent
。可以理解成dispatchInteractiveEvent
在执行前都会确保之前所有操作都已最总commit到DOM树,再开始自己的流程,并最终触发dispatchEvent
。但由于目前React仍是同步渲染的,所以这两个函数在目前的表现是一致的,希望React17会带给我们默认打开的异步渲染功能。
到现在我们已经在document结点上监听了事件了,现在需要来看如何将我们在jsx中写的handler存起来对应到相应的结点上。
在我们每次新建或者更新结点时,React最终会调用createInstance
或者commitUpdate
这两个函数,而这两个函数都会最终调用updateFiberProps
这个函数,将props
也就是我们的onClick
,onChange
等handler给存到DOM结点上。
至此,我们我们已经在document上监听了事件,并且将handler存在对应DOM结点。接下来需要看React怎么监听并处理浏览器的原生事件,最终触发对应的handler了。
事件触发
这里我做了个动画,希望能够对你们理解有帮助。点击绿色的按钮>播放下一步。
抱歉需要插入链接,掘金不允许插入iframe。
以简单的click
事件为例,通过事件绑定我们已经在document
上监听了click
事件,当我们真正点击了这个按钮的时候,原生的事件是如何进入React的管辖范围的?如何合成SyntheticEvent
以及如何模拟捕获和冒泡的?以及最后我们在jsx中写的onClick
handler是如何被最终触发的?带着这些问题,我们一起来看一下事件触发阶段。
我会大概用下图这种方式来解析代码,左边是我点击一个绑定了handleClick
的按钮后的js调用栈,右边是每一步的代码,均已删除部分不影响理解的代码。希望通过这种方式能使大家更易理解React的事件触发机制。
当我们点击一个按钮是,click
事件将会最终冒泡至document,并触发我们监听在document上的handler dispatchEvent
,接着触发batchedUpdates
。batchedUpdates
这个格式的代码在React的源码里面会频繁的出现,基本上React将所有能够批量处理的事情都会先收集起来,再一次性处理。
可以看到默认的isBatching
是false的,当调用了一次batchedUpdates
,isBatching
的值将会变成true,此时如果在接下来的调用中有继续调用batchedUpdates
的话,就会直接执行handleTopLevel
,此时的setState
等不会被更新到DOM上。直到调用栈重新回到第一次调用batchedUpdates
的时候,才会将所有结果一起flush掉(更新到DOM上)。
有的同学可能问调用栈中的BatchedUpdates$1
是什么?或者浏览器的renderer和Native的renderer是如果挂在到React的事件系统上的?
其实React事件系统里面提供了一个函数setBatchingImplementation
,用来动态挂载不同平台的renderer,这个也体现了React事件系统的复用
。
这里的interactiveUpdates
和batchedUpdates
的区别在上文已经解释过,这里就不再赘述。
handleTopLevel
会调用runExtractedEventsInBatch()
,这是React事件处理最重要的函数。如上面动画我们看到的,在EventEmitter
里面做的事,其实主要就是这个函数的两步。
- 第一步是根据原生事件合成合成事件,并且在vDOM上模拟捕获冒泡,收集所有需要执行的事件回调构成回调数组。
- 第二步是遍历回调数组,触发回调函数。
首先调用extractEvents
,传入原生事件e
,React事件系统根据可能的事件插件合成合成事件Synthetic e
。 这里我们可以看到调用了EventConstructor.getPooled()
,从事件池中去取一个合成事件对象,如果事件池为空,则新创建一个合成事件对象,这体现了React为了性能实现了池的思想。
然后传入Propagator,在vDOM上模拟捕获和冒泡,并收集所有需要执行的事件回调和对应的结点。traverseTwoPhase
模拟了捕获和冒泡的两个阶段,这里实现很巧妙,简单而言就是正向和反向遍历了一下数组。接着对每一个结点,调用listenerAtPhase
取出事件绑定时挂载在结点上的回调函数,把它加入回调数组中。
接着遍历所有合成事件。这里可以看到当一个事件处理完的时候,React会调用event.isPersistent()
来查看这个合成事件是否需要被持久化,如果不需要就会释放这个合成事件,这也就是为什么当我们需要异步读取操作一个合成事件的时候,需要执行event.persist()
,不然React就是在这里释放掉这个事件。
最后这里就是回调函数被真正触发的时候了,取出回调数组event._dispatchListeners
,遍历触发回调函数。并通过event.isPropagationStopped()
这一步来模拟停止冒泡。这里我们可以看到,React在收集回调数组的时候并不会去管我们是否调用了stopPropagation
,而是会在触发的阶段才会去检查是否需要停止冒泡。
至此,一个事件回调函数就被触发了,里面如果执行了setState
等就会等到调用栈弹回到最低部的interactiveUpdate
中的被最终flush掉,构造vDOM,和好,并最终被commit到DOM上。
这就是事件触发的整个过程了,可以回去再看一下动画,相信你会更加理解这个过程的。
例子Debug
现在我们对React事件系统已经比较熟悉了,回到文章开头的那两个玄学问题,我们来看一下到底为什么?
例子一
如果想看题目内容或者忘记题目了,可以点击这里查看。
相信看完这篇文章,如果你已经对React事件系统有所理解,这道题应该是不难了。
- 因为React事件监听是挂载在document上的,所以原生系统在
#outer
上监听的回调C
会最先被输出;接着原生事件冒泡至document进入React事件系统,React事件系统模拟捕获冒泡输出A
和B
;最后React事件系统执行完毕回到浏览器继续冒泡到window,输出D
。 - 原生系统在
#outer
上监听的回调C
会最先被执行;接着原生事件冒泡至document进入React事件系统,输出A
,在React事件处理中#inner
调用了stopPropagation
,事件被停止冒泡。
所以,最好不要混用React事件系统和原生事件系统,如果混用了,请保证你清楚知道会发生什么。
例子二
如果想看题目内容或者忘记题目了,可以点击这里查看。
这个问题就稍微复杂一点。首先我们点击edit
按钮浏览器触发一个click
事件,冒泡至document进入React事件系统,React执行回调调用setState
,此时React事件系统对事件的处理执行完毕。由于目前React是同步渲染的,所以接着React执行performSyncWork
将该button改成type="submit"
,由于同个位置的结点并且tag都为button,所以React复用了这个button结点(具体原因可以参考)并更新到DOM上。此时浏览器对click
事件处理执行继续,发现该结点的type="submit"
,又在form下面,则对应触发submit
事件。
解决的办法就有很多种了,给button加上key
;两个按钮分开写,不要用三元等都可以解决问题。
具体可以看一下下面的这个调用图,应该也很好理解,如果有不能理解的地方,请在下面留言,我会尽我所能解释清楚。
额外多说一个点,“setState是异步的”
相信对于很多React开发者来说,“setState是异步的”这句话应该经常听到,我记得我一开始学习React的时候经常就会看到这句话,然后说如果需要用到之前的state需要在setState中采用setState((preState)=>{})
这样的方式。
但其实这句话并不是完全准确的。准确的说法应该是setState有时候是异步的,setState相对于浏览器而言是同步的
目前而言setState
在生命周期以及事件回调中是异步的,也就是会收集起来批量处理。在其它情况下如promise,setTimeout中都是同步执行的,也就是调用一次setState就会render一次并更新到DOM上面,不信的话可以点击这里尝试。
且在JS调用栈被弹空时候,必定是已经将结果更新到DOM上面了(同步渲染)。这也就是setState相对于浏览器是同步的含义。如下图所示
异步渲染的流程图大概如下图所示,最近一次思考这个问题的时候,发现如果现在是异步渲染的话,那我们的例子二将变成偶现的坑😂,因为如果setState
的结果还没被更新到DOM上,浏览器就不会触发submit事件。
不过React团队已经为异步渲染的愿景开发了两年,且React16中已经采用了Fiber reconciliation和提供了异步渲染的api unstable_xxx
,相信在React17中我们可以享受到异步渲染带来的性能提升,感谢React团队。
总结
希望读完此文,能让你React事件系统有个简单的认识。知道“为什么React需要自己实现一套事件系统?”和“React的事件系统是怎么运作起来的?”。React为了性能和复用,采用了事件代理,池,批量更新,跨浏览器和跨平台兼容等思想,将事件监听挂载在document上,并且构造合成事件,并且在内部模拟了一套捕获和冒泡并触发回调函数的机制,实现了自己一套事件系统。
如果你还有哪里不清楚,发现文章有错漏,或是单纯的交流相关问题,请在下面留言,我会尽我所能回复和解答你的疑问的。 如果你喜欢我的文章,请关注我和我的博客,谢谢。