今天来和大家聊React的渲染更新过程。


React是JavaScript代码

在聊渲染更新之前,我们不能忽视的一个概念是——React是JavaScript代码。

我们都知道React传给浏览器的并不是一个HTML代码,而是一段js脚本。

而在浏览器接收到js脚本之后,再执行并生成对应的html元素,插入到DOM中。

这种做法提供了前端组件化的能力,能够让前端的同学不再“面向UI”进行操作。

image-20200521113240566

例如上面的例子,我们把一段原生的HTML元素封装成了一个Component组件。

组件成了一个独立的模型概念,而组件内部的div等HTML元素,就成了封装的UI细节。

这样一来,我们就可以在开发时把更多精力放在模型实现上(功能),而暂时不需要视觉显示(UI)。

React框架会帮我们将模型装换成相应的HTML元素,挂载至DOM树上。

这就实现了Model和View的关注点分离。

这样我们如果需要进行业务模型的变化,组件就能够高效复用。

比如我们这里把收藏改为点赞。就可以这样写。

image-20200521135751484

从虚拟DOM到DOM

渲染是一个“重”操作

React将我们从复杂的HTML的DOM节点操作中解放出来。

但是浏览器终究只能解析渲染真实的HTML元素,而不是jsx定义的语法糖。

任何在对React组件进行的变更操作,最终还是要转换成HTML才能在浏览器渲染。

然而,重绘整个HTML的DOM是一件非常耗性能的工作。

原本只需要操作一个div元素进行修改,如果现在用React需要对整个DOM进行刷新,肯定是不能接受的。

那能不能每次React组件更新,只修改组件对应的DOM节点内容呢?

构建虚拟DOM

在React中,组件是一个封装后的概念。组件的渲染还是会依赖于HTML的元素。

那么如果我们把React从root挂载的组件开始“解封装”,会得到一个只有HTML元素组成的树。

这颗“React 树”的结构在大部分情况下和实际渲染后的DOM树结构是一模一样的。

(注:“小部分”使两颗树不一样的点在于React的Portals – React概念,这里暂时不讲,不会影响后续内容)

image-20200521142340731

这样一来,我们只要观察这颗虚拟的“DOM”树上的结点变化,再刷新真实DOM树上对应的结点,就能实现对特定的HTML元素的更改,进而达到高性能更新UI。

虚拟DOM之间的比较

有了上述的知识,我们现在开始看看React的更新过程。

React出现了”更新”,意味着树的结构出现了变化。

为了性能考虑,我们比较变更时,只会选择新旧两颗虚拟DOM树之间的变化比较。

image-20200521143928036

以上面图中的变更为例。

对于我们肯定很容易想到,只要对节点B和节点C交换,再向节点E添加一个子节点F就可以了。

但是对于React来说,不是这样。

对两颗树做“完全”的比较是一个复杂度为O($n^3$)的算法。

这么高的时间复杂度是不能接受的。

启发式Diffing算法

React在做比较算法时做了一个假设——在HTML的DOM节点中,叶节点(包括子树)的添加和删除是常态,而插入和移动是罕见的。

这个假设带来的就是,在React的比较算法中,只要发现对应节点位置的元素不一致,就会将整个节点对应的子树全部更新。

image-20200521145147535

以上图为例,在比较到B节点位置发现节点类型变化了,所以整个子树发生了替换。从而对应的HTML的DOM子树也发生了更新。

这种启发式算法只需要对整个虚拟DOM树做一次遍历,所以复杂度降低到了O(n)。

没有第二颗树

聊到这里,我们再思考一下。我们真的有必要把新的虚拟DOM树完全构建出来么?

既然我们的diff算法只是遍历一次虚拟DOM树,那么我们就可以把生成节点和比较放在一起。

还是以上一个小节的例子,模拟一遍React的diff过程。

首先是节点A的state发生了变更,所以触发了自身的render方法,得到子节点C和E。

image-20200521155756447

注意,此时节点C和E还没有完全“创建”,即没有走构造函数,只是一个用于比较的中间对象。

接着就可以对节点B和C进行diff。

image-20200521155939450

diff结果,发现是不一样类型的对象。

因此,需要对虚拟DOM中B的子树进行销毁,然后替换为节点C。

此时B子树的组件都会执行componentWillUnmount

然后创建组件C的实例,挂载到虚拟DOM,并且执行componentDidMount

image-20200521160137258

C节点作为一个新挂载的节点,不需要进行diff,直接将子节点生成并挂载即可。

image-20200521160720098

接着对比节点E,此时发现节点E的类型相同。

此时React不会创建新的实例,而是复用E原有实例,并且传入props,进行render。

image-20200521160907739

由于节点E原来没有子节点,因此直接挂载节点F即可。

image-20200521160951730

我们可以看到React的整个渲染更新过程,只有在一个虚拟DOM树上进行更新。

这样可以尽量减少时间和内存消耗

render和虚拟DOM树更新是两个过程

最后有一个小问题,下图中由于A节点的state发生改变,导致虚拟DOM更新,会导致哪些组件触发render

(注:图中每个节点都是React.Component,线段上表示传入的props)

image-20200521164817794

当当当当,答案是全都会触发render。

有同学问了,为什么会这样,明明B节点子树都没有变化,为什么要触发这些“多余”的render。

这里,其实很容易混淆的一点是,render和虚拟DOM树更新,是两个过程。

当我们在对节点B进行diff算法的时候,我们并不知道,节点B的子节点渲染出的结果一定是一致的。

所以React必须对每一个组件调用render方法,再进行对比。

props和state决定render结果

有同学会不服气,明明是传入的相同的props,渲染结果为什么会不一致?

能这么问,说明你平时开发保持很好的习惯。

对于React组件来说,推荐的情况是props和state决定render结果。

如果这么做了,的确能够在比较节点B后,就确定B子树的render结果一定相同。

但是现实是,React没有办法约束大家这么做。

开发权在我们手里,我们完全可以让一个组件每次都随机生成render的结果。

所以React没有办法,只能依次比较。

React.PureComponent

那有没有解决方案呢?

有,你可以把一个组件继承自React.PureComponent

这样会对React表明,这个组件一定是由props和state决定render结果。

这样React在节点B对比后,就不会继续比较它的子树了。

(注意:React.PureComponent还是有一些使用前提的,这里暂时不展开,大家可以去看官网文档)

小结

这次我们探索了React的渲染和更新机制,发现了以下几点:

  • React通过js控制渲染
  • 通过启发式diff算法,减少时间复杂度
  • 通过单独的虚拟DOM减少空间复杂度
  • 发现render和DOM更新属于不同的过程

正是这些算法的一步步优化,实现了React的高性能渲染和更新方案。

让React的使用者提升了开发效率,也增强了产品的用户体验


参考文档:


本文会经常更新,请阅读原文: https://xinyuehtx.github.io/post/%E7%90%86%E8%A7%A3React%E7%9A%84%E6%B8%B2%E6%9F%93%E6%9B%B4%E6%96%B0.html ,以避免陈旧错误知识的误导,同时有更好的阅读体验。

知识共享许可协议 本作品采用 知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议 进行许可。欢迎转载、使用、重新发布,但务必保留文章署名黄腾霄(包含链接: https://xinyuehtx.github.io ),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请 与我联系