响应式原理与虚拟DOM
恭喜你进入精通篇!前面的章节中,你已经完成了从基础到实战的全部历程,能够熟练地使用 Vue 3 构建应用。但”会用”和”精通”之间,还隔着一层对底层原理的理解。从本章开始,我们将深入 Vue 3 的引擎室,看看那些 API 背后到底发生了什么。
本章是精通篇的第一章,我们将从两大核心机制切入:响应式系统和虚拟 DOM。响应式系统解决的是”数据变了,谁需要更新”的问题;虚拟 DOM 解决的是”需要更新时,如何高效操作真实 DOM”的问题。二者协同,构成了 Vue 3 的渲染引擎。
📋 开篇自测
在正式开始之前,先检验一下你的前置知识:
Object.defineProperty和Proxy分别可以拦截哪些操作?二者在使用上有什么本质区别?- JavaScript 的事件循环中,微任务(microtask)和宏任务(macrotask)的执行顺序是什么?
- 为什么在
v-for中使用列表渲染时,Vue 要求提供key属性?
如果这三个问题你都能清晰回答,本章的学习会非常顺畅。如果有模糊的地方,也不用担心,我们会在过程中逐步讲清。
一、响应式系统设计——从 defineProperty 到 Proxy
1.1 为什么需要响应式?
先想一个最基本的问题:当我们修改一个 JavaScript 变量时,视图怎么知道要更新?
let msg = 'hello'
// 修改 msg 之后,页面上显示的内容怎么自动变化?
msg = 'world'
普通的变量赋值不会产生任何”通知”。要实现数据驱动视图,我们必须有一种机制能够拦截数据的读取和修改,在读取时记录”谁在用这个数据”,在修改时通知”所有使用者去更新”。这就是响应式系统的核心诉求。
1.2 Vue 2 的方案:Object.defineProperty
Vue 2 使用 Object.defineProperty 来实现数据拦截。我们用一段简化代码来理解其核心思想:
// 迷你版 Vue 2 响应式
function defineReactive(obj, key, val) {
// 每个属性对应一个依赖收集器
const deps = new Set()
Object.defineProperty(obj, key, {
get() {
// 读取时:收集当前正在执行的副作用函数
if (activeEffect) {
deps.add(activeEffect)
}
return val
},
set(newVal) {
if (newVal === val) return
val = newVal
// 写入时:通知所有依赖方执行更新
deps.forEach(effect => effect())
}
})
}
function observe(obj) {
Object.keys(obj).forEach(key => {
defineReactive(obj, key, obj[key])
})
}
这套方案能工作,但存在三个先天缺陷:
缺陷一:无法检测属性的新增和删除。 Object.defineProperty 只能拦截已有属性的读写,对象上新增一个属性不会被拦截。这就是 Vue 2 中需要 Vue.set() 的原因。
缺陷二:数组的变异方法需要特殊处理。 对数组使用索引赋值(如 arr[0] = 'x')或修改 length 无法被检测到,Vue 2 不得不重写了 push、pop、splice 等 7 个数组方法。
缺陷三:初始化时需要递归遍历整个对象树。 无论某些深层属性是否会被用到,都要在初始化阶段全部转换为响应式,在大型对象上会产生不必要的性能开销。
1.3 Vue 3 的方案:Proxy
Proxy 是 ES6 提供的元编程能力,它可以对目标对象创建一个代理层,拦截几乎所有操作:
const handler = {
get(target, key, receiver) { /* 拦截读取 */ },
set(target, key, value, receiver) { /* 拦截写入 */ },
deleteProperty(target, key) { /* 拦截 delete */ },
has(target, key) { /* 拦截 in 操作符 */ },
ownKeys(target) { /* 拦截 Object.keys / for...in */ }
}
const proxy = new Proxy(target, handler)
对比 Object.defineProperty,Proxy 的优势一目了然:
+---------------------+--------------------+--------------------+
| 能力 | defineProperty | Proxy |
+---------------------+--------------------+--------------------+
| 拦截属性读写 | Yes | Yes |
| 拦截属性新增/删除 | No | Yes |
| 拦截 in / for...in | No | Yes |
| 拦截数组索引操作 | 需 hack | Yes |
| 惰性递归(按需) | No | Yes |
| 需要递归初始化 | Yes | No |
+---------------------+--------------------+--------------------+
其中惰性递归是一个关键优化:Vue 3 只在属性被访问时才对其子对象创建 Proxy,而不是像 Vue 2 那样在初始化时一次性递归到底。这意味着如果某个深层嵌套的属性从未被模板或计算属性读取,它就不会被代理,节省了启动时间和内存。
二、响应式核心实现——reactive 与 ref
2.1 从零构建 reactive
理解了 Proxy 的优势后,我们来从零构建一个迷你版的 reactive:
// 缓存已创建的代理,避免重复代理同一个对象
const reactiveMap = new WeakMap()
function reactive(target) {
// 只代理对象类型
if (typeof target !== 'object' || target === null) {
return target
}
// 如果已经代理过,直接返回缓存
const existingProxy = reactiveMap.get(target)
if (existingProxy) {
return existingProxy
}
const proxy = new Proxy(target, {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)
// 依赖收集
track(target, key)
// 惰性递归:访问到子对象时才递归代理
if (typeof result === 'object' && result !== null) {
return reactive(result)
}
return result
},
set(target, key, value, receiver) {
const oldValue = target[key]
const result = Reflect.set(target, key, value, receiver)
// 值真正变化时才触发更新
if (oldValue !== value) {
trigger(target, key)
}
return result
},
deleteProperty(target, key) {
const hadKey = key in target
const result = Reflect.deleteProperty(target, key)
if (hadKey && result) {
trigger(target, key)
}
return result
}
})
reactiveMap.set(target, proxy)
return proxy
}
这里有几个值得注意的设计要点:
- 使用
WeakMap缓存代理对象。同一个原始对象只会创建一个代理,并且当原始对象被垃圾回收时,缓存也会自动释放。 - 使用
Reflect而非直接操作target。Reflect.get/set能正确处理receiver(即代理对象自身),确保this指向正确,在原型链继承场景下不会出问题。 - 惰性递归。
get中对子对象调用reactive(result),只有真正访问到的嵌套属性才会被代理。
2.2 ref 的实现原理
reactive 基于 Proxy,而 Proxy 只能代理对象。对于原始类型(string、number、boolean),Vue 3 提供了 ref,其核心思想是用一个对象包裹原始值:
function ref(rawValue) {
const refObject = {
_value: rawValue,
get value() {
// 依赖收集
track(refObject, 'value')
return this._value
},
set value(newValue) {
if (newValue !== this._value) {
this._value = newValue
// 触发更新
trigger(refObject, 'value')
}
}
}
return refObject
}
上面是简化的概念实现。Vue 3 的实际源码使用 class RefImpl 来实现 ref,通过类的 getter/setter 实现依赖追踪,并在内部区分了原始类型值和对象值(对象值会被进一步传递给 reactive() 处理)。感兴趣的读者可以查看 vue/core 源码中的 ref.ts。
这就是为什么使用 ref 时需要通过 .value 来访问和修改值——因为底层必须依靠对象属性的 getter/setter 来触发依赖收集和更新通知。而在模板中使用时,Vue 的编译器会自动帮我们解包 .value,这是一个编译时的便利。
三、副作用系统——effect 与依赖追踪
3.1 什么是副作用函数
在响应式系统中,“副作用函数”(effect)是指读取了响应式数据的函数。当响应式数据变化时,这些函数需要重新执行。组件的渲染函数就是一个典型的副作用函数。
我们来构建迷你版的 effect:
// 当前正在执行的副作用函数
let activeEffect = null
function effect(fn) {
const effectFn = () => {
// 执行前,将自身设为"当前活跃 effect"
activeEffect = effectFn
// 执行 fn 时会触发响应式数据的 get,从而收集依赖
const result = fn()
activeEffect = null
return result
}
// 立即执行一次,完成初始的依赖收集
effectFn()
return effectFn
}
使用方式:
const state = reactive({ count: 0 })
effect(() => {
console.log('count is:', state.count)
})
// 打印: count is: 0
state.count = 1
// 自动打印: count is: 1
当 effect 执行回调函数 fn 时,fn 内部访问了 state.count,触发了 Proxy 的 get 拦截器。此时 activeEffect 指向当前的 effectFn,于是 track 函数就把 effectFn 记录为 state.count 的一个依赖。之后当 state.count 被修改时,trigger 函数找到所有依赖并重新执行它们。
3.2 依赖追踪的数据结构
track 和 trigger 的核心是维护一套精巧的数据结构,把”哪个对象的哪个属性”映射到”哪些副作用函数”:
targetMap (WeakMap)
|
|-- target1 (Map)
| |-- key1 --> Set { effectA, effectB }
| |-- key2 --> Set { effectC }
|
|-- target2 (Map)
|-- key1 --> Set { effectA }
用 ASCII 图更清晰地表示这个三层结构:
WeakMap Map Set
+----------+ +-----------+ +----------------+
| target --|----->| 'count' --|----->| effectFn1 |
| | | 'name' --|--+ | effectFn2 |
+----------+ +-----------+ | +----------------+
| target2--|--+ |
+----------+ | +-->+----------------+
| | effectFn3 |
+-->+-----------+ +----------------+
| 'age' ---|----->+----------------+
+-----------+ | effectFn1 |
+----------------+
为什么选择这三种数据结构?
- WeakMap(第一层):以原始对象为 key。当对象不再被引用时,相关的依赖信息可以被自动垃圾回收,不会造成内存泄漏。
- Map(第二层):以属性名为 key,因为属性名是字符串或 Symbol,普通 Map 即可。
- Set(第三层):存放 effect 函数,天然去重,同一个 effect 不会被重复收集。
下面是 track 和 trigger 的实现:
const targetMap = new WeakMap()
function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect)
}
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const deps = depsMap.get(key)
if (!deps) return
// 创建副本执行,避免在遍历过程中集合被修改导致无限循环
const effectsToRun = new Set(deps)
effectsToRun.forEach(effectFn => effectFn())
}
3.3 嵌套 effect 与 effect 栈
在真实场景中,effect 是可以嵌套的。比如父组件的渲染 effect 中嵌套了子组件的渲染 effect:
effect(() => {
// 父组件的渲染
console.log('parent:', state.a)
effect(() => {
// 子组件的渲染
console.log('child:', state.b)
})
// 回到父组件,此时 activeEffect 应该指向父组件的 effect
console.log('parent continues:', state.c)
})
如果只用一个 activeEffect 变量,当内层 effect 执行完后,activeEffect 会被置为 null,导致外层 effect 后续的依赖收集失败。
解决方案是使用一个栈结构来管理 effect 的嵌套:
let activeEffect = null
const effectStack = []
function effect(fn) {
const effectFn = () => {
activeEffect = effectFn
// 入栈
effectStack.push(effectFn)
const result = fn()
// 出栈,恢复外层 effect
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1] || null
return result
}
effectFn()
return effectFn
}
这样,无论嵌套多少层,每一层 effect 执行完毕后都能正确恢复到外层的 activeEffect。Vue 3 源码中使用了类似的 parent 指针方案,本质上是同样的思路——用链表或栈来维护 effect 的嵌套关系。
3.4 依赖清理:避免无效更新
考虑这样一个场景:
const state = reactive({ show: true, a: 1 })
effect(() => {
if (state.show) {
console.log('a:', state.a)
}
})
// 隐藏后,a 的变化不应再触发 effect
state.show = false
state.a = 2 // 理想情况下不应触发 effect
第一次执行 effect 时,state.show 和 state.a 都被读取了,所以两者都收集了该 effect。但当 state.show 变为 false 后,state.a 的分支不再执行,此时 state.a 的依赖集合中仍然保留着这个 effect,导致修改 state.a 时会产生无效的重新执行。
Vue 3 的解决思路是:每次 effect 重新执行前,先把自身从所有依赖集合中移除,然后在执行过程中重新收集依赖。 这样,不再被访问的属性自然就不会再包含该 effect。
function effect(fn) {
const effectFn = () => {
// 清理:从所有依赖集合中移除自身
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
const result = fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1] || null
return result
}
// effectFn 记录自身被哪些依赖集合收集
effectFn.deps = []
effectFn()
return effectFn
}
function cleanup(effectFn) {
effectFn.deps.forEach(depSet => {
depSet.delete(effectFn)
})
effectFn.deps.length = 0
}
// track 中也要反向记录
function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) targetMap.set(target, (depsMap = new Map()))
let deps = depsMap.get(key)
if (!deps) depsMap.set(key, (deps = new Set()))
deps.add(activeEffect)
// 反向记录:让 effect 知道自己被哪些 Set 收集了
activeEffect.deps.push(deps)
}
Vue 3.2 对这一机制做了进一步优化,使用二进制标记位来区分”已有依赖”和”新依赖”,避免了每次都全量清除再全量重建的开销。但核心思想不变:依赖关系是动态的,每次执行都可能不同,必须及时清理过期依赖。
四、批量更新与调度
4.1 问题:频繁更新的性能灾难
来看一个典型场景:
const state = reactive({ count: 0 })
effect(() => {
document.title = `Count: ${state.count}`
})
// 点击按钮时循环修改 1000 次
for (let i = 0; i < 1000; i++) {
state.count++
}
如果每次 state.count++ 都立即触发 effect 执行,DOM 就会被更新 1000 次。但用户最终看到的只是 Count: 1000,中间的 999 次更新全是浪费。
4.2 调度器与任务队列
Vue 3 的解决方案是将更新任务推入队列,在当前同步代码执行完毕后统一批量处理。这个机制的核心是 scheduler(调度器):
// 更新队列,用 Set 天然去重
const queue = new Set()
// 标记是否已经安排了刷新
let isFlushing = false
function queueJob(job) {
queue.add(job) // Set 自动去重
if (!isFlushing) {
isFlushing = true
// 在微任务中批量执行
Promise.resolve().then(flushJobs)
}
}
function flushJobs() {
// 按优先级排序:父组件先于子组件
const jobs = [...queue].sort((a, b) => a.id - b.id)
queue.clear()
for (const job of jobs) {
job()
}
isFlushing = false
// 如果在执行过程中又有新任务加入,递归处理
if (queue.size > 0) {
flushJobs()
}
}
在 Vue 3 中,组件的渲染 effect 并不直接执行 componentUpdateFn,而是通过调度器将其推入队列:
// 伪代码:组件的 effect 创建
const renderEffect = new ReactiveEffect(
componentUpdateFn,
() => queueJob(update) // 这就是 scheduler
)
当响应式数据变化时,trigger 发现 effect 有 scheduler,就调用 scheduler 而不是直接调用 effect.run()。scheduler 将 update 推入队列,同一个组件的多次数据修改只会在队列中保留一条记录。
4.3 nextTick 的本质
nextTick 的作用是让用户代码可以在 DOM 更新完成之后执行。它的实现其实非常简单——返回的就是队列刷新完成后的 Promise:
function nextTick(fn) {
// currentFlushPromise 是 queueFlush 中创建的 Promise
const p = currentFlushPromise || Promise.resolve()
return fn ? p.then(fn) : p
}
这就是为什么 await nextTick() 能确保在 DOM 更新后执行代码。整个流程如下:
同步代码执行 微任务队列
+-----------------+ +------------------------+
| state.count++ | | |
| state.count++ |--->| flushJobs() |
| state.count++ | | --> componentUpdateFn |
| await nextTick()| | --> nextTick callback |
+-----------------+ +------------------------+
| |
v v
1000次++只产生 DOM只更新一次
一个队列任务 count: 0 -> 1000
4.4 更新的三个阶段
Vue 3 的更新过程分为三个阶段:
- Pre 阶段(更新前):
watch和watchEffect的回调默认在此阶段执行(flush: 'pre'),在组件 DOM 更新之前触发。需要注意,当父子组件之间存在交叉数据监听时,watcher 的执行顺序可能更复杂——Pre 队列中的 watcher 按创建顺序执行,而非严格按组件层级排序。 - Flushing 阶段(更新中):按照组件 ID 从小到大排序执行更新队列,保证父组件先于子组件更新。
- Post 阶段(更新后):
mounted、updated等生命周期钩子,以及flush: 'post'的侦听器在此阶段执行。
五、虚拟 DOM 与 VNode
5.1 为什么需要虚拟 DOM
直接操作真实 DOM 是昂贵的。一个 div 元素上挂载了数百个属性和方法,创建和修改 DOM 节点涉及浏览器的布局计算、样式计算、绘制等一系列开销。
虚拟 DOM 的核心思想是:用轻量的 JavaScript 对象来描述 DOM 结构,在内存中完成新旧对比,最后只将差异部分应用到真实 DOM 上。
除了性能优化之外,虚拟 DOM 还带来了一个重要能力:跨平台渲染。由于 VNode 只是普通的 JavaScript 对象,与具体平台无关,通过替换不同的渲染后端,可以将同样的组件树渲染到浏览器 DOM、服务端 HTML 字符串、原生移动端控件等不同目标。
5.2 VNode 的结构设计
一个 VNode 本质上是一个描述节点信息的对象。我们来定义一个简化版:
// 简化版 VNode 结构
const vnode = {
type: 'div', // 标签名、组件对象或 Fragment/Text 等特殊类型
props: { // 属性:class、style、事件等
class: 'container',
onClick: handleClick
},
children: [ // 子节点:字符串(文本)或 VNode 数组
{
type: 'span',
props: null,
children: 'Hello World'
}
],
key: null, // diff 算法使用的唯一标识
el: null, // 对应的真实 DOM 引用(挂载后填充)
shapeFlag: 0 // 位运算标记:元素/组件/文本等类型
}
Vue 3 使用 shapeFlag 进行位运算标记,高效判断节点类型:
// 通过位运算组合标记
const ShapeFlags = {
ELEMENT: 1, // 0001 — 普通 DOM 元素
FUNCTIONAL_COMPONENT: 1 << 1, // 0010 — 函数式组件(Vue 3 源码中有实际用途,此处简化示例未涉及)
STATEFUL_COMPONENT: 1 << 2, // 0100 — 有状态组件
TEXT_CHILDREN: 1 << 3, // 1000
ARRAY_CHILDREN: 1 << 4, // 10000
}
// 判断时用按位与
if (vnode.shapeFlag & ShapeFlags.ELEMENT) {
// 是普通 DOM 元素
}
位运算的好处是一个数字可以同时携带多种标记信息,且判断速度极快。
5.3 h() 函数与 VNode 创建
h() 函数(hyperscript 的缩写)是创建 VNode 的工厂函数:
function h(type, props, children) {
// 确定 shapeFlag
let shapeFlag = 0
if (typeof type === 'string') {
shapeFlag = ShapeFlags.ELEMENT
} else if (typeof type === 'object') {
shapeFlag = ShapeFlags.STATEFUL_COMPONENT
}
// 确定 children 类型并组合标记
if (typeof children === 'string') {
shapeFlag |= ShapeFlags.TEXT_CHILDREN
} else if (Array.isArray(children)) {
shapeFlag |= ShapeFlags.ARRAY_CHILDREN
}
return {
type,
props,
children,
key: props?.key ?? null,
el: null,
shapeFlag
}
}
// 使用示例
const vnode = h('div', { class: 'app' }, [
h('h1', null, 'Title'),
h('p', null, 'Content')
])
模板中的 HTML 会在编译阶段被转换为 h() 函数调用。例如:
<div class="app">
<h1>Title</h1>
<p>{{ msg }}</p>
</div>
编译后大致等价于:
function render(ctx) {
return h('div', { class: 'app' }, [
h('h1', null, 'Title'),
h('p', null, ctx.msg)
])
}
5.4 挂载流程
有了 VNode 之后,需要将其渲染成真实 DOM。挂载流程的核心逻辑如下:
function mount(vnode, container) {
if (vnode.shapeFlag & ShapeFlags.ELEMENT) {
mountElement(vnode, container)
} else if (vnode.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
mountComponent(vnode, container)
}
}
function mountElement(vnode, container) {
// 1. 创建真实 DOM 元素
const el = (vnode.el = document.createElement(vnode.type))
// 2. 设置属性
if (vnode.props) {
for (const key in vnode.props) {
if (key.startsWith('on')) {
el.addEventListener(key.slice(2).toLowerCase(), vnode.props[key])
} else {
el.setAttribute(key, vnode.props[key])
}
}
}
// 3. 处理子节点
if (vnode.shapeFlag & ShapeFlags.TEXT_CHILDREN) {
el.textContent = vnode.children
} else if (vnode.shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
vnode.children.forEach(child => mount(child, el))
}
// 4. 插入容器
container.appendChild(el)
}
整个渲染流程可以用下图概括:
createApp(App)
|
v
createVNode(App) // 创建根组件 VNode
|
v
render(vnode, container) // 渲染入口
|
v
patch(null, vnode, container) // 初始化 = 全量 patch
|
+-- 组件类型 --> mountComponent
| |
| setup() + render()
| |
| 生成 subTree (子树 VNode)
| |
| patch(null, subTree, container)
| |
+-- 元素类型 --> mountElement
|
创建 DOM + 设置属性
|
递归挂载子节点
|
插入到容器中
六、Diff 算法——高效更新的核心
6.1 Patch 的入口
当响应式数据变化触发组件重渲染时,会生成新的 VNode 树(nextTree),需要与旧的 VNode 树(prevTree)进行比对,找出差异并更新 DOM。这个过程就是 patch:
function patch(n1, n2, container) {
// n1: 旧 VNode,n2: 新 VNode
// 类型完全不同,直接卸载旧的,挂载新的
if (n1 && n1.type !== n2.type) {
unmount(n1)
n1 = null
}
if (!n1) {
// 旧节点不存在,直接挂载
mount(n2, container)
} else {
// 新旧节点类型相同,执行更新
if (n2.shapeFlag & ShapeFlags.ELEMENT) {
patchElement(n1, n2)
} else if (n2.shapeFlag & ShapeFlags.STATEFUL_COMPONENT) {
patchComponent(n1, n2)
}
}
}
对于元素节点的更新,需要做两件事:更新属性、更新子节点。子节点的更新是 diff 算法的核心所在。
6.2 子节点更新的情况分析
新旧子节点各有三种可能:文本、数组、空。因此组合起来有 9 种情况:
新子节点
文本 数组 空
旧 文本 | 替换 | 清文本 | 清文本 |
子 | | 挂新组 | |
节 数组 | 卸旧 | DIFF | 卸旧 |
点 | 设文本| | |
空 | 设文本| 挂新组 | 无操作 |
最复杂也最值得研究的是新旧子节点都是数组的情况,这就是 diff 算法要解决的核心场景。
6.3 Vue 3 的快速 Diff 算法
Vue 3 的 diff 算法分为五个步骤,我们逐一拆解。
步骤一:从头比对相同前缀
旧: [a] [b] [c] [d] [e] [f] [g] [h]
新: [a] [b] [e] [c] [d] [i] [g] [h]
^ ^
i --> 当 c !== e 时停止, i = 2
从头部开始,逐个比较新旧节点的 type 和 key。相同则 patch 更新,不同则停止。
步骤二:从尾比对相同后缀
旧: [a] [b] [c] [d] [e] [f] [g] [h]
^ ^
新: [a] [b] [e] [c] [d] [i] [g] [h]
^ ^
e1 <-- 当 f !== i 时停止, e1 = 5
e2 <-- e2 = 5
从尾部开始,逐个比较。相同则 patch 更新,不同则停止。
步骤三:简单新增
如果 i > e1 && i <= e2,说明旧节点已经比完了但新节点还有剩余,这些是新增节点:
旧: [a] [b] [c]
新: [a] [b] [d] [e] [c]
~~~ 新增 d, e
步骤四:简单删除
如果 i > e2 && i <= e1,说明新节点已经比完了但旧节点还有剩余,这些节点需要卸载:
旧: [a] [b] [d] [e] [c]
新: [a] [b] [c]
~~~ 删除 d, e
步骤五:处理未知序列(核心)
当步骤三、四都不满足时,中间会剩下一段”乱序”区间,这是最复杂的部分。以下面的例子为例:
经过步骤 1、2 后:
旧中间段: [c] [d] [e] [f] (i=2, e1=5)
新中间段: [e] [c] [d] [i] (i=2, e2=5)
处理过程分三步:
(a) 构建新节点的 key-index 映射
// keyToNewIndexMap = { e: 2, c: 3, d: 4, i: 5 }
const keyToNewIndexMap = new Map()
for (let j = s2; j <= e2; j++) {
keyToNewIndexMap.set(newChildren[j].key, j)
}
(b) 遍历旧节点,匹配并记录位置
// newIndexToOldIndexMap 记录新节点在旧序列中的位置 (0 表示新增)
// 对于 [e, c, d, i],在旧序列中的位置是:
// e -> 旧序列 index 4 (存 5, 因为+1 避免 0 歧义)
// c -> 旧序列 index 2 (存 3)
// d -> 旧序列 index 3 (存 4)
// i -> 不存在 (存 0, 表示新节点)
// newIndexToOldIndexMap = [5, 3, 4, 0]
在遍历旧节点的过程中,还会标记是否存在需要移动的节点:如果旧节点在新序列中的索引出现了递减,说明有节点需要移动。同时,旧节点中不存在于新序列中的节点(如 f)会被立即卸载。
(c) 利用最长递增子序列(LIS)最小化移动
这是 Vue 3 diff 算法最精妙的一步。对 newIndexToOldIndexMap = [5, 3, 4, 0],求其最长递增子序列:
数组: [5, 3, 4, 0]
LIS: [3, 4] --> 索引为 [1, 2]
[1, 2] 对应的是新序列中的 c 和 d——它们在旧序列中的相对顺序已经是正确的,不需要移动。只需要:
- 将
e移动到c前面 - 在
d后面插入新节点i
操作前: [c] [d] [e] (f 已删除)
1. 新增 i 到 d 后面: [c] [d] [i] [e]
2. 移动 e 到 c 前面: [e] [c] [d] [i]
这样,4 个节点的更新只需要 1 次移动 + 1 次新增,而不是暴力重排所有节点。
6.4 最长递增子序列算法
Vue 3 使用贪心 + 二分查找来求 LIS,时间复杂度为 O(n log n)。核心思路是:
- 维护一个结果数组
result,存放递增子序列的索引 - 遍历输入数组,如果当前值大于
result末尾的值,追加 - 否则,用二分查找找到
result中第一个大于当前值的位置,替换它
但直接替换会导致结果不正确(位置信息被破坏),所以需要一个辅助数组 p 来记录每个位置的前驱索引,最后通过回溯 p 数组还原正确的 LIS。
function getSequence(arr) {
const p = arr.slice() // 前驱索引数组
const result = [0] // 结果数组,存索引
const len = arr.length
for (let i = 0; i < len; i++) {
const arrI = arr[i]
if (arrI === 0) continue // 0 表示新节点,跳过
const last = result[result.length - 1]
// 当前值大于 result 末尾,直接追加
if (arr[last] < arrI) {
p[i] = last // 记录前驱
result.push(i)
continue
}
// 二分查找:找到第一个 >= arrI 的位置
let lo = 0, hi = result.length - 1
while (lo < hi) {
const mid = (lo + hi) >> 1
if (arr[result[mid]] < arrI) {
lo = mid + 1
} else {
hi = mid
}
}
// 替换
if (arrI < arr[result[lo]]) {
if (lo > 0) {
p[i] = result[lo - 1] // 记录前驱
}
result[lo] = i
}
}
// 回溯,从 result 末尾开始,通过 p 数组还原正确序列
let length = result.length
let idx = result[length - 1]
while (length-- > 0) {
result[length] = idx
idx = p[idx]
}
return result
}
以 [5, 3, 4, 0] 为例,运行过程:
i=0: arrI=5, result=[0] -> result=[0]
i=1: arrI=3, 3<5, 替换 -> result=[1]
i=2: arrI=4, 4>3, 追加 -> result=[1,2]
i=3: arrI=0, 跳过(新节点)
回溯: result = [1, 2]
最终得到索引 [1, 2],对应 newIndexToOldIndexMap 中位置 1 和 2 的元素(即 c 和 d),它们不需要移动。
6.5 key 的作用
现在你应该能深刻理解 key 的意义了。在 diff 算法中,Vue 通过 type + key 来判断两个节点是否是”同一个节点”:
function isSameVNodeType(n1, n2) {
return n1.type === n2.type && n1.key === n2.key
}
如果不提供 key(或使用 index 作为 key),所有同类型节点都会被认为是”同一个”,diff 算法只能逐个 patch,无法正确识别哪些节点被移动、新增或删除。
不用 key 的列表:
旧: [li] [li] [li]
新: [li] [li] [li] [li]
type 都相同,只能逐个 patch,新增一个
用 key 的列表:
旧: [li:a] [li:b] [li:c]
新: [li:c] [li:a] [li:b] [li:d]
key 不同,能精确识别 c 需要移动,d 需要新增
因此:在列表可能重新排序、增删的场景下,不要用 index 作为 key。index 作为 key 会导致错误的节点复用,轻则产生性能问题,重则出现状态混乱的 Bug。只有在列表完全静态(不重排、不增删)且列表项无内部状态时,index 才是可接受的。
🤔 思考题
-
为什么
reactive返回的代理对象和原始对象不是同一个引用? 如果在代码中混用原始对象和代理对象会发生什么?(提示:考虑依赖收集是否生效) -
Vue 3 的响应式系统能否追踪到
Map和Set的变化? 如果能,它需要怎样特殊处理?(提示:Map.set()不是属性赋值操作) -
假设有一个深层嵌套的对象
{ a: { b: { c: { d: 1 } } } },模板中只使用了state.a.b,那么state.a.b.c.d是否会被 Proxy 代理? 如果此时直接修改state.a.b.c.d = 2,视图会更新吗? -
Vue 3 的快速 diff 算法相比 Vue 2 的双端 diff 做了哪些改进? 提示:Vue 3 仍保留了头尾对比的步骤,但在处理乱序段时引入了最长递增子序列(LIS)来最小化 DOM 移动。在什么场景下这种优化效果最明显?
-
如果在
watchEffect中使用了async/await,await之后访问的响应式数据还能被正确追踪吗? 为什么?
📝 本章自测
-
Vue 3 选择 Proxy 替代 Object.defineProperty 的核心原因中,不包括以下哪项?
- A. Proxy 能拦截属性的新增和删除
- B. Proxy 的运行时性能比 defineProperty 快 10 倍
- C. Proxy 可以实现惰性递归代理
- D. Proxy 能拦截数组索引的直接赋值
-
在 Vue 3 的依赖追踪数据结构中,第一层使用 WeakMap 而非普通 Map 的原因是?
- A. WeakMap 的查找速度更快
- B. WeakMap 的键是弱引用,不会阻止目标对象被垃圾回收
- C. WeakMap 支持任意类型的键
- D. WeakMap 可以被遍历
-
当同一个组件内的两个响应式数据同步修改时,组件会更新几次?
- A. 2 次,每个数据变化触发一次
- B. 1 次,得益于批量更新和队列去重机制
- C. 0 次,需要手动调用
nextTick才会更新 - D. 不确定,取决于数据变化的顺序
-
在 Vue 3 的 diff 算法中,最长递增子序列的作用是?
- A. 决定哪些节点需要被删除
- B. 找出不需要移动的节点,从而最小化 DOM 操作
- C. 决定节点的渲染顺序
- D. 优化属性的比对过程
-
下面关于
key的说法,错误的是?- A. key 帮助 diff 算法识别哪些节点可以复用
- B. 使用 index 作为 key 在列表顺序不变时不会有问题
- C. 没有 key 时 Vue 会使用就地更新策略
- D. key 值可以重复,Vue 会自动处理冲突
查看答案
-
B。Proxy 并不比 defineProperty 快 10 倍,在某些微基准测试中 defineProperty 的单次访问甚至更快。Vue 3 选择 Proxy 是因为其功能更完善(A、C、D 都是正确原因),而非单纯的性能差距。
-
B。WeakMap 的键是弱引用,当原始对象不再被其他地方引用时,垃圾回收器可以回收该对象及其对应的依赖信息,避免内存泄漏。
-
B。Vue 3 的调度器会将同一个组件的更新任务去重后放入微任务队列,在当前同步代码执行完毕后统一批量处理,因此只会更新 1 次。
-
B。LIS 找出的是在新旧序列中相对顺序一致的最长子序列,这些节点不需要移动,从而将 DOM 移动操作降到最少。
-
D。key 值不能重复,重复的 key 会导致 diff 算法行为异常,Vue 在开发模式下会给出警告。
本章小结
本章我们从设计动机出发,深入探讨了 Vue 3 的两大核心机制:
响应式系统方面,我们理解了从 Object.defineProperty 到 Proxy 的演进原因;通过迷你实现掌握了 reactive、ref、effect、track、trigger 的协作流程;了解了 WeakMap -> Map -> Set 三层依赖存储结构的设计考量;以及批量更新、调度器和 nextTick 如何协同避免无效的重复渲染。
虚拟 DOM 方面,我们理解了 VNode 的结构设计和 h() 函数的创建流程;掌握了 patch 的整体入口逻辑和子节点更新的分类处理;深入拆解了 Vue 3 快速 diff 算法的五个步骤,特别是最长递增子序列如何将 DOM 移动操作降到最少。
这些底层原理不仅帮助你理解”Vue 为什么这样工作”,更重要的是当你遇到性能问题或诡异 Bug 时,能够从原理层面定位和解决问题。下一章我们将继续深入编译器和渲染优化机制,看看 Vue 3 如何在编译阶段就为运行时铺平道路。
购买课程解锁全部内容
渐进式到全面掌控:12 章系统精通 Vue 3
¥29.90