React 虚拟Dom渲染算法

React提供了一系列声明性的API接口,因此在使用时不必担心每次库的更新会修改API接口。这样可以降低编写应用的复杂度,但是带来的问题是无法很好的理解React是如何实现这些功能的。这篇文章会介绍React的差异比对算法——“融合算法”是如何执行的。

差异匹配算法实现的前提

我们先来看看第一个值得关注的我问题: render() 方法的作用是创建React元素的树形结构,当state或props发生更新后, render() 会返回一个与之前有差异的结构树。在这个机制下,React需要弄清楚如何匹配最近的树并有效的更新UI。

针对以上问题,有一些通用的算法可供参考,比如比对2颗树的差异,在前一个颗树的基础上生成最小操作树,但是这个算法的时间复杂度为n的三次方=O(n*n*n),当树的节点较多时,这个算法的时间代价会导致算法几乎无法工作。

假设在我们使用React时,一共使用了1000个Dom标签元素,那么使用上面的算法,我们要比对数亿次才能得到比对的结果,根本不可能在一个浏览器中短时间完成。React实现了一个计算复杂度是O(n)的算法来解决这个问题,这个算法基于2个假设:

  1. 不同类型的2个标签元素产生不同的树。
  2. 开发人员可以为不同的子节点在渲染之前设定一个“key”属性值。

差异算法

对于2颗有差异的树,React首先比对2颗树的根节点。根据跟节点的类型是否相同,算法接下来会执行不同的操作。

Types不一样

一旦2棵树之间的根元素类型不一样,React会直接移除旧的树并构建出新的树。例如从 <a> 变更为 <img>、 <Article> 变更为 <Comment>、 <Button> 变更为 <div> ,所有的这些变化都会导致整颗树重构。

重构一棵新的树时,所有的旧节点都会移除。组件的componentWillUnmount()方法会被调用。 然后到构建完成之后新的Dom会替换原来的Dom。此时组件的componentWillMount()componentDidMount()会依次被调用。旧树Dom上的所有状态都会丢失。

根据这个特性,根节点之后的所有组件都会卸载并重建,状态也会随之改变。例如下面2个组件对比:

<div>
  <Counter />
</div>

<span>
  <Counter />
</span>

Counter 组件会被销毁并重新安装一个新的组件。

Dom元素拥有相同的类型

当比较React元素为相同类型时,React会查看元素上的属性来比对。比对之后,React会保持的Dom节点不改变然后仅仅更新不同的属性值,例如:

<div className="before" title="stuff" />

<div className="after" title="stuff" />

在比对这2个元素之后,React知道仅仅需要修改当前Dom的className。在更新style时,React同样知道仅仅需要更新修改部分即可。例如:

<div style={{color: 'red', fontWeight: 'bold'}} />

<div style={{color: 'green', fontWeight: 'bold'}} />

在转换这2个组件时,React知道仅仅需要修改color的样式,而fontWeight不必发生变动。

在处理完当前Dom节点后,React依次对子节点进行递归。

组件元素拥有相同的类型

当一个组件发生更新后,实例依然是原来的实例,所以状态还是以前的状态。React通过属性值(props)的更新来影响需要更新组件,此时组件实例的 componentWillReceiveProps() 和 componentWillUpdate() 方法会被调用。

然后, render() 方法会被调用并返回一个Dom,差异算法会递归比对之前返回Dom的差异。

递归子元素

默认情况下,在递归子元素的Dom节点时,React同时对2个子元素列表进行迭代比对,如果发现差异都会产生一个突变(关于突变的概念请见React学习第六篇性能优化介绍不可变数据结构部分)。

例如,当增加一个元素在子元素的队尾,这2颗树的转换效率很高:

<ul>
  <li>first</li>
  <li>second</li>
</ul>

<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

React先匹配 <li>first</li> 2棵树,然后再匹配 <li>second</li> 。最后直接就添加 <li>third</li> 节点。

如果代码按下面的方式修改2颗树,执行的效率相对较差:

<ul>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

<ul>
  <li>Connecticut</li>
  <li>Duke</li>
  <li>Villanova</li>
</ul>

React会突变修改所有的子节点,最终 <li>Duke</li> and <li>Villanova</li> 会被重新渲染。所以这种方式会带来很大的效率问题。

Keys

为了解决上面的问题,React提供了一个“key”属性。当所有的子元素都有一个key值,React直接使用key值来比对树形结构中的所有子节点列表。例如为上面的的例子增加一个key会大大的提升转换效率:

<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

现在React可以知道key='2014'的节点是一个新值另外2个节点仅仅需要移动一下位置。

在实际使用中,key值并不难找。在常规业务中,很多列表都自然包含业务相关的ID了:

<li key={item.id}>{item.name}</li>

当无法使用业务ID时,也可以额外增加一个ID值来标记列表差异,比如根据要使用的数据生成一个hash值,React不需要key值全局唯一,只需要在兄弟节点之间保持唯一即可。

最差情况下,你可以使用索引数据(0、1、2、....n)。使用索引需要注意的是,如果列表发生重新排序效率会很糟糕。

一些常见的问题

在使用React时需要谨记每次调用 render() 方法,它总会尝试比对调用前后2棵树是否一致。在某些极端情况下,虽然最终呈现效果并没有发生多大的变化,但是有可能每一个简单的操作都导致React全局重新渲染(例如列表没有Key)。

React在当前版本的实现中还存在一个问题,可以快捷的告知React子树中某个节点的位置已经发生改变,但是无法告知React他移动到了什么位置。因此在遇到这种情况时,算法会重构整个子树。这个问题告诉我们,如果遇到弹窗之类需要偶尔出现的组件,最好是通过隐藏属性控制他,而非直接移除Dom。

React依赖启发式算法,如果本文开篇提到的2个基本假设不成立,那么会导致算法效率极差。

  1. 算法不会尝试匹配不同2个组件之间的子树。如果编码中发现2个组件之间有非常相似的输出,应该尝试将2个组件合并为一个类型的组件。在实际应用中,我们还没发现这样导致问题。
  2. 用作列表的key值最好是稳定、可预见、唯一的。易变的key值(比如由Math.random()方法生成的值)将会导致许多组件实例和Dom节点被非必要的重新创建,这会导致性能低下且子组件丢失已有的状态。 

原创声明,本文系作者授权云+社区发表,未经许可,不得转载。

如有侵权,请联系 yunjia_community@tencent.com 删除。

发表于

我来说两句

0 条评论
登录 后参与评论

相关文章

来自专栏程序生活

括号配对问题描述输入输出样例输入样例输出解析代码实现运行结果参考链接

括号配对问题-题目链接 描述 现在,有一行括号序列,请你检查这行括号是否配对。 输入 第一行输入一个数N(0<N<=100),表示有N组测试数据。后面的N行输入...

34050
来自专栏极客编程

用Vue.js递归组件构建一个可折叠的树形菜单

递归组件常用于在blog上显示注释、嵌套的菜单,或者基本上是父和子相同的类型,尽管具体内容不同。例如:

1.9K10
来自专栏Google Dart

AngularDart4.0 指南- 模板语法一 顶

学习如何编写显示数据并在数据绑定的帮助下使用用户事件的模板。 Angular应用程序管理用户看到和可以做的事情,通过组件类实例(组件)和面向用户的模板的交互来...

14910
来自专栏Micro_awake web

HTML&CSS书写规范

第一部分:HTML书写规范: 1.1 HTML整体结构: 1.1.1:HTML基础设施: 文档以"<!DOCTYPE...>"首行顶格开始,推荐使用"<!DOC...

248100
来自专栏纯洁的微笑

springboot(四):thymeleaf使用详解

在上篇文章springboot(二):web综合开发中简单介绍了一下thymeleaf,这篇文章将更加全面详细的介绍thymeleaf的使用。thymeleaf...

820100
来自专栏前端儿

前端代码相关规范

项目目录和文件的命名使用小写字母,避免使用大写或驼峰,多个单词以下划线 _ 分隔  如:my_project/cast_detail.js

41730
来自专栏技术墨客

React学习(9)—— 高阶应用:虚拟Dom差异比对算法

React提供了一系列声明性的API接口,因此在使用时不必担心每次库的更新会修改API接口。这样可以降低编写应用的复杂度,但是带来的问题是无法很好的理解Reac...

10420
来自专栏Angular&服务

Angular2 组件(页面)之间如何传值

在Angular 2中,数据和事件变化检测从上到下发生从<b>父级到子级。</b>

77550
来自专栏软件开发

前端MVC Vue2学习总结(三)——模板语法、过滤器、计算属性、观察者、Class 与 Style 绑定

Vue.js 使用了基于 HTML 的模版语法,允许开发者声明式地将 DOM 绑定至底层 Vue 实例的数据。所有 Vue.js 的模板都是合法的 HTML ,...

646100
来自专栏葡萄城控件技术团队

CoffeeScript和Sass提高Web开发效率

如果您是一位每天都要编写JavaScript和Css的Web前端开发人员,可能您已经开始感觉到JavaScript的关键字 var, function, {} ...

23970

扫码关注云+社区

领取腾讯云代金券