前言
在之前的文章,我们已经介绍过 vue3 的响应式原理。如果还没看过的同学,强烈建议先看看《六千字详解!vue3 响应式是如何实现的?》,该文章用 vue3 ref 的例子,详细地介绍了响应式原理的实现。
而这篇则是在响应式原理的基础上,进一步介绍 Vue3 的另外一个 API —— watch
watch 用法
Vue3 的 watchApi 主要有两类:watch 和 watchEffect。(watchPostEffect 和 watchSyncEffect 只是 watchEffect 的不同参数 flush 的别名)
watch 的用法
- 侦听单一源
typescript
复制代码
// 侦听一个 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
其他阅读
最后
如果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力。