代码评审里,reviewer 在报名表的 <script setup> 中圈出了两组看起来很相似的侦听逻辑。这个报名表刚在第 6 章完成输入收集、字段校验和提交处理:一组侦听器在字段变化后重新计算填写进度 progress、能否提交 canSubmit 和预览摘要 summary,再把三个结果分别写进新的 ref;另一组也在字段变化后运行,但做的是把草稿自动保存到本地存储。
“它们不都是表单一变就要执行吗?”提交代码的人有些疑惑。reviewer 没有先回答该用哪个 API,而是追问了两个问题。
第一个问题是:不访问表单之外的任何东西,只看当前字段,能不能算出唯一答案? 已填字段数决定 progress,必填项与协议状态决定 canSubmit,姓名、联系方式和所选活动决定 summary,答案都是“能”。既然原字段已经是事实,再创建三个可修改的 ref 保存同样的信息,就等于让页面同时维护两份事实。某次新增了必填项,却忘了修改同步进度的侦听器,进度条和真实表单便会悄悄分叉。
第二个问题是:字段变化以后,是否还要执行一个不能靠返回值完成的外部动作? 自动保存要写入本地存储,将来还可能改成请求服务端;它有明确的执行时机,也可能需要防抖、取消和错误处理。这里的答案同样是“是”,但它指向的是侦听器真正擅长的工作。
这两个问题把表面相似的“跟着变化”拆成了两条路:progress、canSubmit 和 summary 应由 computed 随时从表单状态得到;自动保存才交给 watch。能从当前响应式状态得到答案,就用 computed 表达派生值;状态变化后需要执行副作用,才使用 watch 或 watchEffect。 前者像表格里的公式,后者像公式结果变化后发出邮件、保存文件或请求服务器。
如果你暂时分不清一段逻辑属于哪一边,可以问自己:这段代码只需要返回一个值吗?如果答案是“是”,先考虑 computed;如果它要请求网络、写入存储、设置计时器、操作 DOM 或调用外部对象,再考虑侦听器。

很多时候,API 选错不是因为记不住语法,而是因为需求没有被说清楚。比如产品经理说“用户选择省份以后,城市名称也要跟着变”。这里的“跟着变”可能代表两件完全不同的事。
如果城市名称只是由当前城市编号在城市表中查出来,那么它是一个答案,应该用计算属性。如果选择省份后需要请求服务器加载新的城市列表,那么请求是一个动作,应该放进侦听器;请求回来的城市数组是新的源状态,页面再用计算属性从中得到数量、是否为空和展示摘要。
再看一句“总价变化后更新优惠提示”。如果优惠提示完全由总价区间决定,例如满 200 减 20,它仍是派生值。把它写成 computed,总价和提示之间的关系就摆在代码里。如果变化后要调用支付 SDK 重新获取服务端优惠资格,那才是副作用。
我建议在动手前把需求改写成下面两种句式之一:
computed。watch 或 watchEffect。一段需求有时同时包含两种句式。不要强迫一个 API 包办全部工作,把计算和动作拆开即可。比如“根据筛选条件得到请求参数”是计算属性,“请求参数变化后查询列表”是侦听器。这样测试时也很自然:先验证参数算得对不对,再验证变化时有没有发请求。
假设订单里保存了 unitPrice、quantity 和 subtotal 三个可修改字段。只要任何一处代码忘记同步,订单就可能出现“单价 30、数量 2、小计 90”这种互相打架的数据。更稳的设计是只把单价和数量当作事实,小计每次由它们得到。
这就是“单一数据来源”的具体含义:同一件事实只维护一份。计算属性不是复制状态的快捷工具,而是从事实通向答案的一条可重复规则。规则随代码保存,答案随依赖更新,我们不需要再安排谁先同步谁。
我们先看一段再普通不过的代码:
<script setup>
import { ref, computed } from 'vue'
const price = ref(39)
const quantity = ref(2)
const subtotal = computed(() => price.value * quantity.value)
</script>
<template>
<p>小计:¥{{ subtotal }}</p
页面第一次读取 subtotal 时,Vue 会运行它的 getter,也就是传给 computed 的函数。运行期间读取了 price.value 和 quantity.value,Vue 就记下:“subtotal 依赖这两个响应式值。”
假设当前单价是 39 元、数量是 2,结果为 78。模板之后再次读取 subtotal,只要两个依赖都没变,Vue 会直接交出缓存中的 78,不再乘一遍。
点击按钮后,quantity 从 2 变成 3。此时 Vue 不必立刻把所有计算都做一遍,它先把 subtotal 标记成“旧结果不能再信”。等模板或其他代码下一次真的读取 subtotal,getter 才重新执行,得到 117,并把新结果缓存起来。
所以,计算属性的完整过程不是“每隔一会儿检查有没有变化”,而是四步:
第一次读取计算属性时运行 getter,并记录这次计算实际读取过哪些响应式依赖。
getter 的结果被缓存,同一批依赖没有变化时,后续读取直接复用这个结果。
某个依赖变化后,旧缓存会失效,但没有人读取时不必急着计算。
下一次读取时重新运行 getter,收集本次实际用到的依赖,并缓存新结果。
这里有两个容易漏掉的细节。第一,computed() 返回的是一个计算属性 ref,因此在 JavaScript 中要写 subtotal.value;模板会自动解包顶层 ref,所以模板里写 subtotal。第二,依赖是 getter 运行时实际读取到的响应式值,不是变量名看起来像不像状态。
const useMemberPrice = ref(true)
const memberPrice = ref(32)
const normalPrice = ref(39)
const currentPrice = computed(() => {
return useMemberPrice.value
? memberPrice.value
: normalPrice.value
})当 useMemberPrice 为 true 时,这次 getter 没有读取 normalPrice.value,normalPrice 就不是这一轮的有效依赖。以后切换分支,依赖集合也会随着下一次执行重新收集。理解这一点后,很多“为什么它触发了”或“为什么它没触发”的问题都能顺着读取路径找到答案。

计算属性本身也是响应式 ref,所以它可以继续成为另一个计算属性的依赖。商品例子中的关系可以写成一条链:
关键词、分类、库存开关、价格上限、原始商品
↓
筛选后的商品
↙ ↘
结果数量 价格合计
↘ ↙
摘要文本当关键词变化时,Vue 不会无差别地重新运行组件中的所有函数。它沿着实际依赖关系让相关计算结果失效。某一支如果没有被页面或其他响应式逻辑读取,就不必为了“可能会用到”提前工作。
链条写长了也不用紧张。调试时从最终显示值往回看:摘要依赖数量和合计,数量与合计依赖筛选结果,筛选结果再依赖原始状态。每个 getter 只做一小步,反而比一个几百行的大 getter 更容易核对。
不过也不要为了形式把每个加法都拆成计算属性。是否拆分取决于这个中间结果有没有独立含义、是否会复用、能否让代码更清楚。normalizedKeyword 值得单独存在,因为搜索匹配会复用标准化后的关键词;仅使用一次的 product.price * 0.9 通常可以留在同一个 getter 中。
下面两个 getter 看起来都调用了函数,但行为取决于函数内部最终读了什么:
function getTaxRate() {
return taxRate.value
}
function getRandomRate() {
return Math.random()
}
const tax = computed(() => subtotal.value * getTaxRate())
const randomPrice = computed(() => subtotal.value * getRandomRate())tax 会追踪 subtotal 和 taxRate,因为 getter 执行期间确实经过了这两个响应式读取。randomPrice 会追踪 subtotal,但随机数本身不是响应式依赖;只要 subtotal 不变,多次读取仍会拿到同一个缓存结果。Vue 不会分析函数名字,也不会猜“随机数应该变化”,它只记录运行期间发生的响应式访问。
我们做一个商品浏览器。页面有关键词、分类、是否只看有货商品和价格上限,界面还要显示筛选结果数量与总价。原始状态只有商品列表和筛选条件,其余内容都能现算。
<script setup>
import { ref, computed } from 'vue'
const keyword = ref('')
const category = ref('全部')
const onlyInStock = ref(false)
const maxPrice = ref(500)
const products = ref([
{ id:
输入“鼠”时,列表只留下“无线鼠标”,统计区域显示“找到 1 件商品,价格合计 ¥159”。清空关键词、勾选“只看有货”后,缺货的护眼台灯会消失,数量和合计价格也会一起更新。
这里没有任何侦听器。filteredProducts 是对原始列表的视图,resultCount 和 resultTotal 又从筛选结果派生。你甚至可以让一个计算属性依赖另一个计算属性,Vue 会照常建立依赖关系。
注意,模板使用 filteredProducts 三次也不会让筛选逻辑无缘无故执行三遍:v-for 读取列表,resultCount 间接读取它,resultTotal 也间接读取它,只要依赖没有变化,这些读取共享同一份缓存结果。对大列表而言,这比在模板中反复调用筛选函数更合适。
如果商品库存从 0 改为 5,且“只看有货”处于勾选状态,products 的嵌套响应式变化会使筛选结果失效。下一次渲染读取列表时,新商品进入结果。这里不需要给 computed 配置 deep;深度选项是侦听器的概念。计算属性只要在 getter 执行中读取到 product.stock,对应属性就会被追踪。
同样,如果当前没有勾选“只看有货”,表达式 !onlyInStock.value || product.stock > 0 会发生短路,本轮计算可能不会读取某些 stock。这没有问题,因为库存此时不影响筛选答案。以后勾选开关,getter 重新执行并读取库存,依赖也会随之更新。
这种写法有一个很实际的好处:页面中只有一份真正需要维护的商品数据。筛选结果随时都能由“商品列表 + 筛选条件”重新得到,不会出现 products 已更新、filteredProducts 却忘了同步的情况。
下面这段代码能工作,却把简单问题变复杂了:
const filteredProducts = ref([])
watch(
[products, keyword, category, onlyInStock, maxPrice],
() => {
filteredProducts.value = products.value.filter(/* 筛选条件 */)
},
{ immediate: true, deep: true }
)我们复制出第二份状态,又要安排首次执行,还要决定是否深度侦听。以后增加一个筛选条件时,如果忘了把它加入数据源数组,页面就会悄悄显示过期结果。改用 computed 后,getter 读到哪些状态,哪些状态就是依赖,返回值也始终代表当前状态下的答案。
不要因为 watch 也能“让 B 跟着 A 变化”,就用它保存可计算的 B。只要 B 能由现有状态确定,优先让 B 成为计算属性。侦听器更适合没有返回值的动作。

你完全可以写一个普通函数:
function getFilteredProducts() {
return products.value.filter(/* 筛选条件 */)
}然后在模板中调用 getFilteredProducts()。结果可能与计算属性一样,但执行方式不同:普通函数每调用一次就执行一次;计算属性在依赖未变化时,多次读取会复用缓存。
为了亲眼看到差别,可以把下面的组件放进 App.vue:
<script setup>
import { ref, computed } from 'vue'
const price = ref(20)
const unrelatedCount = ref(0)
const discountedPrice = computed(() => {
console.count('计算属性 getter 执行')
return price.value * 0.8
})
function calculateDiscountedPrice
打开浏览器控制台就能看到次数。点击“修改无关状态”会让组件重新渲染。price 没变,discountedPrice 的缓存仍然有效;模板中的普通函数则会随调用再次运行。这里的 console.count() 只用于观察执行过程,正式代码中的 getter 仍应保持纯粹。
缓存不是说 computed 在任何情况下都比函数快。对于一次性的简单拼接,性能差别通常不值得讨论。选择 computed 更主要的理由是语义:它声明“这是一个随依赖变化的值”,可以在模板和其他计算中像值一样读取。普通函数更适合接收参数、执行一次动作,或者明确希望每次调用都重新计算的场景。
计算属性描述的是当前组件状态下的一个值,它的 getter 不是给模板传临时参数的入口。假设列表中每行要按不同税率算含税价,下面的普通函数更直白:
function priceWithTax(price, taxRate) {
return Math.round(price * (1 + taxRate) * 100) / 100
}<li v-for="product in products" :key="product.id">
{{ product.name }}:¥{{ priceWithTax(product.price, product.taxRate) }}
</li>如果这项计算很重并且每个商品都要缓存,问题已经从“computed 还是方法”变成了“缓存如何按商品键管理”。这时可以预先用一个计算属性生成带结果的完整列表,或者在数据进入应用时规范化。不要尝试写出 computed((product) => ...),Vue 传给 getter 的参数不是模板中的商品。
依赖变了,计算属性仍要重新运行。假如每次输入一个字符都要筛选十万条记录,使用 computed 并不会消灭这项成本。你仍可能需要分页、服务端搜索、延迟输入或更合适的数据结构。
所以性能分析应该继续问:数据有多大?变化有多频繁?结果是否被多处读取?卡顿发生在计算、渲染,还是请求?“用了 computed”只是正确建模的一步,不是通用性能开关。
const currentTime = computed(() => Date.now())Date.now() 不是响应式数据。第一次读取以后,没有依赖会通知这个计算属性失效,所以它不会自己变成一块时钟。真正的时钟需要用计时器定期更新一个 ref,再由计算属性格式化这个响应式时间。
import { ref, computed, onMounted, onUnmounted } from 'vue'
const now = ref(Date.now())
let timerId
onMounted(() => {
timerId = window.setInterval(() => {
now.value = Date.now()
}, 1000)
})
onUnmounted(() => {
window.clearInterval(timerId)
计时器是副作用,负责推动 now;timeText 是派生值,只负责把时间戳变成文字。两者各做一件事,代码就很好解释。
“纯粹”并不神秘:给定同一批依赖,getter 应该只计算并返回结果,不顺手修改其他状态,不请求接口,不写本地存储,也不操作 DOM。
下面的写法会直接改变原数组:
const sortedProducts = computed(() => {
return products.value.sort((a, b) => a.price - b.price)
})Array.prototype.sort() 会原地排序。也就是说,我们本想读取一个派生结果,却在读取时改了源状态。别的组件如果依赖原顺序,行为就会变得很难猜。先复制再排序更安全:
const sortedProducts = computed(() => {
return [...products.value].sort((a, b) => a.price - b.price)
})支持 toSorted() 的环境也可以写:
const sortedProducts = computed(() => {
return products.value.toSorted((a, b) => a.price - b.price)
})异步请求也不应该放进 getter:
// 不要这样写
const result = computed(async () => {
const response = await fetch('/api/products')
return response.json()
})此时计算属性返回的是 Promise,而且请求次数会与“有没有代码读取它”纠缠在一起,加载态、错误态和取消请求都无处安放。把请求放进明确的函数或侦听器,把请求结果保存为 ref,再用 computed 处理展示,职责会清楚得多。
还有一个常见误区是修改计算结果本身。计算属性返回的是源状态在当前时刻的派生视图。要改变页面结果,应修改它依赖的源状态,而不是对这个“答案”动手。
计算属性可以返回对象或数组,但调用方如果直接修改返回结果,就可能绕回源状态。比如 filteredProducts 返回的数组是新数组,其中的商品对象仍来自 products;执行 filteredProducts.value[0].stock-- 仍会改到原商品。
这并不是 Vue 偷偷复制失败了,而是 JavaScript 中这些位置仍指向同一个商品对象。更好的约定是:计算属性的结果用于读取和展示,修改业务状态走专门的事件函数,例如 decreaseStock(productId)。这样代码搜索时能找到所有修改入口,也方便以后加库存下限、权限校验或接口调用。
如果 getter 要返回排序后的列表,复制数组能避免改变数组顺序,却不会深拷贝每个对象。是否需要更深的不可变结构取决于业务,不要为了“看起来安全”在每次计算时盲目深拷贝整棵数据,那可能制造比原问题更大的成本。
计算属性默认只读,这恰好符合派生值的性质。少数情况下,界面希望把一个组合值当作输入入口,这时可以同时提供 get 和 set。
<script setup>
import { ref, computed } from 'vue'
const province = ref('浙江省')
const city = ref('杭州市')
const region = computed({
get() {
return `${province.value} / ${city.value}`
},
set
输入“江苏省 / 南京市”时,v-model 会给 region 赋值,从而调用 setter;setter 再把文本拆开,更新真正的源状态 province 和 city。读取 region 时则运行 getter,把两份源状态组合回来。
可写计算属性不是让派生值变成另一份独立状态。setter 的职责仍然是把写入意图翻译成对源状态的修改。如果 setter 需要发请求、弹提示或写存储,通常说明这个过程已经不只是“值的映射”,用提交函数表达会更直接。
可写计算属性经常与 v-model 相连,用户输入不一定永远符合预期。上面的地区 setter 对缺失部分提供了空字符串,但真实页面可能需要拒绝无效格式。此时不要在 getter 中修补,也不要让 setter 悄悄留下半份错误状态。
一种做法是把输入框的草稿单独保存,提交或失焦时通过函数校验,再更新省市源状态。可写计算属性适合稳定、可逆的映射,例如摄氏度与华氏度、名字两部分与完整显示名。输入过程复杂、存在中间态或需要异步校验时,普通 ref 加提交函数通常更容易给用户明确反馈。
现在把场景换成搜索框。关键词改变后,我们要等待用户停顿、发起请求、更新加载状态,并处理失败。这些都不是“返回一个值”能完成的,它们有时间顺序,还会影响组件之外的资源。这里才轮到 watch。
watch 把“追踪什么”和“变化后做什么”分开:
watch(source, (newValue, oldValue, onCleanup) => {
// 副作用
}, options)source 是明确的数据源。回调默认不会在创建时执行,要等数据源真的变化。回调中的 newValue 和 oldValue 让你能比较前后状态;onCleanup 用来登记本轮副作用失效时需要做的清理。
watch 常见的数据源有四种。
import { ref, reactive, computed, watch } from 'vue'
const keyword = ref('')
const priceRange = reactive({ min: 0, max: 500 })
const page = ref(1)
const sort = ref('default')
const normalizedKeyword = computed(() => keyword.value.trim())
最常见的错误,是把响应式对象的普通属性值直接交给 watch:
// 错误:调用 watch 时,priceRange.max 已经被求值成普通数字 500
watch(priceRange.max, () => {})
// 正确:getter 会在侦听过程中读取响应式属性
watch(() => priceRange.max, () => {})watch 需要一个还能被追踪的数据源。priceRange.max 只是调用那一刻的数字;() => priceRange.max 才是一条可重复执行的读取路径。

数据源 getter 不只是为了解决对象属性不能直接传的问题,它还能把侦听边界压缩到业务真正关心的值。
const width = ref(120)
const height = ref(80)
watch(
() => width.value * height.value,
(newArea, oldArea) => {
console.log(`面积从 ${oldArea} 变为 ${newArea}`)
}
)这里宽或高变化都会让数据源 getter 重新求值,但只有最终面积真的变化时才需要执行回调。相比同时侦听 [width, height],回调拿到的也是业务想比较的面积,而不是两组零散尺寸。
同理,可以侦听布尔条件的变化:
watch(
() => cartTotal.value >= 200,
(hasReachedFreeShipping) => {
if (hasReachedFreeShipping) {
showToast('已满足免运费条件')
}
}
)总价在 210 和 230 之间变化时,条件始终为 true,没有必要反复弹提示;只有跨过门槛时数据源结果才改变。这里的免运费资格本身仍可以是 computed,弹提示才是 watch 副作用。一个值既可以作为模板展示的派生结果,也可以成为侦听器的明确数据源。
watch(userId, () => {
console.log('当前主题', theme.value)
loadUser(userId.value)
})这条侦听关系只由 userId 触发。回调执行时读取 theme,只是拿它的当前值,不会让 theme 以后也触发请求。这正是 watch 的可控之处。
如果主题也应该触发逻辑,就明确写成 [userId, theme];如果只是想在用户变化时顺便读取当前主题,保持现状。不要根据“回调里出现了变量”猜测依赖,先看 watch 的第一个参数。
数据页面经常要在进入时加载一次,筛选条件变化时再次加载:
watch(
[keyword, page],
() => {
loadProducts()
},
{ immediate: true }
)immediate: true 会让回调在侦听器创建时先执行一次,之后仍按正常规则响应变化。它适合“初始加载和后续更新走同一条流程”的场景。
首次立即执行时没有一次真实的“前值变化”,所以 oldValue 通常是 undefined。如果回调必须比较新旧值,先处理首次分支,不要直接对 oldValue 调用字符串或数组方法。
watch(
selectedCategory,
(newCategory, oldCategory) => {
if (oldCategory === undefined) {
console.log('首次加载分类', newCategory)
return
}
console.log(`${oldCategory} 切换为 ${newCategory}`)
},
{ immediate: true }
)如果只关心数据源第一次改变,可以使用 once: true:
watch(
() => form.email,
() => {
showEmailTip.value = false
},
{ once: true }
)这里初始提示会保留,用户第一次修改邮箱后关闭,此后不再侦听。once 需要 Vue 3.4 或更高版本。它表达的是“第一次变化”,不要把它误解成“创建时只执行一次”;是否创建时执行由 immediate 决定。
把选项写在一起之前,先用一句话描述预期时机。比如“进入页面先请求,之后筛选每变化一次都请求”对应 immediate: true;“只在用户第一次真正编辑时记录一次”对应 once: true。选项不是越多越完整,恰好表达业务时机才有意义。
先看两个外表相似、行为不同的写法:
const settings = reactive({
theme: 'light',
editor: {
fontSize: 16,
lineHeight: 1.8
}
})
// 直接侦听 reactive 对象:隐式深度侦听
watch(settings, () => {
saveSettings()
})
// 侦听 getter 返回的对象:默认只在返回的对象被替换时触发
watch(
() => settings.editor,
() => {
saveEditorSettings()
}
第一段中,修改 settings.editor.fontSize 会触发回调,因为直接侦听响应式对象会形成深度侦听。第二段默认关心 settings.editor 是否换成另一个对象,单独修改里面的 fontSize 不会触发。
如果确实需要观察 getter 返回对象内部的变化,可以显式设置 deep:
watch(
() => settings.editor,
() => {
saveEditorSettings()
},
{ deep: true }
)深度侦听发生嵌套修改时,newValue 与 oldValue 往往是同一个代理对象,因为对象本身没有被替换,只是内部属性变了。它们不等于两张自动保存的历史快照。真要比较前后结构,应在合适的边界主动保存所需字段或不可变快照,别指望深度侦听替你保留旧对象。
深度侦听还需要遍历嵌套属性来建立依赖。设置对象只有十来个字段时通常没什么感觉;如果对象是一棵包含大量节点、表格行或编辑器内容的大树,deep: true 可能让一次看似普通的更新付出明显成本。
更精确的写法往往更好:
watch(
[
() => settings.theme,
() => settings.editor.fontSize,
() => settings.editor.lineHeight
],
() => {
saveVisibleSettings()
}
)Vue 3.5 及以上还允许把 deep 写成数字,用它限制向下遍历的最大层级。即使如此,也应该先问:我真的关心整棵对象吗?如果业务只用三个叶子字段,直接列出它们通常更清楚、更省事。
假设页面编辑一份包含元数据、正文、评论和历史版本的文档对象,而自动保存只关心标题与正文。直接 watch(document, save, { deep: true }) 会让评论点赞、历史记录展开等无关变化也触发保存。
可以先用计算属性挑出要保存的片段,再侦听这段业务摘要。若后续还要把同一份摘要显示在页面或提交给多个动作,这种拆分很有用。若只在一个地方使用,直接列出字段通常更清楚:
watch(
[() => document.title, () => document.content],
([title, content]) => {
saveDocument({ title, content })
}
)核心思路是先缩小业务边界,再侦听。不要因为数据放在一个大对象里,就默认所有嵌套变化都同样重要。
如果副作用只读取大对象中的少数字段,watchEffect 有时比 deep: true 更精确,因为它只追踪执行时真正访问的属性。
watchEffect(() => {
localStorage.setItem(
'editor-view',
JSON.stringify({
fontSize: settings.editor.fontSize,
lineHeight: settings.editor.lineHeight
})
)
})这种写法的代价是数据源没有单独列出。字段少且依赖清楚时,可以使用 watch 数组;依赖散落在一个同步副作用中、列清单反而重复时,可以考虑 watchEffect。无论选哪个,都比无条件遍历整棵大对象更有针对性。
搜索框最经典的 bug 不是“请求失败”,而是“旧请求最后回来”。假设用户先输入“键盘”,紧接着改成“键帽”。第二次请求可能更快完成,页面先显示键帽结果;几秒后第一次请求才返回,又把页面覆盖成键盘结果。界面最终展示的内容与输入框不一致。
解决办法不是猜哪个请求更快,而是在下一轮侦听开始前让上一轮失效。浏览器的 AbortController 可以取消 fetch,watch 的清理函数负责把两轮连接起来。
下面是一份可直接放进 App.vue 的示例。为了让示例不依赖后端,searchProducts 用计时器模拟不同耗时的请求,同时支持取消。
<script setup>
import { ref, watch } from 'vue'
const keyword = ref('')
const loading = ref(false)
const errorMessage = ref('')
const results = ref([])
const productNames = [
'机械键盘',
'静音键盘'
第一次回调启动后,Vue 保存了通过 onCleanup 登记的取消函数。keyword 再次变化时,在运行新一轮回调之前,Vue 先调用上一轮的清理函数。旧计时器被清除,旧 Promise 以 AbortError 结束,也就没有机会覆盖新结果。
如果项目使用 Vue 3.5 或更高版本,也可以在回调的同步阶段调用 onWatcherCleanup() 登记清理。它必须在第一次 await 之前调用。这里使用回调第三个参数 onCleanup,版本兼容范围更宽,而且它与当前侦听器实例绑定。
实际 fetch 的结构与上面相同:
watch(keyword, async (newKeyword, _oldKeyword, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const response = await fetch(
`/api/products?q=${encodeURIComponent(newKeyword)}`,
{ signal: controller.signal }
)
results.value
除了请求,清理函数还适合移除事件监听、清除计时器、断开订阅。判断标准很简单:本轮副作用如果留下了仍在运行的东西,就考虑下一轮开始前该如何收回。
搜索通常不希望每输入一个字就立刻请求。可以让每轮侦听先安排一个计时器,新输入到来时清掉旧计时器:
watch(keyword, (newKeyword, _oldKeyword, onCleanup) => {
const query = newKeyword.trim()
const timerId = window.setTimeout(() => {
runSearch(query)
}, 400)
onCleanup(() => {
window.clearTimeout(timerId)
})
})用户在 400 毫秒内继续输入,上一轮失效,旧计时器被清除;停顿超过 400 毫秒,才执行搜索。这比在组件外维护一串“上一个计时器编号”更贴合侦听器的生命周期。
防抖与请求取消解决的是两个阶段的问题。防抖减少还没发出的请求;请求一旦已经发出,后续输入仍可能产生竞态,所以真实搜索常把两者组合起来:先等待停顿,再创建 AbortController 发请求,下一轮同时清掉等待中的计时器或取消进行中的请求。
watch(keyword, (newKeyword, _oldKeyword, onCleanup) => {
const controller = new AbortController()
const timerId = window.setTimeout(async () => {
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(newKeyword)}`,
{ signal: controller.signal }
清理函数不需要判断当前处于哪个阶段:未发请求时 clearTimeout 有效,已经发请求时 abort 有效,对已经结束的资源再次清理也不会制造新工作。把“本轮创建了什么”与“本轮结束时收回什么”放在一起,最不容易遗漏。

响应式状态变化后,组件渲染和侦听回调都要执行。为了合并同一轮中的多次修改,Vue 默认会批量安排更新。flush 决定侦听回调落在这条更新时间线的哪个位置。
watch 和 watchEffect 默认使用 flush: 'pre'。回调发生在所属组件 DOM 更新之前,因此适合请求数据、计算要提交的参数、同步普通外部状态。此时如果直接读取本组件 DOM,看到的可能还是更新前内容。
watch(message, () => {
// message 已经是新值
// 但当前组件的 DOM 可能还是旧画面
})如果副作用必须测量更新后的元素高度、移动焦点或读取新文本,可以使用 flush: 'post':
<script setup>
import { ref, watch } from 'vue'
const items = ref(['准备材料'])
const listElement = ref(null)
watch(
() => items.value.length,
() => {
listElement.value?.lastElementChild?.scrollIntoView({
behavior: 'smooth',
block: 'nearest'
使用 post 后,回调运行时新 <li> 已经出现在 DOM 中。watchPostEffect() 是 watchEffect(..., { flush: 'post' }) 的便捷写法。
flush: 'sync' 会在响应式值变化时同步触发,不等待批处理:
watch(isLocked, updateExternalLock, { flush: 'sync' })它偶尔适合非常简单、变化频率低且确实要求同步失效的布尔状态。但如果对一个数组连续 push 一千次,同步侦听器可能跟着执行一千次。默认批处理本来能替你合并工作,所以没有明确理由时不要改成 sync。

pre 适合大多数副作用,读取本组件更新后的 DOM 才需要 post;sync 不参与批处理。function resetFilters() {
filters.keyword = ''
filters.category = '全部'
filters.maxPrice = 500
}这个函数连续修改了三个响应式属性。默认调度下,Vue 会把同一轮同步代码中的组件更新与侦听回调放入队列,避免每改一个字段就立刻完整渲染一次。若侦听的是整个 filters,回调通常在这一轮修改结束后处理最终状态,而不是让你看到三个没有业务意义的半成品界面。
这也是 sync 必须克制的原因:它绕过等待,字段刚改一个就执行,回调可能读到“关键词已重置、分类还没重置、价格也没重置”的中间状态。除非外部系统确实需要逐次同步,否则批处理后的最终状态更接近用户理解的一次“重置”动作。
flush: 'post' 适合“这条侦听关系每次触发都要在 DOM 更新后执行”。如果只在一个普通事件函数中修改状态,随后临时等待一次 DOM 更新,也可以使用 await nextTick()。
import { nextTick } from 'vue'
async function addAndFocus() {
items.value.push(createItem())
await nextTick()
inputElement.value?.focus()
}两种写法没有高下之分。事件函数已经明确掌握“谁发起了这次修改”时,nextTick 的顺序很直观;多个地方都可能改变同一数据,而你希望统一响应时,watch(..., { flush: 'post' }) 更集中。先根据业务所有权选择,再考虑语法长短。
watch 要先列数据源,watchEffect 则会立即运行一次函数,并把本次同步执行过程中读到的响应式值自动记为依赖。
<script setup>
import { ref, watchEffect } from 'vue'
const userId = ref(7)
const compactMode = ref(false)
watchEffect(() => {
document.title = compactMode.value
? `用户 ${userId.value}`
: `用户 ${userId.value} 的个人中心`
这里不需要写 [userId, compactMode],因为 effect 执行时读取了两者。任意一个改变,函数都会再运行。它很适合依赖比较零散、逻辑本身又能清楚暴露这些读取的副作用。
不过,自动并不总是更好。watch 与 watchEffect 的区别可以从控制权理解:
watch 只追踪显式数据源,回调中顺手读取别的 ref 不会把它变成新数据源;它能拿到新旧值,默认懒执行,触发边界清楚。watchEffect 把依赖读取和副作用写在同一个函数里,会立即执行;代码短,但依赖可能随着分支和重构变化。如果你要说“只在订单编号变化时重新请求,即使回调还读取了语言和主题也不要触发”,选 watch。如果你要说“把这个副作用所读取的响应式配置始终同步出去”,watchEffect 往往更自然。
这是 watchEffect 最容易踩的坑:它只能在同步执行阶段知道你读了什么。异步函数遇到第一个 await 后会暂停,稍后恢复的那一段已经离开本轮同步依赖收集。
const userId = ref(7)
const locale = ref('zh-CN')
watchEffect(async () => {
console.log(userId.value) // 会被追踪:出现在第一个 await 之前
const response = await fetch(`/api/users/${userId.value}`)
console.log(locale.value) // 不会因为这次读取而成为依赖
profile.value
以后修改 userId 会重跑 effect,单独修改 locale 不会。不要把它当作随机行为,分界线就是第一次暂停。
如果 locale 本来就应该触发请求,可以在 await 前保存快照:
watchEffect(async (onCleanup) => {
const id = userId.value
const language = locale.value
const controller = new AbortController()
onCleanup(() => controller.abort())
const response = await fetch(
`/api/users/${id}?lang=${language}`,
{ signal: controller.signal }
现在两个响应式值都在第一个 await 前读取,因此都会被追踪。更强调明确边界时,也可以直接改用 watch([userId, locale], ...)。对于网络请求,我通常更偏向 watch:数据源一眼可见,新旧值、immediate 和清理也集中在一起。
自动追踪不是只在第一次执行时固定一张永久清单。每次 effect 重新运行,Vue 都会根据这一次同步读取重新建立依赖。
const mode = ref('simple')
const simpleQuery = ref('')
const advancedQuery = ref({ author: '', year: '' })
watchEffect(() => {
if (mode.value === 'simple') {
syncQuery(simpleQuery.value)
} else {
syncQuery(`${advancedQuery.
简单模式下,simpleQuery 是依赖,高级查询字段没有被读取;切换为高级模式后,effect 重跑,开始追踪作者和年份,旧的简单关键词不再影响当前同步动作。
这种动态性很方便,但也会让隐藏在复杂分支里的依赖不容易审查。如果一次副作用必须始终由固定的三项状态触发,不管分支有没有读取它们,使用 watch([a, b, c], ...) 更能表达约束。watchEffect 适合“执行中用到什么就跟踪什么”,watch 适合“业务规定只能由什么触发”。
自动收集依赖并不会自动猜出如何取消请求或清除计时器。watchEffect 的第一个参数同样可以登记清理:
watchEffect((onCleanup) => {
const currentRoomId = roomId.value
const currentInterval = interval.value
const timerId = window.setInterval(() => {
reportPresence(currentRoomId)
}, currentInterval)
onCleanup(() => window.clearInterval(timerId))
})房间或上报间隔变化时,旧 effect 先清掉旧计时器,再创建新计时器;组件卸载时也会清理最后一轮。若漏掉这一步,每次变化都会多留一个计时器,页面看起来只是“上报越来越频繁”,真正原因却是资源不断累积。
在 <script setup> 顶层同步创建的侦听器会绑定到当前组件,组件卸载时会自动停止。通常不必手动清理侦听器本身,但侦听器每一轮启动的请求、计时器或订阅,仍然要通过清理回调处理。
watch 与 watchEffect 都会返回控制句柄:
const handle = watch(keyword, runSearch)
handle.pause()
handle.resume()
handle.stop()
// 也可以直接调用句柄来停止
handle()暂停适合一段时间内不希望响应变化、稍后还要恢复的场景;停止则表示这条侦听关系彻底结束。
不要把侦听器放进异步回调后再创建,除非你准备自己管理它:
// 不推荐:它不是在 setup 的同步阶段创建
setTimeout(() => {
watchEffect(() => {
console.log(data.value)
})
}, 1000)更常见的做法是同步创建侦听器,在内部等待数据条件成立:
watchEffect(() => {
if (!data.value) return
console.log('数据已就绪', data.value)
})这样它仍与组件生命周期绑定,不需要额外记住何时停止。
下面把这一章的角色放回同一个组件:
ref、reactive 保存。computed 派生。localStorage,这是副作用,使用 watch。watchEffect 负责把页面标题同步为当前结果摘要。<script setup>
import { computed, reactive, ref, watch, watchEffect } from 'vue'
const products = ref([
{ id: 1, name: '机械键盘', category: '办公', price: 329, stock: 8 },
{ id: 2, name: '护眼台灯', category: '居家', price: 219, stock: 0 },
{ id: 3, name: '无线鼠标', category: '办公', price:
刷新页面后,筛选条件会从本地存储恢复;调整任意筛选项,列表、统计标题和浏览器页签标题都会更新。观察这份代码时,可以给每个变量贴标签:filters 和 products 是源状态,四个 computed 是答案,两个侦听器是在答案或状态变化后与外部环境同步。
这份实现还有几个值得顺着读的细节。读取本地存储放在初始化函数里,而不是放进计算属性,因为它是在建立源状态。保存使用直接侦听 reactive 对象的深度行为,适用于当前很小的筛选对象;若以后对象加入大量临时界面字段,就应改为侦听真正要持久化的几个字段。
resultSummary 没有再读原始商品,而是复用 resultCount 与 resultTotal。这样摘要规则只关心“数量和金额如何变成一句话”,筛选规则改变时不用同步修改摘要。watchEffect 又只负责把已经得到的摘要交给浏览器标题,不参与计算摘要本身。
你可以做一个小实验:暂时注释掉更新 document.title 的 effect,页面列表与摘要仍应完全正确。这说明外部同步只是附加动作,不是页面数据正确性的前提。反过来,如果删除某个计算属性,相关展示会缺失,但源商品与筛选条件仍然存在。这样的依赖方向很健康:事实产生答案,答案触发外部同步,副作用不反过来偷偷维护事实。
测试时也能按边界拆开。给定一组商品和筛选条件,断言 filteredProducts、resultCount、resultTotal 的值;再单独替换本地存储或标题环境,验证状态变化时写入的内容。比起一个大侦听器同时筛选、统计、存储、改标题,这种结构更容易定位失败属于哪一层。
响应式问题最怕靠猜。遇到“为什么没更新”或“为什么重复执行”,按下面的顺序查,通常比盯着模板有效。
const { max } = reactive({ max: 500 })
watch(() => max, () => {})这里的 max 在解构时已经变成普通数字,后续没有代理属性读取,侦听器不会触发。保留对象访问,或者用 toRefs()/toRef() 保持连接。
const filters = reactive({ max: 500 })
watch(() => filters.max, () => {})计算属性会根据分支只记录实际读取的依赖;watchEffect 只记录第一次 await 前的读取。可以在关键读取附近暂时加入日志,确认代码路径是否走到。
在开发阶段临时加入 console.count('filteredProducts getter') 或 console.count('search watcher'),然后只执行一次明确操作。若一次输入触发多次,要检查是否创建了多个侦听器、是否使用了 sync、是否在回调中又修改了自己的数据源。
组合式 API 的 computed、watch 与 watchEffect 都支持开发期调试回调:
const total = computed(
() => cart.value.reduce((sum, item) => sum + item.price, 0),
{
onTrack(event) {
console.log('计算时追踪了', event)
},
onTrigger(event) {
console.log('这个变化让缓存失效', event)
}
这些钩子只用于开发调试。定位完成后应删除日志,不要把调试开销和实现细节留在生产逻辑中。
watch(count, () => {
count.value++
})这会形成明显的自我触发循环。更隐蔽的版本可能隔着对象、计算属性或异步回调。把副作用会写哪些状态列出来,再与数据源对照;如果读写形成闭环,就需要重新划分状态所有权或增加明确的停止条件。
const fullName = ref('')
watch([firstName, lastName], () => {
fullName.value = `${firstName.value}${lastName.value}`
})这里用 computed(() => firstName.value + lastName.value) 更直接。复制会带来首次同步、漏依赖和更新顺序问题。
const filtered = computed(() => {
localStorage.setItem('keyword', keyword.value)
return products.value.filter(/* ... */)
})计算属性是惰性读取的。没人读取 filtered 时,存储动作可能根本不执行;多处读取与缓存失效又会让动作时机难以表达。派生列表留在 computed,保存关键词交给 watch(keyword, ...)。
深度修改时,新旧参数可能指向同一个响应式对象。若业务需要审计历史,应在变化发生前后自行建立快照或使用明确的不可变更新策略。
Vue 3.5+ 的 onWatcherCleanup() 必须在侦听回调同步执行阶段调用。先创建取消控制器并登记清理,再开始 await。使用回调参数 onCleanup 时也建议保持这个顺序,读者一眼就能看到资源如何被回收。
默认侦听器执行时,本组件 DOM 可能还没更新。需要新 DOM 就选择 flush: 'post',不需要 DOM 就保留默认值。不要为了“保险”把所有侦听器都改成 post。
同步侦听不参与批处理。数据一次连续变化很多次时,回调也可能连续执行很多次。除非需求明确要求同步,否则让 Vue 批量调度更稳妥。
看到新的 computed 或侦听器,不需要先争论个人偏好。按顺序问五个具体问题,通常就能找出真正的风险。
第一,计算属性返回的内容能不能完全由当前源状态得到?如果还会请求、写存储或改 DOM,副作用需要移出去。第二,getter 有没有修改数组、对象或别的 ref?如果有,读取行为正在偷偷写状态。
第三,侦听器的数据源是不是业务真正关心的边界?直接深度侦听一个庞大对象虽然省了几行代码,却可能让无关变化触发昂贵动作。能列出字段就列出字段,能侦听业务条件就不要侦听全部原料。
第四,副作用会不会跨越时间?只要存在请求、计时器、订阅或事件监听,就要说明旧一轮何时失效、如何清理。没有清理策略的异步侦听器,即使今天接口很快,也可能在网络变慢时暴露竞态。
第五,回调需要读取哪一时刻的 DOM?不读 DOM 就保留默认 pre;要读更新后的本组件 DOM 才选择 post;sync 必须能说出无法等待批处理的具体理由。
这五个问题还有一个附带好处:它们都能转成测试或调试步骤。我们可以给固定源状态检查派生结果,模拟快速连续变化检查取消,批量修改字段检查回调次数,在 post 回调中检查新元素是否存在。所谓“响应式很玄学”,往往只是依赖、时机或资源所有权没有被写清楚。
如果一段逻辑回答不了这些问题,先别急着增加更多选项。把它拆成一个纯计算函数、一个计算属性和一个副作用函数,分别命名之后再看。很多复杂侦听器拆开后,会发现真正需要 watch 的只剩最后一小段。
请分别判断下面四个需求应该优先使用什么:
下面的侦听器为什么不触发?请改正它。
const user = reactive({ profile: { name: '小雨' } })
watch(user.profile.name, (newName) => {
console.log(newName)
})下面的代码在快速切换 userId 时可能被旧响应覆盖。请加入清理:
watch(userId, async (id) => {
const response = await fetch(`/api/users/${id}`)
user.value = await response.json()
})先不要运行,判断修改 theme 会不会让下面的 effect 重新执行:
watchEffect(async () => {
const id = userId.value
await loadUser(id)
console.log(theme.value)
})走到这里,我们不需要死背一张 API 参数表。你只要能把页面逻辑拆成三层,代码通常就不会乱:源状态保存事实,computed 回答由事实能算出的结果,侦听器处理变化后必须执行的动作。
缓存的关键也不在“性能更好”四个字,而在依赖追踪:getter 读取了谁,谁就能让缓存失效;依赖不变时多次读取复用结果。watch 则要明确选数据源,处理好深度成本、执行时机和副作用清理。watchEffect 能自动收集同步读取,但遇到异步要记住第一个 await 的分界线。
回到那张报名表,单个 SFC 内部的逻辑层现在已经分清:字段是源状态,填写进度、提交资格和摘要是计算结果,自动保存是外部动作。但文件继续长大后,还会出现另一个无法靠 computed 或 watch 回答的问题:填写人信息、活动选择、协议确认和提交反馈,哪些部分拥有一套独立结构,接收明确输入,并管理自己的局部状态?这样的部分不该永远挤在同一个模板里,它们已经在形成组件边界。
任务看板也会暴露同样的问题。筛选栏负责输入条件,任务列负责组织一组任务,任务卡片负责展示内容并处理自己的交互状态;它们可以共享数据流,却不必共享一整块模板和所有实现细节。当一块界面能够说清“我接收什么、我内部保留什么、用户操作后我通知什么”,我们要划分的就不再只是计算与副作用,而是一个真正独立的小界面。