React diff 算法与其他框架对比
Diff 算法
diff 算法探讨的就是虚拟 DOM 树发生变化后,生成 DOM 树更新补丁的方式。它通过对比新旧两株虚拟 DOM 树的变更差异,将更新补丁作用于真实 DOM,以最小成本完成视图更新
具体的流程是这样的:
-
真实 DOM 与虚拟 DOM 之间存在一个映射关系。这个映射关系依靠初始化时的 JSX 建立完成; -
当虚拟 DOM 发生变化后,就会根据差距计算生成 patch,这个 patch 是一个结构化的数据,内容包含了增加、更新、移除等; -
最后再根据 patch 去更新真实的 DOM,反馈到用户的界面上。
举一个简单易懂的例子:
import React from 'react'
export default class ExampleComponent extends React.Component {
render() {
if(this.props.isVisible) {
return <div className="visible">visbile</div>;
}
return <div className="hidden">hidden</div>;
}
}
这里,首先我们假定 ExampleComponent 可见,然后再改变它的状态,让它不可见 。映射为真实的 DOM 操作是这样的,React 会创建一个 div 节点。
<div class="visible">visbile</div>
当把 visbile 的值变为 false 时,就会替换 class 属性为 hidden,并重写内部的 innerText 为 hidden。这样一个生成补丁、更新差异的过程统称为 diff 算法。
在整个过程中你需要注意 3 点:更新时机、遍历算法、优化策略
,这些也是面试官最爱考察的。
更新时机
更新时机就是触发更新、进行差异对比的时机。更新发生在
setState
、Hooks
调用等操作以后。此时,树的结点发生变化,开始进行比对。那这里涉及一个问题,即两株树如何对比差异?
这里就需要使用遍历算法。
遍历算法
遍历算法是指沿着某条搜索路线,依次对树的每个节点做访问。通常分为两种:深度优先遍历和广度优先遍历。
-
深度优先遍历,是从根节点出发,沿着左子树方向进行纵向遍历,直到找到叶子节点为止。然后回溯到前一个节点,进行右子树节点的遍历,直到遍历完所有可达节点。 -
广度优先遍历,则是从根节点出发,在横向遍历二叉树层段节点的基础上,纵向遍历二叉树的层次。
React 选择了哪一种遍历方式呢?它的 diff 算法采用了深度优先遍历算法。因为广度优先遍历可能会导致组件的生命周期时序错乱,而深度优先遍历算法就可以解决这个问题。
优化策略
优化策略是指 React 对 diff 算法做的优化手段。
虽然深度优先遍历保证了组件的生命周期时序不错乱,但传统的 diff 算法也带来了一个严重的性能瓶颈,复杂程度为 O(n^3)
(后期备注上角标),其中 n 表示树的节点总数。正如计算机科学中常见的优化方案一样,React 用了一个非常经典的手法将复杂度降低为 O(n),也就是分治,即通过“分而治之”这一巧妙的思想分解问题。
具体而言, React 分别从树、组件及元素三个层面进行复杂度的优化,并诞生了与之对应的策略。
策略一:忽略节点跨层级操作场景,提升比对效率。
这一策略需要进行树比对,即对树进行分层比较。树比对的处理手法是非常“暴力”的,即两棵树只对同一层次的节点进行比较,如果发现节点已经不存在了,则该节点及其子节点会被完全删除掉,不会用于进一步的比较,这就提升了比对效率
策略二:如果组件的 class 一致,则默认为相似的树结构,否则默认为不同的树结构。
在组件比对的过程中:
-
如果组件是同一类型则进行树比对; -
如果不是则直接放入补丁中。 -
只要父组件类型不同,就会被重新渲染。这也就是为什么
shouldComponentUpdate
、PureComponent
及React.memo
可以提高性能的原因。
策略三:同一层级的子节点,可以通过标记 key 的方式进行列表对比。
元素比对主要发生在同层级中,通过标记节点操作生成补丁。节点操作包含了插入、移动、删除等。其中节点重新排序同时涉及插入、移动、删除三个操作,所以效率消耗最大,此时策略三起到了至关重要的作用。
通过标记 key 的方式,React 可以直接移动 DOM 节点,降低内耗。操作代码如下:
<ul>
<li key="a">a</li>
<li key="b">b</li>
<li key="c">c</li>
<li key="d">d</li>
</ui>
以上是 React Diff 算法最基本的内容,除此以外,由于 React 16 引入Fiber 设计,所以我们还需要了解 Fiber 给 diff 算法带来的影响。
Fiber 机制下节点与树分别采用 FiberNode 与 FiberTree 进行重构。FiberNode 使用了双链表的结构,可以直接找到兄弟节点与子节点,使得整个更新过程可以随时暂停恢复。FiberTree 则是通过 FiberNode 构成的树。
Fiber 机制下,整个更新过程由 current 与 workInProgress 两株树双缓冲完成。当 workInProgress 更新完成后,通过修改 current 相关指针指向的节点,直接抛弃老树,虽然非常简单粗暴,却非常合理。
这些就是 React 中 diff 算法的回答要点,我们再来看看其他框架需要掌握什么。
其他框架
Preact
在众多的 React-like 框架中,Preact 适用范围最广,生命力最强。它以仅 3kb 的小巧特点应用于对体积追求非常极致的场景。也正因为体积受限,Preact 在 diff 算法上做了裁剪。
以下 Preact 的 diff 算法的图示,可以看到它将 diff 分为了三个类型:Fragment、Component 及 DOM Node。
-
Fragment 对应 React 的树比较; -
Component 对应组件比较,它们在原理上是相通的,所以这里我们不再赘述; -
最大的不同在于 DOM Node 这一层,Preact 并没有 Patch 的过程,而是直接更新 DOM 节点属性。
Vue
Vue 2.0 因为使用了 snabbdom,所以整体思路与 React 相同。但在元素对比时,如果新旧两个元素是同一个元素,且没有设置 key 时,snabbdom 在 diff 子元素中会一次性对比旧节点、新节点及它们的首尾元素四个节点,以及验证列表是否有变化。Vue 3.0 整体变化不大,依然没有引入 Fiber 等设计,也没有时间切片等功能。
总结
在回答有何不同之前,首先需要说明下什么是 diff 算法。
-
diff 算法是指生成更新补丁的方式,主要应用于虚拟 DOM 树变化后,更新真实 DOM。所以 diff 算法一定存在这样一个过程:触发更新 → 生成补丁 → 应用补丁。 -
React 的 diff 算法,触发更新的时机主要在 state 变化与 hooks 调用之后。此时触发虚拟 DOM 树变更遍历,采用了深度优先遍历算法。但传统的遍历方式,效率较低。为了优化效率,使用了分治的方式。将单一节点比对转化为了 3 种类型节点的比对,分别是树、组件及元素,以此提升效率。 -
树比对:由于网页视图中较少有跨层级节点移动,两株虚拟 DOM 树只对同一层次的节点进行比较。 -
组件比对:如果组件是同一类型,则进行树比对,如果不是,则直接放入到补丁中。 -
元素比对:主要发生在同层级中,通过标记节点操作生成补丁,节点操作对应真实的 DOM 剪裁操作。
以上是经典的 React diff 算法内容。自 React 16 起,引入了 Fiber 架构。为了使整个更新过程可随时暂停恢复,节点与树分别采用了 FiberNode 与 FiberTree 进行重构。fiberNode 使用了双链表的结构,可以直接找到兄弟节点与子节点。
整个更新过程由 current 与 workInProgress 两株树双缓冲完成。workInProgress 更新完成后,再通过修改 current 相关指针指向新节点。
然后拿 Vue 和 Preact 与 React 的 diff 算法进行对比。
Preact 的 Diff 算法相较于 React,整体设计思路相似,但最底层的元素采用了真实 DOM 对比操作,也没有采用 Fiber 设计。Vue 的 Diff 算法整体也与 React 相似,同样未实现 Fiber 设计。
然后进行横向比较,React 拥有完整的 Diff 算法策略,且拥有随时中断更新的时间切片能力,在大批量节点更新的极端情况下,拥有更友好的交互体验。
Preact 可以在一些对性能要求不高,仅需要渲染框架的简单场景下应用。
Vue 的整体 diff 策略与 React 对齐,虽然缺乏时间切片能力,但这并不意味着 Vue 的性能更差,因为在 Vue 3 初期引入过,后期因为收益不高移除掉了。除了高帧率动画,在 Vue 中其他的场景几乎都可以使用防抖和节流去提高响应性能。
end
1. 如果觉得文章帮到您「点赞、在看、分享」,让更多人也能看到这篇文章