技术知识文章集合TECHNICAL ARCHIVE · 457 DOCUMENTS

显示模式

登录
ARCHIVE DOCUMENTVUE

探索 Virtual DOM 的前世今生

所属馆藏
Vue
文件格式
Markdown
原始路径
Vue/81-探索Virtual DOM的前世今生
本文目录9 个章节
  1. 一、Virtual DOM 是什么
  2. 二、为什么需要 Diff
  3. 三、Diff 的三条常见策略
  4. 四、Vue 中的 Virtual DOM
  5. 五、Snabbdom 的 VNode 与 patch
  6. 六、React 的 reconciliation
  7. 七、Virtual DOM 的边界和取舍
  8. 总结
  9. 参考资料

探索 Virtual DOM 的前世今生

原文从 Virtual DOM 的动机、Diff 策略、Snabbdom 和 React Fiber 展开。本文保留这条历史与源码学习路线,并修正“Virtual DOM 一定提升 DOM 性能”“Fiber 是轻量线程”“Diff 一定是 O(n)”等容易误导的表述。Snabbdom 和 React 的源码实现也会随版本变化,示例以当前概念和历史版本差异为边界。

一、Virtual DOM 是什么

Virtual DOM(VDOM)不是浏览器标准,也不是唯一固定的数据结构,而是一种用 JavaScript 对象描述 UI,再由 renderer 同步到真实宿主环境的模式:

const vnode = {
  type: 'div',
  props: { id: 'app' },
  children: [
    { type: 'span', children: 'Hello' },
  ],
}

renderer 可以遍历 VNode 树创建真实节点,这个过程通常称为 mount;当有新旧两棵 VNode 树时,renderer 可以比较并提交变化,这个过程通常称为 patch、diff 或 reconciliation

模型数据、Virtual DOM 与 DOM 的关系(已本地化)

Virtual DOM 的主要价值是:

  • 以声明式方式表达 UI;
  • 让组件和子树可以组合;
  • 把直接 DOM 操作集中到 renderer;
  • 让同一套协调逻辑有机会适配 DOM、Native、Canvas 或其他宿主环境;
  • 结合编译器、响应式系统和调度器减少不必要工作。

它不是“内存中每一个 DOM 节点都永远有一个一一对应的副本”,也不保证比手写 DOM 更快。VNode 创建、Diff、属性更新、组件调用和内存分配都有成本;是否更快要看更新范围、编译优化、浏览器布局/绘制和真实设备测量。

React 早期确实极大推动了 Virtual DOM 和数据驱动组件开发的普及,但这类思想并非凭空由 React 首次创造,Snabbdom、Mithril、早期 MV* 框架和其他 UI 系统也有相近路线。

二、为什么需要 Diff

浏览器 DOM 更新可能引起样式计算、布局、绘制或合成,但“DOM 是性能最大瓶颈”不是所有页面都成立。大量 JavaScript、网络、图片解码和布局触发同样可能成为瓶颈。

声明式 UI 的问题是:状态变化后,应用需要知道哪些真实节点应该更新。如果每次都销毁并重建整棵 DOM,工作量可能很大;如果业务代码手动维护所有增删改,又会失去声明式开发的便利。

Diff 的任务就是在新旧 UI 描述之间找出合理的更新操作:

旧 VNode 树 + 新 VNode 树
            ↓
      reconciliation / diff
            ↓
   创建、更新、移动、删除操作
            ↓
         renderer 提交

一般树编辑距离问题的通用算法可能达到 O(n³) 级别。Web UI 框架通常基于业务场景作启发式假设,把问题限制为可接受的复杂度:

  1. 主要比较同层节点,不尝试任意跨层移动;
  2. 不同类型的节点通常代表不同子树,直接替换;
  3. 列表通过 key 表达节点身份;
  4. 编译器提供静态节点和动态节点提示。

这些假设能在常见 UI 场景中取得很好的效果,但不是对所有树变化的理论最优保证。

按层级比较节点的示意图(已本地化)

三、Diff 的三条常见策略

1. 按层级比较

如果旧树和新树的根节点都表示一个 div,renderer 通常比较它们的属性和同层 children,而不会尝试把旧树某个深层节点任意搬到新树另一个层级。这样可以把通用树编辑问题转化为更适合 UI 的局部比较。

2. 按类型比较

节点类型改变时,旧子树通常没有必要和新子树逐个比较:

旧:<div>...</div>
新:<section>...</section>

常见做法是卸载旧树、创建新树。类型相同才继续 patch 属性和 children。

元素与组件节点类型差异示意图(已本地化)

“类型”在不同实现中表示不同字段:Vue VNode 可能使用 typeshapeFlag 等信息,Snabbdom 使用 selector sel,React 使用元素/组件类型和 key。不能把一种库的 sameVnode 代码直接套到另一种库。

3. 列表 Diff 和 key

列表中的插入、删除和排序需要知道业务实体是否还是同一个节点:

<li v-for="item in items" :key="item.id">
  {{ item.name }}
</li>

没有 key 时,Vue 等框架可能采用就地 patch;有稳定 key 时,可以建立旧节点到新位置的映射并移动正确节点。key 表达的是身份,不是“只渲染新节点”的开关,也不保证每次只执行一条 DOM 操作。

没有 key 与使用 key 的列表匹配示意图(已本地化)

四、Vue 中的 Virtual DOM

Vue 3 的渲染链路是:

template
  ↓ parse / transform / codegen
render function
  ↓ ReactiveEffect 执行
VNode
  ↓ mount / patch
DOM

组件首次挂载时,render effect 读取响应式依赖;依赖变化后,scheduler 安排组件更新,新的 render 结果与旧 VNode 交给 renderer patch。

View、update、patch 和 user 的循环关系(已本地化)

Vue 3 还利用 compiler 和 runtime 紧密配合进行优化:

  • 静态节点提升;
  • Patch Flags;
  • Block Tree 和动态子树扁平化;
  • keyed children 的前后缀同步和最长递增子序列;
  • SSR hydration 的动态节点快速路径。

所以更准确的说法是:Vue 的 Virtual DOM 结合响应式和编译器提示,在很多动态 UI 场景中避免不必要的工作;并不是仅靠一个通用 Diff 就获得所有性能。

五、Snabbdom 的 VNode 与 patch

Snabbdom 是一个独立的 Virtual DOM 库,以小核心、模块化和可扩展性为特点。当前 Snabbdom 3.x 的 README 使用 ESM 形式,历史版本的导入方式可能不同。

import {
  classModule,
  eventListenersModule,
  h,
  init,
  propsModule,
  styleModule,
} from 'snabbdom'

const patch = init([
  classModule,
  propsModule,
  styleModule,
  eventListenersModule,
])

const container = document.querySelector('#container')

let vnode = h('div#container', [
  h('span', { style: { fontWeight: 'bold' } }, 'Hello'),
  h('button', {
    on: { click: () => console.log('clicked') },
  }, '按钮'),
])

vnode = patch(container, vnode)

const nextVnode = h('div#container', [
  h('span', { style: { fontWeight: 'normal' } }, 'Hello Snabbdom'),
  h('button', {
    on: { click: () => console.log('updated') },
  }, '更新后的按钮'),
])

vnode = patch(vnode, nextVnode)

Snabbdom 的 h 用来创建 VNode,init 接收模块并返回 patch。第一次 patch 可以接受真实 DOM 元素,后续调用应传入上一次 patch 返回的 VNode。模块负责 class、props、style、event listeners 等能力。

1. sameVnode

Snabbdom 使用 selector 和 key 等信息判断节点是否可以复用,简化理解为:

function sameVnode(a, b) {
  return a.key === b.key && a.sel === b.sel
}

实际实现还会考虑 VNode 结构、组件/元素以及模块 hooks 等边界。不同节点会创建新节点并删除旧节点;相同节点则进入属性和 children 更新。

2. patchVnode 和 children

function patchVnode(oldVnode, vnode) {
  vnode.elm = oldVnode.elm

  if (vnode.text !== undefined && !vnode.children) {
    if (vnode.text !== oldVnode.text) {
      vnode.elm.textContent = vnode.text
    }
    return
  }

  if (oldVnode.children && vnode.children) {
    updateChildren(vnode.elm, oldVnode.children, vnode.children)
  } else if (vnode.children) {
    addVnodes(vnode.elm, vnode.children)
  } else if (oldVnode.children) {
    removeVnodes(vnode.elm, oldVnode.children)
  }
}

Snabbdom 的 updateChildren 采用与 Vue 2 类似的双端思路:维护旧/新 children 的头尾指针,先尝试头头、尾尾、头尾、尾头匹配;不匹配时借助 key map 查找旧节点,复用并移动,找不到则创建。

Snabbdom 双端 Diff 中旧头移动到新尾的示意图(已本地化)

Snabbdom 双端 Diff 中旧尾移动到新头的示意图(已本地化)

Snabbdom 通过 key map 查找新节点的示意图(已本地化)

当旧节点先遍历完,剩余新节点会被插入;当新节点先遍历完,剩余旧节点会被移除。insertBefore(parent, node, null) 会把节点插入到末尾,但真实实现还需要处理 anchor、模块 hooks、SVG、Fragment 和组件边界。

六、React 的 reconciliation

React 的高层流程可以理解为:

触发更新(初次 render / state update)
          ↓
render:调用组件,得到新的元素树
          ↓
reconciliation:比较前后树,计算变化
          ↓
commit:由 React DOM 等 renderer 提交到宿主环境
          ↓
浏览器绘制

React 文档强调,render 阶段是调用组件并计算结果,不等于立即修改 DOM;commit 阶段才执行必要的宿主更新。React 可能调用组件函数而最终不产生 DOM 变化。

1. Fiber 的历史和现在

React 16 引入 Fiber 架构,目标之一是把渲染工作拆成可调度的单元,使工作有机会暂停、恢复、重用或放弃,并可以为不同更新分配优先级。Fiber 是 React 内部的工作节点和协调架构,不是操作系统线程,也不能简单等同于 Virtual DOM。

Fiber 的常见概念包括:

  • child:第一个子 Fiber;
  • sibling:下一个兄弟 Fiber;
  • return:父 Fiber;
  • alternate:current 与 work-in-progress 两棵 Fiber 树之间的对应节点;
  • update queue:状态更新队列;
  • lanes/优先级:现代 React 调度更新的相关信息。

Fiber 节点数据示意图(已本地化)

React DOM、React Native 等 renderer 可以共享协调器,但最终提交到宿主环境的方式不同。这也是把 reconciliation 和 rendering 分离的意义。

2. React 更新和 Fiber 树

setState 或 Hook state setter 会把更新排入 React 的更新机制;这不等于“整个应用立即操作 DOM”。React 会执行相关组件的 render,依据类型、位置和 key 进行协调,并在 commit 阶段只提交必要变化。

旧的 React 16 源码中可以看到 current/alternateupdateQueueexpirationTimeeffectTag 等字段。现代 React 已经使用 lanes、flags 等不同命名和实现,阅读旧代码时必须注明版本。

Fiber 树中 dirty 节点与更新范围示意图(已本地化)

3. React 列表协调

React 列表的核心规则与 Vue 类似:

  • 不同组件/元素类型通常替换旧子树;
  • 同类型且 key/位置匹配时尝试复用;
  • key 应稳定、可预测且唯一;
  • index 只适合不会重排且没有内部状态的受限列表。

React 旧版 reconcileChildrenArray 先按位置尝试匹配,遇到 key 不匹配后建立剩余旧节点的 map,再处理移动、插入和删除。现代源码仍保留相近的高层策略,但函数签名、字段和调度机制已经改变,不应直接复制旧版本中带 expirationTime 的 Flow 代码。

4. shouldComponentUpdate 与现代优化

类组件可以使用 shouldComponentUpdatePureComponent 做更新跳过;函数组件通常使用拆分组件、稳定 props、memo 和正确的状态边界。它们都是优化手段,不是改变 React 状态语义的方式。

React shouldComponentUpdate 判断更新范围的示意图(已本地化)

React Strict Mode、并发渲染和开发环境重复调用可能暴露不纯的 render 逻辑,因此组件 render 应保持纯函数:相同输入得到相同描述,不在 render 中修改外部可变状态。

七、Virtual DOM 的边界和取舍

优点

  • 声明式 UI 更容易组合和推理;
  • renderer 统一处理挂载和更新;
  • key、编译标记和组件边界可以优化更新;
  • 协调器与宿主 renderer 分离,便于跨平台;
  • 对于复杂动态 UI,减少手写 DOM 状态同步的负担。

成本

  • 创建 VNode/元素描述有内存和 CPU 成本;
  • Diff 采用启发式假设,不是通用最优树编辑;
  • 组件函数、computed、watcher/effect 仍可能执行;
  • 不正确的 key、过大的更新边界和不稳定 props 会放大成本;
  • 服务器渲染还要考虑序列化、hydration 和客户端一致性。

因此,不能把 Virtual DOM 概括为“避免所有 DOM 操作”或“一定比手写 DOM 快”。它提供的是一个声明式协调层,实际性能要结合编译器、响应式、scheduler、DOM 操作和真实测量判断。

总结

  • Virtual DOM 是描述 UI 并由 renderer 同步到宿主环境的一种模式;
  • Diff 通常利用同层比较、类型假设和 key,把通用树比较限制到常见 UI 场景;
  • Vue 2/Snabbdom 常见双端 children Diff,Vue 3 还结合 Patch Flags、Block Tree 和 LIS;
  • React Fiber 是协调和调度架构,不是线程,也不等于整个 Virtual DOM;
  • 当前 React 应区分 trigger、render、reconciliation 和 commit;
  • key 表达身份,不能把它理解成只更新新增项的性能按钮;
  • 使用 Virtual DOM 的价值和成本都要结合具体更新模式与测量结果判断。

参考资料

原文作者:小鱼儿。原文关于 Virtual DOM 动机、Diff 三条策略、Snabbdom、React reconciliation 和 Fiber 的主线予以保留;影视图片、知乎跳转噪声、过时源码的绝对化结论、把 Fiber 说成线程以及把 Virtual DOM 说成必然更快的表述已清理或修正。

457 DOCUMENTS · 10 COLLECTIONS
ARCHIVE SEARCH457 篇文章

SEARCH GUIDE

输入关键词开始搜索

支持搜索文章标题、所属分类和原始文档路径。

按分类浏览

10 COLLECTIONS