虚拟 DOM 与 Diff 算法

1. 为什么需要 Virtual DOM?

很多人以为 Virtual DOM (虚拟 DOM) 是为了“快”,其实不全对。

(1) 核心价值:跨平台

Virtual DOM 本质上是一个 JavaScript 对象。因为它不依赖于浏览器的 DOM API,所以它可以被渲染到任何地方:

  • Web: ReactDOM / Vue Runtime Dom
  • 移动端: React Native / Weex
  • 小程序: Taro / UniApp

(2) 核心价值:声明式编程

它让开发者不需要手动操作 DOM(如 jQuery 时代),只需要关注状态。框架会自动通过 Diff 算法计算出最小的变更并应用到真实 DOM 上。

(3) 关于性能

  • 首次渲染:Virtual DOM 因为多了一层计算,其实比直接操作原生的 innerHTML
  • 更新渲染:在大量数据更新时,Virtual DOM 可以通过 Diff 算法避免不必要的全量重绘,比 innerHTML 全量替换要
  • 结论:Virtual DOM 保证了性能的下限,让你在不进行手动优化的情况下也能获得不错的性能。

2. Diff 算法原理

Diff 的目标是找出两个树结构的最小变更。

  • 传统树 Diff 复杂度:O(n^3)。(需要遍历、比较、修正)。
  • React/Vue 优化后的复杂度O(n)

O(n) 的秘诀 (三大假设)

  1. 分层对比:只对比同级元素,不跨级比较。如果一个节点跨层级移动了,框架会直接销毁并重新创建,而不是移动它。
  2. 类型判断:如果两个节点的标签类型不同 (如 divp),直接销毁重建整个子树,不再去比较子节点。
  3. Key 标识:对于同一层级的子节点列表,通过唯一的 key 来区分。

3. React vs Vue 的 Diff 区别

React (Fiber Diff)

  • 单节点 diff:比较 type 和 props。
  • 多节点 diff:两轮遍历。第一轮处理更新/删除;第二轮处理新增/移动。
  • 核心策略右移策略。React 只会把节点向右移动。如果一个节点本该在左边但现在的下标比较小,它不动,而是更新。

Vue 2 (双端 Diff)

  • 策略:使用 4 个指针(旧头、旧尾、新头、新尾)。
  • 过程:互相进行比较(头头、尾尾、头尾、尾头)。
  • 优点:能更好地处理节点的移动(尤其是反转列表),比 React 的单向移动策略复用率更高。

Vue 3 (快速 Diff)

  • 策略:最长递增子序列 (LIS)。
  • 过程
    1. 先处理头部的相同节点和尾部的相同节点。
    2. 对剩余的乱序节点,生成一个 source 数组,计算最长递增子序列
  • 优势:LIS 算法能计算出最少的移动次数,性能最好。

4. Key 到底有什么用?

Key 是框架用来识别列表元素身份的“身份证”。Diff 算法通过 Key 判断一个元素是新增、删除还是移动:

  • 新列表中出现了新的 Key → 新增
  • 旧列表中的 Key 消失了 → 删除
  • Key 仍然存在但位置变了 → 移动或复用

如果没有稳定的 Key,框架通常只能按位置就地复用、逐个对比。这样不仅会产生不必要的更新,还可能导致组件状态错乱。

没有 Key:就地复用

例如:[A, B, C][A, C, B]

React/Vue 会认为第二个位置从 B 变成了 C,第三个位置从 C 变成了 B,于是更新这两个组件的内容,而不是移动对应的 DOM 节点。如果组件有状态,例如输入框中已经有值,状态就可能跟随被复用的组件留在原位置。

有稳定的 Key

框架知道 Key 为 B 的节点只是移动了位置,可以复用对应节点和组件状态,减少不必要的更新。

为什么不能用 index?

静态列表可以用 index。如果列表永远不增、不减、不排序,index 能够保持稳定。

但动态列表发生头部插入时:

// 原列表:A、B、C 的 key 分别为 0、1、2
[A, B, C]

// 头部插入 New 后,New、A、B、C 的 key 分别为 0、1、2、3
[New, A, B, C]

框架会按 Key 复用原有组件:

  • key=0 的组件从 A 更新成 New
  • key=1 的组件从 B 更新成 A
  • key=2 的组件从 C 更新成 B
  • C 被识别为新增

结果是原有组件都被更新了一遍。如果列表项包含非受控组件,问题会更明显:

items.map((item, index) => <input key={index} defaultValue={item.name} />);

在列表中间插入一项后,输入框内容可能“跑到”其他行,因为框架按 index 复用了组件实例,其内部状态也一起被保留。

Key 的正确选择

使用数据 ID(推荐)

items.map((item) => <Item key={item.id} {...item} />);

来自数据源且不会随列表顺序变化的唯一 ID 最可靠。

使用稳定的内容标识

如果数据没有 ID,可以根据不会变化且不重复的字段生成稳定标识:

items.map((item) => <Item key={generateHash(item.stableField)} {...item} />);

不要在渲染时生成随机 Key,否则每次渲染都会被视为全新的节点。

静态列表才使用 index

items.map((item, index) => <div key={index}>{item.name}</div>);
场景Key 的选择
数据库数据使用 item.id
无 ID 的稳定数据使用不变且唯一的内容标识
不增、不减、不排序的静态列表可以使用 index

一句话总结:列表会变化就使用稳定的唯一 ID,不要使用 index。

5. Vue 3 的编译时优化

除了 Diff 算法优化,Vue 3 最大的杀器是编译时优化

  • Patch Flags:编译模板时,给动态节点打标记(如 Text, Class, Style)。Diff 时只对比有标记的节点,忽略静态节点。
  • Hoist Static:静态节点提升,只创建一次。
  • Block Tree:配合 Patch Flags,实现了 Diff 性能与模板大小无关,只与动态节点数量相关。这是 React 做不到的(React 是完全运行时的)。