AICon日程100%就绪,9折倒计时最后一周 了解详情
写点什么

深入浅出 React(四):虚拟 DOM Diff 算法解析

  • 2015-09-15
  • 本文字数:3787 字

    阅读完需:约 12 分钟

React 中最神奇的部分莫过于虚拟 DOM,以及其高效的 Diff 算法。这让我们可以无需担心性能问题而”毫无顾忌”的随时“刷新”整个页面,由虚拟 DOM 来确保只对界面上真正变化的部分进行实际的 DOM 操作。React 在这一部分已经做到足够透明,在实际开发中我们基本无需关心虚拟 DOM 是如何运作的。然而,作为有态度的程序员,我们总是对技术背后的原理充满着好奇。理解其运行机制不仅有助于更好的理解 React 组件的生命周期,而且对于进一步优化 React 程序也会有很大帮助。

什么是 DOM Diff 算法

Web 界面由 DOM 树来构成,当其中某一部分发生变化时,其实就是对应的某个 DOM 节点发生了变化。在 React 中,构建 UI 界面的思路是由当前状态决定界面。前后两个状态就对应两套界面,然后由 React 来比较两个界面的区别,这就需要对 DOM 树进行 Diff 算法分析。

即给定任意两棵树,找到最少的转换步骤。但是标准的的 Diff 算法复杂度需要 O(n^3),这显然无法满足性能要求。要达到每次界面都可以整体刷新界面的目的,势必需要对算法进行优化。这看上去非常有难度,然而 Facebook 工程师却做到了,他们结合 Web 界面的特点做出了两个简单的假设,使得 Diff 算法复杂度直接降低到 O(n)

  1. 两个相同组件产生类似的 DOM 结构,不同的组件产生不同的 DOM 结构;
  2. 对于同一层次的一组子节点,它们可以通过唯一的 id 进行区分。

算法上的优化是 React 整个界面 Render 的基础,事实也证明这两个假设是合理而精确的,保证了整体界面构建的性能。

不同节点类型的比较

为了在树之间进行比较,我们首先要能够比较两个节点,在 React 中即比较两个虚拟 DOM 节点,当两个节点不同时,应该如何处理。这分为两种情况:(1)节点类型不同 ,(2)节点类型相同,但是属性不同。本节先看第一种情况。

当在树中的同一位置前后输出了不同类型的节点,React 直接删除前面的节点,然后创建并插入新的节点。假设我们在树的同一位置前后两次输出不同类型的节点。

复制代码
renderA: <div />
renderB: <span />
=> [removeNode <div />], [insertNode <span />]

当一个节点从 div 变成 span 时,简单的直接删除 div 节点,并插入一个新的 span 节点。这符合我们对真实 DOM 操作的理解。

需要注意的是,删除节点意味着彻底销毁该节点,而不是再后续的比较中再去看是否有另外一个节点等同于该删除的节点。如果该删除的节点之下有子节点,那么这些子节点也会被完全删除,它们也不会用于后面的比较。这也是算法复杂能够降低到 O(n)的原因。

上面提到的是对虚拟 DOM 节点的操作,而同样的逻辑也被用在 React 组件的比较,例如:

复制代码
renderA: <Header />
renderB: <Content />
=> [removeNode <Header />], [insertNode <Content />]

当 React 在同一个位置遇到不同的组件时,也是简单的销毁第一个组件,而把新创建的组件加上去。这正是应用了第一个假设,不同的组件一般会产生不一样的 DOM 结构,与其浪费时间去比较它们基本上不会等价的 DOM 结构,还不如完全创建一个新的组件加上去。

由这一 React 对不同类型的节点的处理逻辑我们很容易得到推论,那就是 React 的 DOM Diff 算法实际上只会对树进行逐层比较,如下所述。

逐层进行节点比较

提到树,相信大多数同学立刻想到的是二叉树,遍历,最短路径等复杂的数据结构算法。而在 React 中,树的算法其实非常简单,那就是两棵树只会对同一层次的节点进行比较。如下图所示:

React 只会对相同颜色方框内的 DOM 节点进行比较,即同一个父节点下的所有子节点。当发现节点已经不存在,则该节点及其子节点会被完全删除掉,不会用于进一步的比较。这样只需要对树进行一次遍历,便能完成整个 DOM 树的比较。

例如,考虑有下面的 DOM 结构转换:

A 节点被整个移动到 D 节点下,直观的考虑 DOM Diff 操作应该是

复制代码
A.parent.remove(A);
D.append(A);

但因为 React 只会简单的考虑同层节点的位置变换,对于不同层的节点,只有简单的创建和删除。当根节点发现子节点中 A 不见了,就会直接销毁 A;而当 D 发现自己多了一个子节点 A,则会创建一个新的 A 作为子节点。因此对于这种结构的转变的实际操作是:

复制代码
A.destroy();
A = new A();
A.append(new B());
A.append(new C());
D.append(A);

可以看到,以 A 为根节点的树被整个重新创建。

虽然看上去这样的算法有些“简陋”,但是其基于的是第一个假设:两个不同组件一般产生不一样的 DOM 结构。根据 React 官方博客,这一假设至今为止没有导致严重的性能问题。这当然也给我们一个提示,在实现自己的组件时,保持稳定的 DOM 结构会有助于性能的提升。例如,我们有时可以通过 CSS 隐藏或显示某些节点,而不是真的移除或添加 DOM 节点。

由 DOM Diff 算法理解组件的生命周期

上一篇文章中介绍了 React 组件的生命周期,其中的每个阶段其实都是和 DOM Diff 算法息息相关的。例如以下几个方法:

  • constructor: 构造函数,组件被创建时执行;
  • componentDidMount: 当组件添加到 DOM 树之后执行;
  • componentWillUnmount: 当组件从 DOM 树中移除之后执行,在 React 中可以认为组件被销毁;
  • componentDidUpdate: 当组件更新时执行。

为了演示组件生命周期和 DOM Diff 算法的关系,笔者创建了一个示例: https://supnate.github.io/react-dom-diff/index.html ,大家可以直接访问试用。这时当 DOM 树进行如下转变时,即从“shape1”转变到“shape2”时。我们来观察这几个方法的执行情况:

浏览器开发工具控制台输出如下结果:

复制代码
C will unmount.
C is created.
B is updated.
A is updated.
C did mount.
D is updated.
R is updated.

可以看到,C 节点是完全重建后再添加到 D 节点之下,而不是将其“移动”过去。如果大家有兴趣,也可以 fork 示例代码: https://github.com/supnate/react-dom-diff 。从而可以自己添加其它树结构,试验它们之间是如何转换的。

相同类型节点的比较

第二种节点的比较是相同类型的节点,算法就相对简单而容易理解。React 会对属性进行重设从而实现节点的转换。例如:

复制代码
renderA: <div id="before" />
renderB: <div id="after" />
=> [replaceAttribute id "after"]

虚拟 DOM 的 style 属性稍有不同,其值并不是一个简单字符串而必须为一个对象,因此转换过程如下:

复制代码
renderA: <div style={{color: 'red'}} />
renderB: <div style={{fontWeight: 'bold'}} />
=> [removeStyle color], [addStyle font-weight 'bold']

列表节点的比较

上面介绍了对于不在同一层的节点的比较,即使它们完全一样,也会销毁并重新创建。那么当它们在同一层时,又是如何处理的呢?这就涉及到列表节点的 Diff 算法。相信很多使用 React 的同学大多遇到过这样的警告:

这是 React 在遇到列表时却又找不到 key 时提示的警告。虽然无视这条警告大部分界面也会正确工作,但这通常意味着潜在的性能问题。因为 React 觉得自己可能无法高效的去更新这个列表。

列表节点的操作通常包括添加、删除和排序。例如下图,我们需要往 B 和 C 直接插入节点 F,在 jQuery 中我们可能会直接使用 $(B).after(F) 来实现。而在 React 中,我们只会告诉 React 新的界面应该是 A-B-F-C-D-E,由 Diff 算法完成更新界面。

这时如果每个节点都没有唯一的标识,React 无法识别每一个节点,那么更新过程会很低效,即,将 C 更新成 F,D 更新成 C,E 更新成 D,最后再插入一个 E 节点。效果如下图所示:

可以看到,React 会逐个对节点进行更新,转换到目标节点。而最后插入新的节点 E,涉及到的 DOM 操作非常多。而如果给每个节点唯一的标识(key),那么 React 能够找到正确的位置去插入新的节点,入下图所示:

对于列表节点顺序的调整其实也类似于插入或删除,下面结合示例代码我们看下其转换的过程。仍然使用前面提到的示例: https://supnate.github.io/react-dom-diff/index.html ,我们将树的形态从 shape5 转换到 shape6:

即将同一层的节点位置进行调整。如果未提供 key,那么 React 认为 B 和 C 之后的对应位置组件类型不同,因此完全删除后重建,控制台输出如下:

复制代码
B will unmount.
C will unmount.
C is created.
B is created.
C did mount.
B did mount.
A is updated.
R is updated.

而如果提供了 key,如下面的代码:

复制代码
shape5: function() {
return (
<Root>
<A>
<B key="B" />
<C key="C" />
</A>
</Root>
);
},
shape6: function() {
return (
<Root>
<A>
<C key="C" />
<B key="B" />
</A>
</Root>
);
},

那么控制台输出如下:

复制代码
C is updated.
B is updated.
A is updated.
R is updated.

可以看到,对于列表节点提供唯一的 key 属性可以帮助 React 定位到正确的节点进行比较,从而大幅减少 DOM 操作次数,提高了性能。

小结

本文分析了 React 的 DOM Diff 算法究竟是如何工作的,其复杂度控制在了 O(n),这让我们考虑 UI 时可以完全基于状态来每次 render 整个界面而无需担心性能问题,简化了 UI 开发的复杂度。而算法优化的基础是文章开头提到的两个假设,以及 React 的 UI 基于组件这样的一个机制。理解虚拟 DOM Diff 算法不仅能够帮助我们理解组件的生命周期,而且也对我们实现自定义组件时如何进一步优化性能具有指导意义。


感谢徐川对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们,并与我们的编辑和其他读者朋友交流(欢迎加入 InfoQ 读者交流群)。

2015-09-15 18:3343699

评论 2 条评论

发布
用户头像
+1
2020-08-28 15:55
回复
用户头像
受益匪浅…
2020-03-12 22:50
回复
没有更多了
发现更多内容

记一次CPU持续增长的问题解决

BUG侦探

Python py-spy CPU增长问题

腾讯二面:Linux操作系统里一个进程最多可以创建多少个线程?

Java全栈架构师

Linux 程序员 架构 面试 操作系统

欧拉开发者大会即将开启,全球芯片、整机、软件厂商共建数字基础设施开源操作系统

科技热闻

java培训SpringBoot自动装配原理

@零度

JAVA开发 springboot

web前端培训nginx配置规则

@零度

nginx 前端开发

企业如何搭建一个有效的知识管理系统

小炮

企业知识管理 企业知识管理工具

用uniapp写一个内外循环的全选与反选,不会的赶紧围观

CRMEB

进阶篇|有了这招,用文本编辑器搞前端代码都能保证格式统一

Jianmu

运维 前端 自动化 工作流 格式化

看板的作用是什么?任务看板如何跟进

阿里云云效

云计算 阿里云 持续交付 看板 项目协作

如何使用参数化查询提高Cypher查询的性能

华为云开发者联盟

参数化 Cypher查询 华为云图引擎 GES 参数化查询

Android技术分享| Android 中部分内存泄漏示例及解决方案

anyRTC开发者

音视频 内存 内存泄漏 移动开发 Andriod

基于Flink-CDC数据同步方案

领创集团Advance Intelligence Group

算法 java

一张长图带你看懂物联网产业十数载“江湖风云”!

亚马逊云科技 (Amazon Web Services)

物联网

48天打造你的专属 Twilio——浅谈运营商通信中台

网易云信

通信

亚马逊云科技 loT 百亿连接力量

亚马逊云科技 (Amazon Web Services)

亚马逊云

虎符即将引入稳定币USN 并开启USN专场活动

区块链前沿News

虎符交易所 稳定币

OpenHarmony 3.1 Beta版本关键特性解析——OpenHarmony图形框架

OpenHarmony开发者

OpenHarmony 动画效果

如何优雅的记录操作日志

flyhero

Java Spring Boot 后端 造轮子 4月月更

初创企业需要CRM系统的原因

低代码小观

初创公司 企业管理系统 CRM系统 客户关系管理系统 初创型企业

从趋势到必选项,探讨企业数字化转型方式方法

华为云开发者联盟

数据 数字化 企业数字化转型 业务数字化

恒源云(Gpushare)_自动化训练小技巧白送给你,不要吗?

恒源云

OSS SSH hy-tmp

Thinkphp6实现定时任务功能详解教程

CRMEB

云智慧10年资深架构师带你了解:普通程序员向架构师成长必经之路

云智慧AIOps社区

程序人生 架构师 Meetup 晋升 成长计划

Sitemap的重要性

源字节1号

软件开发 网站优化

踩了个DNS解析的坑,但我还是没想通

捉虫大师

DNS 问题排查 4月月更

去中心化的 React Native 架构探索

Shopee技术团队

前端 去中心化 React Native

解析分布式系统的缓存设计

vivo互联网技术

分布式 服务器 缓存服务

大数据培训Hive如何控制map个数与性能调优参数

@零度

hive map 大数据开发

省掉80%配置时间,这款Mock神器免费又好用

Liam

前端 前端开发 Postman 前端教程 web前端开发

STI生态迎来新进展,登录Gate.io意味着什么?

西柚子

【高并发】一文秒懂Happens-Before原则

冰河

并发编程 多线程 协程 异步编程 精通高并发系列

深入浅出React(四):虚拟DOM Diff算法解析_语言 & 开发_王沛_InfoQ精选文章