我正在参与掘金创作者训练营第4期,点击了解活动详情,一起学习吧!
前言
在之前的文章,我们已经介绍过 vue3 的响应式原理。如果还没看过的同学,强烈建议先看看《六千字详解!vue3 响应式是如何实现的?》,该文章用 vue3 ref 的例子,详细地介绍了响应式原理的实现。
而这篇则是在响应式原理的基础上,进一步介绍 Vue3 的另外一个 API —— watch
watch 用法
Vue3 的 watchApi 主要有两类:watch 和 watchEffect。(watchPostEffect 和 watchSyncEffect 只是 watchEffect 的不同参数 flush 的别名)
watch 的用法
- 侦听单一源
// 侦听一个 getter 函数
const state = reactive({ count: 0 })
watch(
() => state.count,
(count, prevCount) => {
/* ... */
}
)
// 直接侦听一个 ref
const count = ref(0)
watch(count, (count, prevCount) => {
/* ... */
})
- 侦听多个源
watch([fooRef, barRef], ([foo, bar], [prevFoo, prevBar]) => {
/* ... */
})
watchEffect 用法
立即执行传入的一个函数,同时响应式追踪其依赖,并在其依赖变更时重新运行该函数。
const count = ref(0)
watchEffect(() => console.log(count.value))
// -> logs 0
setTimeout(() => {
count.value++
// -> logs 1
}, 100)
watch 的测试用例
it('effect', async () => {
const state = reactive({ count: 0 })
let dummy
watchEffect(() => {
dummy = state.count
})
expect(dummy).toBe(0)
state.count++
// dummy 没有立即被修改
expect(dummy).toBe(0)
await nextTick()
// nextTick 之后 dummy 才会被修改
expect(dummy).toBe(1)
})
it('watching single source: getter', async () => {
const state = reactive({ count: 0 })
let dummy
watch(
() => state.count,
(count, prevCount) => {
dummy = [count, prevCount]
// assert types
count + 1
if (prevCount) {
prevCount + 1
}
}
)
state.count++
// dummy 没有立即被赋值
expect(dummy).toBe(undefined)
await nextTick()
// nextTick 之后 dummy 才会被修改
expect(dummy).toMatchObject([1, 0])
})
从上面测试用例中,我们可以看出,响应式变量被修改后,并不是马上执行 watchEffect 和 watch 的回调函数,而是在 nextTick 只有才执行完成。
为什么会延迟执行 watch 回调?
考虑以下代码:
it('watch 最终的值没有变,则不执行 watch 回调', async () => {
const state = reactive({ count: 0 })
let dummy = 0
watch(
() => state.count,
(count) => {
dummy++
}
)
state.count++
state.count--
// dummy 没有立即被赋值
expect(dummy).toBe(0)
await nextTick()
// nextTick 之后 watch 回调没有被执行
expect(dummy).toBe(0)
})
最终 state.count 的值没有变,没有执行 watch 回调(这个行为是 Vue watch API 所定义的),而不是执行两遍 watch 回调
- 要实现【watch 的最终值不变,则不执行 watch 回调】的行为,就必须要延迟执行,就需要在当前的所有 js 代码(整个 js 执行栈)都执行完之后,再对值的变化进行判断。
- 防止多次修改响应式变量,导致多次执行 watch 回调,导致 vue3 的响应式链路混乱,起到防抖的作用。要知道,watch 的回调,还可能引起其他响应式变量的变化
这个与我们在《六千字详解!vue3 响应式是如何实现的?》文章中,提到过,effect 函数,有什么区别
it('should be reactive', () => {
const a = ref(1)
let dummy
let calls = 0
effect(() => {
calls++
dummy = a.value
})
expect(calls).toBe(1)
expect(dummy).toBe(1)
a.value = 2
expect(calls).toBe(2)
expect(dummy).toBe(2)
})
与 watchEffect 的行为非常的相似,他们主要的区别是:
effect 函数 | watchEffect 函数 | |
---|---|---|
副作用函数的执行时机 | 响应式变量变化后,立即执行 | 响应式变量变化后,延迟执行 |
作用 | 仅仅用于响应式变量开发过程中的调试 | 1. Vue3 官方提供的一个 API,与组件状态耦合 (组件销毁时,watchEffect 不再执行) 2. 延迟执行,目的是为了确定组件更新前,判断响应式数据是否被改变 (可能一开始被改变,但是后来又被改回去,此时不需要更新) |
源码解析
watchEffect 和 watch 的实现,都是 doWatch 函数
export function watchEffect(
effect: WatchEffect,
options?: WatchOptionsBase
): WatchStopHandle {
return doWatch(effect, null, options)
}
export function watch<T = any, Immediate extends Readonly<boolean> = false>(
source: T | WatchSource<T>,
cb: any,
options?: WatchOptions<Immediate>
): WatchStopHandle {
return doWatch(source as any, cb, options)
}
doWatch 的参数如下:
- source:为 watch / watchEffect 的第一个参数,该参数的类型非常多,在 doWatch 内部会进行标准化处理
- cb:仅仅 watch 有该 cb 回调
- options:watch 的配置,有 immediate、deep、flush
doWatch
doWatch 函数主要分为以下几个部分:
- 标准化 source,组装成为 getter 函数
- 组装 job 函数。判断侦听的值是否有变化,有变化则执行 getter 函数和 cb 回调
- 组装 scheduler 函数,scheduler 负责在合适的时机调用 job 函数(根据 options.flush,即副作用刷新的时机),默认在组件更新前执行
- 开启侦听
- 返回停止侦听函数
getter、scheduler、job、cb 它们之间的关系
这个图目前看不懂没有关系,后面还会出现并解释
doWatch 大概代码结构如下(有删减):
function doWatch(
source: WatchSource | WatchSource[] | WatchEffect | object,
cb: WatchCallback | null,
{ immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): WatchStopHandle {
// 1. 根据 source 的类型组装 getter
let getter: () => any
if (isRef(source)) {
getter = ...
} else if (isReactive(source)) {
getter = ...
} else {
...
}
// 2. 组装 job
const job: SchedulerJob = () => {
// ...
}
// 3. 组装 scheduler
let scheduler: EffectScheduler = ...
// 4. 开启侦听,侦听的是 getter 函数
const effect = new ReactiveEffect(getter, scheduler)
effect.run()
// 5. 返回停止侦听函数
return () => {
effect.stop()
if (instance && instance.scope) {
remove(instance.scope.effects!, effect)
}
}
}
可以看出,watch 响应式也是通过 ReactiveEffect 对象实现的,不了解 ReactiveEffect 对象的同学,可以看看该文章:《六千字详解!vue3 响应式是如何实现的?》
这里也大概回顾一下 ReactiveEffect 对象的作用:
-
ReactiveEffect,接受 fn 和 scheduler 参数。ReactiveEffect 被创建时,会立即执行 fn
-
当 fn 函数中使用到响应式变量(如 ref)时,该响应式变量就会用数组收集 ReactiveEffect 对象的引用
-
当响应式变量被改变时,会触发所有的 ReactiveEffect 对象,触发规则如下:
- 如果没有 scheduler 参数,则执行ReactiveEffect 的 fn
- 如果有 scheduler 参数,则执行 scheduler,这时需要在 scheduler 中手动调用 fn
-
执行 fn 时,使用到响应式变量,依赖又会被重新收集
接下来,我们会从 ReactiveEffect 作为切入点,进行介绍(并非按照代码顺序介绍)
开启侦听
// 开启侦听,侦听的是 getter 函数
const effect = new ReactiveEffect(getter, scheduler)
这里会立即调用 getter 函数,进行依赖收集。
如果依赖有变化,则执行 scheduler 函数
getter 函数
getter 函数是最终被侦听的函数,即函数里面用到的响应式变量的改变,都会触发执行 scheduler 函数。
由于 watch/watchEffect 的入参,多种多样,doWatch 在处理时,需要进行标准化处理
下面是 getter 部分的源码:
// 节选自 doWatch 内部实现
const instance = currentInstance
let getter: () => any
let forceTrigger = false // 标记为 forceTrigger ,则强制执行 cb,无论 getter 返回值是否改变
let isMultiSource = false // 标记是否为多侦听源
if (isRef(source)) {
// ref 处理
// 执行 getter,就会获取 ref 的值,从而 track 收集依赖
getter = () => source.value
forceTrigger = !!source._shallow
} else if (isReactive(source)) {
// reactive 对象
getter = () => source
// reactive 需要深度遍历
deep = true
} else if (isArray(source)) {
// 侦听多个源,source 为数组。需要设置 isMultiSource 标记为多数据源。
isMultiSource = true
forceTrigger = source.some(isReactive)
// 遍历数组,处理每个元素,处理方式跟单个源相同
getter = () =>
source.map(s => {
if (isRef(s)) {
return s.value
} else if (isReactive(s)) {
return traverse(s)
} else if (isFunction(s)) {
return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER)
} else {
__DEV__ && warnInvalidSource(s)
}
})
} else if (isFunction(source)) {
// source 是函数
if (cb) {
// 直接用错误处理函数包一层,getter 函数实际上就是直接运行 source 函数
// callWithErrorHandling 中做了一些 vue 错误信息的统一处理,有更好的错误提示
getter = () =>
callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
} else {
// 没有 cb,最后还是直接运行 source
getter = () => {
if (instance && instance.isUnmounted) {
return
}
if (cleanup) {
cleanup()
}
return callWithAsyncErrorHandling(
source,
instance,
ErrorCodes.WATCH_CALLBACK,
[onInvalidate]
)
}
}
} else {
// 兜底处理,到这里证明传入 source 的值是错误的,开发环境下会警告
// 如 watch(ref.value,()={}),而此时 ref.value === undefined
getter = NOOP
__DEV__ && warnInvalidSource(source)
}
// 如果深度监听,则需要深度遍历整个 getter 的返回值
// 例如 reactive,需要访问对象内部的每一个属性,需要进行深度遍历访问
// 当执行 getter 时,由于深度访问了每一个属性,因此每个属性都会 track 收集依赖
if (cb && deep) {
const baseGetter = getter
getter = () => traverse(baseGetter())
}
总的来说,这部分就是根据 source 的不同类型,标准化包装成 getter 函数
- ref:
() => source.value
- reactive:
() => traverse(source)
- 数组:分别根据子元素类型,包装成 getter 函数
- 函数:用
callWithErrorHandling
包装,实际上就是直接调用 source 函数
traverse 的作用是什么?
对于 reactive 对象或设置了参数 deep,需要侦听到深层次的变化,这需要深度遍历整个对象,深层次的访问其所有的响应式变量,并收集依赖。
// 深度遍历对象,只是访问响应式变量,不做任何处理
// 访问就会触发响应式变量的 getter,从而触发依赖收集
export function traverse(value: unknown, seen?: Set<unknown>) {
if (!isObject(value) || (value as any)[ReactiveFlags.SKIP]) {
return value
}
seen = seen || new Set()
if (seen.has(value)) {
return value
}
seen.add(value)
if (isRef(value)) {
traverse(value.value, seen)
} else if (isArray(value)) {
// 继续深入遍历数组
for (let i = 0; i < value.length; i++) {
traverse(value[i], seen)
}
} else if (isSet(value) || isMap(value)) {
value.forEach((v: any) => {
traverse(v, seen)
})
} else if (isPlainObject(value)) {
// 是对象则继续深入遍历
for (const key in value) {
traverse((value as any)[key], seen)
}
}
return value
}
scheduler 函数
当 getter 中侦听的响应式变量发生改变时,就会执行 scheduler 函数
scheduler 用于控制 job 的执行时机,scheduler 会在对应的时机,执行 job,该时机取决于 options 的 flush 参数(pre、sync、post)
// 如果有 cb,则允许 job 递归
// 如:cb 导致 getter 又被改变 trigger 了,这时候应该允许继续又将 cb 加入执行队列
job.allowRecurse = !!cb
let scheduler: EffectScheduler
if (flush === 'sync') {
// 同步调用 job,官方不建议同步调用
scheduler = job as any // the scheduler function gets called directly
} else if (flush === 'post') {
// 异步调用 job,在组件 DOM 更新之后
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else {
// default: 'pre'
scheduler = () => {
if (!instance || instance.isMounted) {
// 异步调用 job,在组件 DOM 更新前
queuePreFlushCb(job)
} else {
// 组件未 mounted 时,watch cb 是同步调用的
job()
}
}
}
queuePostRenderEffect
和 queuePreFlushCb
在该文章不会详细介绍,只需要知道,这两个函数是在 DOM 更新前/后执行传入的函数(这里是 job 函数)即可,这两个函数是 Vue 调度系统的一部分,详情见文章《七千字深度剖析 Vue3 的调度系统》
三个执行时机分别有什么区别
- pre::组件 DOM 更新前,此时拿到的是更新后的 DOM 对象
- post:组件 DOM 更新后,此时拿到的是更新后的 DOM 对象
- sync:在响应式变量改变时,同步执行 job,此时 watch 的 cb 回调还没执行,组件 DOM 也没有更新。这种方式是低效的,因为没有延迟执行,就失去了防抖的效果,也没有办法判断最终的值是否发生变化。尽量避免使用
组装 job 函数
Job 函数在 scheduler 函数中被直接或间接调用。
job 负责执行 effect.run(即执行 getter 函数重新收集依赖)和 cb(watch 才有),对应的是图中的红色部分
let oldValue = isMultiSource ? [] : INITIAL_WATCHER_VALUE
const job: SchedulerJob = () => {
// 如果侦听已经停止,则直接 return
if (!effect.active) {
return
}
if (cb) {
// watch(source, cb) 会走这个分支
// 在 scheduler 中需要手动直接执行 effect.run,这里会执行 getter 函数
// 先执行 getter 获取返回值,如果返回值变化,才执行 cb。
const newValue = effect.run()
// 判断是否需要执行 cb
// 1. getter 函数的值被改变,没有发生改变则不执行 cb 回调
// 2. 设置了 deep 深度监听
// 3. forceTrigger 为 true
if (
deep ||
forceTrigger ||
(isMultiSource
? (newValue as any[]).some((v, i) =>
hasChanged(v, (oldValue as any[])[i])
)
: hasChanged(newValue, oldValue)) ||
(__COMPAT__ &&
isArray(newValue) &&
isCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance))
) {
// 执行 cb,并传入 newValue、oldValue、onInvalidate
callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
newValue,
// pass undefined as the old value when it's changed for the first time
oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
onInvalidate
])
// 缓存 getter 的返回值
oldValue = newValue
}
} else {
// watchEffect
// 在 scheduler 中需要手动直接执行 effect.run,这里会执行 getter 函数
effect.run()
}
}
返回停止侦听函数
// 返回一个停止侦听 effect 的函数
return () => {
effect.stop()
// 移除当前组件上的对应的 effect
if (instance && instance.scope) {
remove(instance.scope.effects!, effect)
}
}
调用该函数会清除 watch
其他阅读
最后
如果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力。