深入 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 性能优化技巧。