作者 | Video++极链科技前端Team超凡
整理 | 包包
前言
React 起源于 Facebook 的内部项目,是一个用于构建用户界面的 Javascript 库。其拥有较高的性能,代码逻辑非常简单,越来越多的人已开始关注和使用它。
本文希望通过参考 React 源码,依葫芦画瓢地完成React的雏形。来帮助理解其内部的实现原理,知其然更要知其所以然。
虚拟DOM(Virtual DOM)
了解React的都知道,其高效的原因,是因为React按照页面的DOM结构,利用Javascript在内存中构建了一套相同结构的虚拟内存树模型,这个内存模型就称为Virtual DOM。每当页面产生了变化,React的diff算法会先在内存模型中进行比对,提取出差异点,在将Virtual DOM转化为原生DOM输出时,按照差异点,只patch出有变动的部分。
下面是VirtualDOM节点的定义:
入口
一切都是从 React.render(, document.body) 开始的,所以先来看看 React是怎么定义的?
React中主要包括:
• render(virtualDom, container) 命令式调用,一般用于应用入口,将虚拟DOM渲染在container容器中;
• createElement(name, props, children) 创建组件时使用,JSX是其语法糖;
• Component 以ES6中的类式语法声明时使用。
createElement(type, props, children)
createElement()的主要作用是根据给定type创建Virtual DOM节点,JSX是它的语法糖形式;其type参数可以是原生的html标签名(如:div、tag等),也可以是React组件类或函数。
组件的实现
React的所有组件,按照类型可以分为三种:
• 文本展示类型 (TextComponent)
• 原生DOM类型 (DomComponent)
• 自定义类型 (CompositeComponent)
每种类型的组件,都需要处理初始化和更新两种逻辑,具体会在下面两个函数中实现:
• mountComponent(rootNodeId) 用于处理初始化逻辑
• updateComponent() 用于处理更新逻辑
初始化mountComponent()的实现
mountComponent() 的实现思路是,根据virtual Dom对象生成HTML代码并返回。
首先定义类型组件的基类 Component ,它只是简单地记录了传入的virtualDom对象,并初始化了组件节点ID。
下面是不同类型组件初始化渲染逻辑的各自实现。
• TextComponent
作为纯展示类型组件,TextComponent 只是简单地将需要展示的内容,使用标签包装并返回就可以了。
• DomComponent
DomComponent类型在处理原生DOM时,需要额外注意一下原生事件部分的处理。
• CompositeComponent
在实现CompositeComponent类型的初始化渲染逻辑之前,先看一下React组件的定义语法。
声明语法中,App继承自React.Component,所以我们先来实现Component这个类。
这里的 React.Component 不要与上面的 Component 混淆, Component 是不同组件类型的基类,抽象了组件渲染与更新;而React.Component则是Composite这种类型组件声明时的基类。
在 React.Component 中,简单地声明了控制数据流向的props属性,以及组件实例内部用于触发更新的setState()函数。
在了解了 React.Component 的定义之后,我们回到 CompositeComponent ,开始实现mountComponent()的逻辑。
首先要了解的是,在composite类型组件中,vDom对象中的type,指向的是组件类的定义, 因此 mountComponent() 函数要做的工作,就是使用vDom的props属性来创建一个type的实例。
思考一下,在JSX语法中,解析器碰到标签后,就会去查找到 MyInput 的定义,上面说过JSX只是createElement的语法糖,因此背后调用的是 React.createElement(MyInput) 。在React规范中,可以使用类或函数来声明组件,因此在 mountComponent() 中使用 new type() ,就可以构造出MyInput的实例了。
更新流程updateComponent()的实现
实现完组件的初始化之后,接下来要实现组件的更新逻辑。
React开放了 setState() 用于组件更新,回顾上面 React.Component 中 setState() 的定义, 实际调用的是 this._reactInternalInstance.updateComponent(null, newState) 这个函数。而 this._reactInternalInstance指向CompositeComponent,困此更新逻辑交回CompositeComponent.updateComponent()来完成。
• CompositeComponent
Composite类型组件的更新函数,需要处理两种流程:
当被定义在其它组件的render函数中时,其包裹组件会构建出新的vDom对象,根据传入新的vDom来处理更新;当组件内部使用setState()触发时,根据新的state来更新;
了解这两种方式的区别,可以帮助我们理解下面updateComponent函数的实现。
我们梳理一下更新流程:
组件在初始化时,记录下了render组件的实例,即this._renderedComponent;在更新环节,重新render()得到新的VDomnextRenderVDom;通过比对前后两个VDom的type和key,来判断是执行原来_renderedComponent的updateComponent函数,还是重新生成新的组件;
上面使用到了shouldUpdateReactComponent这个比对函数,来对vDom的type和key进行比对,其实现如下:
上面这个处理逻辑,就是diff算法的第一个规则: 当两个VDom节点的类型不一致时,重新构建该组件的Virtual DOM树结构。
• TextComponent Text类型组件作为颗粒度最小的组件,更新逻辑非常简单,展示新的文本内容即可。
• DomComponent
因为diff算法的介入,Dom类型的处理逻辑相对复杂。 可以分两步来处理,第一步更新组件输出的容器DOM上面的属性;第二步处理子级DOM。
_updateProperties()函数对比新旧props,完成属性及事件的处理。 特别注意一下事件处理部分,需要注销掉原来DOM上注册的事件。
_updateDOMChildren() 用于处理children部分的更新, 这部分的逻辑相对复杂,也是diff算法的优化点所在。
注:下面的说明中,以名称中含'children'来标识 集合,'child'指代 集合项。
i. 使用 nextChildrenVDoms 数据生成新的nextChildrenComponent;
• DomComponent在初始化流程中,_mountComponent()函数会将组件集合保存下来,存入实例的_renderedChildrenComponent属性中, 通过遍历该属性,可以取得childComponent实例上的_vDom;
• 使用vDom来生成标识索引key,并以childComponent作为索引值,生成childrenComponent的Map结构; (对于Compotite类型,使用vDom.key作为标识索引key; 对于Text和Dom类型,使用childComponent在childrenComponent中所处的索引位置作为标识索引key);
• 使用nextChildrenVDoms生成新nextChildrenComponent的Map结构; 在遍历vDom集合的过程中,会使用上面的标识索引key生成规则,来进行判定,看是复用之前的组件实例触发更新,还是创建一个新的组件;
ii. 经过上面一步得到Map结构的prevChildren和nextChildren之后, 会使用深度遍历算法,递归地比对树结构中,相同层级和位置的两个组件,将差异点保存为特定的diff标识结构,存入diffQueue队列中;
iii. 遍历diffQueue,按照差异的类型,完成最终HTML DOM的变动;
首先是_updateDOMChildren()里的的定义。由于在递归组件树的节点时,存在多次触发_updateDOMChildren()的情况; 因此使用_updateDepth变量,在比对操作前+1,完成后-1,来判定整个树的更新是否全部完成,继而调用_patch()完成HTML DOM的更新;
下面的_diff()中,实现了更新步骤中的1 和2。
值得注意的是_diff过程中lastIndex变量的作用,其记录在遍历过程中,每次访问到的prevChildrenComponent中位置最靠后的组件,这是组件更新的一种排序上面的优化策略,可以参见这一篇文章当中的详细介绍:不可思议的react diff。
在计算出diffQueue的差异队列后,在_patch()函数中完成最终HTML DOM的更新:
总结
至此,我们实现了一个简易版本的React框架,完成了组件类的定义、初始化及更新; 并且梳理了核心diff算法。
下面简单做一下总结:
• 组件分为3种类型来处理组件的初始化渲染和更新:TextComponent、DomComponent和CompositeComponent;
• virtualDom对象中,记录了组件类型type,唯一标识key和属性集合props;
• 组件是由virtual Dom创建而来,vDom上的type和key用来标识组件实例的唯一性;
• diff算法的核心,是对比新旧vDom对象,来完成部分组件实例的复用,并加入了排序优化策略。 通过javascript大量计算的代价,来换取减少页面DOM重排的消耗,从而提高了渲染性能;