Computed、Watch 与 watchEffect
computed 负责声明衍生状态,watch 和 watchEffect 负责响应状态变化并执行副作用。
先建立心智模型
选择 API 前,先判断代码的目标是什么:
- 衍生状态:可以完全由其他状态计算得到,例如总价、筛选结果、完整姓名。
- 副作用:会影响计算过程之外的内容,例如请求接口、操作 DOM、写入本地存储。
import { computed, ref, watch } from 'vue'
const price = ref(100)
const quantity = ref(2)
// 衍生状态:相同输入始终对应相同结果
const total = computed(() => price.value * quantity.value)
// 副作用:total 变化后同步到本地存储
watch(total, (value) => {
localStorage.setItem('total', String(value))
})
不要用 watch 手动维护本可以计算出来的状态:
// 不推荐:创建了可变的重复状态
const total = ref(0)
watch([price, quantity], () => {
total.value = price.value * quantity.value
})
// 推荐:直接描述 total 与源状态的关系
const total = computed(() => price.value * quantity.value)
computed:声明衍生状态
computed() 接收 getter,返回一个计算属性 ref。JavaScript 中通过 .value 读取,模板中会自动解包。
import { computed, ref } from 'vue'
const keyword = ref('')
const products = ref([
{ id: 1, name: 'Keyboard' },
{ id: 2, name: 'Mouse' }
])
const filteredProducts = computed(() => {
const value = keyword.value.trim().toLowerCase()
return products.value.filter((item) => {
return item.name.toLowerCase().includes(value)
})
})
惰性求值、依赖追踪与缓存
计算属性有三个关键特征:
- 自动追踪依赖:执行 getter 时读取的响应式状态会成为依赖。
- 惰性求值:只有结果被读取时,getter 才需要执行。
- 基于依赖缓存:依赖没有变化时,多次读取直接复用上次结果。
可以把内部过程简化为:
- 首次读取计算属性,执行 getter、收集依赖并缓存结果。
- 依赖变化,将计算属性标记为“待更新”。
- 下次读取时重新计算并更新缓存。
因此,计算属性与方法虽然可能返回相同结果,执行时机却不同:
const filteredProducts = computed(() => expensiveFilter(products.value))
function getFilteredProducts() {
return expensiveFilter(products.value)
}
filteredProducts.value 只在响应式依赖变化后重新计算。
getFilteredProducts() 每次调用都会执行。
缓存只与响应式依赖有关。下面的结果不会随时间自动更新,因为 Date.now() 不是响应式状态:
const now = computed(() => Date.now())
getter 应保持纯粹
computed getter 应该同步地根据依赖返回结果,不要在其中请求接口、修改其他状态或操作 DOM:
// 不推荐
const user = computed(async () => {
return fetch('/api/user').then((response) => response.json())
})
异步函数在语法上可以返回 Promise,但这会让计算属性变成 Promise 的缓存,而不是异步数据状态;加载、失败、取消和竞态也无法被清晰管理。异步副作用应使用 watch 或封装成专门的组合式函数。
可写计算属性
计算属性默认只读。少数需要双向转换的场景,可以同时提供 getter 和 setter:
const firstName = ref('Ada')
const lastName = ref('Lovelace')
const fullName = computed({
get() {
return `${firstName.value} ${lastName.value}`
},
set(value) {
const [first, ...rest] = value.trim().split(/\s+/)
firstName.value = first ?? ''
lastName.value = rest.join(' ')
}
})
优先修改源状态。只有当“写入衍生值”能被明确转换为“更新源状态”时,才适合使用可写计算属性。
watch:精确监听并执行副作用
watch() 显式指定监听源,在监听值发生变化时执行回调。它适合需要旧值、新值,或者需要控制执行时机的副作用。
const keyword = ref('')
watch(keyword, (newKeyword, oldKeyword) => {
console.log(`${oldKeyword} -> ${newKeyword}`)
})
默认情况下,回调不会在创建监听器时执行,而是在监听源后续变化时执行。
可以监听什么
监听源可以是 ref、计算属性、响应式对象、getter,或者由这些监听源组成的数组:
const user = reactive({ profile: { name: 'Ada' } })
const page = ref(1)
const pageSize = ref(20)
watch(page, loadPage)
watch(() => user.profile.name, validateName)
watch([page, pageSize], ([newPage, newPageSize], [oldPage, oldPageSize]) => {
// 任一监听源变化时执行
})
不能直接把普通值作为监听源:
// 错误:传入的是当前字符串,而不是响应式监听源
watch(user.profile.name, validateName)
// 正确:用 getter 描述要监听的属性
watch(() => user.profile.name, validateName)
常用选项
watch(
() => user.profile,
(profile) => {
saveProfile(profile)
},
{
immediate: true,
deep: true,
flush: 'post'
}
)
immediate: true:创建监听器时立即执行一次。
deep: true:递归追踪嵌套属性变化;数据结构很大时要注意遍历成本。
flush: 'pre':默认值,在组件自身 DOM 更新前执行。
flush: 'post':在组件 DOM 更新后执行,适合读取更新后的 DOM。
flush: 'sync':同步执行,不参与批处理,应谨慎用于频繁变化的数据。
once: true:源首次变化后停止监听,Vue 3.4+ 支持。
直接监听一个 reactive 对象时会隐式深度监听。由于新旧值指向同一个对象,嵌套属性变化时 newValue === oldValue 可能为 true。如果需要可靠比较变更前后的结构,应监听具体字段,或自行创建快照。
flush:控制回调的执行时机
一次响应式状态修改可能同时触发组件更新和多个监听器。Vue 通常不会立刻逐个执行这些任务,而是把它们放入调度队列并进行批处理。flush 决定监听器回调相对于组件 DOM 更新在什么时机执行:
这里的“所属组件”是创建监听器的组件。默认的 'pre' 并不表示页面上所有 DOM 都尚未更新:父组件可能已经更新,但当前组件自己的 DOM 仍是旧状态。
flush: 'pre':默认的更新前回调
'pre' 监听器会被加入队列,同一个同步任务中的多次修改通常会合并,避免重复执行回调:
const count = ref(0)
watch(count, (value) => {
console.log(value)
}) // 等价于 { flush: 'pre' }
count.value++
count.value++
count.value++
// 回调不会紧跟在每次 ++ 后执行,而会在队列刷新时执行
由于回调运行时所属组件还没有完成本轮 DOM 更新,不应在这里读取依赖最新渲染结果的 DOM。普通的数据请求、日志记录、状态联动等副作用通常保留默认值即可。
flush: 'post':等待所属组件 DOM 更新
需要访问更新后的 DOM 时使用 'post'。例如,根据最新内容读取元素高度:
<script setup>
import { ref, watch } from 'vue'
const message = ref('')
const messageElement = ref(null)
watch(
message,
() => {
// 此时模板已经根据最新 message 完成更新
console.log(messageElement.value?.offsetHeight)
},
{ flush: 'post' }
)
</script>
<template>
<p ref="messageElement">{{ message }}</p>
</template>
对于这个场景,flush: 'post' 通常比在默认监听器中手动 await nextTick() 更直接。两者都能等待 DOM 更新,但 'post' 明确表达了整个监听器都属于“渲染后副作用”。
'post' 仍然参与批处理,只保证 Vue 已经提交本轮 DOM 更新,不保证浏览器已经完成布局和绘制。如果需要把动画或其他视觉操作安排到下一帧,可以再结合 requestAnimationFrame()。
flush: 'sync':响应式变化时同步执行
'sync' 不进入批处理队列。每次检测到变化都会立即调用回调:
const enabled = ref(false)
watch(
enabled,
(value) => {
invalidateCache(value)
},
{ flush: 'sync' }
)
enabled.value = true
// 赋值过程中同步触发回调,执行到下一行前回调已经结束
这会带来两个风险:
- 性能问题:同步连续修改数组等数据时,回调可能执行成百上千次。
- 中间状态暴露:连续修改多个相关状态时,回调可能看到尚未修改完整的临时状态。
const items = ref([])
watch(items, persistItems, {
deep: true,
flush: 'sync'
})
for (let index = 0; index < 1000; index++) {
items.value.push(index) // 可能同步触发 1000 次回调
}
因此只应对更新频率低、逻辑简单,并且确实要求同步执行的数据使用 'sync'。它不是让监听器“更快”的性能选项。
与 watchEffect 的关系
watchEffect() 使用相同的 flush 语义。Vue 还为两种常见时机提供了别名:
import { watchPostEffect, watchSyncEffect } from 'vue'
watchPostEffect(() => {
// 等价于 watchEffect(callback, { flush: 'post' })
})
watchSyncEffect(() => {
// 等价于 watchEffect(callback, { flush: 'sync' })
})
没有 watchPreEffect(),因为 watchEffect() 默认就是 flush: 'pre'。
选择时可以记住一个简单原则:不依赖最新 DOM 就用默认的 'pre';依赖更新后的 DOM 用 'post';只有能够说明“为什么必须在赋值过程中同步执行”时才使用 'sync'。
清理异步副作用
搜索词连续变化时,较早发出的请求可能更晚返回,进而用旧结果覆盖新结果。应在监听器失效时取消上一次请求:
const results = ref([])
watch(keyword, async (value, _oldValue, onCleanup) => {
const controller = new AbortController()
onCleanup(() => {
controller.abort()
})
const response = await fetch(`/api/search?q=${encodeURIComponent(value)}`, {
signal: controller.signal
})
results.value = await response.json()
})
清理函数会在当前回调失效、下一次回调执行前调用,也会在监听器停止时调用。
停止监听
watch() 返回停止函数。在组件 setup() 中同步创建的监听器会随组件卸载自动停止;手动管理或异步创建的监听器应主动清理。
const stop = watch(keyword, search)
// 不再需要监听时
stop()
watchEffect:自动追踪副作用依赖
watchEffect() 会立即执行回调,并自动追踪回调同步执行期间读取的响应式依赖:
const page = ref(1)
const pageSize = ref(20)
watchEffect(() => {
console.log(`加载第 ${page.value} 页,每页 ${pageSize.value} 条`)
})
它适合“使用了哪些依赖”和“依赖变化后做什么”紧密关联的简单副作用。依赖较多时,可以减少重复声明。
watch 与 watchEffect 的核心区别是:
异步 watchEffect 只会追踪第一个 await 之前同步读取的依赖:
watchEffect(async () => {
const id = userId.value // 会被追踪
const response = await fetch(`/api/users/${id}`)
detail.value = await response.json() // await 后读取的依赖不会被追踪
})
这是由 Vue 的依赖收集方式和 JavaScript 的异步执行机制共同决定的。
Vue 执行 watchEffect 回调时,会暂时把当前副作用标记为“正在收集依赖的 effect”。在此期间读取响应式状态,相应的 ref 或 reactive 属性就能把当前 effect 记录为自己的订阅者。同步回调结束后,Vue 会清除这个标记。
可以把这个过程简化为:
let activeEffect
function runEffect(effect) {
activeEffect = effect
effect()
activeEffect = undefined
}
function track(target) {
if (activeEffect) {
target.dependencies.add(activeEffect)
}
}
异步函数执行到第一个 await 时,会立即返回 Promise,本轮同步调用随之结束。await 后面的代码要等 Promise 完成,再进入新的微任务继续执行:
async function effect() {
readA()
await request()
readB()
}
// 可以近似理解为
function effect() {
readA()
return request().then(() => {
readB()
})
}
当 readB() 执行时,Vue 已经结束本轮依赖收集,记录当前 effect 的同步上下文也已经被清除。因此,只有 await 前读取的 A 能与当前 watchEffect 建立依赖关系。
const a = ref(1)
const b = ref(1)
watchEffect(async () => {
console.log(a.value) // await 前读取,会被追踪
await Promise.resolve()
console.log(b.value) // await 后才读取,不会因此被追踪
})
在这个例子中:
- 修改
a.value 会重新执行回调。
- 只修改
b.value 不会重新执行回调。
- 如果
b.value 在 await 前也读取过,那么 b 仍然会被追踪。
需要自动追踪多个依赖时,可以先在 await 前读取并保存它们:
watchEffect(async () => {
const currentUserId = userId.value
const currentLocale = locale.value
const response = await fetch(`/api/users/${currentUserId}`)
detail.value = await response.json()
console.log(currentLocale)
})
如果异步逻辑的依赖较多,使用 watch 显式声明通常更加清晰:
watch([userId, locale], async ([currentUserId, currentLocale]) => {
const response = await fetch(`/api/users/${currentUserId}`)
detail.value = await response.json()
console.log(currentLocale)
})
当依赖关系需要读完整个函数才能确定时,显式的 watch 通常更容易维护。
三者放在同一个场景中
以商品搜索页为例:
const keyword = ref('')
const products = ref([])
// 1. 根据现有数据得到新值:computed
const matchedProducts = computed(() => {
return products.value.filter((product) => {
return product.name.includes(keyword.value)
})
})
// 2. 搜索词变化后请求后端:watch
watch(keyword, fetchProducts, { immediate: true })
// 3. 自动收集多个依赖并记录调试信息:watchEffect
watchEffect(() => {
console.log(keyword.value, matchedProducts.value.length)
})
这三段代码不是互相替代的关系,而是在解决不同问题:
computed 维护数据之间的声明式关系。
watch 精确控制副作用由什么变化触发。
watchEffect 便捷地执行依赖较直观的响应式副作用。
常见误区
1. 用 watch 同步两个状态
如果 B 能完全由 A 计算得出,优先使用 computed。重复状态可能出现更新顺序、初始化和数据不一致问题。
2. 在 computed 中修改依赖
getter 应保持纯粹。在计算过程中修改它依赖的状态,可能产生循环更新和难以预测的行为。
3. 滥用深度监听
deep: true 需要遍历嵌套结构。优先监听真正关心的字段,避免对大型对象做无差别深度监听。
4. 认为 watch 每次赋值都会立即执行
默认 watcher 会被 Vue 调度和批处理,同一轮更新中的多次同步变更不一定产生等量回调。只有确实需要同步行为时才考虑 flush: 'sync'。
5. 忽略异步竞态
监听器可以使用异步回调,但 watch 不会自动保证请求按发出顺序完成。请求类副作用应实现取消、失效处理或结果版本校验。
怎么选
按下面的顺序判断:
- 是否要根据现有状态得到一个值?使用
computed。
- 是否要在明确的数据变化后执行副作用?使用
watch。
- 是否是一个依赖简单、希望立即执行并自动收集依赖的副作用?使用
watchEffect。
- 是否只是由事件触发一次操作?直接在事件处理函数中执行,不需要 watcher。
面试回答
computed 用于声明衍生状态,getter 应保持纯粹。它会自动追踪响应式依赖,并基于依赖进行惰性求值和缓存:依赖不变时,多次读取不会重复计算。
watch 用于监听明确的数据源并执行副作用,可以获取新旧值,并通过 immediate、deep、flush 等选项控制行为,适合请求接口、操作 DOM 和同步外部系统。
watchEffect 也用于副作用,但它会立即执行并自动收集同步执行期间读取的依赖。它写起来更简洁,触发来源却不如 watch 明确。
一句话总结:要一个值用 computed,值变化后要做一件事用 watch;简单且适合自动收集依赖的副作用用 watchEffect。