做 Vue2 项目的人,大概率都遇到过这个诡异场景:data 里明明有个对象,你在方法里给它塞了一个新属性,控制台打印对象,新值也在,页面就是纹丝不动。我第一次掉进这个坑是在做订单筛选面板的时候,接口返回里多了一个“库存状态”字段,我直接this.filterOptions.stockStatus = 1,结果按钮点了,筛选条件里也打印得出值,模板里渲染出来始终是 undefined。后来翻了源码才明白,这不是 Vue 的 bug,而是 Vue2 的数据响应式原理决定了:响应式拦截只在初始化阶段“签合同”,后面新来的属性不在合同里。这篇文章我会把 Vue2 的数据响应式原理从头拆一遍,重点讲清楚为什么给实例新增属性会丢失响应式,以及到底怎么正确地新增响应式属性。
1. 先还原现场:data 里加一个属性,界面怎么就不动了
1.1 一段看着毫无问题的代码
先说一个最典型的例子,几乎每个 Vue2 开发者都写过类似的代码:
<template> <div> <p>用户年龄:{{ userInfo.age }}</p> <button @click="addAge">点击增加年龄</button> </div> </template> <script> export default { data() { return { userInfo: { name: '张三' } }; }, methods: { addAge() { this.userInfo.age = 18; console.log(this.userInfo); // { name: '张三', age: 18 } } } }; </script>这段代码的逻辑很简单:点击按钮,往userInfo对象上挂一个age属性。控制台里userInfo确实变成了{ name: '张三', age: 18 },但页面上的userInfo.age始终是空的。
这就是 Vue2 里著名的“新增属性不响应”问题。很多人第一反应是“数据没变”,实际上数据变了,只是没有触发视图更新。因为 Vue2 的响应式系统在拿到 data 的那一刻,就已经决定好了“哪些属性能触发视图更新”,这个决定一旦做完,后面新增的属性统统不算数。
1.2 响应式丢失的直接后果
这个问题的麻烦程度,比表面看到的要严重得多。视图不更新只是第一层表象,更深层的影响是:
- 模板里依赖
userInfo.age的渲染 Watcher 根本没有被建立,后续即使你再次修改age,页面依然不会更新。 computed或watch中监听userInfo.age也不会生效,因为依赖收集阶段根本没收集到它。- 子组件通过 props 接收这个字段,父组件数据变了子组件一样不会刷新。
- 如果你在
mounted或异步回调里新增属性,问题尤其隐蔽,因为报错和警告都不明显。
换句话说,新增属性等于在 Vue 的响应式体系之外“私建了一块飞地”。它不是数据没变,而是缺少了“变更后通知视图”的通道。要搞清楚为什么会有这么个坑,就得回到 Vue2 响应式系统的地基,也就是Object.defineProperty上。
2. 响应式的地基:Object.defineProperty 把普通对象变成“可观察对象”
2.1 defineProperty 到底改了什么
Vue2 靠的核心 API 是 ES5 的Object.defineProperty。它能在一个对象的已有属性上重新定义 getter 和 setter,从而在属性被读取、被赋值的时候,插入我们自己的逻辑。
看一段最简单的代码:
let value = 'hello'; const obj = {}; Object.defineProperty(obj, 'msg', { enumerable: true, configurable: true, get() { console.log('有人读取 msg'); return value; }, set(newVal) { console.log('有人修改 msg'); value = newVal; } }); obj.msg; // 输出:有人读取 msg obj.msg = 'world'; // 输出:有人修改 msg执行这段代码后,obj.msg的读写行为就被“劫持”了。读的时候能感知,写的时候也能感知。Vue2 就是利用这个机制,在读取属性时做依赖收集,在修改属性时触发视图更新。
但注意一个非常关键的字眼:Object.defineProperty只能处理“已经存在的属性”。obj.msg在调用defineProperty之前必须先在对象里存在。如果你在obj上凭空新增一个obj.newKey = 'xxx',这个新属性不会自动拥有 getter 和 setter,它只是一个普通数据属性。
这就是“新增属性不响应”的最底层原因。
2.2 递归拦截与初始化时机:Vue 只在初始化阶段“签合同”
Vue2 拿到开发者定义的data之后,会进入一个叫Observer的类做处理。这个类的作用是递归遍历data里的所有属性,给每个属性都调用defineReactive方法,把属性改造成 getter/setter 形式。
核心逻辑可以简化成下面这段伪代码:
class Observer { constructor(value) { this.dep = new Dep(); Object.defineProperty(value, '__ob__', { value: this, enumerable: false, writable: true, configurable: true }); if (Array.isArray(value)) { // 数组走另一套逻辑,后面说 this.observeArray(value); } else { this.walk(value); } } walk(obj) { Object.keys(obj).forEach(key => { defineReactive(obj, key, obj[key]); }); } }注意这里的Object.keys(obj):Vue2 只会对当前已经存在的 key做拦截。data里初始化时有什么属性,响应式合同就签到哪些属性。后面你往data对象里塞的新 key,因为不在Object.keys的初始结果里,所以不会被defineReactive处理,自然也就不会有响应式能力。
这个初始化发生在组件实例创建阶段,具体来说是在beforeCreate之后、created之前。等到你在mounted或某个点击事件里往对象上新增属性时,Vue 早已完成了遍历,不会回头再去拦截你新加的属性。
2.3 数组的“特殊待遇”:为什么 push 能更新而 arr[0]=xxx 不行
对象讲完了,数组才是最容易踩坑的地方。Vue2 并没有对数组的每个下标做defineReactive。它选择了另一种方案:重写数组的原生方法。
Vue2 内部把push、pop、shift、unshift、splice、sort、reverse这七个方法做了拦截,在调用这些方法修改数组之后,额外触发一次视图更新。这就是为什么你写this.list.push(1)页面会变,而this.list[0] = 1页面完全没反应。
const arrayProto = Array.prototype; const arrayMethods = Object.create(arrayProto); ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => { const original = arrayProto[method]; Object.defineProperty(arrayMethods, method, { value: function (...args) { const result = original.apply(this, args); const ob = this.__ob__; // 通知依赖更新 ob.dep.notify(); return result; }, enumerable: false, writable: true, configurable: true }); });所以this.list.splice(index, 1, newValue)能更新,this.$set(this.list, index, newValue)也能更新,因为它们最终都走了splice这条被拦截的路径。而直接按下标赋值this.list[index] = newValue,走的是普通对象属性赋值,没有经过任何拦截器,自然无法触发更新。
既然数组下标也能通过defineProperty拦截,Vue2 为什么不逐个拦截?主要原因有两个:一是性能,一个长度上千的数组如果每个下标都变成 getter/setter,初始化开销会非常夸张;二是 ES5 在数组索引上的行为本身有诸多限制,逐项劫持既不可靠也难维护。所以 Vue2 选择了“拦截方法”这种性价比更高的折中方案。
3. 从 getter 到视图刷新:Dep 与 Watcher 是怎么接力跑的
3.1 render 函数第一次执行:依赖收集
光有 getter/setter 还不够。Vue2 的响应式系统还有两个核心角色:Dep和Watcher。Dep是依赖收集器,你可以把它理解成每个响应式属性自带的一份“订阅者名单”。Watcher则是订阅者,组件渲染、computed、watch 都会创建对应的 Watcher。
组件第一次渲染时,会执行渲染函数,也就是 render 函数。render 函数里读取了data中的属性,这一步就触发了对应属性的 getter。Vue2 把当前正在渲染的 Watcher 放到一个全局变量Dep.target上,然后在 getter 中调用dep.depend(),把这个 watcher 加入订阅名单。
用伪代码表示就是:
class Dep { constructor() { this.subs = []; } depend() { if (Dep.target && !this.subs.includes(Dep.target)) { this.subs.push(Dep.target); } } notify() { this.subs.forEach(watcher => watcher.update()); } }对应地,defineReactive的完整形态是这样的:
function defineReactive(obj, key, val) { const dep = new Dep(); Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { if (Dep.target) { dep.depend(); } return val; }, set(newVal) { if (newVal === val) return; val = newVal; dep.notify(); } }); }这里最关键的一点是:只有被 getter 读取过的属性,才会完成依赖收集。模板里没用到的属性,即使有了 getter/setter,也没有 watcher 订阅它,改了页面也不会刷新。这个机制也算解答了另一个经典问题:“为什么我在 data 里声明了某个字段,模板里没用到,它变了视图也不动?”——因为压根没人订阅它。
3.2 setter 触发:派发更新与 nextTick 合并
当某个响应式属性被重新赋值时,setter 会调用dep.notify(),把订阅名单上的所有 watcher 都叫醒。Watcher 收到通知后,并不会立刻重新执行渲染,而是把自己丢进一个异步队列里。Vue 会用nextTick在下一个事件循环里统一执行这些更新任务。
这个设计非常重要。如果一个方法里同时修改了三个响应式属性,理想情况应该是只重新渲染一次,而不是改一个渲染一次。异步队列能把这三次通知合并成一次渲染,性能上有本质区别。
class Watcher { constructor(getter, cb) { this.getter = getter; this.cb = cb; this.id = ++uid; this.value = this.get(); } get() { Dep.target = this; const value = this.getter(); Dep.target = null; return value; } update() { queueWatcher(this); } run() { const value = this.get(); const oldValue = this.value; this.value = value; this.cb.call(this.vm, value, oldValue); } }queueWatcher会先判断队列里有没有相同 id 的 watcher,有就不重复入队,只等下一轮nextTick统一执行。这也是为什么你在同步代码里连续修改多次同一个属性,最终只触发一次视图更新。
3.3 一个 mini 版例子,把响应式链路串起来
为了把这条链路看得更清楚,我们不用 Vue,自己写一个最小化的响应式系统。代码不长,但足以演示 getter 收集依赖、setter 派发更新的完整过程:
class Dep { constructor() { this.subs = []; } depend() { if (Dep.target && !this.subs.includes(Dep.target)) { this.subs.push(Dep.target); } } notify() { this.subs.forEach(w => w.update()); } } Dep.target = null; let uid = 0; class Watcher { constructor(getter, cb) { this.id = ++uid; this.getter = getter; this.cb = cb; this.get(); } get() { Dep.target = this; this.getter(); Dep.target = null; } update() { this.cb(); } } function defineReactive(obj, key, val) { const dep = new Dep(); Object.defineProperty(obj, key, { get() { if (Dep.target) dep.depend(); return val; }, set(newVal) { if (newVal === val) return; val = newVal; dep.notify(); } }); } const vm = { data: {} }; defineReactive(vm.data, 'msg', 'hello'); new Watcher(() => { console.log('视图渲染,当前 msg:', vm.data.msg); }, () => { console.log('视图更新,当前 msg:', vm.data.msg); }); vm.data.msg = 'world'; // 输出: // 视图渲染,当前 msg: hello // 视图更新,当前 msg: world vm.data.newKey = 'hi'; // 没有任何输出最后一行是关键:vm.data.newKey = 'hi'执行之后,控制台没有任何反应。因为newKey不是通过defineReactive处理的属性,它没有 Dep,没有订阅者,赋值动作不会通知任何 Watcher。Vue2 里新增属性的问题,在这里就已经完全暴露了。
4. 新增属性失效的根因:defineProperty 的“一次性合同”与 Vue 的初始化盲区
4.1 “一次性合同”:只有初始化时签过约的属性才有人通知
把响应式系统的本质想明白之后,新增属性失效的原因就非常清晰了。Object.defineProperty只能为已经存在的 key 定义拦截逻辑,Vue2 也只是在初始化阶段用Object.keys遍历了 data 中已有的属性。这个“遍历并签约”的动作只发生一次,之后新增的 key 不会被自动处理。
我习惯用一个生活化的类比:响应式属性就像你跟物业签的用电合同,签约时登记了哪几个房间,电路就通到哪几个房间。初始化之后你想在楼顶加盖一间阳光房,物业不会自动帮你把电线拉过去。你需要在盖好房间之后,主动去申请“补签合同”,这通电工作才会做。
Vue2 中这个“补签合同”的动作就是Vue.set或this.$set。它们之所以能解决新增属性不响应的问题,本质上是手动对被新增的 key 执行了一次defineReactive,把它纳入响应式系统,并且手动触发一次依赖通知,让视图立刻刷新。
4.2 数组下标失联的本质
数组的问题本质上是同一个问题的变种。Vue2 没有对数组下标逐个做defineReactive,所以通过下标新增的元素,在响应式系统里同样处于“失联”状态。
直接按下标赋值this.arr[0] = 'new',底层操作是给数组对象添加了一个名为0的属性。这个属性没经过拦截器,没有 dep,自然也不会通知视图。this.arr.length = 0同理,因为 length 属性也没有被改造。
Vue2 提供的解决方式也很明确:能用splice就别直接按下标赋值,能用push/pop就别手动改 length。如果必须精确修改指定位置的元素,优先this.$set(this.arr, index, value)。
4.3 和 Vue3 Proxy 的本质差异
很多人被这个问题折腾几回之后会问:为什么 Vue3 没这个毛病?原因是两者拦截的层面完全不同。
Vue2 用Object.defineProperty拦截的是“属性”,属于对单个 key 的细粒度改造。新增 key 等于在拦截名单之外开了一个口子,除非补一次defineProperty,否则永远漏风。
Vue3 用Proxy代理的是“整个对象”。无论你读取、赋值、删除还是新增属性,所有操作都会先经过 Proxy 的set/get处理器。新增属性本质上也是一次set,Proxy能感知到,所以天然具备新增属性的响应式能力。
const raw = { name: '张三' }; const observed = new Proxy(raw, { get(target, key) { console.log('读取', key); return target[key]; }, set(target, key, value) { console.log('设置', key, value); target[key] = value; // 在这里触发视图更新 return true; } }); observed.age = 18; // 输出:设置 age 18Vue2 需要开发者用$set补位,Vue3 直接消除了这个心智负担。这也是为什么 Vue3 的响应式代码写起来更顺手。不过理解和解决 Vue2 的问题,依然是大量存量项目的现实需求。
5. 正确姿势:Vue.set 和 $set 的工作机制,以及 Object.assign 的坑
5.1 Vue.set / this.$set 怎么用
先说结论:要在 Vue2 中给响应式对象新增一个属性并让视图更新,最标准的写法是this.$set或Vue.set。
// 组件内部 this.$set(this.userInfo, 'age', 18); // 组件外部 / 工具函数中 import Vue from 'vue'; Vue.set(this.userInfo, 'age', 18); // 数组场景 this.$set(this.arr, 1, 'new value');$set和Vue.set完全等价,都指向同一个全局方法。它接收三个参数:目标对象、属性名、属性值。调用之后,新属性会被defineReactive改造,同时当前组件会重新渲染。
有一点要注意:$set的目标必须是一个已经处于响应式体系中的对象。如果目标对象本身就不是响应式的,比如一个普通的局部变量,$set会直接给它赋值,不会额外做什么。更严格地说,如果目标是 Vue 实例自身的数据根节点,比如this.$set(this, 'newTopLevel', 1),Vue 会给出警告,因为根级 data 属性必须在初始化时就声明。日常开发中我们很少直接给根节点加属性,但了解这个约束可以少走弯路。
5.2 源码视角:$set 在对象和数组上分别做了什么
为了真正理解$set,我们看一段简化版本地源码:
function set(target, key, val) { // 数组分支:使用 splice 走原生拦截器 if (Array.isArray(target) && isValidArrayIndex(key)) { target.length = Math.max(target.length, key); target.splice(key, 1, val); return val; } // 如果 key 已存在于目标对象中,直接赋值即可触发响应 if (key in target && !(key in Object.prototype)) { target[key] = val; return val; } const ob = target.__ob__; // 目标是 Vue 实例或根 data,直接告警 if (target._isVue || (ob && ob.vmCount)) { warn('避免向 Vue 实例或其根 $data 添加响应式属性'); return val; } // 非响应式对象,普通赋值即可 if (!ob) { target[key] = val; return val; } // 响应式对象:定义新属性并手动通知 defineReactive(ob.value, key, val); ob.dep.notify(); return val; }逐段拆解一下:
- 数组分支里,
$set其实没有走defineReactive,它走了splice。因为 Vue2 重写了数组的splice方法,调用后会触发ob.dep.notify(),所以视图能更新。这里用target.length = Math.max(...)是为了避免索引越界导致splice不生效。 - 如果 key 已经存在,直接赋值
target[key] = val即可。因为该属性早就被defineReactive处理过了,赋值会触发 setter,不需要额外操作。 - 如果对象不是响应式的,即
target.__ob__不存在,那说明 Vue 根本没有托管它,直接普通赋值就行。 - 只有“响应式对象 + 新增 key”这个组合,才需要
defineReactive补拦截,然后手动调用ob.dep.notify(),确保新增属性之后视图能立即刷新。
__ob__是 Vue 在 Observer 构造阶段给每个响应式对象加的一个隐藏属性,里面保存了 Observer 实例,也保存了这个对象对应的 Dep。判断一个对象是不是响应式,最直接的方式就是看它的__ob__是否存在。
5.3 别死磕 $set,整体替换才是动态字段的常规解法
$set虽然能解决问题,但如果动态字段很多,一个字段一个$set写起来很痛苦,性能也不算好。更常见的做法是“整体替换”。
看一个动态筛选条件的例子:
const newFilters = { keyword: 'vue', status: 1, stockStatus: 2 }; // 错误:直接往原对象上新增字段 Object.assign(this.filters, newFilters); // 正确:生成一个新对象整体赋值 this.filters = Object.assign({}, this.filters, newFilters);第一种写法Object.assign(this.filters, newFilters)是很多人容易踩的坑。它确实会把newFilters的字段复制到this.filters上,但这是“在原对象上增加属性”,新增的stockStatus依然不是响应式的。
第二种写法生成了一个全新对象,再整体赋值给this.filters。因为this.filters本身是响应式属性,重新赋一个对象会触发它的 setter,从而通知视图更新。新对象里的所有字段都是普通数据属性,在重新渲染时会被读取,依赖收集也会正常建立。同理,用扩展运算符也可以:
this.filters = { ...this.filters, ...newFilters };这个思路尤其适合接口返回动态字段、后端配置动态表单的场景。你不需要关心有哪些字段,只需要在赋值时保证对象引用整体变化。代价是会多一次对象创建,但对大多数业务场景来说完全够用。
5.4 $forceUpdate 是退路,不是常规武器
有些人被新增属性折磨过之后,发现了一个“必杀技”:this.$forceUpdate()。调用它,组件会强制重新渲染一次。
这个方法能不能用?能用。但它不解决响应式“根子”上的问题。$forceUpdate只是让当前组件重新跑一次 render 函数,并不会给新增属性补上 getter/setter 和 Dep。这意味着:
- 强制刷新后,模板里能短暂显示新值,因为这次渲染确实读取了它。
- 但新属性仍然没有依赖收集,后面你再次修改这个属性时,不会触发任何更新。
- 如果其他组件也依赖这个属性,
$forceUpdate只刷新当前组件,其他组件依然不会更新。
所以我的观点很明确:$forceUpdate适合做“一次性兜底”,比如某个第三方库直接改写了数据导致视图不刷新,你临时强制刷新一下,可以接受。但把它当常规武器用,相当于给整个响应式系统开外挂,反而会让问题越埋越深。正确做法永远是$set或整体替换。
6. 实战避坑清单:从表单动态字段到复杂嵌套对象的完整建议
6.1 声明时预留字段 vs 动态新增的取舍
最省心的做法,是在data里先把所有可能会用到的字段声明出来,哪怕初始值是null或空字符串。比如用户信息接口不一定总返回age,但你明确知道业务上需要展示年龄,那就在初始化时写上:
data() { return { userInfo: { name: '', age: null } }; }这样做的效果是:字段从一开始就在响应式合同里,不管接口返不返回,后续赋值都能触发更新。不算额外开销,还避免了很多“为什么我的数据变了视图不动”的排查。
但有些场景确实没法预声明,比如动态表单的字段完全由后端配置决定,前端根本不知道会来哪些 key。这时候优先整体替换,或者拿到数据之后用$set逐字段注册。比较推荐的是整体替换,代码干净,心智负担也小。
async loadFormConfig() { const config = await getFormConfig(); this.formConfig = { ...config }; }有人可能会有疑问:整体替换新对象,原来对this.formConfig某些字段的依赖会丢失吗?不会。因为重新赋值触发的是formConfig这个属性的 setter,所有订阅了formConfig的 watcher 都会重新执行,在重新执行的过程中会读取新对象的各个字段,依赖会重新建立。
6.2 数组操作规范:必须用 splice / 替换的写法
数组的响应式丢失虽然没有对象新增属性那么显眼,但在实际项目里出现的频率一点也不低。我把常见的错误写法、正确写法和触发原因整理成了一张表,方便查阅:
| 操作需求 | 错误写法 | 正确写法 | 原因 |
|---|---|---|---|
| 修改指定下标元素 | this.arr[0] = 'x' | this.$set(this.arr, 0, 'x')或this.arr.splice(0, 1, 'x') | 下标赋值未走数组拦截器 |
| 清空数组 | this.arr.length = 0 | this.arr.splice(0)或this.arr = [] | length 属性未被拦截 |
| 追加元素 | 无特殊问题,push可用 | this.arr.push(x) | push已被重写 |
| 过滤/映射生成新数组 | 直接赋值原数组即可 | this.arr = this.arr.filter(item => item.visible) | 整体赋值触发 setter |
| 稳定修改多个元素 | 循环用下标赋值 | 整体替换:this.arr = this.arr.map(...) | 避开逐个下标赋值 |
其中最容易忽略的是第二行:清空数组一定不要用this.arr.length = 0。这个方法在 Vue2 中不触发任何更新,页面上的列表会原封不动。用this.arr.splice(0)或者直接把数组重新赋值为[],才是安全写法。
6.3 排查响应式失效的调试口诀
如果项目里已经出了“数据变了视图不动”的问题,不要慌,按照下面这几步排查,绝大多数情况都能定位到根因。
第一步,看控制台警告。Vue 在遇到$set目标不是响应式对象时,通常会打印黄色警告,比如 “Cannot set reactive property on undefined, null, or primitive value”。这条警告直接告诉你目标对象还没被 Vue 接管。
第二步,打印对象的__ob__。在组件里执行:
console.log(this.userInfo.__ob__);如果输出是undefined,说明this.userInfo不是响应式对象。如果输出的是 Observer 实例,说明对象本身是响应式的,问题大概率出在“新增的 key 没有走 set 或整体替换”。
第三步,在浏览器 DevTools 中搜defineReactive源码打断点。Vue2 打包后的代码里能找到这个方法,给它打断点,然后执行新增属性的逻辑。如果断点没有命中,说明新属性根本没走defineReactive,也就是没被拦截。
第四步,用this.$nextTick验证时序问题。有时候你确实用了$set,但视图还没更新是因为 DOM 更新是异步的。这时候在$nextTick回调里再看一眼:
this.$set(this.userInfo, 'age', 18); this.$nextTick(() => { console.log(document.querySelector('.age').textContent); });如果$nextTick后依然没有新值,那就是响应式链路本身的问题,跟时序无关。
这条排查链路走下来,基本能覆盖我在实际项目中遇到的所有“响应式失效”场景。记得一次在排查一个老项目时,发现问题出在组件外部直接往 store 里的对象添了字段,没有走 mutation,也没有走$set。这种藏在工具函数里的暗伤,靠肉眼扫代码很难发现,但按上述方法定位就很快。
6.4 一个小习惯,帮我少踩了很多坑
最后分享一个我自己的习惯:在封装公共组件或工具函数时,凡是涉及到外部传入对象的字段追加,我都会先判断目标对象是不是 Vue 响应式对象,然后再决定用$set还是普通赋值。这个判断很简单,看看它有没有__ob__就行。
如果确认是响应式对象,但又不想依赖 Vue 的全局 API,也可以手动补一个辅助函数,统一处理新增字段的逻辑:
function addReactiveField(target, key, value) { if (target && target.__ob__) { Vue.set(target, key, value); } else { target[key] = value; } }这个函数在通用业务代码里很实用,尤其是工具类模块往往不在组件上下文中,没法直接用this.$set,但通过Vue.set依然能完成同样的工作。用过一段时间之后,你会发现新增响应式属性这件事并没有那么可怕,关键在于理解响应式系统的边界在哪里,然后顺着它的规则走,而不是跟它硬碰硬。