ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Vue computed不更新?get和set写法与实例化数据更新实战解析

Vue computed不更新?get和set写法与实例化数据更新实战解析 最近在做一个数据回填比较复杂的表单页面遇到了一个很典型的Vue问题组件实例化之后的数据更新派生在页面上的computed没有跟着刷新。排查到最后问题居然不是出在数据源上而是出在computed的get和set用法上——准确地说是很多人在写计算属性时只写了get没写set导致在需要“既能展示、又能赋值”的场景下数据更新链路口径不一致视图和状态就分叉了。这篇文章想把两个密切相关的知识点拆开讲透一个是computed同时写get()和set()到底在解决什么问题二是“实例化后的数据更新”为什么时常不生效、不触发重算。两者放在一起是因为实际项目里它们经常互为因果关系——数据更新方式没走对computed就静默罢工computed没写set后续对派生值的修正就无法回写到底层数据。无论你用的是Vue 2还是Vue 3只要写前端业务页面这篇都值得花几分钟过一遍。1. 从一次表单联动需求说起computed为什么要同时写get()和set()1.1 一个很多人都会撞上的报错先复习一个经典报错如果你曾经像下面这样写大概率见过它// Vue 2 选项式 computed: { fullName() { return this.firstName this.lastName } }然后模板里用了v-modelfullName一输入框改内容控制台立刻弹出类似的警告Computed property fullName was assigned to but it has no setter.翻译成人话就是你给一个计算属性赋值了但它压根没有set方法。Vue在告诉你说fullName是只读的它只能通过firstName和lastName算出来你不能反向去改它。问题在于表单场景里你经常需要绑定一个“展示值”用户改完之后你要把改动映射回底层数据。所以v-model一绑必然触发赋值。解决方式就是给computed补一个setcomputed: { fullName: { get() { return this.firstName this.lastName }, set(value) { const names value.split( ) this.firstName names[0] this.lastName names[1] } } }这样fullName被赋值时实际操作的是里面的firstName和lastName底层的响应式数据被修改全链路就又通了。1.2 v-model绑定computed的“正规姿势”上面这个写法看起来只是解决了报错实际上是解决了一个更本质的问题把“派生值”和“原始数据”解耦。在表单联动场景里所谓的派生值是指不需要单独存储、可以由其他数据计算出来的值。没有set的computed只适合做单向展示有get和set的computed则适合做“镜像编辑”。用户编辑的是镜像最终落地的还是原始数据源。这正好符合表单页面的两个需求用户看到的、用户修改的、底层存储的三者可以不是同一个值。例如金额输入框用户希望输入的是带千分位的字符串“1,234.56”但提交到后端的是数字1234.56。computed就可以这样设计computed: { amountText: { get() { return formatNumber(this.amount) }, set(value) { this.amount parseNumber(value) } } }用户在输入框里改的是amountText底层amount始终是number。你会发现类似需求几乎离不开get和set这么一对组合。1.3 可写computed的执行机制为什么set会生效因为Vue在初始化computed属性时如果发现这个属性不是一个函数、而是一个包含get和set的对象就会同时为它建立读取和赋值拦截。默认情况下计算属性只定义getter并开启缓存当你补充了setter之后赋值行为才变得可用。关键词是“缓存”。computed的get会缓存计算结果只有依赖的响应式数据变化时才重新计算。而set触发时不直接修改computed自身而是修改内部依赖的其它响应式数据所以会走一次完整的响应式更新链路。这也是为什么set内部不能直接“修改自身”——一旦修改自身又触发set无限循环就来了。2. 实例化后的数据更新为什么视图和数据会脱节标题里的关键词“实例化后的数据更新”在实际工程里指的就是Vue组件/实例创建完成之后对data中的数据进行新增、删除或替换预期页面应该更新但实际没有。这背后的原因要分几个层面来看。2.1 最常见的根因新增属性不在响应式系统内Vue 2时代的经典问题没有之一。Vue 2对对象响应式的实现是在实例化阶段通过Object.defineProperty遍历data里的已有属性逐个设置getter/setter。这个过程发生在组件实例化时。换句话说实例化之后你再往data对象上新增一个属性Vue根本不知道它存在自然也不会为这个新属性建立响应式依赖。举一个非常常见的例子data() { return { userInfo: { name: 张三 } } }, mounted() { // 实例化之后给 userInfo 增加一个全新属性 this.userInfo.age 18 // 打印出来数据有但模板里 {{ userInfo.age }} 不会更新 }模板里如果写了{{ userInfo.age }}结果是Vue Devtools里能看到age存在于对象上但视图就是不动computed里如果依赖了userInfo.age同样不会重新计算。原因就是age属性是在实例化完成之后才加到对象上的Object.defineProperty根本没有处理过它后续的改动不会有任何依赖追踪。这个问题在Vue 2里的标准解法有两种// 方式1使用 Vue.set 或 this.$set this.$set(this.userInfo, age, 18) // 方式2整体替换对象推荐触发依赖更新 this.userInfo { ...this.userInfo, age: 18 }两种方式的思路完全不同。$set是对已有对象“事后补桩”让新增属性也被响应式化而整体替换则是利用对象本身已有的响应式getter/setter——只要userInfo这个属性被重新赋值外层依赖就会被触发内层所有属性都会被重新读取。2.2 数组操作的响应式陷阱除了对象新增属性数组也是个重灾区。Vue 2对数组的响应式是通过拦截push、pop、shift、unshift、splice、sort、reverse这七个方法来建立的。也就是说你调用这些方法时Vue能感知到变化。但如果你用arr[0] xxx这种方式去改某个索引位的值或者直接修改arr.lengthVue 2感知不到——因为索引赋值和length修改并没有走被代理的方法。这种场景下你会发现数据变了computed懒洋洋不重算视图也不刷新。处理方式同样是两条路// 方式1使用 $set 指定索引 this.$set(this.arr, 0, 新值) // 方式2用 splice 代替索引赋值 this.arr.splice(0, 1, 新值) // 方式3整体替换数组 this.arr [...this.arr]那我实际更倾向于哪种如果数组不大直接整体替换最省心因为不需要记忆哪几个方法是响应式的、哪些不是。但在大列表场景下splice和$set的效率要远高于整体替换代价是你得记住这些细节。2.3 Vue 3下仍然需要小心的更新场景Vue 3把响应式系统重写成了Proxy理论上“实例化之后新增属性”已经不再是个问题。对象属性不管什么时候加上都会被Proxy拦截。数组索引赋值和修改length也能被代理捕获。但这不意味着Vue 3下可以随意更新数据。我实际开发中踩过几个新坑第一个坑是reactive对象被解构后直接操作解构出来的属性响应性会丢失。比如const state reactive({ count: 0 }) const { count } state count // 这里的 count 已经脱离响应式系统了第二个坑是用一个新的普通对象去覆盖reactive里已有的某个对象如果新对象内部嵌套多层仍旧可以响应但如果你把响应式对象存到非响应式容器里——比如Map或普通对象属性——更新时依然要小心。Vue 3提供了ref和reactive的组合但在复杂嵌套场景下最好配合toRefs或shallowReactive来设计不能因为性能省事就随意打破封装。第三个坑是在computed的get中访问了一个非响应式的全局变量。实例化之后即使这个全局变量的值变了computed也不会重新计算因为它不在响应式依赖图里。这个跟Vue 2、Vue 3无关纯粹是响应式依赖收集机制的自然结果。2.4 computed不更新的完整排查链路如果页面上的computed久久不刷新建议按顺序排查以下几点这是我在项目里沉淀下来的流程先确认computed依赖的数据确实已经改变。用Vue Devtools或console.log看一下不要凭感觉。确认数据改变的方式是否走的是响应式路径。Vue 2重点检查$set、数组spliceVue 3检查是否动了reactive的根引用。确认computed的依赖项是否真的包含了那个数据。比如依赖的是userInfo.name但改的是userInfo.age——computed当然不会重算因为依赖没变。确认computed里没有写异步或随机值。Math.random()、Date.now()这类副作用不会触发重算。确认没有在computed的get里依赖一个会被set修改、但set又反过来修改源数据造成循环依赖的死结。几乎90%的“computed不更新”问题都卡在前三步。只要数据更新的链路是响应式的computed一定自动重算不存在“忘了刷新”这回事。3. computed同时写get()和set()的完整使用方式一览理解了“为什么需要set”和“实例化后数据更新”的原理后我们把标准写法系统过一遍。3.1 Vue 2选项式API写法在Vue 2里data里数据在实例化时会被转化为响应式属性computed基于这些属性派生。要写可写computed直接把computed属性的值改成对象即可。export default { data() { return { firstName: 张, lastName: 三, quantity: 2, price: 100 } }, computed: { fullName: { get() { return ${this.firstName} ${this.lastName} }, set(value) { const names value.trim().split(/\s/) if (names.length 2) { this.firstName names[0] this.lastName names[names.length - 1] } else { this.firstName value this.lastName } } }, totalPrice: { get() { return this.quantity * this.price }, set(value) { // 当总额被外部调整时倒推单价 this.price this.quantity ? value / this.quantity : 0 } } } }这里有几个细节值得注意set里一定要做容错处理因为用户输入是不可控的比如fullName被赋值一个只有名字没有姓氏的字符串直接按空格切分就会越界。写totalPrice这种“总量倒推分量”的逻辑时还要处理除数为0的边界情况。set里不要做太重的逻辑不要把接口请求写到set里set的设计意图是同步回写底层响应式数据不承担副作用。3.2 Vue 3组合式API写法到了Vue 3computed从选项式挪到了组合式API里写法变成了computed()函数。同时写get和set时传入一个对象参数script setup import { ref, computed } from vue const firstName ref(张) const lastName ref(三) const quantity ref(2) const price ref(100) const fullName computed({ get: () { return ${firstName.value} ${lastName.value} }, set: (value) { const names value.trim().split(/\s/) if (names.length 2) { firstName.value names[0] lastName.value names[names.length - 1] } else { firstName.value value lastName.value } } }) const totalPrice computed({ get: () quantity.value * price.value, set: (value) { price.value quantity.value ? value / quantity.value : 0 } }) /scriptVue 3的computed()返回的是一个只读的ref对象但是因为内部有setter所以你可以对这个ref赋值。赋值时会把值传给set函数。这里注意普通ref的.value是可直接改写的而computed返回的ref在类型层面叫WritableComputedRef有写能力。3.3 两者的两版对照对比维度Vue 2 选项式Vue 3 组合式声明方式computed属性值写对象{get(){}, set(){}}computed({ get(){}, set(){} })访问方式this.fullName读取、this.fullName xx赋值fullName.value读取、fullName.value xx赋值模板绑定直接绑定fullName同样直接绑定fullNameref自动解包缓存机制有缓存依赖不变不重算同样有缓存依赖不变不重算触发时机get在首次访问/依赖变化时执行get在首次访问/依赖变化时执行set在赋值时执行掌握一个规律就够了无论Vue 2还是Vue 3computed的核心机制没变——set永远是用来“向外推”修改的不会改变computed自身的缓存值。4. 场景拆解什么时候必须依赖set完成数据回写4.1 全选/反选最经典的可写computed案例复选框全选联动是每个前端都写过的功能也是最适合用可写computed的场景。需求描述一个列表每行有复选框顶部有一个“全选”复选框全选勾选时列表所有项被选中有任何一个未选中时全选自动取消点击全选取消列表所有项取消选中。如果用watch或者事件函数硬写代码会又臭又长。用可写computeddata() { return { items: [ { id: 1, title: 任务一, checked: false }, { id: 2, title: 任务二, checked: false }, { id: 3, title: 任务三, checked: false } ] } }, computed: { allChecked: { get() { return this.items.length 0 this.items.every(item item.checked) }, set(value) { // value 是全选框的新状态 this.items.forEach(item item.checked value) } } }模板里input typecheckbox v-modelallChecked /这个写法的精妙之处在哪里get里维护了一个派生值是否全部选中。它不需要你在每次单行勾选时手动去算。单行状态一变get自动重算。set处理的是用户点击全选框时发起的“批量修改”。v-model发现allChecked被赋值就会执行setset里循环改写所有item.checked。你看这里唯一的“实例化后数据更新”操作是set中对item.checked的直接修改。因为checked是items初始化时就有的属性所以修改完全走响应式链路页面每一行的勾选状态都会同步刷新不会出现标题里说的“更新了但不渲染”的怪象。如果我需求里item一开始没有checked字段是实例化完成后从接口拿到的那我就会在赋值时预先forEach补上默认字段或者用$set避免新增属性不响应的问题。两个知识点的配合就在这里体现。4.2 父子组件v-model用computed桥接props和emitVue 2里v-model就是valueprop加input事件的语法糖。Vue 3里v-model是modelValueprop加update:modelValue事件。子组件接收一个值不能直接修改prop否则Vue会警告。如果你想在内部做一个可编辑的输入框绑定这个prop到内部数据上最简单的写法就是可写computedVue 2子组件props: { value: { type: String, default: } }, computed: { innerValue: { get() { return this.value }, set(val) { this.$emit(input, val) } } }Vue 3子组件props: { modelValue: { type: String, default: } }, emits: [update:modelValue], computed: { innerValue: { get() { return this.modelValue }, set(val) { this.emit(update:modelValue, val) } } }组件内部模板input v-modelinnerValue /这个写法把“props是单向的”这条规则和“表单控件需要双向绑定”这条现实需求完美调和了。get负责读父组件传入的数据set负责向父组件发通知。父组件收到通知后更新自己的数据数据再从get流回来。整个数据流是环形的但是方向清晰——不会出现子组件直接改父组件数据的越权行为。这个场景特别容易踩“实例化后数据更新”的坑如果你在子组件mounted时直接把props.value赋值给data里的某个字段后续父组件数据变了子组件的data字段不会跟着变。而用computed的get后父组件的每次更新都能实时映射到子组件视图里。4.3 格式化显示与元数据剥离很多时候展示给用户的格式和存储的格式不是同一个。日期选择器可能是年-月-日字符串但后端要时间戳金额输入框可能是带百分号的字符串但后端要纯数字。这种场景用computed可写属性很顺手。我从前做过一个大屏配置页面里边的百分比输入框要求用户输入25%但配置项存的是0.25。如果用input直接绑定0.25用户看到的是0.25太不友好。如果绑定字符串25%提交时又得手动转。用可写computedcomputed: { percentText: { get() { return this.percent * 100 % }, set(value) { const parsed parseFloat(value) if (!isNaN(parsed)) { this.percent Math.min(100, Math.max(0, parsed)) / 100 } } } }get负责把存储值转成展示值set负责把用户输入解析还原成存储值同时做了范围限制。这个模式的通用性极强日期、金额、百分比、单位换算都可套用。5. 实例化数据更新的其他诱发computed失败的场景除了新增属性、数组索引这些根因我在实际排查中还遇到几种比较容易忽略的情况一并列出来。5.1 依赖被Object.freeze冻结如果data里的对象被Object.freeze冻结了Vue 2尝试给属性加getter/setter时会静默失败因为它无法重新定义冻结对象的属性。于是这个对象的所有属性都不是响应式的修改它们不会触发computed更新。Vue 3的Proxy虽然能代理冻结对象但对冻结对象的修改也会失败最终同样不会触发视图更新。排查方法看看数据是否经过Object.freeze、Readonly之类的处理。尤其是从某个工具函数或第三方库拿回对象时很难察觉它已经被冻结了。5.2 对整块数据整体替换但computed读的是旧引用如果computed里依赖的是this.list实例化之后你执行了this.list newArray因为list本身是初始化就存在的属性所以整体替换是响应式的computed会正常重算。但如果computed里读取的是this.obj.detail.list而你在更新时直接this.obj.detail newDetail只要obj是响应式对象detail是它的已有属性这依然没问题。真正容易出错的是把this.list存到了一个局部变量里修改局部变量然后期待computed更新。比如mounted() { const list this.list list.push(...) }这种情况push可能触发响应式更新但如果list已经被Object.freeze或者你用普通方式改变了引用就完全脱离响应式系统了。核心原则对响应式数据的更新永远要通过this上的根引用路径去操作不要中间截断。5.3 异步回调中更新数据在setTimeout、Promise.then、接口回调里修改dataVue的响应式系统是能捕获的——只要修改方式是响应式的。但有个细节容易被忽略如果你在异步回调里读取了一个响应式数据的快照在回调里修改快照同样不会更新视图。mounted() { const userInfo { ...this.userInfo } // 浅拷贝了一份普通对象 fetchUser().then(res { userInfo.name res.name // 改的是普通对象视图不更新 this.userInfo userInfo // 必须整体赋回视图才会更新 }) }很多人写异步更新时习惯先取出来改改完忘了赋回去或者只改了取出来的局部引用导致“实例化后的数据更新失败了”。排查这类问题最直接的方式是看看computed依赖的属性在内存中的引用链是否始终指向响应式对象本身。6. 使用边界与进阶思路6.1 set中不应该出现异步逻辑可写computed的set设计意图是同步回写。如果你在set里发请求、做防抖、做异步校验就会出现一个很尴尬的局面v-model绑定后用户每输入一下computed set就被触发一次请求满天飞状态不可预测。正确的做法是用户输入set只负责同步更新一个“草稿状态”真正的接口提交放在表单提交按钮或者单独的watch debounce逻辑里。set保持简单、同步、无副作用是数据链路稳定的大前提。6.2 set中不要反向修改自身这一点前面提过但值得单独强调。computed: { priceText: { get() { return String(this.price) }, set(value) { // 错误的做法给自身赋值会无限触发set this.priceText value // 正确的做法给源数据赋值 this.price parseFloat(value) } } }写了this.priceText value赋值又触发setset又赋值浏览器控制台直接卡死或报“Maximum call stack size exceeded”。这种问题即使老手偶尔也会手滑写的时候要多留个心眼。6.3 与watch的职责边界很多人分不清“computed可写属性”和“watch加内部data字段”的区别我这里给一个明确的分工建议如果某个值可以完全由其它值推导出来就用computed的get。如果外部需要向这个推导值赋值并且赋值需要映射回源数据就补充set。如果数据之间的联动需要执行逻辑、发请求、做校验而不是简单的赋值映射才用watch。换句话说computed set适合的是“数据映射型回写”watch适合的是“行为触发型联动”。在同一个场景里能用computed set写清楚的联动就不要再引入一个中间data字段然后再用watch去同步了那会让代码状态量翻倍、理解成本上升。6.4 可替代方案组件v-model的额外参数Vue 3的v-model支持参数比如v-model:title、v-model:visible所以很多以前必须用computed可写属性解决的“多值同步”场景现在可以通过多个v-model直接完成不再需要包一层computed。比如一个弹窗组件需要控制显示状态、标题、内容三个值Vue 3里可以直接Dialog v-model:visiblevisible v-model:titletitle v-model:contentcontent /这样子组件内部每个prop都要配一个可写computed来emit对应事件。逻辑结构会更清晰。不过如果是单个“镜像编辑”需求computed可写属性依然是更简单直接的选择。6.5 写在最后的一点个人建议从接触Vue 2到现在用Vue 3这个模式是我几乎每个项目都用到的。它最核心的价值是让代码里少了一个需要手工维护的“同步字段”。没有computed set你可能得在data里放一个和源数据重复的副本然后再写watch去同步它们。而用了computed set这个副本根本不存在展示层看到的永远是即时计算出来的派生值赋值操作又自动映射回源数据。这才是响应式编程真正优雅的地方。回到标题里的关键词实例化后的数据更新computed同时写get和set。这两件事放在一起理解其实就是一句话——数据在实例化后无论怎么变只要你修改的方式走的是响应式链路computed就永远不会掉链子而当你需要从末端反向修改数据时给computed补一个set数据流就永远是双向可控的。希望这篇实战笔记能帮到正被这个问题困扰的人。
返回列表