跳到主要内容

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()
})

正确做法:用 watchonCleanup(也叫“失效回调”)在下一次触发前清理上一次副作用,比如取消请求或忽略旧结果:

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 },
)
为什么要用 onCleanup?

因为 watch 的回调可能被重复触发onCleanup 能让你在“下一次开始之前”把上一次留下的定时器、事件监听、请求等资源清掉,避免泄漏与竞态。

2. watch vs watchEffect:核心区别一张图看懂

再用一个表快速对比:

维度watchwatchEffect
依赖来源你显式传入 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(最推荐的写法)

当你要监听的是对象里的某个字段(比如 reactiveprops),推荐写成 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)
},
)
为什么 getter 更好?

因为 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 },
)
深度监听的两个坑
  1. 性能成本高deep 本质上需要遍历对象(以及嵌套对象)来收集依赖,大对象会很慢。
  2. 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 的触发时机可以简单记为两句话:

  1. 下一次回调要执行前,先执行上一次注册的 cleanup
  2. 监听被停止或组件卸载时,也会执行 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?

把常见选择场景记成三句话:

  1. 想得到 new/old,或者依赖很明确 → 用 watch
  2. 依赖不固定、就是想“用到谁盯谁” → 用 watchEffect
  3. 只是从数据推导出新数据,不要副作用 → 用 computed
需求推荐
监听 props.id 变化重新请求watch(() => props.id, ...)
同时监听 keywordpagewatch([keyword, page], ...)
根据多个状态拼出 fullNamecomputed(() => ...)
自动把 a + b 写入 document.titlewatchEffect(() => ...)

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)
}

看完你就能理解三个关键点:

  1. 依赖收集发生在“执行 getter/effect 的那一刻”
  2. cleanup 总是在下一次执行前先跑
  3. flush 决定 job 被放进哪个队列(pre/post/sync)

8. 面试高频问答

Q1:watchwatchEffect 的核心区别是什么?

参考回答:

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 里出现 awaitawait 之后读取到的响应式值可能不会被收集为依赖,导致“以为会触发但没触发”。需要 await 时,更推荐 watch(source, async () => {})


Q8:computedwatch/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]) => {})。这样依赖清晰,回调参数也能一一对应新旧值。

9. 总结(把关键点串起来)