Vue2 $set 深度解析 + 批量更新全套优化方案(原理+实战+踩坑+面试)
文章简介
在 Vue2 项目开发中,$set 是解决对象新增属性、数组索引修改不触发响应式的核心 API。实际业务里,批量循环调用 $set 经常出现页面卡顿;同时业内流传「批量更新优先整体替换数组」,但在 ElementUI Table、Vxe-Table 等大数据表格场景下,整体替换反而卡顿更严重。
本文从底层响应式原理入手,剖析卡顿根源,依次讲解多套优化方案,并解答「直接赋值和 Object.assign 区别」等高频疑问,所有代码均经过生产环境验证,同时汇总踩坑点与面试真题,适合日常开发与面试复习。
运行环境:Vue2(基于
Object.defineProperty实现响应式)
目录
- 一、Vue2 $set 底层原理与卡顿根源
- 二、为什么表格场景整体替换数组反而更卡?
- 三、基础优化方案(3套极简实战方案)
- 四、解惑:为什么推荐 Object.assign?直接赋值不行吗?
- 五、终极通用方案:assign + $set 智能混用(自动区分新旧属性)
- 六、拓展:搭配 $nextTick 优化海量数据更新
- 七、全方案选型对照表
- 八、高频踩坑总结
- 九、面试常考题汇总
- 十、全文总结
一、Vue2 $set 底层原理与卡顿根源
1.1 Vue2 响应式短板
Vue2 采用 Object.defineProperty 劫持对象属性实现响应式,存在两个原生缺陷:
- 直接给响应式对象新增属性,无法完成劫持,视图不会更新;
- 通过索引直接修改数组元素,同样无法劫持,视图不会更新。
$set 就是为了解决以上两个问题而生。
1.2 $set 核心执行逻辑
每次执行 this.$set(obj, key, value),内部主要做两件事:
- 调用
defineReactive为属性绑定getter/setter,将其转为响应式属性; - 执行
dep.notify(),通知所有依赖(页面视图、watch、computed)执行更新。
1.3 真正的卡顿原因(核心重点)
很多开发者误以为卡顿是视图多次渲染,实际并非如此:
- 视图渲染 :Vue 会将同一事件循环内的渲染
watcher存入异步队列并自动去重。哪怕循环上千次$set,视图最终也只会渲染 1 次; - 性能瓶颈 :每一次
$set都会触发dep.notify(),而watch、computed不会被去重合并。调用多少次 $set,回调函数就会执行多少次,大量重复逻辑阻塞主线程,这才是卡顿的根本原因。
二、为什么表格场景整体替换数组反而更卡?
不少场景下使用 list = list.map(...) 整体替换数组写法简洁,但大数据表格、复杂自定义行组件严禁使用,原因如下:
- 数组引用变更,触发全量重渲染
整体替换会改变数组内存引用,Vue 会判定整份列表数据全部变更,进而销毁原有全部 DOM,重新创建。 - 表格组件渲染成本极高
ElementUI、VxeTable 等表格除了渲染单元格,还需要实时计算行高、列宽、固定列、合并单元格、滚动条位置等,全量重建 DOM 会产生巨额性能开销。 :key="index"加剧卡顿
若列表使用数组索引作为key,整体替换后key全部失效,DOM 完全无法复用,卡顿问题会进一步放大。
场景区分
- ✅ 推荐整体替换:简单文本列表、下拉选择、少量数据展示;
- ❌ 禁止整体替换:大数据表格、带复杂自定义行的列表。
三、基础优化方案(3套极简实战方案)
针对「循环 $set 频繁触发 watch/computed 卡顿」问题,整理 3 套低侵入、易上手的基础方案,可根据业务场景直接选用。
方案一:状态锁拦截(新增属性/必须使用 $set 场景 · 推荐)
适用场景
需要新增响应式属性、必须依赖 $set;大数据表格、复杂列表通用;不想修改底层响应式源码。
实现思路
定义一个更新状态标识,批量更新前「上锁」,更新完成后「解锁」;在 watch 中增加状态判断,更新过程中直接跳过回调逻辑,避免重复执行。
完整代码
javascript
export default {
data() {
return {
isUpdating: false, // 批量更新状态锁
tableList: []
}
},
methods: {
batchUpdate() {
// 1. 上锁:标记当前正在批量更新
this.isUpdating = true
// 循环执行 $set 新增/修改属性
this.tableList.forEach(item => {
this.$set(item, 'status', 1)
this.$set(item, 'tag', '已完成')
})
// 2. 下一帧解锁,恢复监听逻辑
this.$nextTick(() => {
this.isUpdating = false
})
}
},
watch: {
tableList: {
handler(newVal) {
// 批量更新中,直接跳过,防止重复执行
if (this.isUpdating) return
// 原有正常业务逻辑
console.log('列表数据变更', newVal)
},
deep: true
}
}
}
优缺点
✅ 代码简单、无黑魔法、DOM 局部复用,表格场景性能优秀;
❌ 需要在关联的 watch 中增加一行状态判断。
方案二:非响应式副本更新(零改造 · 简单列表专用)
适用场景
简单列表、少量数据;不想修改任何 watch / computed 代码,追求极简开发。
禁止场景
大数据表格、复杂行组件(会触发全量 DOM 重建)。
实现思路
通过深拷贝生成非响应式数据副本,在副本中批量修改数据(全程不触发任何响应式回调),修改完成后一次性赋值给原数组,仅触发 1 次全局更新。
完整代码
javascript
methods: {
batchUpdate() {
// 1. 深拷贝生成非响应式副本,切断响应式依赖
const tempList = JSON.parse(JSON.stringify(this.tableList))
// 2. 自由修改数据,无需 $set,不触发 watch
tempList.forEach(item => {
item.status = 1
item.tag = '已完成'
})
// 3. 一次性赋值,仅触发 1 次响应式更新
this.tableList = tempList
}
}
优缺点
✅ 零代码侵入、无需改造监听函数,中间过程无重复回调;
❌ 改变数组引用,表格会全量重渲染;超大数组深拷贝存在轻微性能损耗。
方案三:Object.assign 批量赋值(仅修改已有属性 · 性能最优)
适用场景
只修改对象已存在的属性,无需新增字段,表格高频更新首选方案。
实现思路
对于已有属性,放弃循环 $set,使用 Object.assign 批量合并属性,减少响应式通知次数,DOM 完全局部复用。
完整代码
javascript
methods: {
batchUpdate() {
this.tableList.forEach(item => {
// 批量修改已有属性,性能最优
Object.assign(item, {
status: 1,
tag: '已完成',
remark: '批量更新'
})
})
}
}
重要避坑
Object.assign 无法为对象新增响应式属性:
- 修改已有属性:正常触发响应式,视图更新 ✅;
- 新增不存在的属性:本次渲染临时生效,但新增字段无响应式,后续单独修改视图不会更新 ❌。
优缺点
✅ 性能最佳、代码简洁、DOM 局部复用;
❌ 不支持新增响应式属性。
四、解惑:为什么推荐 Object.assign?直接赋值不行吗?
很多同学会产生疑问:既然直接赋值也能修改属性并触发视图更新,为什么还要刻意使用 Object.assign?这里澄清核心误区。
4.1 核心结论
修改对象已有响应式属性时,直接赋值 和 Object.assign 效果完全一致,两者都会正常触发响应式、更新视图,性能也没有区别。
javascript
// 写法1:直接赋值(完全可行)
item.name = "张三";
item.age = 18;
item.status = 1;
// 写法2:Object.assign(同样可行)
Object.assign(item, {
name: "张三",
age: 18,
status: 1
});
4.2 推荐使用 Object.assign 的真正原因
并非响应式能力有差异,而是出于工程化与代码规范考量:
- 批量赋值更优雅:一次性更新多个字段时,聚合式写法结构更清晰,可读性更强;
- 便于统一处理:可将更新字段封装为独立对象,方便做字段过滤、数据校验、日志埋点等通用逻辑;
- 适配批量更新方案:能够完美配合「自动区分新旧属性」的批量更新逻辑,实现代码统一管理。
4.3 边界区分(必记)
- 修改已有属性 :
直接赋值/Object.assign二选一即可; - 给对象新增属性 :两者都无法生成响应式属性,必须使用 $set。
一句话总结
已有属性:直接赋值 = Object.assign;
新增属性:只有 $set 能保证响应式。
五、终极通用方案:assign + $set 智能混用(自动区分新旧属性)
5.1 方案背景
前面三种基础方案都存在局限性:人工区分新旧属性代码冗余、Object.assign 不支持新增属性、纯循环 $set 易触发卡顿。
本方案实现自动判断属性是否存在:
- 已有属性 → 使用
Object.assign保证运行性能; - 新增属性 → 使用
$set保证响应式完整性;
一套代码适配全场景,兼顾性能、功能与可维护性。
5.2 工具函数实现
提供组件局部使用 和全局挂载两种方式,按需选择。
方式1:组件内局部方法(单组件使用)
javascript
export default {
methods: {
/**
* 智能更新响应式对象属性
* @param {Object} target 目标响应式对象
* @param {Object} updateData 待更新键值对
*/
updateReactiveProps(target, updateData) {
// 边界校验
if (!target || typeof target !== 'object' || !updateData) return
const existProps = {} // 存储已有属性
const newProps = {} // 存储新增属性
// 自动分类新旧属性
Object.keys(updateData).forEach(key => {
if (target.hasOwnProperty(key)) {
existProps[key] = updateData[key]
} else {
newProps[key] = updateData[key]
}
})
// 1. 批量更新已有属性(高性能)
Object.assign(target, existProps)
// 2. 逐个处理新增属性(保证响应式)
Object.keys(newProps).forEach(key => {
this.$set(target, key, newProps[key])
})
}
}
}
方式2:全局挂载(整个项目通用,在 main.js 配置)
javascript
import Vue from 'vue'
// 全局注册智能更新方法
Vue.prototype.$updateReactiveProps = function (target, updateData) {
if (!target || typeof target !== 'object' || !updateData) return
const existProps = {}
const newProps = {}
Object.keys(updateData).forEach(key => {
if (target.hasOwnProperty(key)) {
existProps[key] = updateData[key]
} else {
newProps[key] = updateData[key]
}
})
Object.assign(target, existProps)
Object.keys(newProps).forEach(key => {
this.$set(target, key, newProps[key])
})
}
5.3 实战调用示例
javascript
export default {
data() {
return {
tableList: [
{ id: 1, name: '小明', age: 20 },
{ id: 2, name: '小红', age: 22 }
]
}
},
methods: {
batchUpdate() {
this.tableList.forEach(item => {
// 无需手动区分新旧属性,工具函数自动处理
this.updateReactiveProps(item, {
name: '用户' + item.id, // 已有属性 → Object.assign
age: item.age + 1, // 已有属性 → Object.assign
status: '正常' // 新增属性 → $set
})
})
}
}
}
5.4 方案优势
- 自动分类新旧属性,减少人工编码成本;
- 已有属性走
Object.assign,避免watch/computed频繁执行; - 新增属性走
$set,保证完整响应式; - DOM 局部更新,大数据表格流畅不卡顿;
- 无侵入改造,支持全局复用。
六、拓展:搭配 $nextTick 优化海量数据更新
如果一次性更新上万条海量数据 ,可结合 $nextTick 将所有更新逻辑放入微任务中执行:
- 避免 JS 主线程与浏览器重排交替执行;
- 所有逻辑执行完毕后,浏览器仅执行一次页面重排,进一步提升流畅度。
示例代码
javascript
methods: {
batchUpdate() {
this.$nextTick(() => {
this.tableList.forEach(item => {
this.$updateReactiveProps({
name: '测试用户',
status: '在线'
})
})
})
}
}
七、全方案选型对照表
| 方案 | 核心用法 | 响应式完整性 | 性能表现 | 最佳适用场景 | 不推荐场景 |
|---|---|---|---|---|---|
| 状态锁拦截 | isUpdating + 循环 $set | 完整 | 优秀 | 需新增属性、大数据表格、复杂列表 | 无 |
| 非响应式副本 | 深拷贝 + 一次性赋值 | 完整 | 良好 | 简单列表、少量数据、不想改造 watch | 大数据表格 |
| Object.assign | 批量合并属性 | 新增属性无响应式 | 最优 | 仅修改已有字段、表格高频更新 | 需要新增响应式属性 |
| 智能区分方案 | 自动分类 + assign + $set | 完整 | 优秀 | 新旧属性混合更新、全场景通用 | 无 |
八、高频踩坑总结
-
hasOwnProperty特性工具函数使用
hasOwnProperty仅判断对象自身属性,不会读取原型链属性,符合 Vue 响应式对象使用规范,无需额外处理。 -
数组索引场景
本文所有方案主要面向普通对象 ;Vue2 中修改数组索引,依旧需要单独使用
$set:javascriptthis.$set(arr, 0, '新值') -
深层嵌套对象
工具函数仅处理对象第一层属性 ;如果是多层嵌套对象,内层字段新增时,仍需要单独调用
$set。 -
Object.assign新增属性坑牢记:
Object.assign只能修改已有属性,新增的字段不具备响应式。 -
表格慎用整体替换
大数据表格优先局部更新,坚决避免数组整体替换导致的全量 DOM 重建。
九、面试常考题汇总
1. 循环多次调用 $set,视图会渲染多少次?卡顿根源是什么?
答:同一事件循环内视图仅渲染 1 次,Vue 会对渲染 watcher 去重;卡顿根源是每次 $set 都会触发 dep.notify(),导致 watch、computed 回调重复执行,阻塞主线程。
2. 直接赋值、Object.assign、$set 三者区别?
答:修改已有属性时,直接赋值和 Object.assign 效果一致,均可触发响应式;新增属性时,前两者无法生成响应式,必须使用 $set。批量场景推荐 Object.assign,代码更规范。
3. 为什么大数据表格不建议整体替换数组?
答:整体替换会改变数组引用,Vue 触发全量 DOM 销毁重建;表格需要重新计算列宽、固定列、滚动条等,渲染开销极大,局部更新可复用 DOM,性能更优。
4. 自动区分属性方案的设计思路?
答:通过 hasOwnProperty 判断字段是否为对象原有属性,已有属性使用 Object.assign 提升性能,新增属性使用 $set 保证响应式,兼顾性能与功能。
5. Vue3 还需要使用 $set 吗?
答:不需要。Vue3 基于 Proxy 实现响应式,天然支持对象新增属性、数组索引修改,直接赋值即可。
十、全文总结
- Vue2 循环
$set卡顿并非视图渲染导致 ,核心原因是watch/computed被频繁触发; - 简单列表可使用「非响应式副本」,仅修改已有字段优先
Object.assign,必须新增属性推荐「状态锁」; - 新旧属性混合更新的通用场景,首选自动区分新旧属性方案,是 Vue2 批量更新最优解;
- 大数据表格禁止无脑整体替换数组,优先局部更新复用 DOM;
- 海量数据批量更新,可搭配
$nextTick优化代码执行时机,提升页面流畅度。
实战口诀:旧属性 assign 提速,新属性 $set 兜底,表格拒绝全量替换,批量更新从此不卡顿。