React前端学习小结

正式开始系统地学习前端已经三个多月了,感觉前端知识体系庞杂但是又非常有趣。前端演进到现在对开发人员的代码功底要求已经越来越高,几年前的前端开发还是大量操作DOM,直接与用户交互,而React、Vue等MVVM框架的出现,则帮助开发者从DOM中解放出来,将关注点转移到数据上来,也使前端开发愈发工程化和规范化。我入门的第一个MVVM框架是React,正所谓知其然更要知其所以然,再加上本身也对React中的虚拟DOM之类的新奇玩意儿非常感兴趣,因此最近两星期在工作间隙参照着几篇非常棒的博客,浅读了React16.0.0的源码,在此把一些感想分享出来,由于水平有限,如果有什么理解不正确的话,欢迎交流与指正。

由于网上的博客已经有了非常详细的代码分析,因此我在此不再贴代码细节,只是进行一下简单的梳理,建议各位看官可以参阅下面两个系列的文章,我觉得写得非常好:
React源码分析系列
React源码解析
另外,如果你还没有学习过react,强烈建议你跟着下面链接里的教程走进React世界:
React小书
下面开始我的分享了

React组件

React开发者干的事情很简单,就是玩弄组件。而从你写下class XXX extend Component再到你的组件成功渲染在真实DOM上,中间其实经历了一个复杂的过程。其中涉及三个重要的对象,可以认为是React的核心,这三个对象形象地说就是三种视角下的React组件。
React前端学习小结
首先,对开发人员来说,React组件就是ReactClass,我们通过class XXX extend Component(ES5是调用createClass函数)这种方式构建我们自己的组件,在组件内部,我们可以随心所欲的玩弄state,props,生命周期函数等,最终目的是根据需要,在render函数中return 一个我们需要的HTML模板,最终挂载到DOM树上,而中间这个过程,则需要涉及React中的另外两个核心对象。

React如此风靡的原因在于它帮助开发人员从DOM中解放出来,不是直接操作DOM,而是操作React的Virtual-DOM,然后通过强大的diff算法,先更新Virtual-DOM,然后最合理高效地更新实际DOM。因此在render函数中,我们最后return的并非实际的DOM元素,事实上,如果不用JSX的语法,我们最后实际上是调用了createElement方法,return 出一个ReactElement对象——这就是React组件在内存中的存在方式。

ReactElement内部含type,key,context, props四个关键属性。用过React的人应该很熟悉后三个,而type则用于标识组件的类型,type字段如果是字符串(如“div”,“p”等),则表示组件对应的是一个实际DOM对象,如div,p等,如果type字段是ReactClass的构造函数,则表示组件是我们自定义的。因此传说中的Virtual-DOM实质就是各种ReactElement构成的javaScript对象树,是实际DOM的ReactElement对象映射。然而ReactElement相当于只是数据的容器,它无法更改数据,因此我们还需要一个数据的操作者,这个操作者就是ReactComponent,它就是React系统眼里的组件。

根据组件类型的不同,ReactComponent又分为四种:ReactDOMTextComponent(后文中记为RTC)、ReactDOMComponent(后文中记为RDC)、ReactCompositeComponent(后文中记为RCC)、ReactDOMEmptyComponent(后文中记为REC),具体含义从名字就可以判断出来。这四种ReactComponent是通过一个工厂函数instantiaReactComponent生成的,它接收一个node参数,如果node是null,则生成ReactDOMEmptyComponent,如果node是数字或字符串,则生成eactDOMTextComponent,如果传入的node是一个对象,没错,你肯定猜到了这个对象正是ReactElement对象,我们则可以通过它的type属性判断是普通DOM元素还是自定义的组件,由此分别生成ReactDOMComponent和ReactCompositeComponent,这四种ReactComponent虽然是不同的对象,但是都实现了mountComponent,receiveComponent和unmountComponent三个关键的方法,mountComponent方法用于把ReactElement转化为HTML标记,最终挂载到DOM上,而经浏览器解析后的DOM元素,就是用户视角看到的React组件了。receiveComponent方法接收新的组件信息,用于更新组件,unmountComponent方法则显然是卸载组件用的,不同的Component对这三个方法有不同的实现方式,但是都提供了名字相同的接口,其实有点类似java中的多态。

值得一提的是在mountComponent被调用时,ReactComponent标记了_currentElement, _instance两个内部属性,用于记录与之关联的ReactElement和ReactClass实例,这两个属性非常重要,它们是把React中的几个核心对象联系起来的桥梁。我把React中三个核心对象之间的联系表示成上面的框图,从开发者构造出组件,再到最后渲染出DOM元素展示给用户,正是沿着红色的路径实现的。

挂载

前面提到,从ReactElement到实际的HTML标记,是通过ReactComponent的mountComponent方法实现的,文本节点和空节点暂且不谈,我们关注一下自定义组件和DOM元素的挂载方法。同样用框图的形式展现出来。

React前端学习小结

首先看RCC,在挂载初期,把ReactElement(后文称element)和对应的ReactClass实例(后文称instance)放入ReactInstanceMap中,留给以后使用,然后在performInitialMount方法中才进行真正的挂载过程,这个方法中先调用其对应instance的componentWillMount方法,然后调用render方法生成一个新的element,将render出的element传InstantiateReactCompoentn方法,生成对应的Component实例,然后接着调用该实例的mountComponent方法即可。没错,这是一个递归的过程,你发现componentWilMount方法被放在了子元素的mount方法之前,componentDidMount方法被放在了子元素mount方法之后,因此在递归调用过程中,父元素的componentWillMount方法总是在子元素的componentWillMount方法之前被调用,而父元素的componentDidMount方法则总是在子元素的componentDidMount方法之后被调用。另外,你一定也看到了‘伪多态’的好处,你不用管render出来的是什么类型的元素,反正直接甩锅调用它的mountComponent方法就行了,因此,RCC的mount过程,实际最后是落实到另外三个component的mountComponent方法去生成HTML标记的。我们再来看一下RDC的mountComponent方法,在实际源码中,DOM元素的挂载和更新方法都隐藏得比较深,调用链很长,我建议大家如果有兴趣的话直接看我分享的第二个链接里的简易版实现,其大致流程大致如框图中所示,比较清晰了,只需要知道在拼接属性的时候需要对事件属性单独处理,因为React实现了一套合成事件系统,尽可能实现了浏览器兼容。然后,你会发现DOM元素的mout也是一个递归过程,获取当前元素的标签名和属性后,标签里的内容又交给子元素的mountComponent方法去实现即可。

看到这里,你会发现RCC和RDC的mountComponent方法核心都是两个字——递归,对子元素递归调用mountComponent方法。根元素经过这个过程之后,就能得到一个完整的DOM树,再把根节点通过ReactDOM.render方法插入容器中即可,这个不再详述了。

更新

刚才说到组件的挂载过程实际核心就是递归调用子组件的挂载过程,接下来你会发现,组件的更新,实质也是通过递归完成的。先从比较简单的RCC看起

React前端学习小结

不要被这些乱七八糟的线条吓到,其实自定义组件的更新过程并不复杂。首先,该方法接收一个新的ReactElement,组件什么时候会更新呢?有两种情况,一种是组件接收到上层组件传来的新props,这种时候新的ReactElement的props字段和旧element是不同的。另一种情况是在组件内部调用了setState方法,由于ReactElement里面不保存state,因此这种情况下新旧ReactElement是相同的,根据这个特点,可以判断是否调用componentWillReceiveProps方法。接下来调用实例中的shouldUpdateComponent方法,如果该方法return值为false或者开发人员没有写这个方法,就会调用接下来的渲染和子更新过程,如果该方法值为true,那么更新过程在这里就终止了,不会调用接下来的渲染和子更新。这个特性使得我们能通过这个方法手动决定是否要进行渲染,达到提升性能的效果。接着往下走,调用了componentWillUpdate方法后,instance实例将根据传入的新element更新props,然后把state的值更新为最新的(setState过程将在后文中分析),这时候再调用render方法,就能得到一个最新的renderdElement方法了,看过了挂载的过程你可能已经想到了接下来只需要把这个新的renderdElement传入子元素的receiveComponent方法中即可。但是其实在这一步之前还有一个判断的过程,这个过程封装在名字叫shouldeUpdateReactComponent方法中,该方法非常重要,在后面RDC的更新过程中也会用到,它接收两个element参数,判断这两个element是否key和type都相同,如果是则返回true,否则返回false。在RCC的更新过程中,会把未更新时render出的element和最新render出的element作为参数传入该方法,如果比较结果为true,则对render出的子元素生成的component递归调用receiveComponent方法,否则,直接对当前组件先卸载再重新挂载。由于一个自定义组件render出的元素只有一个,不可能是数组,因此对RCC来说,这个比对过程中其实key的意义不大,关键还是比对type是否一致,因此RCC更新过程就是,如果最新render出的元素与之前render的元素类型相同,比如原来render出的element是div,状态更新之后render出来还是div,那就对这个div继续深入更新,否则,直接先卸载当前的组件,再重新挂载一个最新的。

接下来再看RDC的更新过程,在源码中,RDC的更新过程同样被包装和隐藏得比较深,建议大家同样参看第二个链接里的源码分析系列。

React前端学习小结

更新一个DOM节点需要做的事情有两件,1. 更新顶层标签的属性,2. 更新这个标签包裹的子节点。更新属性比较容易理解,大家参阅一下代码很容易看懂,重点是对子节点的更新。

子节点的更新过程做的事情也只有两件:1. 找出状态更新后DOM树与状态更新前DOM树的所有不同,把这些差异按类型组装成差异对象放入diffQueue队列中,差异对象有INSERT_MARKUP, MOVE_EXISTING, REMOVE_NODE, SET_MARKUP, TEXT_CONTENT几种,这个过程称为diff,2.从diffQueue中依次取出差异对象,在真实DOM树中完成变更。总之就是批量找差异,批量修改DOM树。我们再来看实现细节,首先,既然要更新子元素,肯定得拿到子元素的Component实例,flattenChildren函数做的正是这个工作,我们知道一个元素的子元素,在ReactElement中存储在props.children数组中,flattenChildren把这个childrent数组中的element通通转化为component存在了一个map中,这个map中的键是什么呢?如果我们在使用组件实例的时候传入了key属性,那么map的键就是我们传入的这个key,否则,这个map的键就是children数组的下标(看到这里,就可以思考一下key的作用是什么了,在此我不继续展开,后续会写新的博文详细讲述)。接下我们需要调用generateComponentChildren方法,该方法接收的参数是传入的新的子element合集,它的内部做了什么呢?还是和对待老childElements一样,先对每个element生成键,然后关键来了,我们将从刚才flattenChildren生成的旧components中,取出同键component,然后得到该component的element,记得RCC更新时候调用的shouldUpdateReactComponent方法吗,我们又要重新调用它了。如果type相同则复用旧的component,执行receiveComponent方法递归更新,否则就生成新的Component,最后同样得到一个component map,这个新的map中的键还是一样的,如果传入了key字段,那么键就是key,否则键就是数组下标,而这个map的值是component,这些component中,有的是还是上一状态的component,只是执行了更新而已,有的则是新生成的component。生成这个新的component集合之后,我们就可以比对前后两次的同键component,如果两个component相同,说明我们在本层复用了之前的状态未更新前的组件,那么我们只需要移动之前的组件即可,否则说明我们在本层不能用之前的组件了,那么我们就需要删除旧组件,插入新组件。我们暂时不执行这些操作,而是先把这些差异组装成对象放到队列中,到最后统一更新即可,这个统一更新的过程就是patch,在此不再详述。

这一部分相对比较难理解,但也正是React diff算法的核心,不知你有没有发现,我们比对差异对象只在同一层之间对比,这正是React diff算法高效的原因,因为在Web中,对DOM树的操作很少跨层,因此只比对同层差异,就可以只遍历一遍所有节点就找出所有差异,算法复杂度只有O(n)。当然了,带来的问题就是如果真的出现了跨层移动节点的操作,我们就没法复用这个节点,而是得先卸载再挂载,但是在几乎不会有跨层操作的DOM树中来说,这点代价与对性能的提升相比是很小的。要理解这一部分需要对递归理解比较透彻,建议大家对着代码仔细捋一捋。

相关推荐