ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Vue3计算属性从原理到实战:缓存、依赖追踪与methods/watch选型

Vue3计算属性从原理到实战:缓存、依赖追踪与methods/watch选型 做 Vue3 项目越久你会越发现一个规律大部分业务页面本质上就是“数据进来、界面出去”的组装过程。价格换算、列表筛选、表单校验提示、购物车合计这些场景都需要根据已有的响应式数据计算出新的数据。如果模板里全都塞三元表达式、methods 里全写“计算逻辑”代码很快就会糊成一团。Vue3 计算属性就是专门来解决这个问题的——它把“根据已有数据派生新数据”这件事变成声明式的、带缓存的、自动追踪依赖的写法代码干净性能也稳。这篇是《Vue3魔法手册》第05节主角就是计算属性。我会从三种写法Option API、Composition API、getter/setter到原理剖析再到和 methods、watch 的选型对比最后把面试里经常翻车的细节一起讲透。适合刚入门 Vue3 的朋友也适合写了一段时间但还没认真抠过计算属性细节的开发者。1. 先搞清楚计算属性到底解决了什么问题1.1 一个让模板“变脏”的典型场景我有一次接手一个后台订单管理页面里面要展示订单的折扣价、满减价、实付价三个字段都依赖原价和优惠信息。写这个页面的兄弟大概是从“能用就行”的角度出发模板里直接写了这样一串template div 原价¥{{ product.price }} 折后价¥{{ product.price * product.discount }} 满减后¥{{ product.price * product.discount 100 ? product.price * product.discount - 20 : product.price * product.discount }} /div /template我第一反应不是“这代码错了”而是“这代码能跑但谁看谁头大”。第二反应是“万一产品又说满减门槛改到 150这模板得改几处”这就是计算属性要解决的最原始的问题模板里能写表达式但不适合写业务逻辑。如果把上面这段逻辑搬进计算属性页面瞬间清爽const payPrice computed(() { const discountPrice product.value.price * product.value.discount return discountPrice 100 ? discountPrice - 20 : discountPrice })模板里只剩一个变量名语义清晰修改规则时也只需要动一处。这就是张天禹这套“魔法手册”系列里反复强调的一个理念模板越干净项目越长寿。1.2 计算属性的本质带缓存的数据派生很多教程会把计算属性描述成“模板里放不下复杂逻辑时的备用方案”这话对但没说透。计算属性真正的本质是当一组响应式数据发生变化时自动重新求值并把结果缓存起来。我习惯拿 Excel 表格来类比。你在 Excel 里写一个公式C1*C2C1 或 C2 一变公式所在单元格立刻自动更新这叫“响应式”但如果你只改了 D1和公式无关的单元格不会重新算这叫“依赖追踪”。计算属性就是这么个工作方式只有它依赖的数据变了它才重新执行依赖没变访问多少次拿到的都是上次算出来的结果。这个“缓存”特性在面试里几乎必考但很多人理解只停留在背概念。我举个实际例子一个电商列表页有 200 个商品每个商品都带一个finalPrice计算属性。如果计算属性没有缓存每次模板重新渲染200 个计算函数全都要重新执行一遍——虽然计算量不大但如果是更重的逻辑比如遍历一千条流水算汇总性能差异立刻出来了。而因为有缓存同样的输入值只会算一次后续访问直接读缓存结果性能消耗几乎为零。1.3 为什么有人说“用 methods 也能实现别用 computed”这是初学者最容易陷入的纠结。确实methods 里放一个函数也能计算同样的结果模板里调用getFinalPrice()效果看起来差不多。我当年刚学 Vue 的时候也被这个概念绕了一下后来在真实项目里才真正体会到两者的差别。差别就一条methods 没有缓存computed 有缓存。methods 每次渲染组件只要模板里调用了这个方法方法就一定执行一遍——哪怕它依赖的数据一次都没变。computed 则不一样只有依赖的响应式数据发生变化才会重新计算。所以从“计算派生数据”这个需求出发computed 是更优解。methods 擅长的是“响应事件”点击按钮、提交表单、调用接口这些属于动作不是数据派生。如果非要用 methods 去模拟 computed结果就是每次哪怕改一个无关的 data 字段导致组件重新渲染所有计算方法都会白跑一遍。注意Vue3 的 computed 如果依赖的数据变化非常频繁缓存优势会被冲淡但此时你要解决的其实是数据更新的频率问题而不是换工具的问题。后面第 4 部分我会专门聊 watch 和 computed 的分工。2. 两种 API 都要会Option 写法与 Composition 写法2.1 Option API 里的 computed 到底怎么组织Vue3 虽然主推 Composition API但大部分存量项目、以及很多用script setup之前习惯写选项式的团队仍然保留着 Option API 的写法。而且你出去干活接手老项目时八成会遇到这种代码。所以两种写法都得拿得出手。Option API 写法下computed 是一个独立的配置项和 data、methods 平级script export default { data() { return { basePrice: 100, discount: 0.8, coupon: 10 } }, computed: { finalPrice() { return this.basePrice * this.discount - this.coupon }, formattedPrice() { return ¥${this.finalPrice.toFixed(2)} } } } /script看到没计算属性内部可以依赖另一个计算属性这就是链式计算属性。上面formattedPrice依赖finalPricefinalPrice依赖 data 里的三个字段。只要其中一个基准字段变了整条链上的计算属性都会自动重新计算。这种链式写法在复杂业务里特别有用——你把一个大计算拆成几个小步骤每一层都清晰可测。有一点要注意Option API 下计算属性函数里的this指向组件实例所以可以访问this.basePrice。但这也带来一个隐患如果你在计算属性里写了箭头函数this指的就是外层作用域而不是组件一旦访问this.xxx就会报错。我在面试别人时经常用这个小细节来筛人。2.2 Composition API 里用 ref 还是 reactive到了 Composition APIcomputed 变成了一个独立的函数需要手动引入。基本用法是这样script setup import { ref, computed } from vue const price ref(100) const count ref(2) const total computed(() price.value * count.value) /script这里有个新手容易懵的点computed 返回的是一个ref对象所以访问total要写.value。在模板里 Vue 会自动解包可以直接写{{ total }}但在 JS 逻辑里用total.value才能拿到值。如果依赖的数据来自 reactive 对象也同样没问题import { reactive, computed } from vue const cart reactive({ price: 100, count: 2 }) const total computed(() cart.price * cart.count)reactive 对象的属性访问不用写.value因为它本身就是基于 Proxy 的响应式对象。两种方式共存时记住一个原则计算属性内部只要读取了响应式数据它就会自动注册依赖。不管这个数据是 ref、reactive、props还是另一个 computedVue 的响应式系统都会帮你盯着。2.3 getter 和 setter计算属性真的只能读吗默认情况下computed 只读——你只能通过它获取计算结果不能直接给它赋值。但在某些场景下我们需要让计算属性“可写”全选/全不选复选框勾选状态是根据列表项状态计算出来的通过 v-model 绑定一个计算属性的表单控件用一个变量同时控制多个状态。这些场景就需要用到 computed 的完整写法setter getter。拿“全选按钮”举例我按真实项目里的写法给你演示const products ref([ { id: 1, checked: false }, { id: 2, checked: false }, { id: 3, checked: false } ]) const isAllChecked computed({ get() { return products.value.length 0 products.value.every(item item.checked) }, set(value) { products.value.forEach(item item.checked value) } })模板里用v-modelisAllChecked勾选状态下拉全选取消勾选状态一键取消全选。原理就是用户操作改变 checkbox 的值时Vue 去调用set(value)而列表里任何一个子项的 checked 状态变化时getter 重新计算又反过来驱动界面上全选按钮的视觉状态。写 setter 时的最大坑不要在 setter 里直接修改自己依赖的数据之外的属性。比如你在 setter 里往数组里 push 一个新项或者发请求这就不是“设置派生值”的职责了。计算属性的 setter 应该只负责“把新值同步回源数据”比如上面的场景就是把所有子项 checked 改成同一个值。额外逻辑放进方法里别塞在 setter 中不然排查问题的时候特别抓狂。3. 缓存与依赖追踪从原理层面拆穿“魔法”3.1 缓存到底是怎么实现的很多人用 Vue3 的 computed 用得很溜但一旦被问到“计算属性为什么有缓存”就只会回答“由于缓存机制”。其实原理并不复杂Vue3 的 computed 底层是基于effect和Ref实现的核心就两个点惰性求值和脏值标记。第一次访问计算属性时getter 会真正执行把结果存下来并把dirty标记设为false。之后再次访问只要依赖没有改变直接返回上次存储的结果getter 不再执行。当依赖发生变化时响应式系统会通知计算属性的 effect 把dirty重新标记为true下次访问时重新求值。用大白话讲就是备菜不意味着每顿饭都重新切一遍。你冰箱里切好的土豆丝只有当你去“折腾”土豆的时候才需要重新切你没动过土豆客人来多少次盘子里的土豆丝还是那一盘。这个机制在源码里体现在ComputedRefImpl这个类中核心字段就是_dirty。Vue 在触发依赖更新时并不会马上重新执行 getter而是先“标脏”等到有人真正访问这个计算属性时再算。这个设计叫“惰性求值”好处是如果一个计算属性即使依赖变了但整个应用没去访问它它就不会做无用功。3.2 依赖追踪Vue 怎么知道“该重新算了”依赖追踪靠的是 Vue3 的响应式系统。简单捋一捋这个过程渲染函数执行时会访问模板中用到的计算属性从而触发计算属性的 getter。getter 内部读取响应式数据比如price.value这时当前正在运行的“副作用”effect会被记录为该响应式数据的依赖。当price.value被修改时响应式系统找到依赖列表通知它们重新执行。这个过程很像订阅模式计算属性“订阅”了它依赖的数据数据一变订阅者收到消息但消息只是“通知你可能过期了”不会强制立刻重算。真正重算的时机是有人读取这个计算属性时。还有一个细节值得注意模板渲染本身也是一个 effect。如果一个组件模板里访问了某个计算属性那么这个计算属性就会成为渲染 effect 的依赖。计算属性重算后渲染 effect 再被触发最终界面更新。这条链路是响应式数据 - 计算属性 - 组件渲染。理解这层关系后你就明白为什么计算属性适合做“中间层”了它把数据变化和视图更新解耦开业务逻辑里你只管修改源数据不用手动调用某个刷新函数。3.3 为什么“计算属性里不要写副作用”是一条铁律写副作用指的是修改其他响应式数据、调用接口、操作 DOM、写 localStorage 等行为。很多人知道这个规矩但不明白为什么。我讲个最直接的理由计算属性可能是惰性执行的也可能在意外时机执行它不保证只执行一次。由于缓存机制理论上一个计算属性依赖没变时只执行一次。但如果你在 getter 里调了一个接口一旦依赖发生变化并重新求值接口就会再次被调用。更有意思的是如果计算属性被多个组件使用每个组件渲染时访问它也可能会再次触发求值。把这些情况叠加起来你在 getter 里写的副作用可能执行无数次而你根本控制不了时机。我在一个项目里见过这样的代码计算属性里调了一个message.value 计算中结果每次重新计算都把用户的输入框内容覆盖成“计算中”页面看起来像抽风一样。这其实就是把副作用放进了不该放的地方。所以记住这条规矩计算属性的 getter 只做一件事——根据已有数据计算出新值不做任何“事情”。如果你确实需要在数据变化时执行一些操作那是 watch 的工作。4. 计算属性、methods、watch 三兄弟到底怎么分工4.1 同场景三方案对照购物车合计我拿一个经典场景来对比购物车合计金额。三个方案都能实现但心智负担完全不同。方法一computedconst cartItems ref([ { price: 30, count: 2 }, { price: 20, count: 1 } ]) const totalPrice computed(() { return cartItems.value.reduce((sum, item) sum item.price * item.count, 0) })方法二methodsfunction getTotalPrice() { return cartItems.value.reduce((sum, item) sum item.price * item.count, 0) }模板里用{{ getTotalPrice() }}。方法三watchconst totalPrice ref(0) watch(cartItems, (val) { totalPrice.value val.reduce((sum, item) sum item.price * item.count, 0) }, { deep: true })三种方案的差别不是“能不能实现”而是“什么时候更新、值从哪来”。computed 方案值是一个派生状态cartItems 一变totalPrice 自动更新模板读取时永远是当前数据的结果。methods 方案没有缓存每次组件更新都重新执行函数。如果 cartItems 很大这里的性能开销就有点冤了。watch 方案需要单独维护一个 totalPrice 响应式变量还要监听到cartItems且开启 deep一旦忘配了 deep 选项或监听路径写错总计就不动了。我的选型建议数据之间有关系、需要派生新值用 computed某个值变化后要执行“动作”比如调接口、跳路由、写缓存用 watch事件触发时才执行的逻辑比如点击、提交用 methods。这份分工用在真实项目里代码基本不会出现不知道该写哪里的情况。4.2 watch 的三大使用场景别再用错方向有的同学动不动就 watch 一个响应式对象然后在回调里做一堆计算最后发现界面不更新或计算延迟大概率是把 computed 和 watch 的角色弄混了。watch 真正擅长的场景是“数据变化后的额外处理”比如搜索框关键词变化后防抖调接口查询列表路由参数变化时重置页面状态某个筛选值变化后需要同时调整多个不相关组件里的数据。我看过一个反面案例某个页面需要根据用户选择的会员等级计算折扣开发者用 watch 监听等级然后手动更新一个折扣值变量。问题在于等级初始化时模板渲染依赖的折扣变量还是空的导致首帧显示错误。改成 computed 就完全没这个问题——computed 初始访问时就会基于当前数据求值不存在“还没反应过来”的空窗期。所以一个非常简单的判断标准你需要在“数据变化后”做额外动作用 watch你只是想要“基于数据变化的计算结果”用 computed。这两者的核心区别本质上一个是“效应”一个是“派生值”。4.3 组合使用computed 负责算watch 负责执行算清楚之后很多时候两者还要配合。我举个例子会员等级升级后除了要展示新的折扣价还要调用一次价格同步接口。折扣价用 computed 来算接口调用用 watch 来触发const memberLevel ref(gold) const discount computed(() { const map { gold: 0.8, silver: 0.9, normal: 1 } return map[memberLevel.value] }) watch(discount, (newVal, oldVal) { if (newVal ! oldVal) { syncPriceApi(newVal) } })这种“computed 负责结果watch 负责动作”的模式是我在项目里用得最多的组合之一。注意 watch 的回调里我做了新旧值比较避免不必要的接口请求。oldVal在计算属性内部发生变化时Vue 会把新旧值都传出来这比你自己手动记一个 previous 变量要省事得多。5. 进阶玩法与典型陷阱5.1 链式计算属性怎么设计才不乱链式设计的核心思路是把复杂的“数据整形”拆成多个小步骤每一步只解决一个问题。我用一个商品列表搜索的场景来说明const allProducts ref([...]) // 原始商品列表 const searchKeyword ref() // 搜索关键词 const selectedCategory ref(all) // 分类筛选 const keywordFiltered computed(() { const kw searchKeyword.value.trim().toLowerCase() if (!kw) return allProducts.value return allProducts.value.filter(item item.name.toLowerCase().includes(kw)) }) const categoryFiltered computed(() { if (selectedCategory.value all) return keywordFiltered.value return keywordFiltered.value.filter(item item.category selectedCategory.value) }) const displayedProducts computed(() { return categoryFiltered.value })代码读起来就像一段流程所有商品 - 关键词过滤 - 分类过滤 - 展示列表。每层都可以单独测试也可以随时在中间插入新逻辑。如果全部塞在一个计算属性里后期加排序、加价格区间时那段 reducer 一样的长逻辑会变得非常难以维护。我只是为了演示链式才写得稍长实际项目中你完全可以把两步合并成一个计算属性但如果你的过滤步骤超过三步或者中间结果需要在多处复用拆开绝对划算。这不仅是为了性能中间结果带缓存更是为了可读性和可持续维护。5.2 计算属性和异步操作为什么不能用 async有一个高频翻车点在计算属性里写async函数。很多人以为只要await一下就能返回最终数据结果发现拿到的值永远不是想要的那个。原因是computed 的 getter 要求同步返回一个值在 Promise 还没 resolve 的时候getter 已经执行完了。如果你写const result computed(async () { const data await fetchList() return data.filter(item item.active) })那么result其实是一个状态为 pending 的 Promise 被当做了响应式数据。模板里读取它时拿到的是一个 Promise 对象而不是过滤后的数组而且 Vue 的响应式系统无法追踪异步请求完成后的更新。遇到异步数据正确解法是先用普通 ref/reactive 定义容器再通过 watch 或者事件触发异步更新。比如搜索接口的常见模式const keyword ref() const searchResult ref([]) watch(keyword, async (val) { if (!val) { searchResult.value [] return } const res await fetchSearchApi(val) searchResult.value res.data }, { immediate: true })这里的searchResult就是一个普通的响应式状态由 watch 负责异步填充而不是用 computed 硬扛。5.3 计算属性依赖 props 时的注意点在组件开发中经常会用计算属性去“加工”父组件传进来的 props。这完全没问题但有三个细节容易踩坑第一不要在计算属性里直接修改 props。props 是单向数据流你改它不会让父组件数据跟着变甚至会在控制台报警告。正确做法是基于 props 计算出本地需要的形态然后用emit通知父组件修改源数据。第二props 里的对象或数组默认是浅层响应式的。如果父组件把数组传给子组件子组件内部计算属性依赖数组项的属性那么数组项内部的属性变化是可以被追踪到的。但如果父组件整体替换了这个数组计算属性同样会重新计算。第三当计算属性拿 props 里的引用类型作为返回值时模板里拿到的也是一个引用。有人以为这是“复制了一份随便改”实际上它和源数据指向同一个地址。我见过一个 bug子组件通过计算属性拿到父组件的对象后给对象 add 了一个属性结果父组件的数据也被污染了。所以如果你需要“原数组 加工”的副本一定要用.map、filter等方法返回一个新数组或者展开对象。5.4 常见报错自查清单模板里用计算属性不带.valueVue3 模板会自动解包 ref这是正常的不用担心。在 JS 逻辑里忘了.value那就拿不到真实值只会拿到一个 ref 对象。计算属性里访问不存在的响应式属性如果源数据来自 reactive 且你登记了一个动态添加的属性可能不会触发更新。这是 Vue3 响应式的“新增属性陷阱”最好提前在 reactive 里声明默认值或者用 Map。依赖循环、无限递归比如 computed A 改了 B 依赖的数据B 又反过来影响 A就会导致循环调用浏览器卡死。出现这种问题先看是不是计算属性里写了赋值逻辑。v-model 绑定 computed 但没有写 setter控制台会提示 “Write operation failed: computed value is readonly”。加一个 setter 即可或者改用 writable 的 ref。这些坑我在后面第六部分会给一张速查表这里先做到心里有数。6. 面试自查与我的实操习惯6.1 高频面试题computed 与 watch 的区别怎么答这道题几乎是 Vue3 面试的保留节目很多人背了答案但还是答不到点子上。我给一个能体现水平的回答思路先说共同点两者都能响应数据变化。再说核心差异computed 是派生值有缓存依赖不变就不会重新计算适合模板或业务里的计算逻辑watch 是副作用监听数据变化时执行回调适合做异步请求、手动操作 DOM 等动作。然后补充几个常人想不到的深度点computed 底层是惰性求值加 dirty 标记watch 本身不做缓存每次监听值变化都会执行回调computed 返回的是只读 refwatch 监听的是响应式源本身watch 支持deep、immediate、flush配置项而 computed 没有源码中 watch 相当于doWatch而 computed 基于 effect 和 Ref 实现。最后给一个落地建议能用 computed 表达就先不要动用 watchwatch 用得越多代码里“手动刷新”逻辑就越多状态管理越容易乱。6.2 一份计算属性排查操作清单我在工作中封装过一个自定义调试方法用于快速定位计算属性是不是重算了。本质是在 getter 里加一行 console但在排查问题后再移除const total computed(() { console.log(total recalculated) return price.value * count.value })如果发现模板更新但 console 没打印说明计算属性根本没重新执行问题出在依赖没有正确追踪。可能是依赖了非响应式数据、依赖了数组的 index但没访问 length、或者用了对象新增属性却没有提前声明。列一个排查清单按顺序走基本能定位 80% 的问题检查计算属性依赖的数据是不是响应式ref / reactive / computed / props。检查在计算属性里是否误用了普通变量、第三方库的类实例或全局状态。检查是否有同名变量覆盖了响应式数据。检查模板里访问的字段名和计算属性返回对象的属性名是否一致。检查父子组件中 props 传递的引用类型是否被意外修改。检查是否有多个组件共用一个模块级响应式对象导致计算顺序错乱。这套清单陪我解决过不少“界面不更新”的疑难杂症建议截图收着。6.3 我在项目里的几条个人守则守则一凡是模板里有超过一段的表达式逻辑一律提取成计算属性。这不是什么硬性性能要求而是让代码看起来像个人写的。守则二计算属性绝不发请求、不写 localStorage、不操作 DOM。违反这条的代码我基本都会拉出来开会。守则三多个组件共享的复杂派生逻辑抽成独立的 composable 函数。比如一个统一的折扣计算函数封装好之后所有组件复用比各自写计算属性强得多。// composables/useDiscount.js function useDiscount(basePrice, memberLevel, coupon) { const price computed(() { const levelRate { gold: 0.8, silver: 0.9, normal: 1 }[memberLevel.value] ?? 1 const discounted basePrice.value * levelRate return Math.max(0, discounted - (coupon.value ?? 0)) }) return { price } }守则四计算属性不一定只服务模板。在 Pinia 的 getter 里、路由守卫的业务判断里、表单校验逻辑里它同样适用。本质上computed 是“响应式派生数据”的统一抽象不局限于某个 UI 层。守则五调试时多用 computed 的可读性优势。给计算属性起一个见名知意的名字比如filteredOrders、payAmount、isFormValid。我见过有人命名成getData、xxx_cs这种回头自己都看不懂。命名就是隐形的文档这条在任何项目里都值钱。写到这里关于 Vue3计算属性从用法到原理、从选型到踩坑的内容就差不多齐了。张天禹这套《Vue3魔法手册》有一个特点就是喜欢把每个知识点放在真实开发场景里讲不空谈语法。后续如果你在看手册的过程中遇到某个技巧在项目里跑不通或者想深入聊聊响应式系统底层的实现可以按里面的例子复现一下再回来看这篇——每次复现都会对缓存、依赖追踪这些概念有更直观的理解。
返回列表