Vue 3 的 computed 几乎是每个项目都绕不开的 API,但我发现很多同学对它的理解停留在"用来计算属性"这一个层面上。真要碰到缓存不更新、死循环、类型报错这类问题,往往就卡住了。这篇文章我从 computed 的底层原理讲起,再配合实际项目里最常见的用法和踩坑记录,争取让你看完之后不仅会用,还能知道它为什么这么设计,遇到问题能自己定位。
这个内容适合所有正在用 Vue 3 写项目的开发者,不管你是刚入门还是已经写了半年一年,我相信里面都会有你能用上的东西。特别是那些被 computed 报错折磨过的同学,第五部分的排查实录可以直接当速查表用。
1. computed 的核心原理:它到底凭什么能缓存
1.1 懒计算与缓存:computed 区别于 method 的根本
很多人刚接触 Vue 3 的时候会有个疑问:computed 能做的事,我写个函数在模板里调用也能做,为什么非要用 computed?
<template> <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 记录到自己的依赖列表里。
我拿一个具体场景拆解一下。假设有这样一个 computed:
const 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-model="keyword" placeholder="输入关键词搜索" /> <ul> <li v-for="item in filteredList" :key="item.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 可写 computed:getter 与 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-model="fullName"就是合法的。用户输入新值的时候,会走 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 依赖 totalPrice,totalPrice 依赖 selectedItems,selectedItems 又依赖 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 工具是 Volar(Vue 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 自动推断为ComputedRef<number>,在模板里可以直接当 number 用。
但有些场景需要手动标注类型。比如 computed 是从多个可能为 null 的数据中计算的:
const user = ref<{ name: string; age: number } | null>(null) const displayName = computed<string>(() => { return user.value ? user.value.name : '未登录' })这里标注<string>的作用是约束返回值必须是 string。如果不标注,TypeScript 也能从三目表达式推断出 string 类型,所以标不标其实影响不大。真正需要显式类型的场景是定义可写 computed 的 setter 参数类型:
const count = ref(0) const double = computed<number>({ get: () => count.value * 2, set: (val: number) => { count.value = val / 2 } })另一种值得注意的情况是 computed 返回联合类型,但后续逻辑只处理其中一种类型。这时候最好在 computed 内部就把类型收窄,而不是在外部到处做类型断言。
const status = ref<'loading' | '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 undefined | computed 返回值可能为 undefined,模板里继续访问了深层属性 | computed 内部做空值兜底,或用v-if判断再渲染 |
| computed 依赖的数据变了,页面不更新 | getter 里访问的是非响应式数据,或者对普通变量做了操作 | 确认所有依赖的数据都用 ref/reactive 包裹 |
computed 对象被直接渲染成[object Object] | 在模板里把 ComputedRef 对象本身当成了数据 | 模板中访问 computed 不需要.value,但检查是不是错误地多层引用了一个对象 |
Write operation failed: computed value is readonly | 尝试直接给只读 computed 赋值 | 改用可写 computed,或改成 ref |
Maximum recursive updates exceeded | computed 的 getter 里修改了这个 computed 依赖的数据 | 拆分数据源,不要在 getter 里写副作用 |
类型报红:computed is not defined | 用了<script setup>但没从 vue 导入 | 顶部补上import { computed } from 'vue' |
4.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 依赖 B,B 依赖 C,C 又依赖 D,如果 D 的某个字段类型变了,可能要到 F 层才会体现出异常,中间环节全靠人肉推导,很难快速定位。
我的建议是控制 computed 链的长度在 2 到 3 层以内。如果发现链太长,就考虑中间加一个 watch 或者用 store(比如 Pinia)中转。成熟的团队项目里,复杂数据流的优先级是:多数据组合 > 多行链式 computed,因为前者可以通过拆分函数和单元测试来处理,后者容易变成黑盒。
当然,这不代表链式计算不能写,只是提醒你不要把整条链都堆在一个文件里,并且尽量给每个中间结果起一个清晰的名字。数据流可读性和可维护性在这个场景下比炫技重要得多。
5.3 性能优化:什么时候需要手动控制 re-render
computed 的设计初衷是帮你避免不必要的计算,但它不是万能的。有一个容易被忽略的场景:当 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。