再也不怕面试官问 watch、computed、watchEffect 的区别了
在 Vue 中,响应式系统会在读取状态时建立依赖关系,在状态写入后通知相关 effect 或组件更新。
watch、computed和watchEffect都建立在这套机制之上,但职责不同:一个主要处理副作用,一个产生派生值,一个自动追踪副作用依赖。
一、先给出面试结论
computed:从已有响应式状态得到派生值,默认惰性求值并缓存,getter 应尽量保持纯净;watch:监听明确的响应式 source,在变化后执行副作用,可以拿到新值和旧值;watchEffect:立即执行一个副作用,并自动追踪同步执行期间访问的响应式依赖;- 需要“算出一个值”优先使用
computed;需要“变化后做事情”使用watch或watchEffect。
二、watch
1. 基本用法
<script setup lang="ts">
import { ref, watch } from 'vue'
const keyword = ref('')
const stop = watch(keyword, (newValue, oldValue) => {
console.log('keyword:', oldValue, '→', newValue)
})
// 不再需要时可以停止监听
// stop()
</script>
Composition API 的 watch source 可以是:
import { reactive, ref, watch } from 'vue'
const count = ref(0)
const state = reactive({ user: { name: 'Ada' } })
watch(count, value => {})
watch(() => state.user.name, (name, oldName) => {})
watch([count, () => state.user.name], ([nextCount, nextName]) => {})
watch(state, value => {}) // 直接监听 reactive 对象时默认深度追踪
Composition API 中不能把任意字符串当成路径表达式:
// ❌ 不是 Composition API watch 的通用写法
watch('user.name', callback)
// ✅ 用 getter 表达依赖
watch(() => state.user.name, callback)
Vue 2.7 的 Options API 或 $watch 仍可以监听有限的简单路径:
export default {
watch: {
'user.name'(value, oldValue) {
// Vue 2 Options API 的简单路径写法
},
},
}
2. immediate、deep 和 flush
watch(
() => route.params.id,
id => loadArticle(id),
{
immediate: true,
flush: 'post',
},
)
immediate: true:创建监听时先执行一次;deep: true:递归观察嵌套变化,可能有较大开销;flush: 'pre':默认调度语义附近,在组件 DOM 更新前执行;flush: 'post':组件 DOM 更新后执行;flush: 'sync':同步执行,可能失去批量更新好处,应谨慎使用。
Vue 3.5 支持数字形式的深度限制:
watch(source, callback, { deep: 2 })
深度监听不是性能优化手段。数据树很大时,应尽量监听真正需要的叶子值或拆分状态。
3. 异步请求和清理
watch 适合执行异步副作用,但必须防止旧请求覆盖新请求:
import { onWatcherCleanup, ref, watch } from 'vue'
const keyword = ref('')
const results = ref<unknown[]>([])
watch(keyword, async value => {
const controller = new AbortController()
onWatcherCleanup(() => controller.abort())
const response = await fetch(`/api/search?q=${encodeURIComponent(value)}`, {
signal: controller.signal,
})
results.value = await response.json()
})
onWatcherCleanup 是 Vue 3.5 新增 API,必须在异步回调第一次 await 之前注册。为了兼容更早的 Vue 3 版本,也可以使用第三个参数:
watch(source, async (value, oldValue, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
// ...
})
watch() 会返回停止监听的函数,不应说成“没有返回值”。
三、computed
1. 派生数据和缓存
<script setup lang="ts">
import { computed, ref } from 'vue'
const count = ref(0)
const doubledCount = computed(() => count.value * 2)
const label = computed(() => `Count: ${count.value}`)
</script>
computed 的 getter 第一次读取时执行,之后会缓存结果;只有依赖发生变化,缓存才会失效,下一次读取时重新计算。如果依赖没有变化,多次读取可以复用同一个结果。
它适合:
- 过滤、排序和格式化列表;
- 计算总价、权限、全名等派生值;
- 把复杂模板表达式提取到可复用的响应式值。
不要在 computed getter 中发请求、修改其他状态或执行日志等副作用:
// ❌ 不建议:getter 读取时可能被多次调用,副作用难以预测
const bad = computed(() => {
saveToServer(state.value)
return state.value.id
})
computed 的缓存是语义和性能特征,但不是任何情况下都更快。简单的 count + 1 用普通函数也可能足够,选择时首先看它是否是派生状态。
2. 可写 computed
默认 computed 只有 getter,也可以提供 setter:
const firstName = ref('Ada')
const lastName = ref('Lovelace')
const fullName = computed({
get: () => `${firstName.value} ${lastName.value}`,
set: value => {
const [first, last = ''] = value.split(' ')
firstName.value = first
lastName.value = last
},
})
setter 不是让 computed 变成一份独立缓存状态,而是把写操作转发给真实的响应式状态。
四、watchEffect
1. 自动收集依赖
import { ref, watchEffect } from 'vue'
const count = ref(0)
const stop = watchEffect(() => {
document.title = `Count: ${count.value}`
})
// stop()
watchEffect 会立即执行一次,执行过程中访问的响应式数据会被自动记录;这些数据变化后,effect 会重新执行。
和 watch 对比:
// watch:明确 source,只监听 count,能拿到新旧值
watch(count, (value, oldValue) => {
console.log(value, oldValue)
})
// watchEffect:自动收集回调中读取的所有依赖,不提供新旧值
watchEffect(() => {
console.log(count.value)
})
当依赖列表动态变化、且不需要旧值时,watchEffect 很方便;需要精确控制 source、immediate、deep、flush 或异步竞态时,watch 更清晰。
2. 异步 effect 的边界
异步回调中,只有第一个 await 之前访问的响应式值会被自动追踪:
watchEffect(async () => {
const id = currentId.value // 会被追踪
await fetch(`/api/article/${id}`)
// await 后新读取的响应式值不会作为本次自动依赖
})
需要将异步 source 和清理策略表达清楚时,通常使用 watch 更合适。
五、原理:不要把 Vue 2 的 Watcher 套到 Vue 3
1. Vue 2.7 的大致链路
Vue 2.7 主要使用 Object.defineProperty、Dep 和 Watcher:
响应式 getter 收集 Watcher
↓
响应式 setter 通知 Dep
↓
Watcher 入调度队列
↓
组件更新或执行 watch 回调
computed 使用 lazy watcher 和 dirty 标记:第一次读取时计算,依赖变化时标记失效,下一次读取时再计算。Vue 2 的 watcher 讲解可以使用下面的教学伪代码:
class Watcher {
constructor(getter, callback) {
this.getter = getter
this.callback = callback
this.value = this.get()
}
get() {
pushTarget(this)
const value = this.getter()
popTarget()
return value
}
update() {
const oldValue = this.value
const newValue = this.get()
this.value = newValue
this.callback(newValue, oldValue)
}
}
这不是 Vue 2.7 可直接复制的源码,省略了依赖清理、深度遍历、队列去重和异常处理。
2. Vue 3.5 的大致链路
Vue 3 使用 Proxy/Reflect 及独立的 reactivity 包。watch 的实现位于 runtime-core 的 watcher API,底层会创建响应式 effect,并根据 source 生成 getter;当 effect 被触发时,scheduler 再按 flush 选项安排 job。
Vue 3 中不存在 Vue 2 语境下的 Watcher 类作为 watch 的核心。更准确的抽象是:
source getter / effect
↓ 读取时 track
响应式依赖图
↓ 写入时 trigger
scheduler 调度 job
↓
执行回调或重新运行 effect
computed 内部由 ComputedRefImpl 表示,它维护惰性求值和失效状态;访问 .value 时追踪 computed 自身,依赖变化时让缓存失效或触发下游 effect。它不是“数据变化时调用 computed setter”:setter 只有在使用者主动给可写 computed 赋值时才执行。
六、三个 API 如何选择
| 需求 | 推荐 API | 原因 |
|---|---|---|
| 由状态计算列表、总价、标签 | computed | 派生值、缓存、可在模板中复用 |
| 状态变化后请求接口、写缓存、操作第三方库 | watch | 明确 source,支持新旧值和清理 |
| 自动使用当前 effect 中读取的依赖 | watchEffect | 不需要手动列出 source |
| 只想同步两个字段的展示结果 | computed | 避免手动 watch 再写回第三份状态 |
| 需要 DOM 更新后运行副作用 | watch(..., { flush: 'post' }) | 明确 DOM 时机 |
例如,下面的派生值不应使用 watch 维护第二份状态:
// ❌ 多维护了一份 doubledCount,存在同步风险
watch(count, value => {
doubledCount.value = value * 2
})
// ✅ 直接使用 computed
const doubledCount = computed(() => count.value * 2)
七、面试版总结
computed用于派生状态,getter 默认惰性执行并缓存,应该保持纯净;watch用于监听明确的响应式 source,在变化后执行副作用,支持新旧值、immediate、deep、flush 和清理;watchEffect会立即执行并自动追踪同步执行期间访问的依赖,适合不需要新旧值的简单副作用。Vue 2.7 的实现常用 Dep 和 Watcher,Vue 3.5 则使用 Proxy、ReactiveEffect 和 scheduler,不能把 Vue 2 的 Watcher 源码直接当作 Vue 3 实现。
参考资料
原文作者:Bun。原文关于三个 API 的定义、原理和选择场景予以保留,损坏的代码围栏、错误的 Vue 3 Watcher 源码描述、抓取噪声和版权推广内容已清理。