Vue 3 中 watch 与 watchEffect 的深度解析:从用法差异到源码实现

在 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 调度、依赖收集与清理机制上的源码实现,有助于在开发中做出更明智的选择,并在遇到性能或时序问题时能够从底层找到答案。

相关推荐
ikun_文42 分钟前
Vue3框架项目初始化
vue.js·element·vue-router
卤蛋fg643 分钟前
vue 表格组件 vxe-table 鼠标滑动选择多行
vue.js
A黄俊辉A5 天前
uniapp webview中实现 app和内嵌的H5双向通信
vue.js·json
honkun66 天前
vue 表格组件 vxe-table 配置 ajax 请求自动加载数据与表单查询
vue.js·vxe-table
kybs19916 天前
全球灾害数据分析可视化 毕业设计-附源码66794
vue.js·spring boot·mysql·安全·django·c#·asp.net
雪芽蓝域zzs6 天前
第26章:ECharts 自定义主题 + 页面一键切换主题(深色 / 浅色两套)
vue.js·echars
雪芽蓝域zzs6 天前
第22章:ECharts 图表联动(多图表交互联动)
vue.js·echars
daols886 天前
vue 表格组件 vxe-table 实现虚拟滚动与无需滚动加载
vue.js·vxe-table
雪芽蓝域zzs6 天前
第18章:ECharts 折线图 + 标记点、标记线,自定义 tooltip
vue.js·echars