前端 Vue 专栏 05:从 Object.defineProperty 到 Proxy,彻底理解 Vue 响应式原理
前言
上一篇文章介绍了 Composition API,并使用了很多响应式 API:
js
const count = ref(0)
const user = reactive({ name: '张三' })
const doubleCount = computed(() => count.value * 2)
watch(count, newValue => {
console.log('count 变化了:', newValue)
})
这些 API 看起来各不相同,但它们背后都离不开同一套响应式系统。
当我们执行:
js
count.value++
Vue 为什么知道哪些地方使用了 count?
为什么它只更新相关组件,而不是重新渲染整个页面?
computed 为什么能够缓存结果?
watch 为什么能在数据变化后执行回调?
Vue 2 为什么需要 Vue.set,Vue 3 为什么不再需要?
这一篇将沿着一条完整的主线回答这些问题:
text
状态被读取
↓
收集依赖 track
↓
状态被修改
↓
通知依赖 trigger
↓
重新执行组件渲染、computed 或 watch
本文会先建立响应式的核心模型,再分别讲解 Vue 2 的 Object.defineProperty 和 Vue 3 的 Proxy,最后手写一套简化版响应式系统。
需要提前说明:本文的手写代码用于解释原理,并不是 Vue 源码的逐行复制。真实 Vue Core 还处理了数组、集合类型、迭代依赖、调度批处理、版本号优化、只读代理、浅层代理、作用域清理等大量边界情况。
一、什么是响应式
先看一个普通 JavaScript 例子:
html
<span id="count"></span>
<script>
let count = 0
const countElement = document.querySelector('#count')
countElement.textContent = count
count++
</script>
执行 count++ 后,变量已经变成 1,但页面仍然显示 0。
原因很简单:
text
JavaScript 变量变化,不会自动修改 DOM。
我们必须再次手动执行:
js
countElement.textContent = count
而在 Vue 中:
vue
<script setup>
import { ref } from 'vue'
const count = ref(0)
</script>
<template>
<button @click="count++">
{{ count }}
</button>
</template>
修改 count 后,页面会自动更新。
所谓响应式,可以先理解为:
当状态发生变化时,所有依赖该状态的计算和界面能够自动得到更新。
这里有两个关键词:
- 状态 :例如
count、user.name、商品列表; - 依赖:读取并使用这些状态的组件渲染、计算属性或侦听器。
响应式并不是"数据自己修改 DOM",而是 Vue 建立了状态与副作用之间的联系。
二、什么是副作用 effect
下面这个函数只是进行纯计算:
js
function add(a, b) {
return a + b
}
它只根据输入返回结果,不修改函数外部的内容,通常称为纯函数。
再看这个函数:
js
function render() {
document.querySelector('#count').textContent = state.count
}
它读取 state.count,并修改了外部 DOM。
类似这种会影响函数外部状态的操作,可以称为副作用。
在响应式系统中,effect 可以理解为:
一个会读取响应式状态,并且需要在这些状态变化后重新执行的函数。
常见 effect 包括:
text
组件渲染函数;
watchEffect 中的回调;
watch 所使用的响应式取值函数;
需要根据响应式数据重新执行的用户副作用。
例如:
js
effect(() => {
document.body.textContent = state.count
})
我们希望响应式系统完成两件事:
- 第一次执行函数时,记录它读取了
state.count; state.count变化时,再次执行这个函数。
这就是整套系统最核心的任务。
三、响应式的核心不是 Proxy,而是依赖关系
很多人在面试中会回答:
text
Vue 2 使用 Object.defineProperty;
Vue 3 使用 Proxy。
这句话没错,但还没有解释响应式原理。
Object.defineProperty 和 Proxy 主要解决的是:
text
Vue 怎样感知属性被读取和被修改。
真正让页面自动更新的,是读取和修改时建立的依赖关系:
text
读取属性 → track 收集当前 effect
修改属性 → trigger 找到并通知相关 effect
可以把它想成一张订阅表:
text
state.count
├── 组件 A 的渲染函数
├── doubleCount 的计算逻辑
└── 某个 watch 回调
state.name
└── 组件 B 的渲染函数
当 state.count 变化时,只需要通知订阅了 count 的 effect,不必把 state.name 的使用者一起重新执行。
所以面试中更完整的回答应该是:
Vue 通过 getter/setter 或 Proxy 拦截响应式数据的读取和修改。读取时通过 track 收集当前正在执行的副作用,修改时通过 trigger 找到该属性对应的副作用并通知它们重新执行或进入调度队列。
四、为什么普通局部变量不能直接响应
下面是普通变量:
js
let count = 0
count++
JavaScript 没有提供一种通用机制,让 Vue 拦截任意局部变量的读取和赋值:
js
console.log(count) // Vue 无法在这里自动插入 track
count = 1 // Vue 无法在这里自动插入 trigger
所以 Vue 需要把状态放进一个能够拦截访问的容器中。
Vue 3 常见的两种容器是:
js
const count = ref(0)
const state = reactive({ count: 0 })
它们分别通过下面的方式拦截访问:
text
ref:拦截 value 属性的 getter 和 setter;
reactive:使用 Proxy 拦截对象属性操作。
这也解释了 ref 为什么需要 .value:
js
count.value++
Vue 不是无缘无故增加一层写法,而是需要一个属性访问边界,才能感知读取和修改。
在模板中,Vue 会自动解包符合条件的 ref,所以可以写:
vue
<p>{{ count }}</p>
但在普通 JavaScript 中,仍然需要:
js
console.log(count.value)
五、先用 getter 和 setter 理解最小响应式
暂时不考虑多个对象和多个属性,只实现一个响应式值:
js
let activeEffect = null
const effects = new Set()
function effect(fn) {
activeEffect = fn
fn()
activeEffect = null
}
function ref(rawValue) {
let value = rawValue
return {
get value() {
if (activeEffect) {
effects.add(activeEffect)
}
return value
},
set value(newValue) {
if (Object.is(newValue, value)) {
return
}
value = newValue
effects.forEach(effectFn => effectFn())
}
}
}
使用它:
js
const count = ref(0)
effect(() => {
console.log('当前 count:', count.value)
})
count.value++
第一次调用 effect 时:
text
activeEffect = 当前函数
执行当前函数
读取 count.value
getter 把 activeEffect 放进 effects
修改 count.value 时:
text
setter 保存新值
遍历 effects
重新执行依赖 count 的函数
输出结果是:
text
当前 count:0
当前 count:1
这段代码已经包含响应式最重要的两个动作:
text
get 时收集依赖;
set 时触发依赖。
但是它还存在明显问题:所有 ref 共用同一个 effects,无法区分不同对象和属性;也没有处理嵌套 effect、分支切换和调度。
后面会逐步补全。
六、Vue 2 为什么使用 Object.defineProperty
Vue 2 的响应式系统主要通过 Object.defineProperty() 把对象已有属性转换为 getter 和 setter。
基本用法如下:
js
const user = {}
let internalName = '张三'
Object.defineProperty(user, 'name', {
enumerable: true,
configurable: true,
get() {
console.log('读取了 name')
return internalName
},
set(newValue) {
console.log('修改了 name:', newValue)
internalName = newValue
}
})
执行:
js
console.log(user.name)
user.name = '李四'
就会进入对应的 getter 和 setter。
因此 Vue 2 可以在 getter 中收集依赖,在 setter 中通知依赖。
需要注意,Object.defineProperty 本身并不是 Vue 专用 API,也不是只能监听对象变化。它的作用是定义或修改对象某一个属性的描述符,Vue 2 利用其中的访问器能力构建响应式系统。
七、Vue 2 怎样递归处理 data
假设 Vue 2 组件中有:
js
data() {
return {
user: {
name: '张三',
address: {
city: '上海'
}
}
}
}
Vue 2 初始化时会遍历对象已有属性,把它们转换成 getter 和 setter。
简化实现如下:
js
function observe(value) {
if (typeof value !== 'object' || value === null) {
return
}
Object.keys(value).forEach(key => {
defineReactive(value, key, value[key])
})
}
function defineReactive(target, key, value) {
observe(value)
Object.defineProperty(target, key, {
enumerable: true,
configurable: true,
get() {
console.log(`读取 ${key}`)
return value
},
set(newValue) {
if (Object.is(newValue, value)) {
return
}
value = newValue
observe(newValue)
console.log(`触发 ${key} 的更新`)
}
})
}
执行:
js
const state = {
user: {
name: '张三'
}
}
observe(state)
console.log(state.user.name)
state.user.name = '李四'
访问 state.user.name 时,会先读取 user,再读取 name。
这里有一个关键点:
text
Vue 2 需要在初始化时递归遍历已有对象属性。
如果 setter 接收到一个新对象,也要继续观察新对象,否则新对象内部的属性无法响应。
八、Vue 2 中的 Dep 和 Watcher
只有 getter 和 setter 还不够。
Vue 还需要知道:
text
谁读取了这个属性?
属性变化后应该通知谁?
在 Vue 2 的经典实现模型中,可以使用 Dep 管理一个属性的订阅者,使用 Watcher 表示需要响应数据变化的观察者。
1. Dep:依赖管理器
简化后可以这样理解:
js
class Dep {
constructor() {
this.subscribers = new Set()
}
depend() {
if (Dep.target) {
this.subscribers.add(Dep.target)
}
}
notify() {
this.subscribers.forEach(watcher => {
watcher.update()
})
}
}
Dep.target = null
每一个响应式属性可以拥有自己的 Dep。
2. Watcher:使用响应式数据的人
js
class Watcher {
constructor(getter) {
this.getter = getter
this.get()
}
get() {
Dep.target = this
this.getter()
Dep.target = null
}
update() {
this.get()
}
}
把它们放入 defineReactive:
js
function defineReactive(target, key, value) {
const dep = new Dep()
Object.defineProperty(target, key, {
get() {
dep.depend()
return value
},
set(newValue) {
if (Object.is(newValue, value)) {
return
}
value = newValue
dep.notify()
}
})
}
使用过程:
js
const state = {}
defineReactive(state, 'count', 0)
new Watcher(() => {
console.log('渲染 count:', state.count)
})
state.count++
可以画成下面的流程:
text
创建 Watcher
↓
Dep.target 指向当前 Watcher
↓
执行 getter 函数并读取 state.count
↓
属性 getter 调用 dep.depend()
↓
Dep 记住当前 Watcher
↓
修改 state.count
↓
属性 setter 调用 dep.notify()
↓
Watcher.update()
真实 Vue 2 的 Watcher 和依赖清理更加复杂,但这个模型已经能解释其核心工作方式。
九、Vue 2 为什么检测不到新增和删除属性
Object.defineProperty 处理的是某一个已经存在或明确指定的属性:
js
Object.defineProperty(user, 'name', descriptor)
假设初始化时只有:
js
const user = {
name: '张三'
}
Vue 2 可以把 name 转成 getter 和 setter。
后来直接新增:
js
user.age = 18
这个赋值没有经过 name 的 setter,也不会自动为 age 创建 setter。因此 Vue 2 无法检测这次新增。
删除属性也有类似问题:
js
delete user.name
删除操作不是给 name 赋值,不会进入它的 setter。
所以 Vue 2 提供了:
js
Vue.set(user, 'age', 18)
Vue.delete(user, 'name')
组件中也可以使用:
js
this.$set(this.user, 'age', 18)
this.$delete(this.user, 'name')
Vue.set 的核心思路不是让 Object.defineProperty 突然获得监听新增属性的能力,而是:
text
由 Vue 主动给新属性创建响应式访问器,并通知相关依赖更新。
因此,在 Vue 2 中如果一开始就知道对象可能有哪些字段,最好提前声明:
js
data() {
return {
user: {
name: '',
age: null
}
}
}
十、Vue 2 怎样处理数组
数组也是对象,但直接给每个索引设置 getter 和 setter 会带来复杂度和性能问题,而且数组长度会频繁变化。
Vue 2 主要通过改写数组的七个变更方法来检测数组操作:
text
push
pop
shift
unshift
splice
sort
reverse
概念上可以理解为:
js
const originalPush = Array.prototype.push
arrayMethods.push = function (...items) {
const result = originalPush.apply(this, items)
// 观察新增元素
observeArray(items)
// 通知依赖数组的 Watcher
this.__ob__.dep.notify()
return result
}
因此下面的写法可以触发更新:
js
this.users.push(newUser)
this.users.splice(index, 1)
但 Vue 2 无法可靠检测下面两种直接操作:
js
this.users[0] = newUser
this.users.length = 0
通常改为:
js
this.$set(this.users, 0, newUser)
this.users.splice(0)
注意,非变更方法本身不会修改原数组,例如:
js
filter()
map()
slice()
concat()
使用这些方法时,一般把返回的新数组重新赋值:
js
this.users = this.users.filter(user => user.active)
十一、Vue 3 为什么改用 Proxy
Proxy 可以为整个对象创建代理,而不是逐个改造已有属性。
基本用法如下:
js
const user = {
name: '张三'
}
const proxyUser = new Proxy(user, {
get(target, key, receiver) {
console.log('读取属性:', key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
console.log('设置属性:', key, value)
return Reflect.set(target, key, value, receiver)
},
deleteProperty(target, key) {
console.log('删除属性:', key)
return Reflect.deleteProperty(target, key)
}
})
即使原对象中没有 age,下面的操作仍然可以被代理捕获:
js
proxyUser.age = 18
delete proxyUser.name
Proxy 可以拦截的操作比属性 getter/setter 更完整,例如:
text
读取属性;
设置属性;
删除属性;
判断属性是否存在;
遍历对象;
获取属性描述符;
设置原型等。
这让 Vue 3 更自然地处理:
- 对象属性新增和删除;
- 数组索引和
length变化; Map、Set、WeakMap、WeakSet等集合类型;- 与遍历、
in操作符相关的依赖。
因此,Vue 3 中通常不再需要 Vue.set:
js
const user = reactive({ name: '张三' })
user.age = 18
delete user.name
这些操作可以直接被 Proxy 拦截。
十二、Proxy 与 Object.defineProperty 的本质区别
可以用一张表概括:
| 对比项 | Vue 2:Object.defineProperty | Vue 3:Proxy |
|---|---|---|
| 代理对象 | 针对对象的具体属性 | 针对整个对象 |
| 初始化 | 需要遍历已有属性 | 创建对象代理 |
| 新增属性 | 无法自动检测,需要 Vue.set | 可以通过 set 拦截 |
| 删除属性 | 无法自动检测,需要 Vue.delete | 可以通过 deleteProperty 拦截 |
| 数组索引 | 不能可靠检测直接索引修改 | 可以拦截索引设置 |
| 数组 length | 不能可靠检测直接修改 | 可以拦截 |
| Map、Set | 不适合直接处理 | 可以通过集合相关封装处理 |
| 兼容性 | 支持较老浏览器 | Proxy 无法被完整 polyfill |
但不要把它理解成:
text
Object.defineProperty 完全不能做响应式,Proxy 才能做。
Vue 2 已经证明 Object.defineProperty 可以构建成熟的响应式框架。Vue 3 改用 Proxy,是为了获得更完整的拦截能力、更自然的数据操作方式以及更好的系统设计空间。
十三、为什么 Proxy 中经常使用 Reflect
我们当然可以写:
js
get(target, key) {
return target[key]
}
但 Vue 一类的实现通常使用:
js
get(target, key, receiver) {
return Reflect.get(target, key, receiver)
}
主要原因包括:
1. 与 Proxy 陷阱一一对应
Proxy 中有 get、set、deleteProperty,Reflect 中也有:
js
Reflect.get()
Reflect.set()
Reflect.deleteProperty()
代码语义更加统一。
2. 返回操作是否成功
例如:
js
const success = Reflect.set(target, key, value, receiver)
return success
这符合 Proxy set 陷阱应返回布尔值的要求。
十四、Vue 3 的依赖表长什么样
一个应用中可能存在:
text
多个响应式对象;
每个对象有多个属性;
每个属性被多个 effect 使用。
因此不能像最初示例一样,把所有 effect 放进同一个 Set。
我们需要三级映射:
text
目标对象 target
↓
属性 key
↓
依赖集合 Set<effect>
常见学习模型的数据结构是:
js
const targetMap = new WeakMap()
它的完整形状可以写成:
text
WeakMap<target, Map<key, Set<ReactiveEffect>>>
假设有:
js
const state = reactive({
count: 0,
name: '张三'
})
依赖表可能类似:
text
targetMap
└── 原始 state 对象
└── depsMap
├── count
│ └── Set [组件渲染 effect, watchEffect A]
└── name
└── Set [组件渲染 effect B]
为什么最外层使用 WeakMap
targetMap 的键是原始对象。
WeakMap 对键保持弱引用:当这个原始对象在应用中已经没有其他引用时,WeakMap 不会仅仅因为自己保存了它,就阻止垃圾回收。
此外,WeakMap 的键必须是对象,正好符合响应式对象的存储需求。
为什么第二层使用 Map
属性键可能是字符串或 Symbol,需要从 key 查到对应依赖,因此使用 Map。
为什么最后使用 Set
同一个 effect 在一次执行中可能多次读取同一个属性:
js
effect(() => {
console.log(state.count, state.count)
})
Set 可以自动去重,避免一个属性变化后重复执行同一个 effect。
十五、实现 effect:记录当前正在执行的副作用
先实现基础版 effect:
js
let activeEffect
function effect(fn) {
const effectFn = () => {
activeEffect = effectFn
fn()
activeEffect = undefined
}
effectFn()
return effectFn
}
调用:
js
effect(() => {
console.log(state.count)
})
执行函数之前,让全局变量 activeEffect 指向当前 effect。
这样 state.count 的 getter 被触发时,就能知道应该收集谁。
这里不是说 Vue 只能同时存在一个 effect,而是 JavaScript 在当前同步调用位置上,需要有一个上下文表示"现在是谁在读取响应式数据"。
十六、实现 track:读取时收集依赖
js
const targetMap = new WeakMap()
let activeEffect
function track(target, key) {
if (!activeEffect) {
return
}
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
let dep = depsMap.get(key)
if (!dep) {
dep = new Set()
depsMap.set(key, dep)
}
dep.add(activeEffect)
}
流程可以拆成:
text
1. 没有 activeEffect,说明当前读取不需要建立依赖;
2. 根据 target 找到这个对象的 depsMap;
3. 根据 key 找到这个属性的 dep;
4. 把 activeEffect 放入 dep。
例如普通日志读取:
js
console.log(state.count)
如果它不在 effect 中执行,此时没有 activeEffect,就不需要收集依赖。
否则每次随便读取一个值都建立订阅,会造成没有意义的内存和更新开销。
十七、实现 trigger:修改时通知依赖
js
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) {
return
}
const dep = depsMap.get(key)
if (!dep) {
return
}
const effectsToRun = new Set(dep)
effectsToRun.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectFn()
}
})
}
这里先复制出一个新的 Set:
js
const effectsToRun = new Set(dep)
原因是 effect 重新执行时,后面可能会清理并重新收集依赖。如果一边遍历原 Set,一边删除和重新添加元素,可能造成重复执行甚至无限循环。
同时跳过当前正在执行的 effect:
js
if (effectFn !== activeEffect)
用于降低 effect 在执行过程中修改自己依赖的数据而立即递归触发自己的风险。
真实框架还会有更完整的递归控制和调度规则,不能只靠这一句处理所有情况。
十八、实现 reactive
把 track 和 trigger 放进 Proxy:
js
function reactive(target) {
return new Proxy(target, {
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)
track(target, key)
return result
},
set(target, key, value, receiver) {
const oldValue = target[key]
const success = Reflect.set(target, key, value, receiver)
if (!Object.is(oldValue, value)) {
trigger(target, key)
}
return success
}
})
}
测试:
js
const state = reactive({
count: 0,
name: '张三'
})
effect(() => {
console.log('count:', state.count)
})
state.count++
state.name = '李四'
输出:
text
count:0
count:1
修改 name 不会重新执行只依赖 count 的 effect。
这证明依赖是按照:
text
target + key
精确保存的,而不是对象中任意属性变化都触发所有逻辑。
十九、为什么 effect 需要依赖清理
看一个带条件分支的例子:
js
const state = reactive({
ok: true,
name: '张三'
})
effect(() => {
console.log(state.ok ? state.name : '暂不显示')
})
第一次执行时:
text
读取 state.ok;
因为 ok 为 true,又读取 state.name。
所以这个 effect 同时被收集到 ok 和 name 的依赖集合中。
接着执行:
js
state.ok = false
effect 重新执行,但这一次只读取 state.ok,不再读取 state.name。
此时从逻辑上说:
text
name 已经不再是当前 effect 的依赖。
如果不清理旧依赖,再修改:
js
state.name = '李四'
effect 仍然会被错误触发。
这类依赖叫作失效的分支依赖。
解决思路是:
text
effect 每次重新执行前,先从过去订阅的所有 dep 中移除自己;
本次执行期间,再根据真实读取情况重新收集。
实现清理:
js
function cleanup(effectFn) {
effectFn.deps.forEach(dep => {
dep.delete(effectFn)
})
effectFn.deps.length = 0
}
track 收集时需要反向记录:
js
function track(target, key) {
if (!activeEffect) {
return
}
let depsMap = targetMap.get(target)
if (!depsMap) {
depsMap = new Map()
targetMap.set(target, depsMap)
}
let dep = depsMap.get(key)
if (!dep) {
dep = new Set()
depsMap.set(key, dep)
}
if (!dep.has(activeEffect)) {
dep.add(activeEffect)
activeEffect.deps.push(dep)
}
}
现在形成双向关系:
text
dep 知道有哪些 effect 依赖自己;
effect 也知道自己加入过哪些 dep。
双向记录的目的不是重复保存信息,而是让 effect 能够快速从旧依赖中退出。
二十、为什么 effect 需要栈
effect 可能发生嵌套:
js
effect(() => {
console.log('外层:', state.foo)
effect(() => {
console.log('内层:', state.bar)
})
console.log('外层继续:', state.baz)
})
如果只使用一个 activeEffect:
text
执行外层 → activeEffect 是外层;
执行内层 → activeEffect 被改成内层;
内层结束 → activeEffect 变成空;
继续读取 baz → 无法收集到外层。
因此需要栈保存执行上下文:
js
let activeEffect
const effectStack = []
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
try {
return fn()
} finally {
effectStack.pop()
activeEffect = effectStack.at(-1)
}
}
effectFn.deps = []
effectFn.scheduler = options.scheduler
if (!options.lazy) {
effectFn()
}
return effectFn
}
内层执行结束后,栈顶重新回到外层 effect,因此后续读取仍能正确归属于外层。
try...finally 也很重要:即使用户函数执行时抛出异常,也应该恢复 effect 上下文,避免污染后续依赖收集。
二十一、为什么需要 scheduler 调度器
目前 trigger 会立刻执行 effect:
js
effectFn()
但不同 effect 对"怎样更新"的需求并不相同。
例如:
text
组件渲染:通常放入更新队列,批量执行并去重;
computed:依赖变化时先标记为过期,不一定立即重新计算;
watch:需要在合适时机调用用户回调;
自定义 effect:可能希望延迟执行或自行控制。
因此 effect 可以接受调度器:
js
effect(() => {
console.log(state.count)
}, {
scheduler(effectFn) {
queueMicrotask(effectFn)
}
})
修改 trigger:
js
function trigger(target, key) {
const depsMap = targetMap.get(target)
const dep = depsMap?.get(key)
if (!dep) {
return
}
const effectsToRun = new Set(dep)
effectsToRun.forEach(effectFn => {
if (effectFn === activeEffect) {
return
}
if (effectFn.scheduler) {
effectFn.scheduler(effectFn)
} else {
effectFn()
}
})
}
调度器把"依赖应该更新"与"依赖何时、以什么方式执行"分开了。
这是连接响应式系统与 Vue 异步更新队列的关键接口。
二十二、嵌套对象为什么也能响应
假设:
js
const state = reactive({
user: {
profile: {
name: '张三'
}
}
})
只给最外层对象创建 Proxy,并不会自动让内部所有原始对象也变成 Proxy。
因此 get 中需要处理对象结果:
js
get(target, key, receiver) {
const result = Reflect.get(target, key, receiver)
track(target, key)
return isObject(result)
? reactive(result)
: result
}
读取:
js
state.user.profile.name
过程可以理解为:
text
读取 state.user → 得到 user 的代理;
读取 user.profile → 得到 profile 的代理;
读取 profile.name → 收集 name 的依赖。
这通常是一种按访问需要进行的深层转换,而不是创建最外层 reactive 时就立即递归代理所有嵌套对象。
这和 Vue 2 初始化时递归遍历已有属性的方式不同。
二十三、reactive 返回的是原对象吗
不是。
js
const raw = {
count: 0
}
const proxy = reactive(raw)
console.log(proxy === raw) // false
reactive() 返回的是原对象的 Proxy。
因此应该尽量通过代理读写:
js
proxy.count++
如果绕过代理修改原对象:
js
raw.count++
操作不会经过 Proxy 的 set 陷阱,也就不能按正常方式触发响应式更新。
还要注意身份比较:
js
const rawUser = {}
const proxyUser = reactive(rawUser)
console.log(rawUser === proxyUser) // false
项目中不要随意混用原始对象和代理对象进行严格相等比较。需要获取原始对象时,Vue 提供 toRaw(),但通常不应长期持有并用它绕开响应式系统修改状态。
二十四、为什么 reactive 解构后可能失去响应性
看下面的代码:
js
const state = reactive({
count: 0
})
const { count } = state
此时读取 state.count 的动作确实经过了 Proxy,但解构得到的 count 是一个普通数字:
js
console.log(count) // 0
之后执行:
js
state.count++
不会自动修改局部变量 count。
原因不是"解构语法破坏了 Proxy",而是:
text
后续读取的是独立的普通局部变量,不再通过 Proxy 访问 state.count。
可以使用:
js
const count = toRef(state, 'count')
或者:
js
const { count } = toRefs(state)
此时 count 是一个 ref,通过 .value 把访问继续转发到原响应式属性。
对于对象值需要稍微区分:
js
const { user } = state
如果 user 本身已经是代理对象,修改 user.name 仍可能响应;但把 user 局部变量整体重新赋值,不会替换 state.user。面试回答时不要笼统地说"任何解构都会彻底失去响应性",应该说明断开的究竟是哪一层属性访问连接。
二十五、ref 的响应式原理
Proxy 只能代理对象,不能直接代理数字、字符串、布尔值等基本类型:
js
new Proxy(1, {}) // TypeError
因此 Vue 使用一个对象包装值,并通过 .value 的 getter 和 setter 拦截访问。
简化实现:
js
function convert(value) {
return isObject(value)
? reactive(value)
: value
}
function ref(rawValue) {
let value = convert(rawValue)
const wrapper = {
get value() {
track(wrapper, 'value')
return value
},
set value(newValue) {
if (Object.is(newValue, rawValue)) {
return
}
rawValue = newValue
value = convert(newValue)
trigger(wrapper, 'value')
}
}
return wrapper
}
使用:
js
const count = ref(0)
effect(() => {
console.log(count.value)
})
count.value = 1
如果 ref 保存对象:
js
const user = ref({
name: '张三'
})
二十六、computed 为什么能够缓存
先看使用:
js
const firstName = ref('三')
const lastName = ref('张')
const fullName = computed(() => {
console.log('重新计算')
return lastName.value + firstName.value
})
计算属性有两个重要特点:
text
只有被读取时才需要得到结果;
依赖没有变化时,多次读取可以复用缓存。
一个便于学习的简化模型如下:
js
function computed(getter) {
let value
let dirty = true
const computedRef = {
get value() {
if (dirty) {
value = runner()
dirty = false
}
track(computedRef, 'value')
return value
}
}
const runner = effect(getter, {
lazy: true,
scheduler() {
if (!dirty) {
dirty = true
trigger(computedRef, 'value')
}
}
})
return computedRef
}
第一次读取
js
console.log(fullName.value)
因为 dirty 为 true,执行 getter,保存结果,然后把 dirty 改成 false。
依赖没有变化时再次读取
js
console.log(fullName.value)
因为 dirty 为 false,直接返回缓存值。
依赖发生变化
js
firstName.value = '四'
调度器不会立刻计算新值,而是:
text
把 dirty 标记为 true;
通知依赖 computed.value 的外层使用者。
下一次真正读取 fullName.value 时才重新计算。
因此计算属性缓存的依据不是"参数是否相同",而是它所追踪的响应式依赖是否发生了变化。
二十九、watch 的响应式原理
watch 需要完成:
text
执行数据源并收集依赖;
依赖变化后重新得到新值;
比较和保存新旧值;
按照调度策略调用用户回调;
在下一次执行前清理失效副作用。
一个极度简化的模型:
js
function traverse(value, seen = new Set()) {
if (!isObject(value) || seen.has(value)) {
return value
}
seen.add(value)
for (const key in value) {
traverse(value[key], seen)
}
return value
}
function watch(source, callback) {
const getter = typeof source === 'function'
? source
: () => traverse(source)
let oldValue
let cleanup
function onCleanup(fn) {
cleanup = fn
}
const job = () => {
const newValue = runner()
if (cleanup) {
cleanup()
cleanup = undefined
}
callback(newValue, oldValue, onCleanup)
oldValue = newValue
}
const runner = effect(getter, {
lazy: true,
scheduler: job
})
oldValue = runner()
}
使用:
js
watch(
() => state.user.name,
(newName, oldName, onCleanup) => {
console.log(oldName, '→', newName)
const controller = new AbortController()
onCleanup(() => {
controller.abort()
})
}
)
为什么 source 经常写成 getter
js
watch(() => state.user.name, callback)
Vue 执行 getter 时读取 state.user.name,响应式系统才能收集对应依赖。
下面这样写不对:
js
watch(state.user.name, callback)
因为调用 watch 前,表达式已经求值成一个普通字符串,watch 无法通过这个字符串知道它来自哪个对象的哪个属性。
deep 为什么需要遍历
监听整个对象的深层变化时,需要读取嵌套属性,才能让 getter/Proxy 的 get 陷阱被触发并建立依赖。
因此深度监听通常意味着遍历,数据结构很大时会有成本。真实 Vue 还要处理数组、集合、循环引用、最大遍历深度等情况。
三十、watchEffect 为什么能自动收集依赖
使用方式:
js
watchEffect(() => {
console.log(state.user.name)
console.log(state.page)
})
Vue 执行回调期间,读取了 user.name 和 page,这些属性会自动收集当前副作用。
所以 watchEffect 不需要显式声明 source。
这不是 Vue 对代码进行了字符串分析,也不是它提前知道函数里有哪些变量,而是:
text
真正执行函数;
谁在执行期间被读取,谁就建立依赖。
这也解释了为什么异步回调通常只能自动追踪第一次 await 之前同步读取的依赖:
js
watchEffect(async () => {
console.log(state.id) // 同步执行阶段,可以追踪
await fetchData()
console.log(state.page) // 已经离开原同步收集上下文
})
依赖追踪依靠的是当前同步执行上下文,而不是贯穿任意异步任务的全局猜测。
三十一、组件是怎样自动更新的
前面一直使用:
js
effect(() => {
console.log(state.count)
})
组件渲染也可以用相似的思路理解。
模板:
vue
<template>
<p>{{ count }}</p>
</template>
会被编译成渲染函数。概念上类似:
js
function render() {
return h('p', null, String(count.value))
}
Vue 为组件建立渲染相关的响应式订阅:
js
effect(() => {
const nextVNode = render()
patch(previousVNode, nextVNode)
previousVNode = nextVNode
})
第一次渲染时:
text
执行渲染逻辑;
读取 count;
count 收集组件更新相关的 effect。
修改 count 时:
text
trigger 找到组件更新 effect;
调度器把组件更新任务放入队列;
队列在合适时机执行;
组件重新运行渲染函数,得到新的 VNode;
patch 对比新旧 VNode,并更新必要的真实 DOM。
所以"响应式更新"和"虚拟 DOM diff"不是同一件事:
text
响应式系统负责判断哪个组件需要重新运行;
虚拟 DOM 和 patch 负责判断组件内部哪些 DOM 需要改变。
这也是 Vue 不需要在任何数据变化后重新构建整个页面的原因。
三十二、为什么修改状态后 DOM 不一定立即变化
连续执行:
js
count.value++
count.value++
count.value++
如果每一次修改都立即操作 DOM,会产生重复工作。
Vue 通常会通过调度器将组件更新任务放入队列,并进行去重,再在后续时机统一刷新。
因此:
js
count.value++
console.log(element.textContent)
这里读取到的 DOM 可能仍然是更新前的内容。
需要等待 Vue 完成 DOM 更新时,可以使用:
js
import { nextTick } from 'vue'
count.value++
await nextTick()
console.log(element.textContent)
但要区分:
text
JavaScript 中的 count.value 已经同步改变;
延迟的是组件渲染和 DOM 提交。
下一篇文章会专门解释更新队列、微任务、nextTick 和组件更新时机。
三十三、响应式不等于深拷贝
reactive 返回代理,并不会深拷贝原对象:
js
const raw = {
user: {
name: '张三'
}
}
const state = reactive(raw)
响应式系统主要做的是:
text
在访问原对象数据时增加代理行为。
它不是把所有数据复制出一份。
因此不要把下面几个概念混在一起:
text
响应式代理:拦截读取和修改;
浅拷贝:复制第一层结构;
深拷贝:递归复制数据;
不可变数据:更新时创建新结构而不是原地修改。
它们解决的是不同问题。
三十四、响应式数据能直接传给所有第三方库吗
不一定。
有些第三方库会进行严格身份判断、冻结对象、序列化、克隆,或者要求原始对象实例。传入 Proxy 后可能出现兼容问题。
可以按场景考虑:
js
const rawData = toRaw(state)
或者创建普通快照:
js
const snapshot = structuredClone(toRaw(state))
但二者含义不同:
text
toRaw 返回代理背后的原对象,不是拷贝;
structuredClone 创建新的结构化克隆,但只支持可克隆的数据类型。
不要通过 toRaw 得到原对象后长期修改它,否则会绕过代理。
三十五、常见错误理解
1. 错误:Vue 3 不再使用 getter 和 setter
不准确。
reactive 对象主要使用 Proxy,但 ref 的 .value 仍可以通过 getter/setter 访问器完成依赖追踪和触发。
准确说法是:
text
Vue 3 使用 Proxy 实现响应式对象,使用访问器实现 ref.value 等响应式容器。
2. 错误:Proxy 会自动更新 DOM
Proxy 只能拦截对象操作。
DOM 能够更新,是因为 Vue 在拦截时完成依赖追踪和触发,再通过组件渲染与 patch 更新页面。
3. 错误:读取响应式数据一定会建立依赖
只有在可追踪的响应式 effect 上下文中读取,才需要收集依赖。
普通读取:
js
console.log(state.count)
通常不会因为这行日志创建一个未来要重新执行的订阅。
4. 错误:一个对象变化会让所有使用它的组件更新
依赖通常按属性和操作类型追踪。
只读取 state.count 的组件,不应该因为 state.name 修改就更新。
5. 错误:computed 每次读取都会执行 getter
依赖没有变化时,computed 会复用缓存。依赖失效后,下一次需要值时才重新求值,这是它适合派生状态的重要原因。
6. 错误:Vue 3 有 Proxy,所以没有任何响应式限制
仍然存在边界:
text
普通局部变量无法被代理;
绕过代理修改原对象不会正常触发;
基本类型需要 ref 容器;
解构基本类型属性会断开后续代理访问;
外部系统可能不认识 Proxy;
异步代码的依赖追踪受执行上下文影响。
7. 错误:响应式数据变化后 DOM 同步完成
状态通常同步改变,但组件 DOM 更新会被调度和批处理。需要等待 DOM 完成时使用 nextTick,而不是反复读取或使用随意的 setTimeout 猜时间。
三十六、面试高频问题
1. Vue 响应式原理是什么
可以这样回答:
Vue 会拦截响应式状态的读取和修改。读取时通过 track 把当前正在执行的响应式副作用收集到属性依赖中;修改时通过 trigger 找到相关副作用,并直接执行或交给调度器。组件渲染也是一种响应式订阅,因此状态变化后会进入组件更新队列,重新生成 VNode 并 patch 必要的 DOM。
2. Vue 2 和 Vue 3 响应式有什么区别
可以这样回答:
Vue 2 主要使用 Object.defineProperty 把初始化时已有属性转换成 getter 和 setter,需要递归遍历对象,并且不能直接检测对象属性新增、删除以及部分数组索引和 length 操作。Vue 3 使用 Proxy 代理整个对象,能够拦截更多操作,也更适合数组和集合类型;ref 则仍通过 value 访问器追踪和触发。
3. Vue 2 为什么需要 Vue.set
可以这样回答:
Object.defineProperty 只能拦截已经被定义访问器的具体属性。对象初始化完成后直接新增一个属性,不会经过任何已有 setter。Vue.set 会为新属性建立响应式处理,并通知对象相关依赖更新。
4. Vue 2 怎样监听数组
可以这样回答:
Vue 2 会对数组原型上的 push、pop、shift、unshift、splice、sort 和 reverse 七个变更方法做响应式封装,在方法执行后观察新增元素并通知依赖。它不能可靠检测直接修改数组索引或 length,因此通常使用 Vue.set 或 splice。
5. Proxy 相比 Object.defineProperty 有什么优势
可以这样回答:
Proxy 代理整个对象,可以拦截属性读取、设置、删除、存在性判断和遍历等操作,不需要为每个新增属性预先设置 setter,还可以更自然地处理数组及 Map、Set 等集合。代价是 Proxy 无法通过普通 polyfill 完整兼容不支持它的旧浏览器。
6. track 和 trigger 分别做什么
可以这样回答:
track 在响应式属性被读取时,把 activeEffect 收集到 target 和 key 对应的依赖集合中;trigger 在属性变化时,根据相同的 target 和 key 找到依赖,并通知这些 effect 重新执行或进入 scheduler。
7. activeEffect 是什么
可以这样回答:
activeEffect 表示当前正在同步执行、并允许收集响应式依赖的副作用。effect 执行前把自己设为 activeEffect,响应式 getter 就知道应该收集谁;执行结束后恢复之前的上下文。存在嵌套执行时需要用栈或父级引用正确恢复。
8. 为什么依赖使用 WeakMap、Map 和 Set
可以这样回答:
WeakMap 用原始目标对象作为键,不会仅因为依赖表引用目标就阻止其垃圾回收;Map 负责从属性 key 映射到依赖集合;Set 保存 effect 并自动去重。整体结构可以理解为 WeakMap<target, Map<key, Set>>。
9. effect 为什么需要清理依赖
可以这样回答:
effect 每次执行时实际读取的属性可能因条件分支而变化。如果不清理旧依赖,它会继续订阅已经不再读取的属性,导致无效更新。通常在重新执行前从旧 dep 中删除 effect,再按照本轮真实读取重新收集。
10. reactive 为什么可以处理嵌套对象
可以这样回答:
外层 Proxy 的 get 捕获到嵌套属性后,如果结果仍是对象,就返回该对象对应的 reactive 代理。这个过程通常按访问发生,并通过 WeakMap 缓存代理,避免同一原始对象被重复创建代理。
11. 为什么 ref 要有 .value
可以这样回答:
JavaScript 不能直接代理数字、字符串等基本类型,也不能拦截普通局部变量赋值。ref 用对象包装任意值,并通过 value 属性的 getter 和 setter 提供 track 和 trigger 的拦截入口,所以 JavaScript 中需要使用 .value。
12. computed 为什么有缓存
可以这样回答:
computed 会追踪 getter 中读取的响应式依赖,并保存计算结果。依赖未变化时直接返回缓存;依赖变化后先把计算结果标记为失效并通知使用者,下一次读取时再重新计算。它建立在同一套依赖追踪、触发和调度机制之上。
13. watch 的原理是什么
可以这样回答:
watch 会把显式数据源转换为可执行的 getter,并在响应式订阅上下文中运行它以收集依赖。依赖变化后由 scheduler 执行任务,重新获取新值、处理旧值与清理函数,再按照 flush 等配置调用用户回调。
14. reactive 解构为什么可能失去响应性
可以这样回答:
Proxy 只能拦截通过代理对象发生的属性访问。把 state.count 解构成普通数字后,后续读取的是局部变量,不再经过 state 的 Proxy,因此不会随 state.count 自动变化。可以使用 toRef、toRefs,或者保留 state.count 的访问方式。
15. reactive 对象和原对象相等吗
可以这样回答:
不相等。reactive 返回原始对象的 Proxy,因此 proxy !== raw。应该尽量通过代理读写;绕过代理修改 raw 不会正常触发依赖。Vue 会缓存同一个原对象对应的代理,以保持代理身份稳定。
16. 为什么 Vue 不会每次修改状态都立即更新 DOM
可以这样回答:
trigger 表示依赖已经失效,但组件 effect 通常由 scheduler 放入异步更新队列,同一轮中的重复任务会被去重和批处理,从而减少重复渲染。状态值本身已经同步改变,只是 DOM 提交通常要等队列刷新;需要等待时使用 nextTick。
17. Proxy 是否解决了所有问题
可以这样回答:
没有。Proxy 提供更完整的对象操作拦截,但基本类型仍需要 ref,解构普通值仍会断开代理访问,绕过代理修改原对象仍不能正常触发,数组和集合还需要框架实现专门的依赖语义,组件更新也仍然需要调度器和虚拟 DOM。
18. 响应式系统和虚拟 DOM 有什么关系
可以这样回答:
响应式系统负责追踪组件渲染读取了哪些状态,并在这些状态变化后调度对应组件更新;虚拟 DOM 则描述更新后的界面,并通过 patch 比较新旧 VNode,尽量只修改必要的真实 DOM。一个解决"谁需要更新",另一个解决"具体更新什么"。
三十七、本文知识地图
最后把整篇文章压缩成一张知识地图。
1. 响应式目标
text
状态变化后,自动通知依赖该状态的计算、侦听和组件更新。
2. 拦截方式
text
Vue 2:Object.defineProperty 转换已有属性;
Vue 3 reactive:Proxy 代理整个对象;
Vue 3 ref:通过 value 的 getter/setter 包装任意值。
3. 核心流程
text
effect 执行
↓
读取响应式属性
↓
track(target, key)
↓
保存 activeEffect
↓
修改响应式属性
↓
trigger(target, key)
↓
effect 直接执行或交给 scheduler
4. 依赖数据结构
text
WeakMap<target, Map<key, Set<effect>>>
5. 必要的工程能力
text
cleanup:移除失效分支依赖;
effect 栈:处理嵌套执行;
scheduler:控制执行方式与时机;
Proxy 缓存:保持身份稳定;
操作类型:处理新增、删除、遍历、数组和集合。
6. 上层 API
text
reactive:返回对象 Proxy;
ref:通过 value 建立响应式容器;
computed:缓存派生值,并在依赖变化时失效;
watch:追踪指定数据源,并在变化后调度回调;
组件渲染:追踪渲染期间读取的状态,并调度组件更新。
三十八、总结
Vue 响应式看起来涉及很多 API,但主线并不复杂:
text
读取时建立联系,修改时通知联系。
Vue 2 使用 Object.defineProperty 为已有属性设置 getter 和 setter,再通过 Dep 和 Watcher 完成依赖管理。由于它针对具体属性工作,所以对象新增、删除以及部分数组操作需要额外 API 协助。
Vue 3 使用 Proxy 代理整个对象,可以拦截新增、删除、遍历、数组和集合等更多操作;同时使用 ref 的 .value 访问器解决基本类型无法被 Proxy 直接代理的问题。
但 Proxy 只是入口,完整响应式还需要:
text
effect 上下文;
track 依赖收集;
trigger 依赖触发;
分支清理;
调度队列;
代理缓存;
对数组和集合语义的特殊处理。
真正理解这套链路后,之前学过的 API 会连成一体:
text
computed 为什么缓存;
watch 为什么要传 getter;
watchEffect 为什么自动追踪;
reactive 为什么不能随便解构;
ref 为什么需要 .value;
组件为什么只更新相关部分。
下一篇将继续讲解 Vue 的异步更新机制,重点包括:
text
为什么连续修改状态不会连续更新 DOM;
Vue 更新队列怎样去重和排序;
微任务与宏任务有什么区别;
nextTick 到底在等待什么;
组件更新和生命周期钩子的执行顺序。