我重写了 Vue3 的响应式,才明白 Vue2 的 defineProperty 到底差在哪
摘要:Vue2 不是"优化不够",是 defineProperty 天生只能劫持已有属性——我用 300 行重写 Vue3 响应式,把
$set、数组重写、按需递归这三件事一次讲透。
写过 Vue2 的同学,大概率都遇到过这两个场景:
- 给
data里的对象动态加了个字段,视图没更新,最后靠this.$set救场 - 直接改数组的某个索引,
arr[0] = 'xxx',页面纹丝不动
网上大部分解释都是"Vue2 用的是 Object.defineProperty,它监听不到新增属性和数组索引"。
这句话没错,但它没解释"为什么监听不到"。直到我自己动手用 Proxy 重写了一遍 Vue3 的响应式,才真正想明白这件事——defineProperty 不是"性能差"或者"优化不够",它是能力边界问题。
这篇会先带你回顾 Vue2 的妥协清单,再手写一份能跑起来的 Vue3 响应式,最后回到面试里最高频的那个问题。
一、先看 Vue2 的"妥协清单"
Vue2 的响应式核心就一句话:用 Object.defineProperty 把 data 的每个属性改造成 getter/setter。
但正因为这个方案,Vue2 被逼出了下面这些东西:
| Vue2 的 API / 方案 | 它存在的理由 |
|---|---|
Vue.set / this.$set | 新增的属性监听不到 |
Vue.delete / this.$delete | 删除的属性监听不到 |
| 重写数组 7 个方法(push/pop/shift/unshift/splice/sort/reverse) | 数组索引直接赋值监听不到 |
| 初始化时递归遍历整个 data | 必须提前"认识"每一个属性,才能给它们加 getter/setter |
注意,这四件事不是四个独立的坑,是同一个原因导致的四种现象。
Object.defineProperty 这 API 只能作用于"已经存在且被显式指定过的属性"。你告诉它 obj.key,它就只给你管 obj.key 这一个属性的读写。
新加的属性?它压根就不知道有这回事。
我最早对这件事有实感,是做后台管理系统的时候。当时列表数据是从接口拿的,我想给每行加个 checked 字段配合 Element UI 的多选表格:
// 拿到列表后,给每条数据补一个 checked
this.tableData.forEach(item => {
item.checked = false
})
写完跑起来,勾选框点了没反应。翻文档才反应过来——checked 是接口返回之后才加上去的字段,它没被 defineProperty 处理过,压根不是响应式的。最后改成:
this.tableData.forEach((item, index) => {
this.$set(this.tableData[index], 'checked', false)
})
那会儿只是"知道要用 $set",但没想明白为什么。后来重写 Proxy 版才反应过来——$set 干的事,其实就是"运行时再补一次 defineProperty"。
二、用 30 行写一个 Vue2 风格的响应式
先上对照物。既然要讲差距,那"旧方案"得先摆出来。
环境:Node 或浏览器均可,不依赖任何库。
// 极简依赖容器
class Dep {
constructor() {
this.subs = []
}
depend() {
if (Dep.target) this.subs.push(Dep.target)
}
notify() {
this.subs.forEach(fn => fn())
}
}
// 把某个属性改造成响应式
function defineReactive(obj, key, val, dep) {
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
dep.depend() // 访问时收集依赖
return val
},
set(newVal) {
if (newVal === val) return
val = newVal
dep.notify() // 变化时通知更新
}
})
}
// 遍历 data 的所有 key
function observe(data) {
const dep = new Dep()
Object.keys(data).forEach(key => {
defineReactive(data, key, data[key], dep)
})
}
跑一下:
const data = { name: '张三', age: 23 }
observe(data)
// 修改已有属性 → 触发 set
data.name = '李四' // ✅ 会通知
// 新增属性 → 完全没有 getter/setter,天然就"看不见"
data.newKey = 'hello' // ❌ 不会通知
observe 执行完之后,data 只有 name 和 age 两个属性有 getter/setter。newKey 是运行时才被写进去的,它根本没有被 defineReactive 处理过。
那 $set 干了什么?
Vue.set = function (obj, key, val) {
// 如果 key 还不存在,补一次 defineReactive
defineReactive(obj, key, val, obj.__dep__)
obj[key] = val
}
$set 的本质就是"运行时再补一次 defineProperty"。
这里就是第一个关键认知:$set 不是 API 冗余,是 defineProperty 能力边界下唯一能做的事。
三、Proxy 不是"升级版 defineProperty"
很多人把 Proxy 当成 defineProperty 的 2.0,只是"性能更好"。
不对。
它俩的作用对象根本不是一个层级:
| 维度 | Object.defineProperty | Proxy |
|---|---|---|
| 作用对象 | 属性(obj.key) | 整个对象(obj) |
| 拦截范围 | 只能拦截指定 key 的读写 | 读写、删除、in、Object.keys 全都能拦 |
| 新增属性 | ❌ 需要 $set | ✅ 自动拦截 |
| 删除属性 | ❌ 需要 $delete | ✅ deleteProperty 拦截 |
| 数组索引赋值 | ❌ 需要重写数组方法 | ✅ 自动拦截 |
| 初始化开销 | 必须递归遍历全部属性 | 按需递归,访问到才处理 |
| 兼容性 | IE9+ | 不支持 IE11 |
用一句话总结:defineProperty 是"点对点"地管属性,Proxy 是"面到面"地管对象。
再看第二层:Vue2 的"初始化慢",不完全是性能问题——是机制上必须提前把每个属性都扫一遍,因为它只能对已知属性生效。而 Proxy 可以在访问时才递归,一个在启动时付全款,一个在使用时按需付费。
我第一次翻 Vue3 的 baseHandlers.ts 看到这段时,愣了一下:
function createGetter(isReadonly = false, shallow = false) {
return function get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
// ...
if (isObject(res)) {
return isReadonly ? readonly(res) : reactive(res) // ← 访问到才递归
}
return res
}
}
reactive(res) 是在 get 里才被调用的。也就是说,一个嵌套很深的 data 对象,只有真正被访问到的那个分支才会变成响应式。
这不就是懒加载吗?我当时脑子里第一反应就是这个——跟 Vue2 初始化时 Observer 一路递归到底完全不是一个思路。
四、手写 Vue3 响应式(完整可跑)
代码我尽量压到 200 行以内,复制就能跑。
需要实现的 API:
reactive(target)—— 返回 Proxy 代理effect(fn)—— 注册副作用,依赖变化时自动重跑ref(value)—— 基本类型的响应式包装track/trigger—— 依赖收集与派发
4.1 依赖存储结构
Vue3 用的是一个三层嵌套结构:
// WeakMap<target, Map<key, Set<effect>>>
const targetMap = new WeakMap()
let activeEffect = null
const effectStack = []
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)
}
function trigger(target, key) {
const depsMap = targetMap.get(target)
if (!depsMap) return
const dep = depsMap.get(key)
if (dep) {
// 复制一份再遍历,防止执行过程中 dep 被修改
[...dep].forEach(fn => fn())
}
}
为什么用 WeakMap?因为 target 是对象,WeakMap 的键是弱引用,当这个响应式对象不再被引用时,垃圾回收能自动回收它的依赖表,不会内存泄漏。
4.2 reactive 拦截器
const reactive = (target) => {
if (typeof target !== 'object' || target === null) return target
return new Proxy(target, {
get(obj, key, receiver) {
const result = Reflect.get(obj, key, receiver)
track(obj, key)
// 嵌套对象按需递归:访问到时才包一层 reactive
if (typeof result === 'object' && result !== null) {
return reactive(result)
}
return result
},
set(obj, key, value, receiver) {
const oldValue = obj[key]
const result = Reflect.set(obj, key, value, receiver)
if (oldValue !== value) {
trigger(obj, key)
}
return result
},
deleteProperty(obj, key) {
const hadKey = Object.prototype.hasOwnProperty.call(obj, key)
const result = Reflect.deleteProperty(obj, key)
if (hadKey) {
trigger(obj, key)
}
return result
}
})
}
有三个地方我踩过,先说清楚:
Reflect.get(obj, key, receiver)里的receiver不能省。它的作用是让对象内部的 getter/setter 里的this指向代理对象,而不是原始对象——否则嵌套对象的依赖收集会乱。- 嵌套对象是访问时才包装的,这就是"按需递归",比 Vue2 的初始化递归省很多。
deleteProperty里为什么要判hadKey?因为删除一个本来就不存在的 key,也不应该触发更新。
4.3 effect + ref
function effect(fn) {
const wrapped = () => {
effectStack.push(wrapped)
activeEffect = wrapped
try {
fn()
} finally {
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1] || null
}
}
wrapped() // 立即执行一次,收集初始依赖
return wrapped
}
function ref(value) {
const obj = { value }
return reactive(obj) // 本质就是被 reactive 包过的 { value }
}
写到 ref 的时候我突然想通了:它就是一个 { value: xxx } 被 reactive 代理了一下。.value 不是"多打几个字",是因为 Proxy 代理不了基本类型,只能靠包一层对象解决。
4.4 跑起来验证
const state = reactive({ count: 0, name: 'Vue' })
effect(() => {
console.log('count changed:', state.count)
})
// 立刻打印:count changed: 0
state.count++
// 打印:count changed: 1
state.newProp = '我是新加的'
// set 拦截器能捕获,其他 effect 如果依赖 newProp 也会被通知
原来的"新增属性不响应"没了。因为 set 拦截器不知道哪些 key 是"预定义的",它拦截的是整个对象的写操作。
五、踩坑记录(都是我自己写挂的)
写完能跑的代码不难,真正卡住我的是下面这几个地方。
坑 1:activeEffect 的嵌套覆盖
如果你在 effect 里再调 effect,activeEffect 会被覆盖。第二层 effect 执行完,外层依赖就丢失了。
解决方式就是上面代码里的 effectStack:进入时 push,退出时 pop,activeEffect 永远指向栈顶。
Vue3 源码里也是这么做的,只不过它把 activeEffect 换成了 activeSub。
坑 2:trigger 时无限递归
如果某个 effect 在运行过程中又修改了它自己的依赖,会不断触发 trigger → effect → trigger,直接爆栈。
简化版可以加个标志位,或者用 Set 去重当前正在执行的 effect。Vue3 源码用 shouldTrack + 调度器(scheduler)来管这件事。
坑 3:Reflect.get 的 receiver 忘了传
我第一次写的时候省了 receiver,结果嵌套对象里的 getter 里 this 指向了原始对象,依赖收集到原始对象上,触发时又找不到,trigger 静默失效——特别难排查,因为它不报错,只是"不生效"。
坑 4:ref 为什么不能直接用基本类型?
Proxy 的第一个参数必须是对象。所以 Vue3 里想给数字、字符串做响应式,只能把它们包进 { value } 这个壳里。
.value 不是 Vue 团队"想让你多打几个字"的设计,是这个语言层面的硬性约束。
所以 reactive 不用 .value(对象天然能被代理),ref 必须用 .value(基本类型被包了一层)。它们不是两套设计哲学,是同一个限制下的两种应对。
六、回到 Vue2 的"为什么"
现在回头看开头那两个场景,应该都能回答了。
| Vue2 的坑 | 根本原因 | 用 Proxy 后 |
|---|---|---|
| 新增属性不响应 | defineProperty 只能处理已知属性 | set 拦截器自动处理 |
| 删除属性不响应 | 没有"删除拦截"能力 | deleteProperty 拦截器处理 |
| 数组索引赋值不响应 | 数组也是对象,索引也是"属性",同样是已知属性问题 | set 拦截器统一处理 |
this.$set 必须存在 | 只能运行时补 defineProperty | 不再需要 |
| 重写 7 个数组方法 | 索引赋值监听不到,只能拦截会改数组的方法 | 不再需要 |
但别急着把 Proxy 吹上天。
Vue3 里 ref 依然存在,toRefs 依然存在,shallowRef 依然存在。这些不是 Vue3"没优化好",是 JavaScript 语言本身的边界——你能代理对象,但代理不了"一个数字"。
Vue3 的代码看起来更简洁,不是因为它更聪明,是因为 Proxy 替它兜了更多的底。
七、总结 + 面试速答
如果只记三件事:
defineProperty劫持的是属性,Proxy代理的是整个对象——一个管点,一个管面- Vue2 的
$set、$delete、数组方法重写,都是defineProperty能力边界下的必然产物,不是"设计冗余" - 嵌套对象的"按需递归"是 Proxy 方案一个容易忽略的优势——初始化不必全量遍历
面试速答版(可以直接背):
Vue2 用
Object.defineProperty把 data 的每个属性转成 getter/setter。局限是它只能处理初始化时已存在的属性,所以新增/删除属性、数组索引赋值都无法响应,需要$set、$delete和重写数组方法来补。Vue3 用
Proxy代理整个对象,get/set/deleteProperty拦截器天然覆盖了这些场景;嵌套对象采用按需递归,减少了初始化开销。但
Proxy代理不了基本类型,所以ref依然需要.value这个壳。另外Proxy不支持 IE11,这也是 Vue3 不再兼容 IE 的原因之一。
参考
- Vue 3 官方文档 — 深入响应式原理(cn.vuejs.org/guide/extra…
- Vue 2 官方文档 — 响应式原理(v2.cn.vuejs.org/v2/guide/re…
- MDN — Proxy(developer.mozilla.org/zh-CN/docs/…
- MDN — Reflect(developer.mozilla.org/zh-CN/docs/…