资讯动态

Vue2新增属性不响应?一文搞懂数据响应式原理与$set解决之道

发布时间:2026/9/9 4:47:34 来源:尧图企业网站定制
做 Vue2 项目的人大概率都遇到过这个诡异场景data 里明明有个对象你在方法里给它塞了一个新属性控制台打印对象新值也在页面就是纹丝不动。我第一次掉进这个坑是在做订单筛选面板的时候接口返回里多了一个“库存状态”字段我直接this.filterOptions.stockStatus 1结果按钮点了筛选条件里也打印得出值模板里渲染出来始终是 undefined。后来翻了源码才明白这不是 Vue 的 bug而是 Vue2 的数据响应式原理决定了响应式拦截只在初始化阶段“签合同”后面新来的属性不在合同里。这篇文章我会把 Vue2 的数据响应式原理从头拆一遍重点讲清楚为什么给实例新增属性会丢失响应式以及到底怎么正确地新增响应式属性。1. 先还原现场data 里加一个属性界面怎么就不动了1.1 一段看着毫无问题的代码先说一个最典型的例子几乎每个 Vue2 开发者都写过类似的代码template div p用户年龄{{ userInfo.age }}/p button clickaddAge点击增加年龄/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处理器。新增属性本质上也是一次setProxy能感知到所以天然具备新增属性的响应式能力。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] xthis.$set(this.arr, 0, x)或this.arr.splice(0, 1, x)下标赋值未走数组拦截器清空数组this.arr.length 0this.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依然能完成同样的工作。用过一段时间之后你会发现新增响应式属性这件事并没有那么可怕关键在于理解响应式系统的边界在哪里然后顺着它的规则走而不是跟它硬碰硬。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价