误把“回调里改值”当响应式更新?一段真实案例讲清 Vue 的更新时机
先看一段我刚接手项目时遇到的代码。需求很简单:用户点击按钮后,修改一个数组,同时把页面上的“已修改”状态置为 true。同事的写法是这样的:
export default {
data() {
return {
list: [1, 2, 3],
modified: false
}
},
methods: {
handleClick() {
this.list.forEach((item, index) => {
this.list[index] = item * 2
})
this.modified = true
console.log(this.list) // 输出 [2, 4, 6]
console.log(this.modified) // 输出 true
}
}
}
控制台里数据确实变了,但页面上列表纹丝不动,modified 状态也没任何反馈。他反复确认“数据明明改了”,最后甚至怀疑是 CSS 遮挡了更新。
问题出在哪?不是数据没改,而是 Vue 的响应式系统根本没“看见”这次修改。Vue 3 用 Proxy 拦截属性读取和赋值,但 this.list[index] = item * 2 这种通过索引直接赋值的方式,虽然能触发 Proxy 的 set 陷阱,可问题在于:forEach 循环里连续多次同步修改同一个数组,Vue 会把依赖收集和派发更新的过程合并到下一个微任务里执行。而这段代码在同一个事件循环里同步把 modified 也改了,等真正派发更新时,组件已经“认为”自己不需要重新渲染了——因为 modified 的 set 和 list 的 set 发生在同一轮,Vue 的调度器只保留最后一次变更。
更隐蔽的是,如果 list 是 props 传递下来的数组,或者来自 Vuex,这种索引赋值甚至根本不会触发任何响应。因为 props 是只读的,Vuex 的 state 也要求通过 mutation 修改。
正确写法其实很直白。要么用可响应的方法替换整个数组或使用 splice:
handleClick() {
// 方式一:生成新数组整体替换
this.list = this.list.map(item => item * 2)
this.modified = true
}
handleClick() {
// 方式二:splice 触发响应
this.list.splice(0, this.list.length, ...this.list.map(item => item * 2))
this.modified = true
}
关键差异在于:this.list = newArray 会触发 Proxy 对 list 这个属性的 set,Vue 能明确知道“整个数组变了”,从而重新渲染。而索引赋值虽然也走了 set 陷阱,但 Vue 内部对数组的依赖追踪是基于 length 和遍历操作的,直接改索引并不会让依赖了数组内容的渲染函数重新执行。
如果数据来自 Vuex,那就老老实实走 commit:
// store.js
mutations: {
doubleList(state) {
state.list = state.list.map(item => item * 2)
}
}
// 组件里
this.$store.commit('doubleList')
this.modified = true
顺带提一个排查技巧:遇到“数据变了但页面没动”的情况,先打开 Vue Devtools,切到组件树,看目标组件有没有被标记为“已更新”。如果操作前后组件始终是灰色的,说明响应链路根本没通,别再盯着业务代码看了,直接检查赋值方式是否被 Vue 追踪。
最后把这段案例总结成一条口诀:改数组用整体替换或 splice,别用索引;改对象用新对象展开,别直接加属性;所有跨组件的数据,一律走 Vuex mutation。 记住这三条,能省掉大半“为什么没更新”的排查时间。