动画浅析React事件系统和源码

抱歉在掘金页内连接失效,后续会改正html

查看原文前端

TL;DR

本文经过对React事件系统和源码进行浅析,回答“为何React须要本身实现一套事件系统?”和“React的事件系统是怎么运做起来的?”两个问题。React为了性能和复用,采用了事件代理,池,批量更新,跨浏览器和跨平台兼容等思想,将事件监听挂载在document上,构造合成事件,而且在内部模拟了一套捕获和冒泡并触发回调函数的机制,实现了本身的一套事件系统。react

动图

  • 若是你只有几分钟,建议你直接看动画部分
  • 若是你有半个小时,你能够按顺序往下阅读,忽略源码部分。
  • 若是你对React事件系统有较大的兴趣,那么推荐你clone一份React的源码(本文列举的源码来自v16.5.0),而后按照顺序依次往下阅读。

开始

最近在使用React对项目前端进行重构的时候,本身和同事遇到了一些奇怪的问题。因此花了一些时间对React源码进行了研究,此篇的主题为React事件系统,尽可能剔除复杂的技术细节,但愿能以简单直观的方式回答两个问题,分别是**“为何React须要本身实现一套事件系统?”“React的事件系统是怎么运做起来的?”**。git

Stuff can sometimes get surprisingly messy if you don’t know how it works…github

两个简单的例子

例子一

  1. 根据下面代码,点击按钮以后,输出结果会是什么?(ABCD排序)web

  2. 若是我把innerClick中的e.stopPropagation()加上,输出结果又会是什么?(ABCD排序)算法

Edit React事件冒泡例子
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>
    );
  }
}
复制代码

正确答案是(防止大家偷看,请向左滑动 <—— ):shell

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按钮提交表单。代码以下api

Edit yj0z7169l9
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事件ppt.004

我认为这个问题主要是为了性能复用两个方面来考虑。

首先对于性能来讲,React做为一套View层面的框架,经过渲染获得vDOM,再由diff算法决定DOM树那些结点须要新增、替换或修改,假如直接在DOM结点插入原生事件监听,则会致使频繁的调用addEventListenerremoveEventListener,形成性能的浪费。因此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看到在不一样的浏览器和平台上,用户界面上的事件其实很是类似,例如普通的clickchange等等。React但愿经过封装一层事件系统,将不一样平台的原生事件都封装成SyntheticEvent

  • 使得不一样平台只须要经过加入EventEmitter以及对应的Renderer就能使用相同的一个事件系统,WEB平台上加入ReactBrowserEventEmitter,Native上加入ReactNativeEventEmitter。以下图,对于不一样平台,React只须要替换掉左边部分,而右边EventPluginHub部分能够保持复用。
  • 对于不一样的浏览器而言,React帮咱们统一了事件,作了浏览器的兼容,例如对于transitionEnd,webkitTransitionEnd,MozTransitionEndoTransitionEnd, React都会集合成topAnimationEnd,因此咱们只用处理这一个标准的事件便可。

react事件ppt.005

简单而言,就与jQuery帮助咱们解决了不一样浏览器之间的兼容问同样,React更进一步,还帮咱们统一了不一样平台的兼容,使咱们在开发的时候只须要考虑标准化的事件便可。

React的事件系统是怎么运做起来的?

事件绑定

咱们来看一下咱们在JSX中写的onClickhandler是怎么被记录到DOM结点上,而且在document上作监听的。

React对于大部分事件的绑定都是使用trapBubbledEventtrapCapturedEvent这两个函数来注册的。如上图所示,当咱们执行了render或者setState以后,React的Fiber调度系统会在最后commit到DOM树以前执行trapBubbledEventrapCapturedEvent, 经过执行addEventListener在document结点上绑定对应的dispatch做为handler负责监听类型为topLevelType的事件。

这里面的dispatchInteractiveEventdispatchEvent两个回调函数的区别为,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也就是咱们的onClickonChange等handler给存到DOM结点上。

至此,咱们咱们已经在document上监听了事件,而且将handler存在对应DOM结点。接下来须要看React怎么监听并处理浏览器的原生事件,最终触发对应的handler了。

事件触发

这里我作了个动画,但愿可以对大家理解有帮助。点击绿色的按钮>播放下一步。

抱歉须要插入连接,掘金不容许插入iframe。

以简单的click事件为例,经过事件绑定咱们已经在document上监听了click事件,当咱们真正点击了这个按钮的时候,原生的事件是如何进入React的管辖范围的?如何合成SyntheticEvent以及如何模拟捕获和冒泡的?以及最后咱们在jsx中写的onClickhandler是如何被最终触发的?带着这些问题,咱们一块儿来看一下事件触发阶段。

我会大概用下图这种方式来解析代码,左边是我点击一个绑定了handleClick的按钮后的js调用栈,右边是每一步的代码,均已删除部分不影响理解的代码。但愿经过这种方式能使你们更易理解React的事件触发机制。

当咱们点击一个按钮是,click事件将会最终冒泡至document,并触发咱们监听在document上的handler dispatchEvent,接着触发batchedUpdatesbatchedUpdates这个格式的代码在React的源码里面会频繁的出现,基本上React将全部可以批量处理的事情都会先收集起来,再一次性处理。

能够看到默认的isBatching是false的,当调用了一次batchedUpdatesisBatching的值将会变成true,此时若是在接下来的调用中有继续调用batchedUpdates的话,就会直接执行handleTopLevel,此时的setState等不会被更新到DOM上。直到调用栈从新回到第一次调用batchedUpdates的时候,才会将全部结果一块儿flush掉(更新到DOM上)。

有的同窗可能问调用栈中的BatchedUpdates$1是什么?或者浏览器的renderer和Native的renderer是若是挂在到React的事件系统上的?

其实React事件系统里面提供了一个函数setBatchingImplementation,用来动态挂载不一样平台的renderer,这个也体现了React事件系统的复用

这里的interactiveUpdatesbatchedUpdates的区别在上文已经解释过,这里就再也不赘述。

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事件系统有所理解,这道题应该是不难了。

  1. 由于React事件监听是挂载在document上的,因此原生系统在#outer上监听的回调C会最早被输出;接着原生事件冒泡至document进入React事件系统,React事件系统模拟捕获冒泡输出AB;最后React事件系统执行完毕回到浏览器继续冒泡到window,输出D
  2. 原生系统在#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上,而且构造合成事件,而且在内部模拟了一套捕获和冒泡并触发回调函数的机制,实现了本身一套事件系统。

若是你还有哪里不清楚,发现文章有错漏,或是单纯的交流相关问题,请在下面留言,我会尽我所能回复和解答你的疑问的。 若是你喜欢个人文章,请关注我和个人博客,谢谢。

Read More & Reference

相关文章
相关标签/搜索