共计 6220 个字符,预计需要花费 16 分钟才能阅读完成。
正式开始系统地学习前端已经三个多月了,感觉前端知识体系庞杂但是又非常有趣。前端演进到现在对开发人员的代码功底要求已经越来越高,几年前的前端开发还是大量操作 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 组件就是 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 元素的挂载方法。同样用框图的形式展现出来。
首先看 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 看起
不要被这些乱七八糟的线条吓到,其实自定义组件的更新过程并不复杂。首先,该方法接收一个新的 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 的更新过程同样被包装和隐藏得比较深,建议大家同样参看第二个链接里的源码分析系列。
更新一个 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 树中来说,这点代价与对性能的提升相比是很小的。要理解这一部分需要对递归理解比较透彻,建议大家对着代码仔细捋一捋。