watch 与 watchEffect 详解(Vue 3)
在 Vue 里,响应式数据变了,视图会自动更新。但真实业务里,你经常还需要做“视图之外”的事情,比如:
- 路由参数变了,重新拉取数据
- 表单输入变了,做校验 / 自动保存
- 某个开关打开后,绑定事件、启动定时器;关闭后要释放资源
- 数据变了,需要在 DOM 更新后去读尺寸、滚动到某个位置
这些“额外动作”就是典型的 副作用(side effect),而 watch / watchEffect 就是 Vue 3 提供的副作用管理工具。
watch:你告诉 Vue“盯住谁”(显式依赖),然后在回调里处理副作用watchEffect:你在函数里用到谁,Vue 就自动盯住谁(自动依赖),并在依赖变化时重跑函数
1. 从一个问题开始:监听变化后发请求,如何避免“旧请求覆盖新请求”?
常见场景:用户快速切换 userId,你要拉取用户信息。如果不处理竞态,可能出现 后发先至,导致页面显示错数据。
import { ref, watch } from 'vue'
const userId = ref(1)
const user = ref<{ id: number; name: string } | null>(null)
watch(userId, async (newId) => {
// ❌ 竞态风险:newId=2 的请求可能比 newId=3 更晚返回
const res = await fetch(`/api/user/${newId}`)
user.value = await res.json()
})
正确做法:用 watch 的 onCleanup(也叫“失效回调”)在下一次触发前清理上一次副作用,比如取消请求或忽略旧结果:
import { ref, watch } from 'vue'
const userId = ref(1)
const user = ref<{ id: number; name: string } | null>(null)
const loading = ref(false)
watch(
userId,
async (newId, _oldId, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
loading.value = true
try {
const res = await fetch(`/api/user/${newId}`, { signal: controller.signal })
user.value = await res.json()
} finally {
loading.value = false
}
},
{ immediate: true },
)
因为 watch 的回调可能被重复触发。onCleanup 能让你在“下一次开始之前”把上一次留下的定时器、事件监听、请求等资源清掉,避免泄漏与竞态。
2. watch vs watchEffect:核心区别一张图看懂
再用一个表快速对比:
| 维度 | watch | watchEffect |
|---|---|---|
| 依赖来源 | 你显式传入 source | 运行时自动收集 |
| 是否有新旧值 | ✅ 有 newValue/oldValue | ❌ 没有(要自己保存) |
| 是否默认立即执行 | ❌ 默认不执行(可 immediate: true) | ✅ 默认立即执行 |
| 精准性 | ✅ 更精准,适合“只盯一个点” | ⚠️ 可能盯到更多依赖 |
| 常见用途 | 监听 props/路由/某个字段变化 | 自动同步多个依赖的副作用 |
3. watch:更精准、更可控
watch(source, callback, options?) 的核心是:source 决定依赖收集范围。
3.1 监听 ref
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newVal, oldVal) => {
console.log('count:', oldVal, '->', newVal)
})
3.2 监听 getter(最推荐的写法)
当你要监听的是对象里的某个字段(比如 reactive 或 props),推荐写成 getter,这样依赖更精确:
import { reactive, watch } from 'vue'
const state = reactive({
user: { id: 1, name: 'Tom' },
theme: 'light',
})
watch(
() => state.user.id,
(newId, oldId) => {
console.log('user.id:', oldId, '->', newId)
},
)
因为 watch(state, ...) 往往会变成“深度遍历”,触发更频繁,性能更差,而且 newValue/oldValue 还是同一个对象引用(后面会解释)。
3.3 监听多个来源(数组 source)
import { ref, watch } from 'vue'
const keyword = ref('')
const page = ref(1)
watch([keyword, page], ([newKeyword, newPage], [oldKeyword, oldPage]) => {
console.log('keyword:', oldKeyword, '->', newKeyword)
console.log('page:', oldPage, '->', newPage)
})
3.4 immediate:是否要“第一次就执行”
默认情况下,watch 只会在依赖变化后执行;如果希望组件初始化时就执行一次(常用于“初次拉数据”),用 immediate: true:
watch(
() => state.user.id,
(newId, oldId) => {
console.log('first run oldId =', oldId) // 第一次通常是 undefined
},
{ immediate: true },
)
3.5 deep:深度监听(慎用)
当你真的需要“对象内部任意字段变化都触发”时,可以开启深度监听:
import { reactive, watch } from 'vue'
const form = reactive({
name: '',
profile: { city: 'Shanghai' },
})
watch(
form,
() => {
console.log('form changed')
},
{ deep: true },
)
- 性能成本高:
deep本质上需要遍历对象(以及嵌套对象)来收集依赖,大对象会很慢。 - newValue/oldValue 常常“看起来没变”:因为它们往往指向同一个对象引用(对象是被原地修改的)。
更推荐的替代方案:只监听你关心的字段(getter),既快又准确:
watch(
() => form.profile.city,
(newCity, oldCity) => {
console.log('city:', oldCity, '->', newCity)
},
)
3.6 flush:回调到底在什么时候执行?
watch 的回调不是“立刻执行”的(除非 flush: 'sync'),它会根据 flush 选择在不同阶段调度:
flush: 'pre'(默认):组件渲染前触发回调(更适合改状态、发请求)flush: 'post':DOM 更新后触发回调(更适合读写 DOM)flush: 'sync':同步立即执行(慎用,容易导致连锁更新)
一个典型的 flush: 'post' 场景:依赖变化后,你要读取更新后的 DOM 尺寸。
import { nextTick, ref, watch } from 'vue'
const expanded = ref(false)
watch(
expanded,
async () => {
// 方式 1:flush=post(推荐)
// DOM 更新完成后再读尺寸
},
{ flush: 'post' },
)
watch(expanded, async () => {
// 方式 2:默认 flush=pre,但手动 nextTick
await nextTick()
// DOM 已更新
})
3.7 stop + onCleanup:停止监听与清理副作用
watch / watchEffect 都会返回一个 stop(),可以手动停止:
const stop = watch(count, (n) => {
console.log(n)
})
stop() // 不再监听
onCleanup 的触发时机可以简单记为两句话:
- 下一次回调要执行前,先执行上一次注册的 cleanup
- 监听被停止或组件卸载时,也会执行 cleanup
例如:用 watch 管理一个定时器(开启/关闭):
import { ref, watch } from 'vue'
const enabled = ref(false)
const now = ref(Date.now())
watch(enabled, (on, _prev, onCleanup) => {
if (!on) return
const timer = window.setInterval(() => {
now.value = Date.now()
}, 1000)
onCleanup(() => window.clearInterval(timer))
})
4. watchEffect:更省事,但要更小心
watchEffect(effect, options?) 的核心是:effect 里读到的响应式数据,都会变成依赖。
4.1 最小示例
import { ref, watchEffect } from 'vue'
const a = ref(1)
const b = ref(2)
watchEffect(() => {
// a 或 b 变了,这段都会重新执行
console.log('sum =', a.value + b.value)
})
4.2 用 onCleanup 管理副作用(订阅/请求/定时器)
import { ref, watchEffect } from 'vue'
const keyword = ref('')
watchEffect((onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
if (!keyword.value.trim()) return
fetch(`/api/search?q=${encodeURIComponent(keyword.value)}`, {
signal: controller.signal,
})
})
4.3 重要坑:async watchEffect 只会跟踪“第一个 await 之前”的依赖
很多人会写出这样的代码:
import { ref, watchEffect } from 'vue'
const a = ref(1)
const b = ref(2)
watchEffect(async () => {
console.log('track a:', a.value)
await Promise.resolve()
console.log('track b:', b.value)
})
b.value 发生在 await 之后,它往往不会被自动收集为依赖(因为依赖收集发生在 effect 的同步执行阶段)。
watchEffect尽量保持“同步读取依赖”- 只要业务里需要
await,更推荐用watch(source, async () => {}),依赖更明确,也更不容易踩坑
4.4 flush 与两个便捷 API:watchPostEffect / watchSyncEffect
Vue 还提供了两个语义更清晰的封装:
watchPostEffect(fn):等价于watchEffect(fn, { flush: 'post' })watchSyncEffect(fn):等价于watchEffect(fn, { flush: 'sync' })
import { watchEffect, watchPostEffect, watchSyncEffect } from 'vue'
watchEffect(() => {}, { flush: 'pre' }) // 默认
watchPostEffect(() => {}) // DOM 更新后
watchSyncEffect(() => {}) // 同步执行(慎用)
5. 怎么选:watch / watchEffect / computed?
把常见选择场景记成三句话:
- 想得到 new/old,或者依赖很明确 → 用
watch - 依赖不固定、就是想“用到谁盯谁” → 用
watchEffect - 只是从数据推导出新数据,不要副作用 → 用
computed
| 需求 | 推荐 |
|---|---|
监听 props.id 变化重新请求 | watch(() => props.id, ...) |
同时监听 keyword 和 page | watch([keyword, page], ...) |
根据多个状态拼出 fullName | computed(() => ...) |
自动把 a + b 写入 document.title | watchEffect(() => ...) |
6. 常见坑与最佳实践(背下来能少踩一半坑)
6.1 解构会“断开响应式”,watch 不触发
import { reactive, watch } from 'vue'
const state = reactive({ name: 'Tom' })
const { name } = state
watch(
() => name,
() => {
// ❌ 不会触发:name 只是一个普通字符串
},
)
正确做法:用 getter 直接访问,或者把字段转成 ref(如 toRef/toRefs)。
6.2 watch 监听整个对象时,new/old 为何经常相同?
watch(state, (n, o) => {
console.log(n === o) // 很可能是 true
})
原因很简单:对象大多是“原地改字段”,引用没变;因此想比较新旧值,应监听具体字段(getter)或自己做拷贝对比。
6.3 watchEffect 可能“误收集依赖”
例如你在 effect 里顺手打印了一个响应式对象,或者读取了一个你并不想作为依赖的数据,它都会被收集,导致 effect 触发更频繁。
经验:watchEffect 的函数体里,尽量只写“真正需要响应式驱动的逻辑”。
6.4 在回调里修改同一个 source,可能导致循环
watch(count, (n) => {
if (n < 0) count.value = 0 // ✅ 这种带条件的纠正通常 OK
})
如果你在回调里“无条件”修改同一个依赖,容易造成无限触发。解决思路:加条件、做去重、或把逻辑挪到 computed / 事件处理里。
7. 简化版原理:为什么它能“自动触发”?
你可以把 Vue 3 的 watch/watchEffect 理解成:基于响应式 effect 的调度器(scheduler)。
7.1 watch 的核心思路(伪代码)
// 伪代码:只表达思路,不是 Vue 源码
function watch(sourceGetter, cb, options) {
let oldValue
let cleanup
const onCleanup = (fn) => (cleanup = fn)
const job = () => {
cleanup?.()
const newValue = runGetter(sourceGetter) // 依赖收集发生在这里
cb(newValue, oldValue, onCleanup)
oldValue = newValue
}
oldValue = runGetter(sourceGetter)
if (options.immediate) job()
// 当依赖变化时,不是立刻跑 job,而是按 flush 进入不同队列
trackDepsAndSchedule(job, options.flush)
}
7.2 watchEffect 的核心思路(伪代码)
function watchEffect(effect, options) {
let cleanup
const onCleanup = (fn) => (cleanup = fn)
const runner = () => {
cleanup?.()
effect(onCleanup) // effect 内部读取的响应式会被自动收集
}
runner()
trackDepsAndSchedule(runner, options.flush)
}
看完你就能理解三个关键点:
- 依赖收集发生在“执行 getter/effect 的那一刻”
- cleanup 总是在下一次执行前先跑
- flush 决定 job 被放进哪个队列(pre/post/sync)
8. 面试高频问答
Q1:watch 和 watchEffect 的核心区别是什么?
参考回答:
watch 是显式依赖:通过 source 指定要监听的数据,回调里能拿到 newValue/oldValue,触发更精准;watchEffect 是自动依赖:根据 effect 内部读取的响应式自动收集依赖,会立即执行一次,但没有新旧值,可能触发更频繁。通常“依赖明确”用 watch,“依赖不固定或想省事”用 watchEffect。
Q2:为什么 watch(state, cb) 里 newValue === oldValue 很常见?
参考回答:
因为 state 是对象,业务里通常是“原地修改字段”,引用地址不变;watch 回调拿到的新旧值指向同一个对象,自然相等。想获得可比较的新旧值,应该监听具体字段(getter),或者自己对关键字段做快照/拷贝。
Q3:immediate: true 有什么作用?第一次 oldValue 是什么?
参考回答:
immediate: true 会让 watch 在创建时立刻执行一次回调,常用于初始化拉取数据。第一次执行时,oldValue 通常是 undefined(因为还没有“上一次”的值)。
Q4:deep 的原理与代价是什么?有什么替代方案?
参考回答:
deep 会遍历对象(以及嵌套对象)读取属性,从而把内部字段都收集为依赖,因此对象越大、层级越深,成本越高。替代方案是用 getter 精准监听需要的字段,例如 watch(() => form.profile.city, cb),既清晰又高性能。
Q5:flush: 'pre' | 'post' | 'sync' 分别适合什么场景?
参考回答:
pre(默认)在组件渲染前执行,适合改状态、发请求;post 在 DOM 更新后执行,适合读写 DOM(尺寸、滚动);sync 同步执行,响应最快但容易引发连锁更新或递归触发,一般只在非常明确的场景使用。
Q6:如何用 watch 解决异步请求竞态问题?
参考回答:
在回调里使用 onCleanup:每次触发时注册一个 cleanup,在下一次触发前取消上一次请求(比如 AbortController.abort()),或者设置一个标记让旧请求结果失效,从而避免“旧请求覆盖新请求”。
Q7:为什么不推荐直接写 async watchEffect?
参考回答:
因为 watchEffect 的依赖收集主要发生在同步执行阶段;如果 effect 里出现 await,await 之后读取到的响应式值可能不会被收集为依赖,导致“以为会触发但没触发”。需要 await 时,更推荐 watch(source, async () => {})。
Q8:computed 和 watch/watchEffect 有什么区别?
参考回答:
computed 用于“从数据推导数据”,有缓存,应该是纯计算、无副作用;watch/watchEffect 用于处理副作用(请求、订阅、DOM 操作、日志等)。如果只是为了得到一个新值,应优先用 computed。
Q9:如何停止一个 watcher?组件卸载后会怎样?
参考回答:
watch/watchEffect 会返回一个 stop(),调用后停止监听并执行 cleanup。若 watcher 是在组件 setup 内创建的,组件卸载时 Vue 会自动停止它,并触发 cleanup,一般不需要手动 stop(除非你要提前停掉)。
Q10:如何同时监听多个值,并且拿到它们的变化?
参考回答:
使用数组 source:watch([a, b], ([newA, newB], [oldA, oldB]) => {})。这样依赖清晰,回调参数也能一一对应新旧值。