在 Vue 3 的 Composition API 中,watch 和 watchEffect 都用于观察响应式数据的变化并执行副作用,但两者的设计哲学和底层实现存在本质区别。理解这些差异,不仅有助于在开发中做出正确的选择,也能更深入地把握 Vue 3 响应式系统的运行机制。
一、核心差异:一张表看清本质
| 特性维度 | watch |
watchEffect |
|---|---|---|
| 依赖收集方式 | 显式指定监听源(ref、getter、reactive 对象等) | 自动追踪回调函数内部访问的所有响应式依赖 |
| 初始执行时机 | 默认惰性执行,仅在源变化时触发;可通过 immediate: true 立即执行 |
创建时立即执行一次,以收集初始依赖 |
| 回调参数 | 接收 (newValue, oldValue),支持新旧值对比 |
无参数,仅能通过闭包访问当前最新值 |
| 依赖精确度 | 高,仅追踪显式声明的源 | 可能追踪到回调中所有被访问的响应式属性 |
| 适用场景 | 需要旧值对比、精确控制触发条件、条件性监听 | 依赖关系复杂或动态、无需旧值、初始化即执行的副作用 |
二、watch 的深入用法与源码剖析
2.1 基本使用
watch 接受三个参数:source(监听源)、callback(回调函数)、options(配置对象)。
javascript
javascript
import { ref, watch } from 'vue'
const count = ref(0)
watch(count, (newVal, oldVal) => {
console.log(`count 从 ${oldVal} 变为 ${newVal}`)
}, { immediate: true })
source 可以是以下形式:
- 一个
ref或computed引用 - 一个
reactive对象(自动启用深度监听) - 一个 getter 函数(返回需要监听的值)
- 上述类型的数组(同时监听多个源)
javascript
scss
// 监听 reactive 对象
const state = reactive({ a: 1, nested: { b: 2 } })
watch(state, (newVal) => { /* 自动深度监听 */ })
// 监听 getter
watch(() => state.a + state.nested.b, (sum) => { /* ... */ })
// 监听多个源
watch([refA, () => refB.value], ([newA, newB], [oldA, oldB]) => { /* ... */ })
2.2 源码入口:watch 与 doWatch
watch 函数的实现非常简洁,它本质上是一个包装层,最终将调用委托给 doWatch:
javascript
bash
function watch(source, cb, options) {
return doWatch(source, cb, options)
}
doWatch 是 watch 和 watchEffect 的共享核心,位于 packages/runtime-core/src/apiWatch.ts 中。两者的区别在进入 doWatch 之前就已经通过参数确定了:watch 传入非空的 cb,而 watchEffect 的 cb 为 null。
2.3 监听源的标准化
doWatch 的首要任务是将各种形式的 source 统一转换为一个 getter 函数。这个 getter 的作用是返回当前被监听的值,供 ReactiveEffect 执行时进行依赖追踪。
javascript
ini
if (isRef(source)) {
getter = () => source.value
} else if (isReactive(source)) {
getter = () => source
deep = true // reactive 对象默认深度监听
} else if (isArray(source)) {
getter = () => source.map(s => isRef(s) ? s.value : s())
} else if (isFunction(source)) {
getter = source
}
对于 reactive 对象,Vue 会自动启用深度监听,因为 reactive 的 Proxy 特性使得任何嵌套属性的访问都会被追踪。而对于 ref,getter 仅返回 source.value,是否深度监听取决于 deep 选项。
2.4 immediate 的实现机制
immediate: true 的核心逻辑在 doWatch 中处理:
javascript
arduino
const runsImmediately = (cb && immediate) || (!cb && flush !== 'post')
对于 watch,runsImmediately 取决于 immediate 选项;对于 watchEffect(cb 为 null),只要 flush 不是 'post',就会立即执行。当 runsImmediately 为真时,doWatch 会在创建 effect 后立刻调用一次 job() 或 effect.run()。
2.5 flush 选项与调度器
flush 决定了副作用回调的执行时机,底层通过 ReactiveEffect 的 scheduler 实现:
'pre'(默认) :在组件更新前执行,调度器将任务推入queueJob队列,在nextTick中异步执行。'post':在组件更新后执行,调度器使用queuePostFlushCb,确保 DOM 已更新。'sync':同步执行,调度器直接调用job(),不进入异步队列。
javascript
ini
const scheduler = flush === 'sync'
? job
: flush === 'post'
? () => queuePostFlushCb(job)
: () => queueJob(job)
watchPostEffect 和 watchSyncEffect 分别是 watchEffect 在 flush: 'post' 和 flush: 'sync' 下的语法糖。
三、watchEffect 的深入用法与源码剖析
3.1 基本使用与自动依赖收集
watchEffect 只需传入一个副作用函数,Vue 会自动追踪该函数执行过程中访问的所有响应式依赖,并在任何依赖变化时重新执行该函数。
javascript
csharp
import { ref, watchEffect } from 'vue'
const count = ref(0)
const double = ref(0)
watchEffect(() => {
double.value = count.value * 2
console.log('count:', count.value, 'double:', double.value)
})
// 立即执行,输出 count: 0 double: 0
当 count 变化时,副作用函数会自动重新执行,double 也会随之更新。
3.2 源码入口:极简的委托
watchEffect 的实现同样是一个极简的委托:
javascript
javascript
export function watchEffect(effect, options) {
return doWatch(effect, null, options)
}
这里的关键在于:cb 参数为 null,因此 doWatch 会走 watchEffect 的处理分支。
3.3 自动依赖收集的底层机制
watchEffect 的依赖收集完全依托于 Vue 3 的响应式系统。当 doWatch 创建 ReactiveEffect 并执行 effect.run() 时,ReactiveEffect 会被设置为当前活跃的 effect。此时,副作用函数内部对任何响应式属性(ref.value 或 reactive 属性)的读取操作,都会触发 Proxy 的 get 拦截器,进而调用 track 方法,将当前 effect 添加到该属性的依赖集合中。
javascript
csharp
// 简化后的依赖收集流程
function track(target, key) {
const depsMap = targetMap.get(target)
const dep = depsMap.get(key)
dep.add(activeEffect) // activeEffect 即为 watchEffect 的 ReactiveEffect
}
当依赖发生变化时,trigger 方法会遍历该属性的依赖集合,调用每个 effect 的调度器或直接执行 effect。
3.4 动态依赖的"可加不可减"特性
watchEffect 的依赖收集是动态的 ,但有一个重要限制:每次重新执行时,依赖集合会被清空后重新收集。这意味着:
- 如果某次执行中某个依赖没有被访问,它将从依赖集合中移除,后续变化不再触发副作用。
- 如果条件分支导致新的依赖被访问,它们会被加入依赖集合。
javascript
scss
const enabled = ref(false)
const counter = ref(0)
watchEffect(() => {
if (enabled.value) {
console.log('counter:', counter.value) // 仅当 enabled 为 true 时追踪 counter
}
})
当 enabled 为 false 时,counter 不会被追踪;只有 enabled 变为 true 后,counter 才进入依赖集合。
四、深层监听与 deep 选项的源码差异
4.1 watch 的 deep 处理
当 watch 的 source 是 reactive 对象时,deep 自动设为 true。对于 ref 或 getter,需要显式指定 deep: true。Vue 3.5+ 还支持 deep 传入数字,表示监听的层数:
javascript
scss
watch(state, cb, { deep: 2 }) // 只监听两层深度
源码中通过 traverse 函数递归访问对象的每一层属性,从而建立完整的依赖关系。deep: true 等价于 deep: Infinity,即监听所有层级。
4.2 watchEffect 与深层监听
watchEffect 本身没有 deep 选项,因为它会自动追踪回调中实际访问的所有属性。如果回调中访问了嵌套对象的深层属性,该属性自然会被追踪;如果没有访问,则不会被追踪。这种"按需追踪"的方式比 watch 的 deep: true 更加精细,但在需要监听整个对象时,可能需要手动遍历或配合 toRefs 使用。
五、副作用清理:onCleanup 与 onInvalidate
5.1 基本用法
watch 和 watchEffect 都支持副作用清理,用于处理异步请求竞态、定时器清理等场景。
在 watchEffect 中,清理函数通过回调的第一个参数 onCleanup 注册:
javascript
scss
watchEffect((onCleanup) => {
const controller = new AbortController()
fetch(`/api/data/${id.value}`, { signal: controller.signal })
onCleanup(() => controller.abort())
})
在 watch 中,清理函数通过回调的第三个参数 onCleanup 注册:
javascript
scss
watch(id, async (newId, oldId, onCleanup) => {
const controller = new AbortController()
onCleanup(() => controller.abort())
const data = await fetch(`/api/data/${newId}`, { signal: controller.signal })
}, { flush: 'post' })
5.2 清理时机的源码实现
清理函数被存储在 ReactiveEffect 的 onStop 属性上。当副作用即将重新执行前、侦听器被手动停止、或组件卸载时,onStop 会被调用:
javascript
javascript
let cleanup: (() => void) | undefined
const onCleanup = (fn: () => void) => {
cleanup = runner.options.onStop = () => fn()
}
在每次 job 执行前,doWatch 会先调用 cleanup,再执行新的副作用逻辑。Vue 3.5 还引入了 onWatcherCleanup 函数,可以在 watch / watchEffect 的同步执行阶段注册清理函数,提供了更灵活的 API。
六、停止侦听与 pause / resume
6.1 手动停止
watch 和 watchEffect 都返回一个停止函数:
javascript
scss
const stop = watchEffect(() => { /* ... */ })
stop() // 停止侦听
doWatch 的返回值正是 ReactiveEffect 的 stop 方法包装。调用 stop() 后,effect 会从所有依赖集合中移除,后续变化不再触发回调。
6.2 pause 与 resume(Vue 3.5+)
Vue 3.5 为 watch 和 watchEffect 的返回值增加了 pause 和 resume 方法,可以在不销毁侦听器的情况下临时暂停和恢复:
javascript
scss
const { pause, resume } = watchEffect(() => { /* ... */ })
pause() // 暂停侦听
resume() // 恢复侦听
这对于在批量更新数据时避免不必要的回调触发非常有用。
七、最佳实践与选择建议
7.1 何时选择 watch
- 需要旧值对比:如表单验证中比较前后值、状态同步逻辑。
- 需要精确控制监听源:只关心特定属性或计算结果的变化,避免不必要的副作用执行。
- 条件性监听:某些场景下需要根据条件动态启用或禁用监听。
- 需要
immediate控制:默认惰性执行,仅在需要时立即触发。
7.2 何时选择 watchEffect
- 依赖关系复杂或动态变化:如多个状态聚合计算、动态条件分支中的依赖。
- 无需旧值:只关心当前最新状态。
- 初始化即执行:如组件挂载时自动发起数据请求。
- 代码简洁性优先:避免手动维护依赖列表。
7.3 性能考量
watch 的依赖收集更加精确,性能开销通常低于 watchEffect,因为后者可能追踪到回调中所有被访问的响应式属性,包括那些并非"真正需要"触发副作用的属性。但在依赖关系复杂时,watchEffect 的开发效率优势往往更为重要。
7.4 一个常见的陷阱
在 watch 回调中修改监听源本身,可能导致无限循环。务必在回调中添加条件判断,确保仅在特定条件下才执行修改操作。
八、总结
watch 和 watchEffect 共享 doWatch 这一核心实现,但通过 cb 参数的有无和 options 的差异,走上了两条不同的设计路径。watch 提供了更精确的控制和旧值对比能力,适合需要精细管理的场景;watchEffect 则以自动依赖收集和立即执行为核心,在依赖复杂或动态时显著简化代码。理解它们在 getter 标准化、scheduler 调度、依赖收集与清理机制上的源码实现,有助于在开发中做出更明智的选择,并在遇到性能或时序问题时能够从底层找到答案。