Vue 3 响应式原理:从零到一,触及灵魂
Vue 3 响应式原理:从零到一,触及灵魂
这是一篇从零开始的 Vue 3 响应式原理深度剖析。不假设你有任何前置知识,我会带你一步步推导出整个响应式系统的设计,让你真正理解"为什么"而不仅仅是"是什么"。
写在前面:为什么要理解响应式原理?
如果你只用过 Vue 写业务代码,你可能已经习惯了这种写法:
const count = ref(0)
count.value = 1 // 界面自动更新
但你有没想过:为什么赋值操作能触发界面更新?JavaScript 本身并没有这个能力。
这就是响应式系统的使命:在普通的 JavaScript 赋值操作之上,注入"通知机制"。
读完这篇文章,你将能够回答:
- 响应式系统的核心三要素是什么?
- 为什么 Vue 3 要用 Proxy 而不是 Object.defineProperty?
- 依赖收集到底是在收集什么?
- 为什么修改数据后界面不会立即更新?
- computed 的缓存是怎么实现的?
- 响应式数据在组件从创建到销毁的整个生命周期中经历了什么?
第一章:问题的起点——如何让一段代码"记住"另一段代码?
1.1 一个最简单的需求
假设我们有这样一段代码:
let a = 1
let b = 2
let c = a + b // c = 3
a = 2
// 现在 c 还是 3,但我们希望 c 自动变成 4
我们想要的效果是:当 a 或 b 变化时,所有依赖它们的表达式自动重新计算。
这就是响应式最朴素的需求。
1.2 从"手动通知"到"自动追踪"
先看一个最原始的实现思路:
let a = 1
let b = 2
let c = 3
// 我们需要在 a 变化时做点什么
function setA(newValue) {
a = newValue
c = a + b // 手动更新依赖 a 的表达式
}
这显然不可扩展。我们需要一种机制:让变量"知道"谁在依赖它。
这就是响应式系统的核心矛盾:
- 读取时:需要记住"谁在读我"
- 写入时:需要通知"所有在读我的人"
翻译成技术术语:
- 依赖收集(track):在读取时记录依赖关系
- 派发更新(trigger):在写入时触发更新
1.3 响应式系统的"黄金三角"
任何一个响应式系统,都必须解决三个问题:
| 问题 | 技术术语 | 通俗解释 |
|---|---|---|
| 什么时候收集依赖? | track 时机 | 在读取数据时,记录当前是谁在读 |
| 收集什么? | 依赖的数据结构 | 记录"哪个数据"被"哪个函数"依赖 |
| 什么时候触发更新? | trigger 时机 | 数据变化时,找到所有依赖函数并执行 |
这三个问题贯穿整篇文章,请带着它们往下读。
第二章:JavaScript 的局限性——为什么需要 Proxy?
2.1 我们想要的能力
要实现"读取时收集、写入时触发",我们需要在 JavaScript 的属性读取和写入操作上插入拦截代码。
// 我们想要这样的效果
const data = { count: 0 }
// 当读取 data.count 时,执行 track()
// 当写入 data.count 时,执行 trigger()
data.count // 触发 track
data.count = 1 // 触发 trigger
但 JavaScript 原生并没有提供这种拦截能力。我们需要一种方式来"劫持"对象的读写操作。
2.2 Vue 2 的方案:Object.defineProperty
Vue 2 使用 Object.defineProperty 来拦截属性的读写:
function defineReactive(obj, key, val) {
Object.defineProperty(obj, key, {
get() {
console.log('收集依赖', key)
return val
},
set(newVal) {
console.log('触发更新', key)
val = newVal
}
})
}
const data = { count: 0 }
defineReactive(data, 'count', 0)
data.count // 输出:收集依赖 count
data.count = 1 // 输出:触发更新 count
但是,这个方法有严重的局限性:
// 问题1:无法拦截新增属性
const data = { count: 0 }
defineReactive(data, 'count', 0)
data.newProp = 'hello' // 这个操作没有任何拦截!不会触发任何响应
// 问题2:无法拦截删除属性
delete data.count // 也没有拦截
// 问题3:数组的问题
const arr = [1, 2, 3]
// 对数组的 defineProperty 拦截效率极低,且 arr[0] = 100 这种索引赋值也无法完美拦截
// 问题4:初始化时必须递归遍历所有属性
const deep = { a: { b: { c: 1 } } }
// 必须递归遍历 a、b、c,即使 c 可能永远不会被使用
2.3 Vue 3 的方案:Proxy
Proxy 是 ES6 引入的特性,它可以代理整个对象,拦截 13 种基本操作:
const data = { count: 0, nested: { value: 1 } }
const proxy = new Proxy(data, {
get(target, key) {
console.log('读取', key)
return target[key]
},
set(target, key, value) {
console.log('写入', key, value)
target[key] = value
return true
},
deleteProperty(target, key) {
console.log('删除', key)
delete target[key]
return true
}
})
proxy.count // 输出:读取 count
proxy.count = 1 // 输出:写入 count 1
proxy.newProp = 2 // 输出:写入 newProp 2(新增属性也能拦截!)
delete proxy.count // 输出:删除 count
Proxy 解决了 defineProperty 的所有痛点:
| 问题 | defineProperty | Proxy |
|---|---|---|
| 新增属性 | ❌ 无法拦截 | ✅ 可拦截 |
| 删除属性 | ❌ 无法拦截 | ✅ 可拦截 |
| 数组索引赋值 | ⚠️ 需特殊处理 | ✅ 直接拦截 |
| 数组 length 修改 | ⚠️ 需特殊处理 | ✅ 直接拦截 |
| 初始化性能 | 需递归遍历所有属性 | 懒代理,按需代理 |
这就是 Vue 3 选择 Proxy 的根本原因。
第三章:reactive 的实现——从零构建
3.1 最简版 reactive
让我们从零开始构建一个最简单的 reactive 函数:
// 存储所有响应式对象的依赖关系
// targetMap: 对象 → 属性 → 依赖函数集合
const targetMap = new WeakMap()
// 当前正在执行的副作用函数
let activeEffect = null
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
// 依赖收集
track(target, key)
const result = Reflect.get(target, key, receiver)
// 如果读取的值是对象,递归代理(懒代理)
if (result && typeof result === 'object') {
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 = Object.prototype.hasOwnProperty.call(target, key)
const result = Reflect.deleteProperty(target, key)
if (hadKey && result) {
trigger(target, key)
}
return result
}
})
}
function track(target, key) {
if (!activeEffect) return
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
dep.add(activeEffect)
}
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (dep) {
dep.forEach(effect => effect())
}
}
3.2 运行一下
// 创建一个响应式对象
const state = reactive({ count: 0, user: { name: 'Vue' } })
// 定义一个副作用函数
function effect(fn) {
activeEffect = fn
fn()
activeEffect = null
}
// 使用 effect
effect(() => {
console.log('count 变成了:', state.count)
})
// 输出:count 变成了: 0
// 修改数据
state.count = 1 // 输出:count 变成了: 1
state.count = 2 // 输出:count 变成了: 2
// 验证懒代理
console.log(state.user.name) // 读取 user.name,此时 user 才被代理
3.3 关键设计解读
1. 为什么用 WeakMap?
const targetMap = new WeakMap() // 注意是 WeakMap,不是 Map
- 键是原始对象(不是 Proxy)
- WeakMap 对键是弱引用:当原始对象不再被其他代码引用时,可以被垃圾回收
- 这意味着:组件销毁后,整个依赖树自动被回收,无需手动清理
2. 为什么用 Reflect?
Reflect.get(target, key, receiver)
- 保证
this指向正确 - 返回操作的结果(比如
set返回是否成功) - 与 Proxy 的拦截器一一对应,是标准搭配
3. 懒代理是什么?
if (result && typeof result === 'object') {
return reactive(result)
}
- 只有访问到嵌套对象时,才将其转为响应式
- Vue 2 是初始化时递归遍历所有属性
- 如果对象有 1000 个深层属性但只用了 1 个,Vue 3 的性能优势巨大
第四章:ref 的设计——如何让基础值也变得响应式?
4.1 问题:Proxy 只能代理对象
Proxy 只能代理对象,不能代理基础值(number、string、boolean 等)。
const count = 1
// 无法用 Proxy 代理 count,因为它不是对象
但是业务中我们经常需要响应式的基础值:count、isLoading、name 等。
4.2 解决方案:包装对象
Vue 3 的解决方案是 ref:用一个对象把基础值包起来。
// 使用方式
const count = ref(0)
count.value // 读取
count.value = 1 // 修改
简化版实现:
function ref(value) {
// 如果已经是 ref,直接返回
if (value && value.__v_isRef) return value
return new RefImpl(value)
}
class RefImpl {
constructor(value) {
this.__v_isRef = true // 标记这是一个 ref
this._value = toReactive(value) // 存储值,如果是对象则用 reactive 包装
}
get value() {
// 依赖收集
trackRefValue(this)
return this._value
}
set value(newVal) {
if (newVal !== this._value) {
this._value = toReactive(newVal)
// 触发更新
triggerRefValue(this)
}
}
}
function toReactive(value) {
return value && typeof value === 'object' ? reactive(value) : value
}
4.3 ref 的自动解包
Vue 3 在模板和 reactive 对象中会自动解包 ref,不需要写 .value:
<template>
<!-- 模板中直接使用,不需要 .value -->
<div>{{ count }}</div>
</template>
<script setup>
import { ref } from 'vue'
const count = ref(0)
// 修改时还是需要 .value
const increment = () => count.value++
</script>
// reactive 对象中自动解包
const count = ref(0)
const state = reactive({
count: count // ref 作为属性
})
console.log(state.count) // 0,不需要 .value
state.count = 1 // 直接修改,同时也会更新 count.value
console.log(count.value) // 1
4.4 ref vs reactive:如何选择?
| 场景 | 推荐 | 原因 |
|---|---|---|
| 基础值(number、string、boolean) | ref | reactive 无法处理 |
| 需要整体替换的对象 | ref | ref.value = newObj 会触发更新;reactive 整体替换会丢失响应 |
| 固定结构的对象 | reactive | 更直接的访问方式,不需要 .value |
| 不确定类型的值 | ref | 灵活,可以存任何类型 |
第五章:副作用函数 Effect——响应式的"发动机"
5.1 什么是副作用函数?
副作用函数是指那些执行后会对外部产生影响的函数。
在 Vue 的上下文中:
- 组件渲染函数是副作用(它会产生 DOM 更新)
- watchEffect 的回调是副作用(它可能执行任意代码)
- computed 的 getter 也是副作用(它读取响应式数据)
响应式系统的本质:让副作用函数"记住"它访问了哪些响应式数据,当这些数据变化时,重新执行副作用函数。
5.2 完整的 Effect 实现
let activeEffect = null
const effectStack = [] // 处理嵌套 effect
class ReactiveEffect {
constructor(fn, scheduler = null) {
this.fn = fn // 要执行的函数
this.scheduler = scheduler // 调度器(用于异步更新)
this.active = true // 是否激活
this.deps = [] // 反向记录:这个 effect 被哪些 dep 收集了
}
run() {
if (!this.active) {
return this.fn()
}
// 清理旧的依赖(处理条件分支变化的情况)
cleanupEffect(this)
// 压栈,设置当前激活的 effect
effectStack.push(this)
activeEffect = this
// 执行用户函数,触发依赖收集
const result = this.fn()
// 出栈,恢复上一个 effect
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
return result
}
stop() {
if (this.active) {
cleanupEffect(this)
this.active = false
}
}
}
function cleanupEffect(effect) {
const { deps } = effect
for (let i = 0; i < deps.length; i++) {
deps[i].delete(effect)
}
effect.deps.length = 0
}
function effect(fn, options = {}) {
const _effect = new ReactiveEffect(fn, options.scheduler)
_effect.run()
// 返回 runner,可以手动执行或停止
const runner = _effect.run.bind(_effect)
runner.effect = _effect
return runner
}
5.3 为什么需要 effectStack?
考虑嵌套 effect 的场景:
effect(() => { // effect1
console.log('外层', state.a)
effect(() => { // effect2
console.log('内层', state.b)
})
})
执行流程:
- effect1 开始执行,
activeEffect = effect1 - effect1 内部访问
state.a,依赖收集:state.a的 dep 添加 effect1 - effect1 内部创建 effect2,effect2 开始执行,
activeEffect = effect2 - effect2 访问
state.b,依赖收集:state.b的 dep 添加 effect2 - effect2 执行完毕,
activeEffect恢复为 effect1 - effect1 执行完毕,
activeEffect = null
如果没有 effectStack,effect2 执行完毕后我们无法知道上一个 effect 是谁,嵌套依赖收集就会出错。
5.4 为什么需要 cleanupEffect?
考虑条件分支的 effect:
let showA = true
effect(() => {
if (showA) {
console.log(state.a) // 访问 a
} else {
console.log(state.b) // 访问 b
}
})
- 第一次执行(showA = true):依赖 state.a
- 修改 showA = false,重新执行 effect:现在依赖 state.b
问题:state.a 的 dep 中仍然有这个 effect!当 state.a 变化时,effect 还会被触发,造成不必要的执行。
解决方案:每次 effect 重新执行前,先把自己从所有 dep 中删除(cleanupEffect),然后重新收集依赖。
这就是 effect.deps 反向记录的作用——让 effect 知道自己被哪些 dep 收集了,可以精准地"退租"。
第六章:调度器与异步更新——性能的关键
6.1 问题:频繁更新导致性能浪费
const state = reactive({ count: 0 })
effect(() => {
console.log('count:', state.count)
})
state.count = 1
state.count = 2
state.count = 3
没有调度器的话,会输出:
count: 1
count: 2
count: 3
三次输出意味着三次执行。如果这是组件渲染,就会三次操作 DOM,严重浪费性能。
6.2 解决方案:异步批处理
我们想要的:
- 同一个 effect 在同一轮更新中只执行一次
- 所有更新收集起来,在下一个时机批量执行
const queue = new Set() // 用 Set 自动去重
let isFlushing = false
function queueJob(job) {
if (!queue.has(job)) {
queue.add(job)
if (!isFlushing) {
isFlushing = true
nextTick(flushJobs)
}
}
}
function flushJobs() {
// 拷贝一份执行,因为执行过程中可能有新的 job 加入
const jobs = Array.from(queue)
queue.clear()
for (const job of jobs) {
job()
}
isFlushing = false
// 如果在执行过程中有新的 job 加入,递归处理
if (queue.size) {
flushJobs()
}
}
6.3 nextTick 的实现
const nextTick = (() => {
const callbacks = []
let pending = false
function flushCallbacks() {
pending = false
const copies = callbacks.slice()
callbacks.length = 0
for (let i = 0; i < copies.length; i++) {
copies[i]()
}
}
// 使用 Promise 微任务
let timerFunc = () => {
Promise.resolve().then(flushCallbacks)
}
return function nextTick(fn) {
callbacks.push(fn)
if (!pending) {
pending = true
timerFunc()
}
}
})()
为什么用微任务(Promise.then)而不是宏任务(setTimeout)?
事件循环顺序:
同步代码 → 微任务队列 → 宏任务队列
使用微任务:
修改数据 → 推入队列 → 同步代码结束 → 微任务执行 → 更新 DOM
↑ 同一轮事件循环,无延迟
使用宏任务:
修改数据 → 推入队列 → 同步代码结束 → 等待下一轮 → 宏任务执行 → 更新 DOM
↑ 多了一轮事件循环,有可见延迟
6.4 在 effect 中使用调度器
function effect(fn, options = {}) {
const _effect = new ReactiveEffect(fn, options.scheduler)
_effect.run()
return _effect.run.bind(_effect)
}
// 使用示例:带调度器的 effect
const runner = effect(
() => {
console.log('count:', state.count)
},
{
scheduler: (effect) => {
queueJob(effect.run.bind(effect))
}
}
)
组件渲染 effect 正是这样配置的:数据变化时不立即渲染,而是推入队列,等待 nextTick 批量执行。
第七章:computed——带缓存的副作用
7.1 问题:重复计算浪费性能
const state = reactive({ a: 1, b: 2 })
// 如果多个地方需要 a + b 的结果
console.log(state.a + state.b) // 计算1
console.log(state.a + state.b) // 计算2(重复计算)
我们希望:只在依赖变化时重新计算,否则返回缓存值。
7.2 computed 的完整实现
class ComputedRefImpl {
constructor(getter) {
this._value = undefined
this._dirty = true // 脏标志:true 表示需要重新计算
this.dep = new Set() // 依赖这个 computed 的 effect
// 创建一个 effect,但不立即执行
this.effect = new ReactiveEffect(getter, () => {
// scheduler:当依赖变化时
if (!this._dirty) {
this._dirty = true
// 通知所有依赖这个 computed 的 effect
trigger(this, 'value')
}
})
}
get value() {
// 收集依赖(谁在读取这个 computed)
track(this, 'value')
if (this._dirty) {
this._value = this.effect.run() // 重新计算
this._dirty = false
}
return this._value
}
}
function computed(getter) {
return new ComputedRefImpl(getter)
}
7.3 执行流程详解
const state = reactive({ a: 1, b: 2 })
const sum = computed(() => {
console.log('计算中...')
return state.a + state.b
})
// 创建 computed 时:不会立即计算,没有输出
// 第一次访问:触发计算
console.log(sum.value)
// 输出:计算中...
// 输出:3
// _dirty 变为 false
// 第二次访问:直接返回缓存
console.log(sum.value)
// 输出:3(没有"计算中...")
// 依赖变化:state.a = 3
// 触发 state.a 的 set → trigger
// 找到 sum 内部的 effect → 执行 scheduler
// scheduler 设置 _dirty = true
// 同时 trigger(sum, 'value') 通知 sum 的依赖
// 注意:此时没有重新计算,只是标记为脏
// 再次访问:触发重新计算
console.log(sum.value)
// 输出:计算中...
// 输出:5
7.4 为什么需要两阶段通知?
const sum = computed(() => state.a + state.b)
watchEffect(() => {
console.log('sum 变化了:', sum.value)
})
// 当 state.a 变化时:
// 1. state.a 的 trigger 执行
// 2. sum 内部的 effect 的 scheduler 执行
// → _dirty = true
// → trigger(sum, 'value') ← 关键!
// 3. watchEffect 的 effect 被触发
// 4. watchEffect 重新执行,读取 sum.value
// → 发现 _dirty = true,重新计算
// → 输出新值
// 如果没有 trigger(sum, 'value'):
// 第 2 步只设置了 _dirty = true
// watchEffect 不知道需要重新执行
// 直到下次有人主动读取 sum.value 才会更新
第八章:响应式与组件生命周期的完整串联
这是理解 Vue 整体运作的关键。让我们追踪一个组件从创建到销毁的全过程。
8.1 组件初始化
// 1. 创建组件实例
const instance = {
isMounted: false,
subTree: null,
ctx: null, // 组件上下文(响应式数据)
update: null // 更新函数
}
// 2. 执行 setup,创建响应式数据
const setupResult = Component.setup(props, { emit })
instance.ctx = setupResult
// 3. 创建渲染 effect
function setupRenderEffect(instance) {
const componentUpdateFn = () => {
if (!instance.isMounted) {
// 首次挂载
// 执行 beforeMount 钩子
const subTree = instance.render.call(instance.ctx)
patch(null, subTree, instance.container)
instance.subTree = subTree
instance.isMounted = true
// 执行 mounted 钩子
} else {
// 更新
// 执行 beforeUpdate 钩子
const prevTree = instance.subTree
const nextTree = instance.render.call(instance.ctx)
patch(prevTree, nextTree, instance.container)
instance.subTree = nextTree
// 执行 updated 钩子
}
}
// 创建 effect,配置 scheduler 实现异步更新
instance.update = new ReactiveEffect(
componentUpdateFn,
() => queueJob(instance.update) // 调度器
)
// 首次执行,触发依赖收集
instance.update.run()
}
8.2 首次渲染时的依赖收集
执行 instance.update.run()
│
▼
activeEffect = 组件渲染 effect
│
▼
执行 render() 函数
│
├── 访问 state.count → 触发 Proxy get → track(state, 'count')
│ └── 将组件渲染 effect 添加到 state.count 的依赖集合中
│
├── 访问 state.user.name → 触发 Proxy get → track(user, 'name')
│ └── 将组件渲染 effect 添加到 user.name 的依赖集合中
│
└── ... 其他响应式数据访问
│
▼
render() 返回虚拟 DOM
│
▼
patch 将虚拟 DOM 转换为真实 DOM
│
▼
activeEffect = null
结果:组件渲染 effect 被记录到了所有它访问过的响应式数据的依赖集合中。
8.3 数据变化时的更新链路
// 用户操作
function handleClick() {
state.count++ // 假设 state.count 是响应式数据
}
// 完整链路:
//
// 1. state.count++ → 触发 Proxy set 拦截
// ↓
// 2. set 拦截中调用 trigger(state, 'count')
// ↓
// 3. trigger 从 targetMap 中找到依赖 count 的所有 effect
// 找到了:组件渲染 effect
// ↓
// 4. 调用 effect.scheduler(即 queueJob)
// ↓
// 5. queueJob 将 instance.update 推入异步队列
// ↓
// 6. 同步代码执行完毕
// ↓
// 7. nextTick 的微任务执行 flushJobs
// ↓
// 8. flushJobs 执行 instance.update
// ↓
// 9. instance.update 执行 componentUpdateFn
// ├── 执行 beforeUpdate 钩子
// ├── 重新执行 render(),生成新的虚拟 DOM
// ├── 执行 patch,更新真实 DOM
// └── 执行 updated 钩子
// ↓
// 10. 用户看到新界面
8.4 组件销毁时的清理
function unmountComponent(instance) {
// 执行 beforeUnmount 钩子
// 停止渲染 effect
if (instance.update) {
instance.update.effect.stop()
}
// effect.stop() 会:
// 1. 设置 effect.active = false
// 2. 遍历 effect.deps,从每个 dep 中删除自己
// 3. 清空 effect.deps
// 卸载 DOM
unmount(instance.subTree)
// 执行 unmounted 钩子
// 此时,原始对象不再被引用,WeakMap 中的条目自动被垃圾回收
}
8.5 生命周期与响应式的对照表
| 生命周期 | 响应式状态 | 能否访问 DOM | 典型用途 |
|---|---|---|---|
| beforeCreate | 响应式数据未初始化 | ❌ | 插件注入 |
| created | 响应式数据已就绪 | ❌ | 数据请求、事件监听 |
| beforeMount | 首次渲染前 | ❌ | 最后一次修改数据 |
| mounted | 首次渲染后 | ✅ | DOM 操作、第三方库初始化 |
| beforeUpdate | 数据变化、重新渲染前 | ✅ | 获取更新前的 DOM 状态 |
| updated | 重新渲染后 | ✅ | DOM 操作(注意避免死循环) |
| beforeUnmount | 卸载前 | ✅ | 清理定时器、取消请求 |
| unmounted | 卸载后 | ❌ | 最终清理 |
第九章:Vue 2 vs Vue 3——全面对比
9.1 核心差异表
| 维度 | Vue 2 | Vue 3 |
|---|---|---|
| 数据劫持 | Object.defineProperty | Proxy + Reflect |
| 拦截能力 | 只能拦截属性读写 | 拦截 13 种操作(读写、删除、in 等) |
| 新增属性 | 需要 $set | 自动侦测 |
| 删除属性 | 需要 $delete | 自动侦测 |
| 数组变化 | 重写数组原型方法 | 直接代理,索引和 length 都可拦截 |
| 初始化性能 | 递归遍历所有属性 | 懒代理,按需代理 |
| 依赖管理 | Dep + Watcher | targetMap + ReactiveEffect |
| 调度更新 | queueWatcher | queueJob(更通用) |
| TypeScript 支持 | 较差(需装饰器或类组件) | 原生支持 |
| Tree-shaking | 不支持 | 按需引入,体积更小 |
| 响应式核心代码 | 约 2.5KB(gzipped) | 约 1.5KB(gzipped) |
9.2 性能基准测试
| 场景 | Vue 2 | Vue 3 | 提升 |
|---|---|---|---|
| 组件初始化(1000 个) | ~120ms | ~60ms | 50% |
| 列表更新(1000 项) | ~85ms | ~30ms | 65% |
| 深层对象访问 | 初始化时全部代理 | 访问时才代理 | 视使用情况 |
| 内存占用 | 基准 | 约减少 50% | - |
9.3 迁移建议
如果你还在使用 Vue 2,以下情况值得考虑迁移到 Vue 3:
- 项目中有大量的深层嵌套对象
- 频繁需要
$set/$delete - 需要更好的 TypeScript 支持
- 希望获得更好的性能
- 需要使用 Vue 3 独有的新特性(Composition API、Suspense、Teleport 等)
第十章:面试高频题与灵魂回答
Q1:Vue 3 响应式原理是什么?
答:Vue 3 响应式系统建立在三个核心概念之上:
-
Proxy 代理:使用 Proxy 拦截对象的所有读写操作,在 get 中收集依赖(track),在 set/deleteProperty 中触发更新(trigger)
-
副作用函数(ReactiveEffect):封装需要响应式执行的函数,记录它依赖了哪些数据,也记录哪些数据依赖了它(双向记录)
-
调度器(Scheduler):负责异步批量执行副作用,避免频繁更新导致的性能问题
一句话总结:Vue 3 通过 Proxy 在数据读取时记录"谁在读",在数据修改时通知"所有在读的人"重新执行,并通过调度器批量处理更新。
Q2:为什么 Vue 3 用 Proxy 代替 defineProperty?
答:有三个核心原因:
-
能力差异:defineProperty 只能拦截属性的读取和写入,无法拦截新增属性、删除属性、数组索引赋值等操作。Proxy 可以拦截 13 种基本操作,从根本上解决了 Vue 2 需要
$set/$delete的问题。 -
性能差异:defineProperty 需要在初始化时递归遍历所有属性,即使这些属性可能永远不会被使用。Proxy 采用懒代理策略,只在访问到嵌套对象时才进行代理,初始化性能提升 2-4 倍。
-
代码复杂度:defineProperty 需要为数组单独处理(重写 7 个原型方法),Proxy 对数组的拦截是天然的,不需要特殊处理。
Q3:ref 是如何实现响应式的?为什么需要 .value?
答:由于 Proxy 只能代理对象,无法代理基础值(number、string、boolean 等),ref 通过创建一个包装对象 { value: 原始值 } 来解决这个问题。
这个包装对象通过 Object.defineProperty 或类似的机制拦截 .value 的读写:
- 读取时:执行 track,收集当前依赖
- 写入时:执行 trigger,触发所有依赖
.value 的存在是因为 JavaScript 的语法限制——我们无法直接拦截对基础值的访问,必须通过一个属性访问来建立拦截点。
在模板和 reactive 对象中,Vue 3 会自动解包 ref,因此不需要写 .value,这是编译器和运行时的协作结果。
Q4:computed 的缓存是如何实现的?
答:computed 的缓存通过 _dirty(脏标志)实现:
- 每个 computed 内部创建一个
ReactiveEffect,但不立即执行(懒执行) - 首次访问
.value时,_dirty = true,执行 effect 计算值并缓存,然后将_dirty设为false - 后续访问直接返回缓存值,不重新计算
- 当依赖的响应式数据变化时,触发 computed 内部 effect 的 scheduler,将
_dirty设为true,并主动trigger自己的依赖 - 下次访问
.value时发现_dirty = true,重新计算并更新缓存
关键设计:computed 的依赖变化时只标记脏,不重新计算,真正计算发生在被读取时。这种"懒计算"策略避免了不必要的计算。
Q5:Vue 3 的异步更新是如何实现的?
答:Vue 3 的异步更新通过**调度器(Scheduler)+ 队列(Queue)+ 微任务(nextTick)**实现:
-
每个组件渲染 effect 都配置了一个 scheduler,当数据变化触发更新时,scheduler 不直接执行 effect,而是调用
queueJob -
queueJob使用Set数据结构存储需要执行的 job(自动去重),然后通过nextTick调度flushJobs的执行 -
flushJobs遍历队列,执行所有 job
性能提升的本质:一个组件内如果响应式数据被修改了 100 次,没有异步更新会导致 100 次 DOM 操作;有异步更新只会有 1 次 DOM 操作,性能提升 100 倍。
为什么用微任务:微任务在同步代码执行完毕后立即执行,没有额外的事件循环延迟,既能批量处理,又能保证更新的及时性。
Q6:什么是懒代理?有什么好处?
答:懒代理是 Vue 3 响应式系统的核心优化之一。
在 Vue 2 中,data 初始化时会递归遍历所有属性,将其转换为 getter/setter,即使深层属性从未被使用。
在 Vue 3 中,reactive 只代理最外层的对象,内层嵌套对象在被访问时才递归调用 reactive 变成响应式。
好处:
- 初始化速度大幅提升(尤其对于深层嵌套的大对象)
- 内存占用减少(未被访问的属性不会创建响应式开销)
- 对于"宽而浅"的数据结构,优势尤其明显