前端开发2026-03-17 15 分钟

深入 Vue3 源码:响应式、虚拟 DOM 和编译优化到底做了什么

不聊 API 用法,只聊「为什么这样设计」——从源码视角重新认识 Vue3。

用了两年 Vue3,有一天突然被面试官问「Vue3 的响应式和 Vue2 有什么本质区别?」我说了句「用了 Proxy 替代 Object.defineProperty」,然后就没然后了。那次面试让我意识到,只会用 API 和真正理解框架之间差了十万八千里。

这篇文章不是 Vue3 官方文档的复读机。我会带你从源码的关键函数入手,看看 Vue3 在响应式系统、虚拟 DOM 和编译器三个核心模块里到底做了什么。看完之后,你会对 Vue3 的设计哲学有一个全新的理解。

1. 响应式系统:Proxy 不只是「替代品」

很多文章把 Vue3 的响应式简单总结为「用 Proxy 替代了 Object.defineProperty」,但这只是冰山一角。真正的革命在于整个依赖收集和触发更新的架构被重新设计了。

reactive() 的核心:createReactiveObject

当你调用 reactive(obj) 时,源码最终会走到 createReactiveObject 函数。它做了三件事:

// 简化后的源码逻辑 (reactivity/src/reactive.ts)
function createReactiveObject(target, baseHandlers, proxyMap) {
  // 1. 如果已经是代理对象,直接返回(避免重复代理)
  const existingProxy = proxyMap.get(target)
  if (existingProxy) return existingProxy

  // 2. 创建 Proxy,拦截 get/set/deleteProperty/has/ownKeys
  const proxy = new Proxy(target, baseHandlers)

  // 3. 缓存映射关系
  proxyMap.set(target, proxy)
  return proxy
}

源码揭秘:依赖收集的 track 与 trigger

Proxy 的 get 拦截器会调用 track(target, key),它的核心是维护一个三层结构:targetMap → depsMap → dep (Set)

targetMap 是一个 WeakMap,key 是原始对象,value 是该对象所有属性的依赖映射。每个属性对应一个 dep(Set 结构),存储所有依赖这个属性的「副作用函数(effect)」。

当数据变化时,set 拦截器调用 trigger(target, key),从这个三层结构中找到所有相关的 effect 并执行——这就是响应式更新的完整链路。

ref() vs reactive() 的本质区别

ref() 内部其实很简单:它创建一个带有 .value 的对象,在 get value() 中调用 track,在 set value() 中调用 trigger。如果传入的是对象,.value 内部会自动调用 reactive() 进行深层代理。

// 简化后的 RefImpl 类 (reactivity/src/ref.ts)
class RefImpl {
  private _value: any
  public dep: Dep = new Dep()

  constructor(value) {
    // 如果是对象,用 reactive 包装
    this._value = isObject(value) ? reactive(value) : value
  }

  get value() {
    this.dep.track()    // 依赖收集
    return this._value
  }

  set value(newVal) {
    if (hasChanged(newVal, this._value)) {
      this._value = isObject(newVal) ? reactive(newVal) : newVal
      this.dep.trigger() // 触发更新
    }
  }
}

设计启示

为什么 ref 需要 .value?因为 JavaScript 的 Proxy 只能拦截对象属性的访问,无法拦截原始值(string、number、boolean)的读写。.value 就是为了把原始值「包装」成对象属性,让 Proxy 有机会介入。这不是 Vue 的设计缺陷,而是 JavaScript 语言层面的限制。

2. 虚拟 DOM 与 Diff:不是「全量比对」

虚拟 DOM 的 Diff 算法是 Vue 性能的核心。很多人以为 Diff 就是逐个比对新旧节点树,但 Vue3 做了大量优化,实际比对的节点数远比你想象的少。

patchKeyedChildren:最长递增子序列

当带有 key 的列表发生变化时,Vue3 使用了一个经典的算法——最长递增子序列(LIS)——来最小化 DOM 移动操作。

// 核心思路 (runtime-core/src/renderer.ts)
// 假设旧列表 [A, B, C, D, E],新列表 [A, D, B, E, C]

// 第 1 步:从头扫描,找到相同前缀 → A
// 第 2 步:从尾扫描,找到相同后缀 → (无)
// 第 3 步:对剩余部分建立旧节点的 key → index 映射
// 第 4 步:计算最长递增子序列 [D, E] → 这些节点不需要移动
// 第 5 步:只移动不在 LIS 中的节点 B 和 C

源码揭秘:为什么要用最长递增子序列?

DOM 操作(insertBefore、appendChild)是昂贵的。LIS 算法能找到「已经在正确相对位置」的最大节点集合,这些节点完全不需要移动。只有不在 LIS 中的节点才需要执行 DOM 移动操作。

举个例子:如果 10 个节点中有 7 个的相对顺序没变,那只需要移动 3 个节点,而不是重新排列全部 10 个。这个优化在大列表排序场景下效果极其显著。

PatchFlags:编译时标记

Vue3 的编译器会在编译阶段分析模板,给 VNode 打上 PatchFlags 标记。这样运行时的 Diff 算法就能跳过不可能变化的部分。

// 模板
<div class="static-class" :id="dynamicId">
  <!--  -->
</div>

// 编译产物(简化)
createVNode("div", { class: "static-class", id: dynamicId }, message,
  PatchFlags.TEXT | PatchFlags.PROPS, ["id"]
)
// TEXT → 只有文本内容可能变
// PROPS + ["id"] → 只有 id 这个 prop 可能变
// class="static-class" 是静态的,Diff 时直接跳过

设计启示

这就是 Vue3 相比 React 的一个核心设计差异:Vue3 有编译器,能在编译时提取信息指导运行时优化。React 的 JSX 是纯 JavaScript 表达式,编译器无法安全地做这类优化。这不是谁好谁坏的问题,而是「有编译器 vs 纯运行时」两种架构路线的取舍。

3. 编译器优化:你写的代码和运行的代码不一样

Vue3 的编译器不只是把 template 转换成 render 函数,它还会做一系列深度优化。这些优化对开发者完全透明,但对运行时性能影响巨大。

静态提升(Static Hoisting)

如果模板中有完全静态的节点(没有绑定任何动态数据),编译器会把它们「提升」到 render 函数外部,这样无论组件重新渲染多少次,这些静态 VNode 只会被创建一次。

// 模板
<div>
  <p class="title">这是静态标题</p>
  <p>{{ dynamicContent }}</p>
</div>

// 编译产物(简化)
// 静态节点被提升到模块作用域,只创建一次
const _hoisted_1 = createVNode("p", { class: "title" }, "这是静态标题")

function render() {
  return createVNode("div", null, [
    _hoisted_1,               // 直接复用,不重新创建
    createVNode("p", null, ctx.dynamicContent, PatchFlags.TEXT)
  ])
}

Block Tree 与 dynamicChildren

这是 Vue3 最精妙的优化之一。编译器会把模板分割成多个 Block,每个 Block 会收集其中所有动态子节点到一个扁平数组 dynamicChildren 中。Diff 时只需要遍历这个数组,而不是递归整棵树。

// 假设一个组件模板有 100 个节点,但只有 3 个是动态的
// 传统 Diff:遍历 100 个节点
// Block Tree:只比对 dynamicChildren 数组里的 3 个节点

// 编译器生成的代码大致是:
function render() {
  return openBlock(), createBlock("div", null, [
    /* ...100 个子节点... */
  ])
  // openBlock() 开启收集模式
  // 所有带 PatchFlag 的子节点会自动注册到当前 Block
  // 最终 dynamicChildren = [node23, node67, node89] 只有 3 个
}

源码揭秘:openBlock 做了什么

openBlock() 会创建一个数组并推入一个全局栈。之后每次调用 createVNode 时,如果 VNode 带有 PatchFlag,就会自动被收集到这个数组中。当 createBlock 调用时,它从栈中弹出这个数组,赋值给当前 Block 节点的 dynamicChildren

这意味着运行时的 patchBlock 函数可以直接遍历 dynamicChildren 进行 Diff,时间复杂度从 O(模板总节点数) 降为 O(动态节点数)。对于大部分业务组件来说,这是一个数量级的性能提升。

4. 三大模块如何协作

把前面三个模块串起来,Vue3 一次更新的完整链路是这样的:

数据变化 (ref.value = newVal)
  ↓
trigger() → 找到依赖这个数据的 effect(组件的 render effect)
  ↓
调度器 queueJob → 将更新任务推入微任务队列(批量更新)
  ↓
执行 render 函数 → 生成新的 VNode 树(带 PatchFlags)
  ↓
patch(旧VNode, 新VNode)
  ↓ Block Tree 优化
只比对 dynamicChildren(跳过静态节点)
  ↓ PatchFlags 优化
只比对标记变化的 props / text(跳过静态属性)
  ↓
最小化 DOM 操作

编译器负责在构建阶段尽可能多地提取信息(PatchFlags、静态提升、Block Tree),响应式系统负责精确追踪数据变化并通知到对应的组件,虚拟 DOM负责利用编译器提供的信息做最小化的 DOM 更新。三者紧密配合,形成了 Vue3 的性能优势。

写在最后

读源码不是为了炫技,而是为了在遇到性能问题时知道从哪里下手。当你理解了 track/trigger 的三层映射、patchKeyedChildren 的 LIS 算法、编译器的 Block Tree 优化,很多之前觉得「玄学」的性能问题都会变得清晰可解。如果你对性能优化的实战技巧感兴趣,推荐看看我之前写的 Vue3 性能优化技巧