Vue2 数组响应式为什么总"不更新"?深入源码对比 Vue3 的 Proxy 改造
你是否遇到过
this.list[0] = 'new'视图纹丝不动、this.list.length = 0清空后页面没反应?这不是你写错了,而是 Vue2 响应式机制的"历史局限"。本文从源码层拆解 Vue2 如何改写数组原型方法,再看 Vue3 怎样用 Proxy 一把解决这个问题,并附 4 道高频面试题。
一、为什么数组要单独处理?
Vue2 的响应式核心是 Object.defineProperty,它有个硬伤:
- 无法监听数组索引赋值 :
arr[0] = 1这种直接改下标的操作,defineProperty拦截不到(需要对每个索引做 getter/setter,性能代价极高且不现实)。 - 无法监听数组长度变化 :
arr.length = 0清空数组同样拦截不到。 - 如果真的对每个数组元素都做
defineProperty,对于大数组(如万级列表)初始化成本不可接受。
所以 Vue2 选择了一条**"改写法"**的捷径:劫持数组的 7 个会改变自身内容的方法(mutation methods),在调用时主动触发依赖通知,而不是靠属性拦截。
二、Vue2 源码剖析
1. 定义需要劫持的方法
源码位置:src/core/observer/array.js
js
// 会修改原数组的 7 个方法
const methodsToPatch = [
'push',
'pop',
'shift',
'unshift',
'splice',
'sort',
'reverse'
]
💡 注意:像
slice、concat、map、filter这类返回新数组、不改原数组的方法,不会被劫持(它们天然不触发视图更新,也无需劫持)。
2. 创建被增强的原型链
js
// 拿到 Array 的原始原型
const arrayProto = Array.prototype
// 以原始原型为基底,创建一个"拦截层"原型
const arrayMethods = Object.create(arrayProto)
methodsToPatch.forEach(function (method) {
// 缓存原始方法
const original = arrayProto[method]
// 在拦截层上重写该方法
def(arrayMethods, method, function mutator (...args) {
// 1. 先执行原始方法,拿到结果
const result = original.apply(this, args)
// 2. 取出观察者实例上的 __ob__
const ob = this.__ob__
// 3. 处理新增元素(push/unshift/splice 会插入新数据)
let inserted
switch (method) {
case 'push':
case 'unshift':
inserted = args
break
case 'splice':
inserted = args.slice(2) // 第三个参数起才是新增的元素
break
}
// 4. 对新增元素再做一遍响应式观察(递归 observe)
if (inserted) ob.observeArray(inserted)
// 5. 关键:主动通知依赖更新
ob.dep.notify()
return result
})
})
核心思路:原型链劫持 。被观察的数组,其原型不再是 Array.prototype,而是这个被改写的 arrayMethods。当你调用 arr.push(x) 时,实际执行的是 mutator,它先跑原生逻辑,再手动 notify() 通知 Watcher 更新,最后对插入的新元素递归做响应式转换。
3. 观察者里如何挂载
源码位置:src/core/observer/index.js(Observer 构造函数片段)
js
export class Observer {
constructor (value) {
this.value = value
this.dep = new Dep() // 数组自身也持有 dep
def(value, '__ob__', this) // 挂一个不可枚举的 __ob__ 引用
if (Array.isArray(value)) {
if ('__proto__' in {}) {
// 浏览器支持 __proto__:直接把数组的原型指到 arrayMethods
protoAugment(value, arrayMethods)
} else {
// 不支持 __proto__(如 IE):把方法逐个拷贝到数组对象自身
copyAugment(value, arrayMethods, arrayKeys)
}
// 对已有元素逐个 observe
this.observeArray(value)
} else {
// 对象的响应式走 defineProperty
this.walk(value)
}
}
}
protoAugment:value.__proto__ = arrayMethods,通过原型链继承改写后的方法(主流浏览器走这条)。copyAugment:对不支持__proto__的环境,直接把 7 个方法定义到数组实例自身上。
三、Vue2 方案的局限(痛点)
-
无法监听索引赋值 / 长度修改
jsthis.list[0] = 'new' // ❌ 视图不更新 this.list.length = 0 // ❌ 视图不更新Vue2 官方给出的"hack"是:
jsthis.$set(this.list, 0, 'new') // ✅ 内部会 defineReactive + notify this.list.splice(0, 1) // ✅ splice 被劫持,会通知 -
需要维护一份"改写原型" ,逻辑分散在
array.js和index.js两处,且依赖__ob__这个非标准内部属性。 -
__ob__会泄漏到序列化结果 中(虽然被标记了不可枚举,但JSON.stringify仍可能受影响,需用白名单处理)。
四、Vue3 的改进:Proxy 一把梭
Vue3 用 Proxy 重写了响应式(reactive 基于 Proxy + Reflect),数组问题被天然解决,不再需要任何"改写原型"的黑魔法。
Vue3 的核心逻辑(src/reactivity/baseHandlers.ts)
ts
const arrayInstrumentations = {}
// 对会改变长度的 3 个方法,做"追踪 + 触发"的包裹
;['push', 'pop', 'shift', 'unshift', 'splice'].forEach((key) => {
const method = Array.prototype[key]
arrayInstrumentations[key] = function (...args) {
// 暂停依赖追踪,避免新增元素被"length 变化"重复追踪
pauseTracking()
const res = method.apply(this, args)
resetTracking()
return res
}
})
export const mutableHandlers: ProxyHandler<object> = {
get(target, key, receiver) {
if (key === '__v_isReactive') return true
// 数组的这几个方法走"包裹版本",其余正常 Reflect.get
if (arrayInstrumentations.hasOwnProperty(key)) {
return Reflect.get(arrayInstrumentations, key, receiver)
}
const res = Reflect.get(target, key, receiver)
// 依赖收集(track)...
return res
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver)
// 依赖触发(trigger)...
return result
}
}
Vue3 相比 Vue2 的改进点
| 维度 | Vue2 | Vue3 |
|---|---|---|
| 底层机制 | Object.defineProperty + 原型改写 |
Proxy + Reflect |
| 监听索引赋值 | ❌ 不支持,需 $set |
✅ 原生支持 arr[0] = x |
监听 length 变更 |
❌ 不支持 | ✅ 原生支持 arr.length = 0 |
| 数组方法劫持 | 改 Array.prototype 原型链(全局污染风险) |
仅在 get 拦截器里对特定方法做轻量包裹 |
| 新增元素观察 | 手动 observeArray(inserted) |
set 拦截自动递归,track/trigger 全自动 |
| 性能 | 递归 defineProperty 初始化慢 |
惰性代理(访问时才递归代理),初始化快 |
| 代码复杂度 | 分散在 array.js + Observer |
统一在 Proxy handler 中 |
为什么 Vue3 也要对 push/splice 做"包裹"?
虽然 Vue3 能监听到索引和 length,但 push/unshift/splice 这类方法在执行过程中会触发多次内部 set (比如 push 先改 length 再写下标)。如果不加控制,会触发重复的、多余的依赖追踪/触发 ,甚至造成无限循环。Vue3 用 arrayInstrumentations + pauseTracking() 在原生方法执行期间暂停追踪,只在方法结束后统一触发一次更新,既保证正确性又避免性能浪费。
五、一句话总结
- Vue2 :
defineProperty管不了数组,于是改写 Array 原型上的 7 个变更方法 ,在方法内部手动notify()。能用,但有"索引赋值 / 长度修改监听不到"的硬伤,必须靠$set打补丁。 - Vue3 :
Proxy能原生拦截任意属性的读取/写入(含索引、length),数组和普通对象一视同仁,彻底抛弃原型改写;只对个别会自我触发多次的方法做轻量包裹以优化触发次数。
六、延伸思考 / 面试题
- 为什么 Vue2 不把数组每个元素都
defineProperty?(性能) this.$set(arr, index, val)在 Vue2 里到底做了什么?(defineReactive+dep.notify())- Vue3 用 Proxy 后就完全不需要特殊处理数组了吗?(不是,仍需
arrayInstrumentations控制追踪/触发次数) - Proxy 有哪些
defineProperty做不到的能力?(监听新增属性、删除属性、数组索引、length、Map/Set)
📌 觉得有收获? 点赞 + 收藏 + 关注,第一时间获取 Vue 源码系列下一篇! 💬 有疑问或不同见解,欢迎在评论区交流~
#Vue2 #Vue3 #响应式原理 #前端源码 #JavaScript