《Vue3 从入门到大神37篇》Vue3 源码详解(七):watch 与 watchEffect 源码对比——副作用是如何被追踪的?

本篇关键词:watch、watchEffect、job、scheduler、flush、pre / post / sync、cleanup

难度等级:⭐⭐⭐⭐⭐

工程价值 :watch 是 Vue3 中最复杂的响应式 API。读懂它,你才能真正理解"副作用调度""异步更新""竞态处理"这些高阶工程问题。


一、开篇:为什么 watch 比 computed 复杂?

很多同学觉得:

"watch 不就是监听数据变化吗?"

但真实情况是:

  • 为什么 watchEffect 会自动收集依赖?

  • 为什么 watch 需要明确指定 source?

  • 为什么 flush: 'post' 能拿到更新后的 DOM?

  • 为什么 watch 能拿到 新旧值,而 watchEffect 不行?

  • 为什么 watch 能防止"竞态问题"?

👉 答案不在 API 层面,而在调度系统(Scheduler)中。


二、一句话区分 watch 和 watchEffect

API 本质
watchEffect 立即执行的副作用 + 自动依赖收集
watch 显式数据源 + 惰性执行 + 新旧值对比 + 调度控制

👉 watch 是 watchEffect 的"增强版"。


三、watchEffect 源码解析:自动依赖收集的真相

1️⃣ 使用方式

复制代码
watchEffect(() => {
  console.log(state.count)
})

特点:

  • ✅ 立即执行

  • ✅ 自动收集依赖

  • ❌ 拿不到旧值

  • ❌ 无法精确控制触发时机


2️⃣ watchEffect 的核心实现(简化版)

源码位置:packages/runtime-core/src/apiWatch.ts

复制代码
function watchEffect(
  effectFn,
  options = {}
) {
  return doWatch(effectFn, null, options)
}

👉 所有魔法,都在 doWatch 里。


3️⃣ watchEffect 的 effect 创建

复制代码
const effect = new ReactiveEffect(effectFn)

effect.scheduler = () => {
  if (flush === 'sync') {
    job()
  } else {
    queueJob(job)
  }
}

关键点:

  • watchEffect 内部也是一个 ReactiveEffect

  • 通过 scheduler 接入 Vue 的异步调度系统

  • 默认是异步执行(flush: 'pre'


4️⃣ 为什么 watchEffect 能自动收集依赖?

复制代码
effect.run()

执行 effectFn 时:

  • 读取响应式数据

  • 触发 track

  • 建立依赖关系

👉 和普通 effect 没有任何区别。


四、watch 源码解析:精确控制的艺术

1️⃣ watch 的使用方式

复制代码
watch(
  () => state.count,
  (newVal, oldVal) => {
    console.log(newVal, oldVal)
  },
  {
    flush: 'post',
    immediate: true
  }
)

特点:

  • ✅ 明确指定 source

  • ✅ 拿到新旧值

  • ✅ 支持 immediate

  • ✅ 支持 deep

  • ✅ 支持 flush 控制


2️⃣ watch 的核心实现(简化版)

复制代码
function watch(source, cb, options) {
  return doWatch(source, cb, options)
}

👉 没错,watch 和 watchEffect 共用同一个 doWatch

区别在于:有没有 callback。


五、doWatch:watch 系统的真正大脑

1️⃣ source 的三种形态

Vue3 支持三种 watch source:

复制代码
// 1️⃣ getter
watch(() => state.count, cb)

// 2️⃣ ref
watch(countRef, cb)

// 3️⃣ reactive
watch(state, cb, { deep: true })

源码中统一处理:

复制代码
let getter: () => any

if (isRef(source)) {
  getter = () => source.value
} else if (isReactive(source)) {
  getter = () => source
  deep = true
} else if (isFunction(source)) {
  getter = source
}

2️⃣ job:watch 回调的执行单元

复制代码
const job = () => {
  if (cb) {
    const newValue = effect.run()
    if (deep || hasChanged(newValue, oldValue)) {
      cb(newValue, oldValue)
      oldValue = newValue
    }
  } else {
    effect.run()
  }
}

👉 新旧值对比发生在 job 中,而不是 trigger 中。


3️⃣ 为什么 watch 能拿到旧值?

核心秘密:

复制代码
let oldValue = initialValue
oldValue = newValue
  • effect.run() 得到新值

  • 与缓存的旧值对比

  • 符合条件才执行回调

这就是 watch 能精准控制更新的原因。


六、flush 机制:pre / post / sync 的本质

这是 watch 最难理解,但工程价值最高的部分。

1️⃣ flush: 'pre'(默认)

复制代码
flush: 'pre'

执行时机:

复制代码
组件更新前

源码逻辑:

复制代码
queuePreFlushCb(job)

适合:大多数业务逻辑


2️⃣ flush: 'post'

复制代码
flush: 'post'

执行时机:

复制代码
组件更新后 → DOM 已更新

源码逻辑:

复制代码
queuePostRenderEffect(job, instance)

适合:操作 DOM、获取尺寸、Scroll 位置

复制代码
watch(source, cb, { flush: 'post' })

onMounted(() => {
  // 等价于 watch + post
})

3️⃣ flush: 'sync'

复制代码
flush: 'sync'

执行时机:

复制代码
数据变化 → 立即执行

源码逻辑:

复制代码
job()

⚠️ 慎用:可能导致多次同步更新,性能差。


七、deep watch:性能与精度的权衡

1️⃣ 为什么 reactive 默认 deep?

复制代码
watch(state, cb)

因为:

复制代码
getter = () => source
track(state, GET, ITERATE_KEY)

👉 一次性追踪整个对象。


2️⃣ deep 的实现原理

复制代码
function traverse(value, seen = new Set()) {
  if (!isObject(value) || seen.has(value)) return

  seen.add(value)
  for (const key in value) {
    traverse(value[key], seen)
  }
  return value
}

👉 递归访问每一个属性,强制触发 track。

⚠️ 性能代价极高,慎用 deep。


八、cleanup:解决竞态问题的关键

1️⃣ 竞态问题示例

复制代码
watch(id, async (newId) => {
  const res = await fetchData(newId)
  data.value = res
})

如果 id 快速变化:

  • 请求 A 发出

  • 请求 B 发出

  • B 先返回,A 后返回 → 数据错乱


2️⃣ watch 的 cleanup 机制

复制代码
watch(id, async (newId, oldId, onCleanup) => {
  let canceled = false
  onCleanup(() => {
    canceled = true
  })

  const res = await fetchData(newId)
  if (!canceled) {
    data.value = res
  }
})

源码本质:

复制代码
const cleanup = effect.onStop || (() => {})

👉 每次重新执行前,先执行上一次的 cleanup。

这是前端"竞态控制"的标准解法。


九、watchEffect vs watch:源码级对比表

维度 watchEffect watch
依赖收集 自动 显式
立即执行 可选
新旧值
深度监听
调度控制 基础 精细
竞态处理
适用场景 副作用 数据变化响应

十、调试实战:观察 watch 的调度过程

在 VSCode 中打这些断点:

复制代码
packages/runtime-core/src/apiWatch.ts
├── doWatch()
├── job()
├── queueJob()
└── cleanup()

测试代码:

复制代码
watchEffect(() => {
  console.log('effect', state.count)
})

watch(() => state.count, (n, o) => {
  console.log('watch', n, o)
})

state.count++

观察:

  • queueJob 何时入队

  • job 何时执行

  • DOM 更新前后顺序

👉 你会彻底理解 Vue3 的异步更新机制。


十一、工程最佳实践

✅ 优先使用 watchEffect

复制代码
watchEffect(() => {
  document.title = state.title
})

✅ DOM 相关用 flush: 'post'

复制代码
watch(element, () => {
  el.getBoundingClientRect()
}, { flush: 'post' })

✅ 异步请求必用 cleanup

复制代码
watch(id, (newId, _, onCleanup) => {
  const abort = new AbortController()
  onCleanup(() => abort.abort())

  fetch(url, { signal: abort.signal })
})

❌ 避免 deep watch

复制代码
// ❌ 性能差
watch(state, cb, { deep: true })

// ✅ 精确监听
watch(() => state.user.name, cb)

十二、本篇总结

✅ watch / watchEffect 共用 doWatch

✅ 调度系统(queueJob)是异步更新的核心

✅ flush 控制执行时机:pre / post / sync

✅ cleanup 是解决竞态问题的关键

✅ deep watch 有显著性能代价

一句话升华

watch 的本质,不是"监听数据",而是**"在正确的时机,以正确的方式,执行正确的副作用"**。

相关推荐
bloglin999992 小时前
langchain 和 langgraph 和 react
javascript·react.js·langchain
触底反弹2 小时前
🚀 删了数据刷新又回来?3 组件 × 4 回调 × 3 坑讲透 React 父子通信
前端·javascript·react.js
会周易的程序员4 小时前
js-shm: 高性能 Node.js 共享内存模块
开发语言·javascript·c++·node.js·共享内存·shm
带娃的IT创业者5 小时前
重新定义前端构建速度:深度解析 SWC 如何用 Rust 颠覆 JavaScript 工具链
前端·javascript·rust·前端构建·swc
新中地GIS开发老师6 小时前
零基础WebGIS开发入门 | GeoJSON数据持久化
前端·javascript·gis·webgis·三维gis开发
blns_yxl6 小时前
正则驱动实时表单验证(HTML+CSS+JS+正则)
javascript·css·html
Quz7 小时前
QML 与 JavaScript 交互方式:内联函数、外部文件、信号槽与工作线程
javascript·qt
先吃饱再说7 小时前
React 组件通信:从 Props 单向传递到自定义事件
前端·react.js·前端框架
Allshadow7 小时前
Nest.js - 连接数据库
javascript·nestjs