01
60 秒回答模板
可以从 Object.defineProperty 的局限讲起:Vue2 的响应式主要靠 getter/setter 劫持对象属性,但数组的索引变化和 length 变化非常频繁,直接给每个下标都定义 getter/setter 成本高,而且新增下标和直接改 length 并不会天然触发已有属性的 setter。所以 Vue2 没有完整劫持数组下标,而是拦截会改变数组自身的 7 个原型方法:push、pop、shift、unshift、splice、sort、reverse。具体实现是先用 Object.create(Array.prototype) 创建 arrayMethods,再在 arrayMethods 上定义同名方法。数组被 Observer 处理时,会给数组挂一个不可枚举的 __ob__,如果环境支持 __proto__,就把数组实例的 __proto__ 指向 arrayMethods;否则就把这几个方法直接定义到数组实例上。重写的方法内部会先调用原生方法保持原有行为,然后拿到 this.__ob__。如果是 push、unshift 或 splice 这种可能插入新元素的方法,还会把新增元素交给 ob.observeArray 做深度响应式处理,最后调用 ob.dep.notify() 通知 watcher 更新。因此 arr.push(obj) 能触发视图更新,新增的 obj 也会变成响应式;但 arr[index] = value 和 arr.length = n 在 Vue2 中检测不到,通常要用 Vue.set(arr, index, value) 或 arr.splice(index, 1, value) 替代。Vue3 使用 Proxy 后可以拦截更多数组索引和 length 操作,这是和 Vue2 的关键差异。
考点 整体思路
主线 defineProperty 局限
易错点 只说 Vue2 用 Object.defineProp…
02
深入解析
01 整体思路
Vue2 处理数组响应式时没有选择逐个劫持所有数组下标,而是抓住数组变更的主要入口:数组变异方法。它创建一个 arrayMethods 对象,让它继承自 Array.prototype,然后只在这个对象上覆盖会改变原数组的 7 个方法。数组被观测后,实例原型会被指向 arrayMethods,因此调用 arr.push、arr.splice 等方法时,实际会先进入 Vue2 包装过的函数,再由包装函数决定是否继续观测新增元素并通知更新。
02 defineProperty 局限
Vue2 的响应式基础是 Object.defineProperty,它适合拦截对象已有属性的读取和赋值,但不擅长处理数组这种大量依赖数字下标和 length 的结构。如果给数组每个索引都定义 getter/setter,初始化和变更成本都会很高,而且数组新增下标本质上是新增属性,初始化时不存在的下标不会被转换为响应式属性。直接修改 arr.length 也不是对某个已劫持下标的 setter 调用,所以 Vue2 无法稳定捕获这类变化。
03 拦截范围
Vue2 只重写 7 个会改变数组自身内容或顺序的方法:push、pop、shift、unshift、splice、sort、reverse。这些方法覆盖了常见的插入、删除、替换、排序和反转操作,也是面试中最需要准确说出的点。像 map、filter、slice、concat 这类不会原地修改原数组的方法没有被重写,因为它们返回新数组,通常需要把返回值重新赋给响应式字段,重新赋值时会走对象属性本身的 setter。
04 arrayMethods
arrayMethods 的创建方式可以理解为一个影子原型:const arrayMethods = Object.create(Array.prototype)。Vue2 不直接污染全局 Array.prototype,而是在 arrayMethods 上定义同名变异方法。这样只有被 Vue observe 过的数组实例会走增强逻辑,普通数组仍然使用原生行为。这个设计既保留了数组原生方法的返回值和副作用,又把响应式通知逻辑限制在当前响应式数组范围内,避免影响宿主环境中其他数组。
05 原型替换
数组进入 Observer 构造函数后,Vue2 会先通过 def(value, '__ob__', this) 给数组挂上 Observer 实例,再判断是否支持 __proto__。支持时直接执行类似 value.__proto__ = arrayMethods 的 protoAugment;不支持时退化为 copyAugment,把 arrayMethods 上的 7 个方法逐个定义到数组实例自身。两种方式目的相同:让这个数组调用变异方法时优先命中 Vue2 包装后的版本。
06 __ob__ 作用
__ob__ 是数组和 Observer 之间的桥。被重写的数组方法执行时会通过 this.__ob__ 找到当前数组对应的 Observer,进而拿到 ob.dep 和 ob.observeArray。ob.dep 用来收集和通知依赖,ob.observeArray 用来继续观测数组里的对象元素。__ob__ 通常通过 def 定义为不可枚举属性,所以业务遍历数组元素时不会把它当成普通数据处理,但调试时可能会在控制台看到它。
07 新增元素观测
push、unshift 和 splice 可能把新元素放进数组,所以 Vue2 的重写方法会额外提取 inserted。push 和 unshift 的 inserted 就是传入参数,splice 的 inserted 是 args.slice(2),也就是从第三个参数开始的新插入项。如果存在 inserted,就调用 ob.observeArray(inserted),把新增对象或嵌套数组继续递归 observe。这样 arr.push({ name: 'a' }) 之后,后续修改这个对象的响应式字段也能被 Vue2 追踪。
08 通知更新
包装方法最后会调用 ob.dep.notify()。这一步是数组响应式真正驱动视图刷新的关键:组件渲染期间如果读取过这个数组,就会和数组的 dep 建立依赖关系;后续数组通过被拦截的方法发生变化时,notify 会通知相关 watcher,watcher 再触发组件重新渲染。注意 Vue2 并不是在数组每个下标的 setter 里通知,而是在变异方法这个统一出口里手动通知。
09 无法检测场景
Vue2 中数组直接按索引赋值和直接改 length 是检测不到的,例如 vm.items[index] = newValue、vm.items.length = newLength。这和数组方法重写的设计边界一致:如果变更没有经过被包装的 7 个方法,也没有触发某个已定义响应式属性的 setter,Vue2 就没有机会调用 dep.notify。面试回答里要把能触发和不能触发的写法分开说清楚。
10 正确写法
当需要替换数组某一项时,Vue2 推荐用 Vue.set(arr, index, value) 或 arr.splice(index, 1, value)。Vue.set 内部会针对数组下标场景转成 splice,因此最终仍然能走数组方法拦截和通知更新。需要截断数组时,也应该使用 splice(newLength) 这类能被拦截的方法,而不是直接设置 arr.length。这个实践点经常被追问,因为它能体现候选人是否知道原理和业务修复方式之间的联系。
03
易错点
- 只说 Vue2 用 Object.defineProperty,没有说明数组是通过重写变异方法处理的。
- 把被重写的方法说成所有数组方法,或者错误加入 map、filter、slice、concat。
- 漏掉 sort、reverse,只提 push、pop、splice 等常见增删方法。
- 认为 arr[index] = value 在 Vue2 中一定能触发视图更新。
- 认为 arr.length = 0 和 arr.splice(0) 在响应式效果上完全等价。
- 只讲 dep.notify,不讲 push、unshift、splice 插入的新元素还要 observeArray。
- 把 __ob__ 误解成数组数据的一部分,而不是 Observer 挂载在响应式对象上的元信息。
04
面试官追问
为什么 Vue2 不直接对数组每个索引都用 Object.defineProperty?
主要是成本和覆盖范围都不理想。数组可能很长,初始化时给每个索引都定义 getter/setter 会带来明显性能开销;数组还经常新增下标,而新增下标在初始化时并不存在,无法提前转换。直接修改 length 也不会触发某个索引属性的 setter。所以 Vue2 选择拦截数组变异方法,在常见数组变更入口上手动 observe 新元素并 notify 更新。
arr.push({ a: 1 }) 后新对象为什么也是响应式的?
因为 push 被 Vue2 重写了。包装后的 push 会先调用原生 push 把对象放入数组,然后识别 push 的参数就是新增元素,把它们交给 ob.observeArray 继续观测。如果新增元素是普通对象,observe 会创建 Observer 并把对象属性转换成 getter/setter。因此后续修改这个对象中已存在的响应式属性,也能触发依赖更新。
arr[0] = newValue 为什么页面可能不更新?怎么处理?
arr[0] = newValue 是直接给数组索引赋值,没有经过 Vue2 重写的 7 个变异方法,也不一定触发已收集依赖的响应式 setter,因此 Vue2 无法可靠检测。正确做法是使用 Vue.set(arr, 0, newValue) 或 arr.splice(0, 1, newValue)。这两种写法会进入 Vue2 的响应式更新路径,尤其 splice 会命中数组方法拦截并调用 dep.notify。
为什么 map、filter、slice 没有被 Vue2 重写?
因为这些方法不会原地修改原数组,而是返回一个新数组。Vue2 重写的目标是捕获会改变当前数组自身结构或顺序的方法。使用 map、filter、slice 时,如果希望视图更新,通常应把结果重新赋值给响应式属性,例如 this.list = this.list.filter(...)。这次赋值会触发 list 这个响应式属性的 setter,而不是依赖数组方法本身通知。
__ob__ 在数组响应式里具体承担什么角色?
__ob__ 保存当前数组对应的 Observer 实例,相当于响应式数组的元信息入口。重写后的数组方法通过 this.__ob__ 找到 ob.dep,用 dep.notify 通知依赖;也通过 ob.observeArray 继续观测新增元素。它通常是不可枚举属性,不参与普通业务数据遍历,但它让数组实例和 Vue2 的依赖收集、派发更新机制连接起来。