📌 摘要:从源码层面拆解 Vue2
Object.defineProperty响应式三大件,讲清「新增属性不响应、数组下标不更新」的祖传缺陷根因,再对照 Vue3Proxy的针对性重构,附 6 个实战避坑指南。
🤔 前言
「为什么 Vue2 里给对象新增属性不生效?」 「为什么改数组下标页面不更新?」 「Vue3 又是怎么把这些坑一次性填平的?」
响应式(Reactivity)是 Vue 的灵魂。Vue2 用 Object.defineProperty 实现的 Observable 撑起了整整一个大版本,但它有几个"祖传"缺陷。Vue3 干脆换成了 Proxy,从根上重写。
这篇笔记从源码层面 讲清楚 Vue2 Observable 怎么工作,再对照 Vue3 做了哪些针对性 改动,最后落到实战------你日常写代码到底会踩哪些坑、怎么绕、Vue3 该怎么用才不翻车。
1️⃣ Vue2 Observable 是什么
Vue.observable(obj) 是 Vue 2.6+ 暴露出来的 API,作用是让一个普通对象变成"响应式对象"。它其实就是 Vue 内部 observe 用的同一套机制被单拎出来给你用。
javascript
import Vue from 'vue'
// 一个极简的跨组件共享 store
export const store = Vue.observable({
count: 0,
user: { name: '张三' }
})
组件里直接用 store.count,它变了组件就重渲染------不需要 Vuex 也能做轻量状态共享。
⚠️ 版本可用性强提醒 :
Vue.observable只在 2.6.0(2019-02)及以后 才有!更早的 2.x 没有这个 API(可用new Vue({ data })平替),而 Vue3 直接删掉了它 ,改用reactive()。
2️⃣ 源码层面:Vue2 响应式三大件
Vue2 响应式的核心是三个类,围绕 Object.defineProperty 展开:
- Observer:把一个对象的所有属性递归转成 getter/setter
- Dep:依赖收集器,每个响应式属性对应一个 Dep(发布者)
- Watcher:订阅者(渲染 / computed / user Watcher)
一句话概括数据流:
🔄 读取属性 → getter 触发 → Dep 收集当前 Watcher(依赖收集);修改属性 → setter 触发 → Dep 通知所有 Watcher 更新(派发更新)。
🔧 Observer:递归劫持
javascript
export class Observer {
constructor(value) {
this.value = value
this.dep = new Dep() // Observer 自身也挂一个 dep(给 $set/数组用)
def(value, '__ob__', this) // 打标记,避免重复观测
if (Array.isArray(value)) {
protoAugment(value, arrayMethods) // 数组:覆盖原型方法
this.observeArray(value)
} else {
this.walk(value) // 对象:遍历每个 key
}
}
walk(obj) {
const keys = Object.keys(obj)
for (let i = 0; i < keys.length; i++) {
defineReactive(obj, keys[i])
}
}
}
💡 defineReactive:核心中的核心
javascript
export function defineReactive(obj, key, val) {
const dep = new Dep() // 每个 key 一个闭包 dep
if (arguments.length === 2) val = obj[key]
let childOb = observe(val) // 递归观测嵌套对象
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
get() {
const value = val
if (Dep.target) { // Dep.target = 当前正在计算的 Watcher
dep.depend() // 依赖收集
if (childOb) {
childOb.dep.depend() // 子对象 dep 也收集($set 靠这个)
if (Array.isArray(value)) dependArray(value)
}
}
return value
},
set(newVal) {
if (newVal === val) return
val = newVal
childOb = observe(newVal)
dep.notify() // 派发更新
}
})
}
这段代码是整个 Vue2 响应式的心脏。把它读懂,所有"坑"都能自解释。
3️⃣ Vue2 的四大"祖传"缺陷(源码根因)
理解了源码,这些坑就都有了明确的根因,而不是死记硬背。
❌ 缺陷① 新增属性不响应
javascript
const obj = Vue.observable({ a: 1 })
obj.b = 2 // ❌ 不是响应式的,页面不更新
根因 :defineReactive 只在初始化那一刻 对已存在的 key 做了劫持。obj.b 是后加的,从没进过 walk,没有 getter/setter,自然没有 dep。
解法 :Vue.set(obj, 'b', 2) / this.$set。
❌ 缺陷② 删除属性不响应
javascript
delete obj.a // ❌ 不触发更新
根因 :delete 绕过了 setter。解法 :Vue.delete / this.$delete。
❌ 缺陷③ 数组下标 / length 不响应
javascript
const arr = Vue.observable([1, 2, 3])
arr[0] = 100 // ❌ 不更新
arr.length = 1 // ❌ 不更新
根因 :Vue2 出于性能考量 没有对数组的每个索引做 defineProperty(数组可能很长,逐个劫持代价太大)。
🔥 缺陷④ 数组变更方法的特殊处理(源码亮点)
那 push/pop 为什么又能触发更新?因为 Vue2 重写了数组的 7 个变更方法:
javascript
const methodsToPatch = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
methodsToPatch.forEach(function (method) {
const original = Array.prototype[method]
def(arrayMethods, method, function mutator(...args) {
const result = original.apply(this, args)
const ob = this.__ob__
let inserted
switch (method) {
case 'push':
case 'unshift': inserted = args; break
case 'splice': inserted = args.slice(2); break
}
if (inserted) ob.observeArray(inserted) // 新增元素也要观测
ob.dep.notify() // 手动派发更新
return result
})
})
这就是为什么"用 push 能更新,用 arr[i]=x 不能更新"------Vue2 是打补丁式地拦截数组方法,而不是真正劫持了数组本身。
4️⃣ Vue3 的针对性重构:从 defineProperty 到 Proxy
Vue3 把上面所有缺陷从架构层面 一次性解决,核心是换成了 Proxy + Reflect。
🚀 为什么 Proxy 能治本
| 能力 | Object.defineProperty (Vue2) | Proxy (Vue3) |
|---|---|---|
| 拦截读/写 | ✅ | ✅ |
| 拦截新增属性 | ❌ 需 $set |
✅ 原生 set 拦截 |
| 拦截删除属性 | ❌ 需 $delete |
✅ 原生 deleteProperty 拦截 |
| 数组下标/length | ❌ 需重写方法 | ✅ 原生拦截 |
| 初始化开销 | 递归劫持全部属性 | 惰性,访问到才代理 |
| 监听 Map/Set | ❌ | ✅ |
关键差异:defineProperty 是劫持属性 (必须提前知道有哪些 key),Proxy 是代理整个对象(拦截的是"操作"本身,不管 key 存不存在)。所以新增、删除、数组下标全都天然被拦截。
🔧 源码层面:reactive 的实现
typescript
function createGetter(isReadonly = false, shallow = false) {
return function get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
if (!isReadonly) track(target, TrackOpTypes.GET, key) // 依赖收集
// 惰性递归:只有访问到嵌套对象时才变成 reactive
if (isObject(res)) return isReadonly ? readonly(res) : reactive(res)
return res
}
}
function createSetter() {
return function set(target, key, value, receiver) {
const oldValue = target[key]
const hadKey = hasOwn(target, key) // 判断是新增还是修改
const result = Reflect.set(target, key, value, receiver)
if (!hadKey) trigger(target, TriggerOpTypes.ADD, key) // 新增 → 触发
else if (hasChanged(value, oldValue)) trigger(target, TriggerOpTypes.SET, key)
return result
}
}
对比 Vue2 你会发现两个质变:
- 惰性代理 :Vue2 一次性递归全部属性;Vue3 是
get里访问到嵌套对象才reactive(res),大对象初始化性能大幅提升。 - 新增/删除天然响应 :
set里通过hadKey区分 ADD/SET,deleteProperty陷阱处理删除------$set/$delete彻底退休。
📊 概念对照表(帮你把两代对上号)
| Vue2 | Vue3 | 说明 |
|---|---|---|
Observer |
reactive() / Proxy handler |
把对象变响应式 |
Dep |
dep(Set of effect) |
依赖集合 |
Watcher |
ReactiveEffect |
副作用/订阅者 |
Dep.target |
activeEffect |
当前活动的副作用 |
dep.depend() |
track() |
依赖收集 |
dep.notify() |
trigger() |
派发更新 |
Object.defineProperty |
Proxy + Reflect |
底层拦截手段 |
💡 ref 为什么还要存在
Proxy 只能代理对象 ,代理不了 number/string 原始值。所以 Vue3 保留了 ref:用一个带 .value 的对象包裹原始值,靠 class RefImpl 的 getter/setter 拦截 .value 的读写。对象类型的 ref 内部则委托给 reactive。
5️⃣ 实战篇:日常开发到底怎么写
理论讲完,落到手上。以下是最容易踩、也最值得记住的场景。
🛠️ 实战① Vue2:新增属性必须用 $set
javascript
this.user.age = 25 // ❌ 页面不更新
this.$set(this.user, 'age', 25) // ✅ 正确
this.user = { ...this.user, age: 25, city: '北京' } // ✅ 整体替换也行
实战心法 :Vue2 里,凡是"运行时才知道 key"的对象(如后端返回的动态字段),要么预先在 data 里声明好占位,要么老实用 $set。
🛠️ 实战② Vue2:数组更新的正确姿势
javascript
this.list[0] = newItem // ❌ 无效
this.list.length = 0 // ❌ 无效
this.list.splice(0, 1, newItem) // ✅ 替换第 0 项
this.list.splice(0) // ✅ 清空
this.$set(this.list, 0, newItem) // ✅ 改指定下标
this.list = [] // ✅ 最简单粗暴的清空
🛠️ 实战③ Vue2 Observable 做轻量全局 store
不想上 Vuex 的小项目,Observable 是性价比最高的方案:
javascript
// store.js
import Vue from 'vue'
export const store = Vue.observable({ theme: 'light', user: null })
export const actions = {
toggleTheme() { store.theme = store.theme === 'light' ? 'dark' : 'light' },
setUser(u) { store.user = u }
}
⚠️ 注意:同样受上面缺陷限制------给 store 新增顶层字段不响应,要么初始声明齐全,要么用 Vue.set。
🛠️ 实战④ Vue3:reactive 的两个致命坑
坑 A:解构会丢失响应式
javascript
const state = reactive({ count: 0 })
let { count } = state // ❌ count 变成普通 number,断了连接
const { count } = toRefs(state)
count.value++ // ✅ 用 toRefs 保留响应式
坑 B:整体替换会丢失响应式
javascript
let state = reactive({ list: [] })
state = reactive({ list: newData }) // ❌ 模板还引用着旧的 Proxy
state.list = newData // ✅ 改属性而非改引用
const stateRef = ref({ list: [] })
stateRef.value = { list: newData } // ✅ 或用 ref
🛠️ 实战⑤ Vue3:ref vs reactive 选型建议
javascript
const count = ref(0) // 基本值 → 必须 ref
const form = reactive({ username: '', password: '' }) // 一组状态 → reactive
const bigTable = shallowRef([]) // 大数据/第三方实例 → shallowRef
团队实践共识 :默认用 ref(心智负担小、不怕解构),只有一组明显内聚的状态才用 reactive。性能敏感的大数据用 shallowRef + 整体替换。
🛠️ 实战⑥ Vue3 的"隐性利好"
得益于 Proxy,下面这些在 Vue2 里要小心翼翼的操作,Vue3 里直接写就行:
javascript
const state = reactive({ obj: {}, arr: [] })
state.obj.newKey = 1 // ✅ 新增属性直接响应
delete state.obj.newKey // ✅ 删除直接响应
state.arr[0] = 100 // ✅ 数组下标直接响应
state.arr.length = 0 // ✅ 改 length 直接响应
const m = reactive(new Map())
m.set('k', 'v') // ✅ Map/Set 也能响应式(Vue2 完全做不到)
🧭 一图总结
javascript
Vue2 Observable Vue3 Reactivity
───────────── ───────────────
Object.defineProperty ─重构─▶ Proxy + Reflect
├─ 递归全量劫持(慢) ├─ 惰性按需代理(快)
├─ 新增属性 ✗ → $set ├─ 新增属性 ✓
├─ 删除属性 ✗ → $delete ├─ 删除属性 ✓
├─ 数组下标 ✗ → splice ├─ 数组下标 ✓
├─ 重写 7 个数组方法(补丁) ├─ 原生拦截,无需补丁
└─ 不支持 Map/Set └─ 支持 Map/Set/WeakMap/WeakSet
✅ 高频面试 & 自测
- Vue2 为什么给对象新增属性不响应? →
defineReactive只在初始化劫持已有 key,新 key 没 getter/setter。 - Vue2 数组为什么下标改不生效但 push 生效? → 没对索引做 defineProperty(性能),但重写了 7 个变更方法。
- Vue3 为什么用 Proxy? → 拦截"操作"而非"属性",天然支持新增/删除/数组下标/Map/Set,且可惰性代理提升性能。
- Vue3 reactive 解构为什么失效?怎么救? → 解构出的原始值断开代理链;用
toRefs。 - ref 和 reactive 底层关系? → ref 包原始值走 getter/setter,包对象则内部委托 reactive;Proxy 代理不了原始值,所以 ref 不可替代。
📝 写在最后
响应式是 Vue 面试和实战的高频考点,但真正读懂源码,比背八股有用得多 。Vue2 的 Object.defineProperty 虽然坑多,但每一处限制都有清晰的工程权衡;Vue3 的 Proxy 则是从架构层面一次性填平,顺便把性能也优化了。
💬 如果你觉得有用,欢迎点赞 + 收藏 + 关注,评论区聊聊你被 Vue2 响应式坑得最惨的一次~
源码版本参考:Vue 2.6.x(src/core/observer/)、Vue 3.x(packages/reactivity/src/)