
Vue 3 的 computed 几乎是每个项目都绕不开的 API但我发现很多同学对它的理解停留在用来计算属性这一个层面上。真要碰到缓存不更新、死循环、类型报错这类问题往往就卡住了。这篇文章我从 computed 的底层原理讲起再配合实际项目里最常见的用法和踩坑记录争取让你看完之后不仅会用还能知道它为什么这么设计遇到问题能自己定位。这个内容适合所有正在用 Vue 3 写项目的开发者不管你是刚入门还是已经写了半年一年我相信里面都会有你能用上的东西。特别是那些被 computed 报错折磨过的同学第五部分的排查实录可以直接当速查表用。1. computed 的核心原理它到底凭什么能缓存1.1 懒计算与缓存computed 区别于 method 的根本很多人刚接触 Vue 3 的时候会有个疑问computed 能做的事我写个函数在模板里调用也能做为什么非要用 computedtemplate p{{ fullName }}/p p{{ getFullName() }}/p /template script setup import { ref, computed } from vue const firstName ref(张) const lastName ref(三) const fullName computed(() { console.log(computed 执行了) return firstName.value lastName.value }) function getFullName() { console.log(method 执行了) return firstName.value lastName.value } /script这个例子表面上看结果一样但打开控制台你会发现执行次数完全不同。computed 只在第一次访问 fullName 时执行一次之后即使模板重新渲染只要 firstName 和 lastName 没变就不会重新执行。而 getFullName 只要组件重新渲染就会重新调用一次。这就是 computed 的核心设计懒计算加缓存。懒计算指的是它不会在定义的时候立刻执行而是等到真正被访问时才计算。缓存指的是计算一次之后会记住结果只有依赖的响应式数据发生变化才会在下次访问时重新计算。这个设计的价值在模板频繁渲染的场景下体现得非常明显。特别是计算逻辑比较重的情况比如遍历一个几百条数据的数组做筛选和汇总用 method 每次渲染都要算一遍用 computed 算一次就能复用性能差距是肉眼可见的。1.2 依赖收集computed 怎么知道哪些数据变化了要重算要理解 computed 的缓存机制必须理解 Vue 3 的依赖收集系统。Vue 3 的响应式核心是基于 Proxy 的当你在 computed 的 getter 里访问一个响应式数据时这个数据就会把当前 computed 的 effect 记录到自己的依赖列表里。我拿一个具体场景拆解一下。假设有这样一个 computedconst price ref(100) const count ref(2) const total computed(() price.value * count.value)这段代码执行的时候Vue 内部做了这些事情创建 computed 的时候会创建一个 effect副作用函数但不会立刻执行 getter。第一次访问 total 时开始执行price.value * count.value。访问 price.value 时price 这个响应式对象的依赖列表里会加入当前这个 effect。访问 count.value 时count 的依赖列表里也会加入同一个 effect。计算完成把结果 200 缓存起来。然后当你修改 price.value 为 150 时price 的依赖列表里找到了这个 effect把它标记为需要重新计算。但这时候并不会立刻执行 getter而是等下次访问 total 时才重新计算。这里有个很关键的细节computed 的 effect 是惰性的它不像 watch 那样数据一变就去执行回调而是只做个标记。真正触发重新计算的是访问这个动作。这种设计省掉了很多无谓的计算是 computed 性能优势的根本来源。1.3 缓存失效与脏值标记Vue 内部是怎么维护缓存的Vue 3 的 computed 内部维护了一个_dirty标志位用来标记缓存是否有效。这个标志位的状态变化是这样的第一次访问 computed 时_dirty为 true执行 getter 并缓存结果把_dirty设为 false。依赖的数据变化时只把_dirty设为 true不重新计算。再次访问时看到_dirty为 true知道缓存失效了重新执行 getter更新缓存再把_dirty设为 false。这个机制和 Vue 2 的 computed 一脉相承但 Vue 3 用 Proxy 重写了响应式系统之后依赖收集的粒度更细触发更新的路径也更清晰。理解了这个机制之后很多奇怪的问题就都能解释了。比如为什么 computed 里如果依赖了非响应式数据数据变了 computed 却不更新——因为非响应式数据根本不会触发依赖收集computed 的 effect 压根没有被注册进去。再比如为什么 computed 的 getter 里修改了响应式数据会报错——因为 getter 执行的过程中又触发了依赖数据的更新导致依赖列表被反复修改Vue 检测到这个循环后会主动抛出警告防止死循环把页面搞崩。2. 实战场景拆解computed 到底该用在哪2.1 基础派生数据从单一源头计算展示信息computed 最简单的使用场景就是从已有的响应式数据派生出展示用的数据。这类场景通常不会太复杂但也有些小细节值得注意。比如根据用户输入的关键词过滤列表template input v-modelkeyword placeholder输入关键词搜索 / ul li v-foritem in filteredList :keyitem.id{{ item.name }}/li /ul /template script setup import { ref, computed } from vue const keyword ref() const list ref([ { id: 1, name: Vue.js }, { id: 2, name: React }, { id: 3, name: Angular } ]) const filteredList computed(() { const kw keyword.value.trim().toLowerCase() if (!kw) return list.value return list.value.filter(item item.name.toLowerCase().includes(kw)) }) /script这个场景里有个容易忽略的点computed 的 getter 里如果直接return list.value返回的是同一个数组引用。如果后续有人直接往这个数组里 push 内容就会同时影响 list 和 filteredList。这在大部分情况下是符合预期的因为本来就是一个数据源。但如果你不希望 computed 的结果被外部修改可以考虑浅拷贝一层。还有一个常见做法是读取计算、写的同时修改源数据也就是可写 computed这个下一节细说。2.2 可写 computedgetter 与 setter 配合实现双向绑定computed 默认是只读的但 Vue 也提供了可写 computed通过传入一个包含 get 和 set 的对象来实现。const firstName ref(张) const lastName ref(三) const fullName computed({ get() { return firstName.value lastName.value }, set(value) { const parts value.split( ) firstName.value parts[0] lastName.value parts[1] || } })这时候模板里用v-modelfullName就是合法的。用户输入新值的时候会走 set 方法把值拆开写回源数据。这种写法在封装表单组件的时候特别有用。比如封装一个价格输入框组件内部维护一个分单位的数字但对外暴露的是元单位的字符串const priceInCents ref(1990) const priceText computed({ get() { return (priceInCents.value / 100).toFixed(2) }, set(text) { const value parseFloat(text) if (!isNaN(value)) { priceInCents.value Math.round(value * 100) } } })这种模式的核心思想是一个数据源多个视图形态。computed 的 setter 负责把视图形态的值转回数据源的真实格式。但我要提醒一点set 方法里一定要对入参做校验。因为 v-model 传来的值是用户直接输入的格式合法性完全没有保证。漏了这一步轻则数据变 NaN重则引发连串的派生错误。2.3 复杂联动场景多数据源组合与常见数据转换当页面里有多个交互状态相互影响时computed 的优势就非常突出了。比如购物车结算的场景选择商品、修改数量、勾选优惠券最终价格是多个状态的组合结果。const cartItems ref([ { id: 1, name: T恤, price: 99, count: 1, selected: true }, { id: 2, name: 牛仔裤, price: 199, count: 2, selected: false } ]) const coupon ref({ type: percent, value: 0.9 }) // 9折券 const selectedItems computed(() cartItems.value.filter(item item.selected)) const totalPrice computed(() { return selectedItems.value.reduce((sum, item) sum item.price * item.count, 0) }) const finalPrice computed(() { const total totalPrice.value if (coupon.value.type percent) { return Math.round(total * coupon.value.value * 100) / 100 } return total })这里的关键点是computed 是可以链式依赖的。finalPrice 依赖 totalPricetotalPrice 依赖 selectedItemsselectedItems 又依赖 cartItems。Vue 会帮我们维护好这整条依赖链。修改任意一个源数据最终结果都会自动更新。这种链式写法我建议拆得细一点不要什么都写在一个 computed 里。一方面每个 computed 职责单一出问题好排查另一方面中间的每一步都是带缓存的结果可以在多个地方复用。比如有另一个组件要显示已选商品数量直接用selectedItems.value.length就行完全不增加计算成本。2.4 computed 返回函数的特殊用法真正的可复用逻辑有时候我在项目里会看到这种写法const filterFn computed(() { return (item) item.active })看着有点绕但它在某些场景下确实好用。比如当你需要在 v-for 的循环里对每个元素做不同条件的判断而且判断条件本身依赖响应式数据时const roleWeight computed(() { const weightMap { admin: 100, user: 10, guest: 1 } return (role) weightMap[role] || 0 })这种用法本质上是用 computeds 返回一个闭包函数。好处是闭包函数里依赖的数据变化时函数会被重新生成。但说实话我平时很少这么写因为完全可以换个思路用普通函数读取 computed 的值。const weightMap computed(() ({ admin: 100, user: 10, guest: 1 })) function getWeight(role) { return weightMap.value[role] || 0 }效果一样还更容易理解。computed 返回函数这种写法更适合传给子组件 props或者作为 emitter 事件回调传递的场景。能用普通函数替代的话优先用普通函数代码可读性会好不少。3. 工具链配置Volar 环境下 computed 的类型与报错3.1 Volar 的 Vue 3 配置别再让 TS 对 computed 报错Vue 3 项目里官方推荐的 IDE 工具是 VolarVue Language Features它取代了 Vue 2 时代推荐的 Vetur。很多社区里说的 in the vue 3 project, the vue language features (volar) is new recommended 就是这句话。但很多同学装完 Volar 之后发现 computed 在 TypeScript 文件里疯狂报错其实大概率是配置问题。Volar 有两个模式Vue 2 模式和 Vue 3 模式。安装之后需要手动启用 Vue 3 支持否则有些 Vue 3 特有的 API 会提示类型不匹配。最简单的确认方法VSCode 底部状态栏找到 Vue 的图标点击后能看到当前启用的模式。如果你项目里用的是script setup语法确保装的是最新版 Volar旧版本对script setup的支持不够完善computed 的类型推断偶尔会出错。另外建议把Takeover Mode接管模式打开它能让 Volar 接管原本由 VSCode 内置 JS/TS 语言服务处理的文件类型检查的效率会高很多。我之前有一次遇到很诡异的情况同一个项目的 computed 在同事电脑上类型正常在我电脑上就红波浪线。排查到最后发现是 Volar 和 Vetur 同时启用了两个插件的类型服务打架。这种问题把 Vetur 禁用掉就解决了。3.2 computed 的 TypeScript 类型推断与显式类型标注Vue 3 的 computed 类型推断做得相当好正常情况下你不需要手写类型。比如const total computed(() price.value * count.value)total 自动推断为ComputedRefnumber在模板里可以直接当 number 用。但有些场景需要手动标注类型。比如 computed 是从多个可能为 null 的数据中计算的const user ref{ name: string; age: number } | null(null) const displayName computedstring(() { return user.value ? user.value.name : 未登录 })这里标注string的作用是约束返回值必须是 string。如果不标注TypeScript 也能从三目表达式推断出 string 类型所以标不标其实影响不大。真正需要显式类型的场景是定义可写 computed 的 setter 参数类型const count ref(0) const double computednumber({ get: () count.value * 2, set: (val: number) { count.value val / 2 } })另一种值得注意的情况是 computed 返回联合类型但后续逻辑只处理其中一种类型。这时候最好在 computed 内部就把类型收窄而不是在外部到处做类型断言。const status refloading | success | error(loading) const statusText computed(() { switch (status.value) { case loading: return 加载中 case success: return 完成 case error: return 出错 } })Volar 会自动给模板里的 statusText 标注完整的联合类型模板里做条件判断的时候就能触发类型收窄省掉很多不必要的运行时判断。3.3 利用 Vue 3 Snippets 提升书写效率VSCode 里装了 Vue 3 Snippets 插件之后可以直接输入computed触发代码片段补全自动生成带 import 和 return 的完整写法const myComputed computed(() { return something })用 snippets 补全的好处不只是省几行代码更关键的是能避免一些低级错误。比如有些版本的插件生成的ref片段会自动带上.value的访问方式直接模仿的话容易养成模板里访问.value的坏习惯。模板里不用写.value这个必须形成肌肉记忆。我在团队里的规范是script setup中定义变量统一用 snippets模板里取值全部不带.value。这样从源头减少computed.value误用的概率。4. 常见问题与排查技巧实录4.1 computed 报错速查表下面这个表格是我整理的最常见的几种 computed 报错或异常基本覆盖了我在团队里被问到的大多数问题。症状原因解决方案模板渲染正常但控制台报Cannot read properties of undefinedcomputed 返回值可能为 undefined模板里继续访问了深层属性computed 内部做空值兜底或用v-if判断再渲染computed 依赖的数据变了页面不更新getter 里访问的是非响应式数据或者对普通变量做了操作确认所有依赖的数据都用 ref/reactive 包裹computed 对象被直接渲染成[object Object]在模板里把 ComputedRef 对象本身当成了数据模板中访问 computed 不需要.value但检查是不是错误地多层引用了一个对象Write operation failed: computed value is readonly尝试直接给只读 computed 赋值改用可写 computed或改成 refMaximum recursive updates exceededcomputed 的 getter 里修改了这个 computed 依赖的数据拆分数据源不要在 getter 里写副作用类型报红computed is not defined用了script setup但没从 vue 导入顶部补上import { computed } from vue4.2 死循环问题getter 里写副作用的严重后果computed 的 getter 应该是一个纯粹的函数只依赖响应式数据并返回结果。如果你在 getter 里修改了响应式数据就可能在依赖链上插入一个循环。举个真实例子我的一个同事写过这样的代码const items ref([1, 2, 3]) const total computed(() { if (items.value.length 10) { items.value.shift() // 修改了 items } return items.value.reduce((a, b) a b, 0) })这个 computed 第一次访问的时候如果 items 长度超过 10它会shift()掉一个元素。这个修改会触发 items 的更新把 total 对应的 effect 标记为脏。下次访问 total 时发现脏了又执行 getter又 shift……一直循环下去Vue 会抛Maximum recursive updates exceeded警告。解决方案很简单把副作用移出去用 watch 监听并处理数据修正用 computed 只做纯计算。如果不小心写出了一个需要边算边改的逻辑先停下来想想绝大部分情况下是设计思路有问题。4.3 不更新问题依赖了非响应式数据的典型场景另一个高频问题computed 执行了但后续数据修改后页面不更新。最常见的原因是依赖的数据不是响应式的。const list [1, 2, 3] // 普通数组不是 ref 也不是 reactive const total computed(() list.reduce((a, b) a b, 0)) function handleClick() { list.push(4) // 页面不会更新 }这个问题最容易出现在从后端接口拿到数据后直接赋值给普通变量然后丢给 computed 用。或者是在 reactive 对象上动态新增属性忘了用set方法。Vue 3 的 reactive 用的是 Proxy动态新增属性本身就是响应式的这点比 Vue 2 好。但如果你一开始就把数据存在普通变量里或者从reactive对象里解构出了基本类型再赋值给普通变量响应式就丢了。const state reactive({ count: 0 }) const count state.count // 丢响应式 const computedValue computed(() count * 2) // 永远不会更新这种问题不太好排查因为编译期不会报错代码看着也正常。我的经验是排查这类问题时第一步先确认 computed 的 getter 里访问的每个值从根源上是不是 ref/reactive。在项目里也可以用 eslint-plugin-vue 里的规则来辅助它会检查 computed 依赖的表达式及时警告可能不是响应式的情况实际效果不错。4.4 异步操作别放在 computed 里还有一个高频踩坑点我把它单独拿出来说computed 的 getter 里做异步操作返回值永远是 Promise最终渲染出来的数据不会按预期工作。const data ref([]) const processedData computed(async () { const res await fetch(/api/data) return await res.json() })这段代码的问题在于 computed 本来是要同步返回计算结果的你现在返回了一个 Promise 对象模板里访问的时候会得到[object Promise]根本拿不到真实数据而且 Promise 状态变化也不会触发 computed 重新计算。处理异步数据流的正确方式是把异步获取的数据存到 ref 里computed 只负责基于这些同步数据做计算。const rawData ref([]) async function fetchData() { const res await fetch(/api/data) rawData.value await res.json() } const processedData computed(() { return rawData.value.filter(item item.active) })这样 fetchData 负责拉数据和更新响应式数据computed 负责派生数据职责清晰逻辑也稳定。你在实际项目中如果在 computed 里写了 promise 相关的代码建议立刻回想一下是不是搞混了。5. 性能优化与避坑经验分享5.1 computed 与 watch 的取舍各司其职很多同学在某个响应式数据变化后要执行一系列逻辑时会纠结到底用 computed 还是 watch。我的判断标准很简单如果变化之后的目标是产生一个新的展示值用 computed。如果变化之后的目标是执行一系列操作比如调用接口、设置另一个状态、操作 DOM用 watch。如果变化之后要先进行异步操作再派生出显示值那流程上用 watch 加一个 ref而不是把异步逻辑塞进 computed。这里有个反例我见过有人这样处理输入防抖的const keyword ref() const debouncedKeyword computed(() { // 借助外部变量强行模拟防抖 return keyword.value })这就是用 computed 做本不该做的事情。防抖的核心是延迟和定时器本身是副作用应该用 watch 处理。5.2 计算链越长越要小心computed 链式依赖的维护成本computed 链式依赖虽然好写但链越长排查问题的成本就越高。比如 A 依赖 BB 依赖 CC 又依赖 D如果 D 的某个字段类型变了可能要到 F 层才会体现出异常中间环节全靠人肉推导很难快速定位。我的建议是控制 computed 链的长度在 2 到 3 层以内。如果发现链太长就考虑中间加一个 watch 或者用 store比如 Pinia中转。成熟的团队项目里复杂数据流的优先级是多数据组合 多行链式 computed因为前者可以通过拆分函数和单元测试来处理后者容易变成黑盒。当然这不代表链式计算不能写只是提醒你不要把整条链都堆在一个文件里并且尽量给每个中间结果起一个清晰的名字。数据流可读性和可维护性在这个场景下比炫技重要得多。5.3 性能优化什么时候需要手动控制 re-rendercomputed 的设计初衷是帮你避免不必要的计算但它不是万能的。有一个容易被忽略的场景当 computed 依赖的响应式数据是大型对象时依赖收集会非常细。比如依赖一个 1000 条数据的数组里的某一个字段这时候任何一条数据变化都会触发 computed 重新计算。const rows ref( Array.from({ length: 1000 }, (_, i) ({ id: i, selected: false })) ) const selectedCount computed(() rows.value.filter(row row.selected).length)rows.value[3].selected true的时候selectedCount 会重新计算。因为依赖的是整条.value链Vue 的依赖收集粒度是响应式对象属性的读取数组上的 length 和任意元素的属性更新都会触发重新计算。这种场景如果计算量大可以考虑两个方向把是否选中这个状态抽成独立的 ref 数组computed 只依赖selectedIds而不是遍历大数组。如果遍历不可避免把计算逻辑拆分到更小的 computed 里减少单次重算的遍历范围。5.4 computed 的源码视角effect 与 scheduler 的组合最后从源码角度简单串一下 computed 的实现这能帮你理解为什么会有上面这些坑。Vue 3 的 computed 内部创建了一个ComputedRefImpl实例它本质上是ref的特化版本有value属性同时挂着一个_dirty标志以及一个约束了执行时机的 effect。// 源码简化示意不是完整源码 class ComputedRefImpl { _value _dirty true dep effect constructor(getter) { this.effect new ReactiveEffect(getter, () { if (!this._dirty) { this._dirty true triggerRefValue(this) } }) } get value() { trackRefValue(this) if (this._dirty) { this._dirty false this._value this.effect.run() } return this._value } }这里的ReactiveEffect的第二个参数叫做 scheduler它不会立刻执行 getter而是把_dirty置为 true并触发依赖了这个 computed 的地方比如组件的 render effect重新执行。下一次访问 value 时才真正计算新值。这个设计也解释了为什么 computed 在模板里能生效模板的 render effect 在渲染时会读取 computed.value就注册到了 computed 的依赖列表里。computed 发生变化时通过 triggerRefValue 触发 render effect 重新渲染于是页面就更新了。理解了这一点你对 computed 的信任感会强很多它不是魔法而是一套严谨的响应式调度机制。写在最后关于 computed 我的一点体会我用 Vue 3 computed 写了不少项目从简单的搜索过滤到复杂的多级联动都有涉猎。最大的体会是computed 看着简单真正用好需要把它当成一个纯函数来对待——输入是响应式数据输出是派生值不在这条链上加任何副作用。另外我在团队里经常强调一点代码评审的时候凡是看到 computed 的 getter 里出现push、shift、fetch、console.log、setTimeout这类有副作用的代码一律打回重写。不是说这些代码一定会出问题而是它违背了 computed 的设计本意未来维护成本会越来越高。最后分享一个小的实用技巧你在调试 computed 的时候可以在 getter 里临时加一行console.log配合 Vue DevTools 的 timeline 面板看更新触发时机。遇到为什么没重新计算之类的问题这一步往往比翻源码更快定位原因。排查完记得把调试代码删掉就行别让我刚才说的纯函数洁癖变成flag。