☰
深入Vue computed:响应式原理、缓存机制与常见陷阱
2026/10/13 8:41:56 网站建设 项目流程

用过 Vue 的人基本都写过 computed,但说实话,大多数人把它当成一个"模板里写着麻烦所以抽出来"的工具。真正常见的困惑是:为什么 computed 里的数据变了,页面会自动更新?为什么它不会像方法一样每次渲染都重新执行?为什么有时候 computed 拿到的是"旧值"?这些问题的答案,藏在那条从数据定义到视图渲染的完整响应式链路里。这篇文章打算把这套链路掰开讲一遍,适合对 Vue 有基本使用经验、想搞懂内部机制而不只是会调 API 的开发者,也适合那些被 computed 缓存诡异行为坑过、想彻底排查清楚的人。

1. 为什么需要 Computed:模板表达式撑不住的场景

1.1 模板里写逻辑的痛

先从一个最直白的场景说起。假设有个订单列表,需要展示"已支付且金额大于 100 的订单数量",很多人一开始会这么写:

<template> <p>{{ orders.filter(o => o.paid).filter(o => o.amount > 100).length }}</p> </template>

这段代码在语法上没问题,甚至可以正常工作。但问题在于它把业务规则直接塞进了模板。第一次这么写还行,等到规则变成"已支付、金额大于 100、且不是测试订单",模板会变得越来越长,可读性直线下降。更麻烦的是,这种表达式在每次渲染时都会执行一遍完整计算,没有缓存,如果 orders 是一个几百上千项的数组,又恰好发生在列表频繁更新的页面上,性能开销是真实存在的。

我见过一些项目里模板表达式复杂到需要拉滚动条才能看完,后面维护的人根本不敢动,因为你不知道改这一行会影响多少地方。把计算逻辑抽出来,起一个语义化的名字,是最低成本的维护策略。这也是 computed 存在的第一个理由:让模板保持干净,让计算逻辑可以被复用、被测试。

1.2 方法调用与 Computed 的本质区别

有人会想:"那我用方法不也一样吗?"

<template> <p>{{ getPaidOrderCount() }}</p> </template>
function getPaidOrderCount() { return orders.value.filter(o => o.paid && o.amount > 100).length; }

表面上看确实能达到"模板变干净"的效果,但二者的差异在性能上非常关键。方法调用是"每次渲染都执行"的,哪怕这次渲染和上一次相比没有任何相关数据变化,只要组件重新渲染了,方法就会重新计算一遍。而 computed 是"基于依赖缓存"的,只有它的响应式依赖发生变化时,才会重新计算。

想象一个场景:组件里有一个跟订单完全无关的搜索框,用户每敲一个字符,组件都会重新渲染。如果订单统计用的是方法,那么搜索框每敲一下,订单过滤逻辑就要白跑一遍;如果用的是 computed,Vue 会意识到订单统计的依赖没有变,直接复用上次的计算结果。这个区别在数据量大、渲染频繁的页面里会被放大得很明显。所以选择依据很简单:模板里需要"根据响应式数据派生出来"的值,就用 computed;需要绑定事件或者主动触发的行为,才用方法。

1.3 Computed 与 Watch 的分工

还有一个经常被搞混的边界是 computed 和 watch。从响应式系统的角度看,watch 的定位是"侦测变化然后执行副作用",比如数据变了之后发请求、写本地缓存、操作 DOM。computed 的定位是"根据现有响应式数据计算出新值",这个新值本身要能被模板或其他逻辑消费。

很多刚接触 Vue 的人会写出这样的代码:监听一个数据的变化,然后在回调里手动给另一个数据赋值。这本质上是把"派生状态"用副作用的方式去维护,不仅容易漏掉初始状态的同步,还会因为赋值时机的问题导致额外渲染。正确做法是直接用 computed 表达这种派生关系,让数据流保持单向、可预测。

反过来,如果你在 computed 里发请求或者修改其他状态,那就是在把纯计算的地方变成副作用发生地,后面会单开一节说这个问题。先把边界记住:computed 负责派生值,watch 负责响应变化后的行为。

2. Computed 的定义方式与核心特性

2.1 选项式与组合式:两种写法,一条链路

先看两种最常见的定义方式。选项式 API 里这样写:

export default { data() { return { price: 100, quantity: 3 }; }, computed: { total() { return this.price * this.quantity; } } };

组合式 API 里这样写:

import { ref, computed } from 'vue'; const price = ref(100); const quantity = ref(3); const total = computed(() => price.value * quantity.value);

两者最终都生成一个"惰性求值、依赖追踪、带缓存"的计算属性对象。选项式写法本质上就是把多个 computed 定义收集起来,然后逐个转成组合式内部的 computed 实例。所以理解了组合式里的 computed 是怎么运作的,就理解了选项式。这也是我建议新手优先用组合式理解原因:它更接近 Vue 响应式系统的真实模型。

注意一个细节:computed(() => {...})返回的是一个 ref 对象,访问值必须用.value。在模板中,Vue 会自动解包,所以可以直接写{{ total }},但在 composition 函数或普通 JavaScript 代码里,千万别漏了.value,这是个极其常见的报错来源。

2.2 缓存机制:computed 的"计算一次、复用多次"

缓存是 computed 区别于普通方法的核心特性。它的运作方式可以概括为:computed 内部维护了一个标记,依赖没变就用旧值,依赖变了才重新算。

用刚才的例子展开。total依赖price和quantity两个 ref。第一次访问total.value时,Vue 执行 getter 计算出300,并把结果缓存起来。之后同一轮渲染或同一段逻辑里再次访问total.value,只要price和quantity都没变,就直接返回300,getter 不会再执行。

等到某个时刻price.value被改成200,Vue 的响应式系统会通知所有依赖了price的"订阅者"。computed 在里面做了一件很聪明的事:它把自己标记为"需要重新计算",但并不会立刻算。只有当下一次有人访问total.value时,它才会重新执行 getter,得到新的600。这就是"惰性求值"——能拖到最后一刻才做的工作,绝不提前做。这个设计对性能很友好,因为一次数据变更引发的渲染过程中,一个 computed 可能被多个地方读取,缓存保证了它只计算一次。

2.3 Setter 与可写 Computed:默认别用

computed 默认是只读的,但 Vue 也允许你传一个包含get和set的对象,把它变成可写的:

const firstName = ref('张'); const lastName = ref('三'); const fullName = computed({ get() { return firstName.value + ' ' + lastName.value; }, set(newValue) { const parts = newValue.split(' '); firstName.value = parts[0]; lastName.value = parts[1]; } });

这种写法在封装表单双向绑定时很顺手,比如一个日期范围选择器绑定一个组合字符串。但我的经验是:可写 computed 是"风险开关",默认别开。因为 setter 本质上是在计算属性的赋值入口处注入了副作用,它很容易掩盖数据流的真实走向。如果你发现 setter 里要做的逻辑很复杂,比如要判断、要联动其他字段,那大概率应该把这个状态拆成显式的普通数据加方法,而不是硬塞进 computed 里。

3. 响应式依赖收集:Computed 如何"知道"该更新

3.1 响应式系统的三个核心角色

要理解 computed 为什么能感知数据变化,得先认识 Vue 响应式系统的三个核心角色:响应式对象(Reactive)、依赖(Dep)、副作用函数(Effect)。

可以把响应式对象想象成一个会"广播"的仓库,每个属性都挂了一个依赖列表,列表里存着"谁在用它"。副作用函数就是"谁在用"的那一方,一个时刻只有一个"当前正在执行的副作用"被记录在全局。Vue 内部暴露了track和trigger两个关键操作:

  • track:在读取响应式属性时调用,把"当前副作用"登记到这个属性的依赖列表里。
  • trigger:在修改响应式属性时调用,通知这个属性依赖列表里的所有副作用"你关注的东西变了"。

在 Vue 3 里,响应式对象基于Proxy实现,读取和写入都会经过代理拦截,所以track和trigger可以做到完全透明,不需要开发者手动操作。Vue 2 用的是Object.defineProperty的 getter/setter 拦截,原理类似,但对新增属性和数组索引的侦测有限制。

3.2 依赖收集发生在哪个时机:惰性求值的关键

computed 本质上是一个"特殊效果的副作用函数"。它有一个核心标记叫_dirty,初始值为true。整个依赖收集过程可以分为两段:第一次被读取和后续依赖变化后重新读取。

第一次读取total.value时,Vue 发现_dirty是true,于是执行 getter。getter 内部访问了price.value和quantity.value,这两个属性在代理中被track,把"当前正在执行的 computed 内部副作用"登记进各自的依赖列表。此时price和quantity的依赖列表里都有这个 computed,依赖关系建立完成。getter 返回结果后,Vue 把结果缓存起来,并把_dirty置为false。

之后再看同一次渲染里再次读取total.value,因为_dirty已经是false,Vue 直接返回缓存值,不会重新执行 getter。这就是"依赖收集发生在第一次读取时"的真实含义。

3.3 从数据变化到 Dirty 标记的完整信号链

现在把数据变化那一端补上。当price.value = 200这行代码执行时,Proxy 的 set 拦截触发trigger,Vue 会遍历price属性依赖列表里的所有副作用并执行它们。

关键是:computed 的副作用在注册时,除了 getter 本身,还配置了一个scheduler(调度器)。当依赖的数据变化时,Vue 不会直接重新执行 getter,而是先执行这个调度器。调度器做的事很简单:把_dirty置回true。getter 的真实重算被推迟到了下一次读取时。

这里就能看出 computed 和普通 watcher 的差别。普通 watcher/渲染副作用,数据一变就会立刻被标记为需要重新执行,然后被推入更新队列;computed 则把"标记变脏"和"重新计算"拆成了两步,后者被延迟到读取时。这带来的直接好处是:如果某个 computed 在某一轮更新中压根没人读取,那它就真的不计算,节省了无谓的开销。

4. 从 Computed 到视图:一次完整更新链路

4.1 一个最小示例的链路走查

为了把"computed 到视图"这条链路讲透,我用一个经典购物车场景。注意,这里的每一步都对应着 Vue 源码中真实发生的过程。

import { ref, computed, reactive, onMounted } from 'vue'; const items = ref([ { name: '苹果', price: 5, count: 2 }, { name: '香蕉', price: 8, count: 1 } ]); const totalPrice = computed(() => items.value.reduce((sum, item) => sum + item.price * item.count, 0) );

模板里渲染{{ totalPrice }}。整个链路的参与者有三个:items(响应式数据)、totalPrice(computed)、渲染副作用(render effect)。

首次渲染:渲染副作用开始执行,读取到totalPrice,触发 computed 的依赖收集,执行 getter,getter 里读取items.value并对数组做遍历,因此items被 track 进 computed 的依赖列表。计算得到18,缓存下来,渲染副作用把18写入视图。同时,渲染副作用自身也被登记到totalPrice的依赖列表里,因为渲染副作用读取了totalPrice。

数据变更:某个时刻用户把苹果的数量从2改成3。数组内部属性的 set 拦截触发trigger,依赖列表里有 computed 的调度器,调度器执行,把totalPrice标记为_dirty = true。

关键一问:数据变了,视图怎么知道要更新?答案是 computed 在标记变脏之后,马上触发它自己的依赖列表——也就是触发了渲染副作用。渲染副作用被推入更新队列,等待 Vue 的调度器在当前 tick 统一执行。

重新渲染:渲染副作用重新执行,再次读取totalPrice。这次_dirty是true,所以 getter 重新运行,算出23,缓存更新为23。视图同步为23。

4.2 为什么说 Computed 是"渲染链路的二级缓存"

上面这个链路里,有一个设计值得多说几句:computed 的依赖列表里趴着渲染副作用,但它触发渲染副作用的方式是"间接"的。

流程可以简化成:items 变化 → computed 标记变脏 → computed 触发自己的依赖(渲染副作用) → 渲染副作用读取 computed → computed 重新计算 → 视图更新。

这个间接设计的妙处在于"合并依赖"。渲染副作用不需要直接侦测items里的每一个字段变化,它只需要侦测totalPrice这一个"出口"。不管计算逻辑里读了多少个响应式属性,这些属性的变化最终都汇聚到一个 computed 身上,再由它统一通知视图。这大大缩小了依赖追踪的范围,也是 computed 相比在模板里直接写复杂表达式性能更好的深层原因。

4.3 更新是同步还是异步:与 nextTick 的配合

很多人会问:computed 的更新是同步的还是异步的?答案拆开看:标记变脏是同步的,视图更新是异步的。

数据修改的那一刻,_dirty立即被置回true,依赖列表被立即触发。但渲染副作用不会立刻执行 DOM 更新,而是被放入一个异步队列,等当前同步代码跑完之后统一 flush。这就是为什么你连续执行item.count = 3; item.count = 4;时,视图最终只会更新一次为最新值。

对应的陷阱是:如果你在修改数据后立即读取totalPrice.value,在同步代码里它可能已经重新计算了(因为 computed 是同步重算的),但如果你想在 DOM 更新后做点什么,就必须等到nextTick。我在项目里遇到过不少次类似的问题:改完数据立刻去测 DOM 高度或者滚动位置,拿到的总是旧值。这个不是 computed 的问题,而是视图异步更新的固有节奏,需要用await nextTick()来对齐。

5. 实战经验:Computed 使用的常见坑与排查思路

5.1 Computed 拿到"旧值":缓存没失效的真相

这个坑几乎是每个人都会遇到的。场景:computed 里依赖了一个数组,你用arr.push(...)的方式往里面加了数据,结果 computed 没有更新。

const list = ref([]); const total = computed(() => list.value.length); function addItem() { list.value.push({ id: 1 }); // 页面上的 total 没有变化 }

原因在于:push本身不改变list.value这个引用,Vue 3 虽然能侦测到数组内部的新增索引,但 computed 的依赖追踪追踪的是"读取了 list 的哪些属性"。list.value.length读取了 length 属性,而push确实会触发 length 的更新,按理说应该会触发。但如果你在 computed 里读的不是 length,而是某个索引或者整个数组的迭代结果,情况可能不同。

更稳妥的写法是修改引用本身:

function addItem() { list.value = [...list.value, { id: 1 }]; }

或者确保 computed 里的依赖属性是你实际会修改的属性。这个坑的根本教训是:computed 的依赖追踪是"细粒度"的,它只在乎 getter 里真正读到的东西。你以为是"整个数组变了所以该重算",但 Vue 追踪的是"哪个具体属性被读了"。排查思路就一条:把 computed 的 getter 从头读一遍,看它到底 track 了哪些属性,再对照你实际修改了哪些属性,两边匹配不上,就该换写法。

5.2 不要在 Computed 里做副作用:一次血泪教训

computed 的 getter 在语义上应该是一个纯函数:同样的输入,永远得到同样的输出,不修改外部状态。但实际操作中,我看到过有人在 computed 里做这些事:

const displayList = computed(() => { if (someCondition.value) { logAnalytics('页面展示了列表'); // 副作用 } return items.value.map(...); });

这种代码非常危险。原因前面已经讲过:computed 是惰性求值的,你无法预测它何时执行、执行几次。它可能在没人读取的情况下完全不执行,也可能在同一轮渲染里因为依赖变化被多次重算。把日志上报、请求发送这类副作用写进去,会导致行为不可预期:有时候多发了请求,有时候一条都没发。

正确做法是:computed 只负责算值,副作用要么放到watchEffect,要么放到事件处理方法里。我在团队里定过一条规矩:review 代码时看到 computed 里有 console、请求、DOM 操作、对外部变量的赋值,一律打回。这不是教条,而是这些写法最终都会变成线上事故的源头。

5.3 依赖"吞变化":对象引用与深层响应式

还有一种常见但隐蔽的情况:computed 返回的是一个响应式对象本身,而不是它的某个标量值。

const user = reactive({ name: '张三', profile: { age: 18 } }); const userName = computed(() => user.profile.age);

当user.profile.age变化时,userName自然会更新。但如果 computed 返回对象:

const userInfo = computed(() => user.profile);

模板里绑定userInfo.age。此时userInfo的依赖是user.profile这个引用,只要你没有整体替换user.profile,computed 本身不会重新执行,但是模板里读取userInfo.age时会追踪到age属性,数据变化依然能触发视图更新。这个机制容易让人困惑:computed 的值没变,但视图变了。要强调的是,模板里不要深度读取 computed 返回对象内部属性来依赖更新,容易造成局部更新失效的错觉。最佳实践是让 computed 返回你真正要渲染的那个标量值,保持派生关系精确。

5.4 性能排查:什么时候该拆 Computed

最后一个经验是关于性能的。computed 本身很快,但计算量过大的 computed 会拖慢首次渲染和依赖变化后的重算。一个几百行数据做 filter + map + sort 的 computed,如果执行频率高,照样能造成卡顿。

我一般会按两个维度判断要不要拆:依赖粒度和计算成本。

依赖粒度是指,一个 computed 是否把多个独立变化的源数据搅在一起。比如:

const result = computed(() => filterByKeyword(allItems.value, keyword.value) + summarize(userList.value));

allItems变了要算,keyword变了要算,userList变了也要算,三者互不相干却被迫绑在一起。拆成两个 computed 后,每个的失效范围就缩小了,更新时能少算很多。

计算成本是指单次 getter 的开销。如果确实有复杂的计算,可以考虑用 computed 做一层"预处理缓存",把中间结果缓存下来,避免每次读取都从零算。例如把"原始待处理列表"先算成一个 computed,再基于它做过滤和排序,形成计算链。这样每一层的变化都只重算受影响的那一段,整体性能会比一个巨型 computed 好很多。

我在实际调试中遇到过印象很深的一个场景:某个列表页在输入过滤关键词时持续卡顿,查下来是最外层一个 computed 里做了全量数据的深拷贝。深拷贝的成本和数据量成正比,而每次键盘输入都会触发它重新计算。后来把深拷贝拆到一个独立 computed 里,并基于数据引用变化去触发,卡顿立刻消失。排查这类问题的思路很简单:打开性能面板,看哪个 computed 的重新执行次数最多、单次耗时最长,然后顺着依赖链去拆。

写在最后

回到开头那个问题:computed 的响应式链路到底是怎么回事?一句话概括,就是"读取时收集依赖、写入时标记变脏、读取时惰性重算、通过自身依赖触发视图更新"。这套机制保证了计算逻辑的纯度,也保证了渲染的性能。踩过几次坑之后,我个人的体会是:computed 最值得记住的不是 API 用法,而是它的假设——你给它的 getter 必须是可预测的,依赖必须是精确的。带着这个假设去写代码,很多诡异问题从一开始就能避免。

最后再分享一个小技巧:排查 computed 不更新的问题时,不要急着改代码,先在 getter 里打一条console.log,观察它到底什么时候执行。如果数据变了但 getter 没执行,说明依赖没追踪上;如果 getter 执行了但视图没变,问题就在渲染层。这一条日志就能把问题定位缩小一半,比瞎猜高效得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询