07-watch的实现原理
本章目标:深入理解 watch 如何响应数据变化执行副作用
从一个真实场景说起
假设你正在开发一个搜索功能:用户输入关键词,自动发起搜索请求。这是一个再常见不过的需求:
import { ref, watch } from 'vue'
const keyword = ref('')
const searchResults = ref([])
// 监听关键词变化,自动搜索
watch(keyword, async (newKeyword, oldKeyword) => {
if (!newKeyword) {
searchResults.value = []
return
}
const results = await fetchSearchResults(newKeyword)
searchResults.value = results
})
这段代码看起来简单,但背后隐藏着几个关键问题:
- 竞态条件:用户快速输入时,先发出的请求可能后返回,导致显示错误的结果
- 依赖追踪:Vue 如何知道
keyword变化了? - 新旧值对比:
newKeyword和oldKeyword是怎么来的?
这些问题的答案,都藏在 watch 的实现中。
watch 在响应式系统中的位置
在深入源码之前,先看看 watch 在 Vue 响应式系统中扮演的角色:
flowchart TB
subgraph 响应式数据层
A["reactive/ref"]
end
subgraph 副作用层
B["ReactiveEffect"]
end
subgraph 应用层API
C["computed"]
D["watch/watchEffect"]
E["组件渲染"]
end
A -->|"track/trigger"| B
B --> C
B --> D
B --> E
C -->|"惰性求值"| F["派生数据"]
D -->|"主动执行"| G["副作用操作"]
E -->|"更新视图"| H["DOM"]
核心定位:watch 是 ReactiveEffect 的上层封装,专门用于在数据变化时执行副作用(网络请求、DOM 操作、日志记录等)。与 computed 不同,watch 不产生派生数据,而是执行"动作"。
watch 的多态设计:一个 API,多种用法
watch 是 Vue 中最灵活的响应式 API 之一。它的灵活性体现在 source 参数的多态性——你可以传入 ref、reactive 对象、getter 函数,甚至是它们的数组组合:
// 1. 监听 ref
watch(count, (newVal) => console.log(newVal))
// 2. 监听 reactive 对象
watch(state, (newVal) => console.log(newVal))
// 3. 监听 getter 函数
watch(() => state.count, (newVal) => console.log(newVal))
// 4. 监听多个源
watch([count, () => state.name], ([newCount, newName]) => {
console.log(newCount, newName)
})
这种多态设计的背后,是一套统一的 getter 转换机制:
flowchart TB
A["watch(source, cb)"] --> B{"source 类型判断"}
B -->|"isRef(source)"| C["getter = () => source.value"]
B -->|"isReactive(source)"| D["getter = () => reactiveGetter(source)"]
B -->|"isFunction(source)"| E["getter = source"]
B -->|"isArray(source)"| F["getter = () => source.map(...)"]
C --> G["统一的 getter 函数"]
D --> G
E --> G
F --> G
G --> H["new ReactiveEffect(getter)"]
来看源码中的实现:
// 文件: packages/reactivity/src/watch.ts
export function watch(
source: WatchSource | WatchSource[] | WatchEffect | object,
cb?: WatchCallback | null,
options: WatchOptions = EMPTY_OBJ,
): WatchHandle {
const { immediate, deep, once, scheduler, augmentJob, call } = options
let effect: ReactiveEffect
let getter: () => any
let forceTrigger = false
let isMultiSource = false
// 根据 source 类型构建不同的 getter
if (isRef(source)) {
// ref:直接读取 .value
getter = () => source.value
forceTrigger = isShallow(source) // shallowRef 需要强制触发
} else if (isReactive(source)) {
// reactive:通过 reactiveGetter 处理深度遍历
getter = () => reactiveGetter(source)
forceTrigger = true // reactive 对象总是强制触发
} else if (isArray(source)) {
// 数组:映射每个元素
isMultiSource = true
forceTrigger = source.some(s => isReactive(s) || isShallow(s))
getter = () =>
source.map(s => {
if (isRef(s)) return s.value
else if (isReactive(s)) return reactiveGetter(s)
else if (isFunction(s)) return s()
})
} else if (isFunction(source)) {
if (cb) {
// watch(getter, callback) 模式
getter = source as () => any
} else {
// watchEffect 模式:source 本身就是副作用函数
getter = () => {
if (cleanup) {
pauseTracking()
try {
cleanup() // 执行清理时暂停依赖追踪
} finally {
resetTracking()
}
}
const currentEffect = activeWatcher
activeWatcher = effect
try {
return source(boundCleanup) // 执行副作用并传入清理注册函数
} finally {
activeWatcher = currentEffect
}
}
}
}
// ...
}
关键设计:无论传入什么类型的 source,最终都会被转换为一个 getter 函数。这个 getter 会被传给 ReactiveEffect,在执行时自动收集依赖。
reactiveGetter:深度控制的秘密
当 source 是 reactive 对象时,watch 需要决定追踪的深度。这由 reactiveGetter 函数控制:
// 文件: packages/reactivity/src/watch.ts
const reactiveGetter = (source: object) => {
// deep: true 时,完全遍历(在外层 getter 中处理)
if (deep) return source
// deep: false 或 0,或者是 shallowReactive,只遍历第一层
if (isShallow(source) || deep === false || deep === 0)
return traverse(source, 1)
// deep: undefined(默认),对 reactive 对象深度遍历
return traverse(source)
}
Vue 3.4 引入了 deep: number 的支持,让你可以精确控制遍历深度——这在监听大型嵌套对象时非常有用,可以避免不必要的性能开销。
watchEffect vs watch:同源不同形
虽然 watchEffect 和 watch 看起来是两个不同的 API,但它们实际上共享同一套实现。核心区别在于是否有回调函数 cb:
flowchart LR
subgraph "watch(source, cb)"
A1["getter 获取值"] --> A2["值变化?"]
A2 -->|是| A3["cb(newVal, oldVal)"]
end
subgraph "watchEffect(fn)"
B1["fn 即是 getter"] --> B2["执行 fn"]
B2 --> B3["自动收集依赖"]
B3 -->|依赖变化| B2
end
| 特性 | watch | watchEffect |
|---|---|---|
| 回调函数 | 有 cb,接收 newValue/oldValue | 无 cb,source 即副作用 |
| 依赖收集 | 通过 getter 显式声明 | 自动收集执行过程中的依赖 |
| 首次执行 | 默认不执行 cb(除非 immediate) | 立即执行 |
| 使用场景 | 需要对比新旧值 | 只关心副作用执行 |
在源码中,这个区别体现在 job 函数的分支处理:
// 文件: packages/reactivity/src/watch.ts
const job = (immediateFirstRun?: boolean) => {
if (cb) {
// watch(source, cb) 模式:获取新值,对比后执行回调
const newValue = effect.run()
if (deep || forceTrigger || hasChanged(newValue, oldValue)) {
if (cleanup) cleanup()
cb(newValue, oldValue, boundCleanup)
oldValue = newValue
}
} else {
// watchEffect 模式:直接执行副作用
effect.run()
}
}
job 函数:更新的核心逻辑
job 函数是 watch 的心脏——它决定了何时以及如何执行回调。每当依赖变化时,scheduler 会调用 job:
flowchart TB
A["依赖变化触发 trigger"] --> B["scheduler 调用 job"]
B --> C{"effect.flags & ACTIVE?"}
C -->|否| D["返回,watcher 已停止"]
C -->|是| E{"effect.dirty?"}
E -->|否| D
E -->|是| F{"有 cb?"}
F -->|是| G["effect.run() 获取新值"]
G --> H{"值变化了?<br/>deep/forceTrigger/hasChanged"}
H -->|是| I["执行 cleanup"]
I --> J["调用 cb(newVal, oldVal, onCleanup)"]
J --> K["oldValue = newValue"]
H -->|否| D
F -->|否| L["effect.run() 执行副作用"]
完整的 job 实现:
// 文件: packages/reactivity/src/watch.ts
let oldValue: any = isMultiSource
? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE)
: INITIAL_WATCHER_VALUE
const job = (immediateFirstRun?: boolean) => {
// 检查 effect 是否活跃且需要更新
if (
!(effect.flags & EffectFlags.ACTIVE) ||
(!effect.dirty && !immediateFirstRun)
) {
return
}
if (cb) {
// watch(source, cb) 模式
const newValue = effect.run()
if (
deep ||
forceTrigger ||
(isMultiSource
? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i]))
: hasChanged(newValue, oldValue))
) {
// 执行清理函数
if (cleanup) cleanup()
const currentWatcher = activeWatcher
activeWatcher = effect
try {
const args = [
newValue,
// 首次执行时 oldValue 应该是 undefined
oldValue === INITIAL_WATCHER_VALUE
? undefined
: isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE
? []
: oldValue,
boundCleanup,
]
oldValue = newValue
cb!(...args)
} finally {
activeWatcher = currentWatcher
}
}
} else {
// watchEffect 模式:直接执行
effect.run()
}
}
关键设计点:
- INITIAL_WATCHER_VALUE:一个空对象
{},用于标记首次执行。首次执行时 oldValue 应该是 undefined,而不是这个标记对象 - hasChanged 检查:使用
Object.is比较新旧值,只有值真正变化时才执行回调 - forceTrigger:对于 reactive 对象和 shallowRef,即使引用相同也可能需要触发(因为内部属性可能变了)
- activeWatcher 切换:在执行回调时设置当前活跃的 watcher,支持
onWatcherCleanup的注册
traverse 函数:深度遍历的实现
当监听 reactive 对象时,默认会深度追踪所有嵌套属性。这是通过 traverse 函数实现的——它递归访问对象的每个属性,触发依赖收集:
flowchart TB
A["traverse(value, depth)"] --> B{"depth <= 0?"}
B -->|是| C["返回 value"]
B -->|否| D{"已访问过且深度足够?"}
D -->|是| C
D -->|否| E["记录到 seen Map"]
E --> F{"value 类型?"}
F -->|"Ref"| G["traverse(value.value, depth-1)"]
F -->|"Array"| H["遍历每个元素"]
F -->|"Set/Map"| I["遍历每个值"]
F -->|"PlainObject"| J["遍历每个属性<br/>包括 Symbol 键"]
H --> K["递归 traverse"]
I --> K
J --> K
// 文件: packages/reactivity/src/watch.ts
export function traverse(
value: unknown,
depth: number = Infinity,
seen?: Map<unknown, number>,
): unknown {
// 边界条件:深度耗尽、非对象、或标记跳过
if (depth <= 0 || !isObject(value) || (value as any)[ReactiveFlags.SKIP]) {
return value
}
// 防止循环引用导致无限递归
seen = seen || new Map()
if ((seen.get(value) || 0) >= depth) {
return value // 已经以更大或相等的深度访问过
}
seen.set(value, depth)
depth--
// 根据类型递归遍历
if (isRef(value)) {
traverse(value.value, depth, seen)
} else if (isArray(value)) {
for (let i = 0; i < value.length; i++) {
traverse(value[i], depth, seen)
}
} else if (isSet(value) || isMap(value)) {
value.forEach((v: any) => traverse(v, depth, seen))
} else if (isPlainObject(value)) {
for (const key in value) {
traverse(value[key], depth, seen)
}
// 别忘了 Symbol 键
for (const key of Object.getOwnPropertySymbols(value)) {
if (Object.prototype.propertyIsEnumerable.call(value, key)) {
traverse(value[key as any], depth, seen)
}
}
}
return value
}
设计考量:
- 循环引用处理:使用 Map 记录已访问的对象和访问时的深度。为什么用 Map 而不是 Set?因为同一个对象可能在不同深度被访问,需要记录最大深度
- 深度控制:Vue 3.4+ 支持
deep: number,可以限制遍历深度,避免大对象的性能问题 - 类型覆盖:处理了 Ref、数组、Set、Map、普通对象等各种类型
- Symbol 键:不要忘记对象可能有 Symbol 类型的键
清理机制:解决竞态问题
回到开头的搜索场景。用户快速输入 "a" → "ab" → "abc",会发出三个请求。如果请求 "a" 最后返回,就会覆盖正确的 "abc" 结果。
watch 提供了 onCleanup 参数来解决这个问题:
watch(keyword, async (newKeyword, oldKeyword, onCleanup) => {
let cancelled = false
onCleanup(() => {
cancelled = true // 标记请求已取消
})
const results = await fetchSearchResults(newKeyword)
if (!cancelled) {
searchResults.value = results // 只有未取消时才更新
}
})
sequenceDiagram
participant U as 用户输入
participant W as watch
participant C as cleanup
participant A as 异步请求
U->>W: 输入 "a"
W->>A: 发起请求1
U->>W: 输入 "ab"
W->>C: 执行 cleanup
C->>A: cancelled = true
W->>A: 发起请求2
U->>W: 输入 "abc"
W->>C: 执行 cleanup
C->>A: cancelled = true
W->>A: 发起请求3
A-->>W: 请求1返回(cancelled,忽略)
A-->>W: 请求3返回(使用)
A-->>W: 请求2返回(cancelled,忽略)
清理函数的存储和执行机制:
// 文件: packages/reactivity/src/watch.ts
const cleanupMap: WeakMap<ReactiveEffect, (() => void)[]> = new WeakMap()
let activeWatcher: ReactiveEffect | undefined = undefined
export function onWatcherCleanup(
cleanupFn: () => void,
failSilently = false,
owner: ReactiveEffect | undefined = activeWatcher,
): void {
if (owner) {
// 获取或创建该 effect 的清理函数数组
let cleanups = cleanupMap.get(owner)
if (!cleanups) cleanupMap.set(owner, (cleanups = []))
cleanups.push(cleanupFn)
}
}
清理函数在两个时机执行:
- 回调再次执行前:在 job 函数中,执行新回调前先调用 cleanup
- watcher 停止时:通过
effect.onStop钩子执行
// 文件: packages/reactivity/src/watch.ts
cleanup = effect.onStop = () => {
const cleanups = cleanupMap.get(effect)
if (cleanups) {
for (const cleanup of cleanups) cleanup()
cleanupMap.delete(effect)
}
}
WatchHandle:控制 watcher 的生命周期
watch 返回的不是简单的停止函数,而是一个功能完整的 WatchHandle:
// 文件: packages/reactivity/src/watch.ts
export interface WatchHandle extends WatchStopHandle {
pause: () => void
resume: () => void
stop: () => void
}
stateDiagram-v2
[*] --> Running: watch() 创建
Running --> Paused: pause()
Paused --> Running: resume()
Running --> Stopped: stop()
Paused --> Stopped: stop()
Stopped --> [*]
实现代码:
// 文件: packages/reactivity/src/watch.ts
const scope = getCurrentScope()
const watchHandle: WatchHandle = () => {
effect.stop() // 停止 effect
if (scope && scope.active) {
remove(scope.effects, effect) // 从 effectScope 中移除
}
}
// once 选项:执行一次后自动停止
if (once && cb) {
const _cb = cb
cb = (...args) => {
_cb(...args)
watchHandle() // 执行完回调后自动停止
}
}
// 绑定控制方法
watchHandle.pause = effect.pause.bind(effect)
watchHandle.resume = effect.resume.bind(effect)
watchHandle.stop = watchHandle
return watchHandle
三种控制能力:
- stop():完全停止 watcher,清理所有资源,从 effectScope 中移除
- pause():暂停 watcher,不响应依赖变化,但保留状态
- resume():恢复暂停的 watcher,继续响应变化
初始化执行:immediate 的实现
watch 的初始化执行逻辑根据配置不同而不同:
// 文件: packages/reactivity/src/watch.ts
// initial run
if (cb) {
if (immediate) {
job(true) // 立即执行 job,传入 true 表示首次运行
} else {
oldValue = effect.run() // 只获取初始值,不执行回调
}
} else if (scheduler) {
scheduler(job.bind(null, true), true) // watchEffect 通过 scheduler 执行
} else {
effect.run() // 直接执行
}
执行时机对比:
| 模式 | 首次执行 | oldValue |
|---|---|---|
| watch 默认 | 不执行 cb | 有初始值 |
| watch + immediate | 执行 cb | undefined |
| watchEffect | 执行 fn | 无 |
总结
watch 的核心设计可以概括为:
flowchart TB
subgraph 输入层
A["ref / reactive / getter / array"]
end
subgraph 转换层
B["统一 getter"]
end
subgraph 执行层
C["ReactiveEffect"]
D["job 函数"]
end
subgraph 控制层
E["WatchHandle"]
F["cleanup 机制"]
end
A --> B
B --> C
C --> D
D --> E
D --> F
核心机制回顾:
- 多态 source → 统一 getter:不同类型的 source 转换为统一的 getter 函数
- job 函数:控制回调执行时机,处理新旧值对比,只有值真正变化时才触发
- traverse 深度遍历:递归访问对象属性实现依赖收集,支持深度控制
- cleanup 清理机制:解决异步操作的竞态问题
- WatchHandle:提供 stop/pause/resume 完整的生命周期控制
🤔 面试常问 & 易错点
Q1: watch 的 immediate 选项和 watchEffect 有什么本质区别?
答:虽然两者都会"立即执行",但机制完全不同:
watch + immediate:首次执行时,oldValue 是 undefined(因为还没有旧值)watchEffect:没有新旧值的概念,每次执行都是重新收集依赖
更重要的区别是依赖收集方式:
- watch 的依赖在 getter 中显式声明
- watchEffect 的依赖在执行过程中自动收集
Q2: 为什么 traverse 用 Map 而不是 Set 来处理循环引用?
答:因为同一个对象可能在不同深度被访问。Map 存储的是 对象 → 访问深度 的映射。
const obj = { a: { b: {} } }
obj.a.b.c = obj.a // 循环引用
// 第一次访问 obj.a 时深度是 2
// 通过 obj.a.b.c 再次访问 obj.a 时深度是 0
// 需要比较深度,决定是否继续遍历
Q3: forceTrigger 在什么情况下起作用?
答:forceTrigger 用于处理"引用相同但内容可能变化"的情况:
- reactive 对象:
forceTrigger = true,因为对象引用不变但属性可能变了 - shallowRef:
forceTrigger = isShallow(source),因为.value是浅层响应式
const state = reactive({ count: 0 })
watch(state, (newVal) => {
// newVal === oldVal(同一个对象引用)
// 但 forceTrigger 确保回调仍然执行
})
state.count++
Q4: onCleanup 的清理函数什么时候执行?stop() 后还会执行吗?
答:清理函数在两个时机执行:
- 回调再次执行前:确保上一次的副作用被清理
- watcher 停止时:通过
effect.onStop钩子
所以 stop() 后清理函数会执行,这是正确的行为——停止 watcher 时应该清理所有未完成的副作用。
本章涉及源码:
packages/reactivity/src/watch.ts- watch、watchEffect、traverse、onWatcherCleanup
watch 让我们能够响应数据变化执行副作用,但在实际开发中,一个组件可能创建多个 effect、watch、computed。当组件卸载时,这些副作用都需要被清理。下一章,我们将深入 effectScope 的批量管理机制。