Vue3 源码解析(十):watch 的实现原理

本篇文章笔者会讲解 Vue3 中侦听器相关的 api:watchEffect 和 watch 。在 Vue3 以前 watch 是 option 写法中一个很经常使用的选项,使用它能够很是方便的监听一个数据源的变化,而在 Vue3 中随着 Composition API 的写法推行也将 watch 独立成了一个 响应式 api,今天咱们就一块儿来学习 watch 相关的侦听器是如何实现的。vue

👇 储备知识要求:react

在阅读本文前,建议你已经学习过本系列的第 7 篇文章的 effect 反作用函数的相关知识,不然在讲解反作用的相关部分可能会出现不理解的状况。git

watchEffect

因为 watch api 中的许多行为都与 watchEffect api 一致,因此笔者将 watchEffect 放在首位讲解,为了根据响应式状态自动应用和从新应用反作用,咱们可使用 watchEffect 方法。它当即执行传入的一个函数,同时响应式追踪其依赖,并在以来变动时从新运行该函数。github

watchEffect 函数的实现很是简洁:api

export function watchEffect(
  effect: WatchEffect,
  options?: WatchOptionsBase
): WatchStopHandle {
  return doWatch(effect, null, options)
}

首先来看参数类型:数组

export type WatchEffect = (onInvalidate: InvalidateCbRegistrator) => void

export interface WatchOptionsBase {
  flush?: 'pre' | 'post' | 'sync'
  onTrack?: ReactiveEffectOptions['onTrack']
  onTrigger?: ReactiveEffectOptions['onTrigger']
}

export type WatchStopHandle = () => void

第一个参数 effect,接收函数类型的变量,而且在这个函数中会传入 onInvalidate 参数,用以清除反作用。闭包

第二个参数 options 是一个对象,在这个对象中有三个属性,你能够修改 flush 来改变反作用的刷新时机,默认为 pre,当修改成 post 时,就能够在组件更新后触发这个反作用侦听器,改同 sync 会强制同步触发。而 onTrack 和 onTrigger 选项能够用于调试侦听器的行为,而且两个参数只能在开发模式下工做。并发

参数传入后,函数会执行并返回 doWatch 函数的返回值。异步

因为 watch api 也会调用 doWatch 函数,因此 doWatch 函数的具体逻辑咱们会放在后边讲。先看 watch api 的函数实现。函数

watch

这个独立出来的 watch api 与组件中的 watch option 是彻底等同的,watch 须要侦听特定的数据源,并在回调函数中执行反作用。默认状况下这个侦听是惰性的,即只有当被侦听的源发生变化时才执行回调。

与 watchEffect 相比,watch 有如下不一样:

  • 懒性执行反作用
  • 更具体地说明说明状态应该处罚侦听器从新运行
  • 可以访问侦听状态变化先后的值

watch 函数的函数签名有许多种重载状况,且代码行数较多,因此笔者不许备分析每一个重载状况,一块儿来看一下 watch api 的实现。

export function watch<T = any, Immediate extends Readonly<boolean> = false>(
  source: T | WatchSource<T>,
  cb: any,
  options?: WatchOptions<Immediate>
): WatchStopHandle {
  if (__DEV__ && !isFunction(cb)) {
    warn(
      `\`watch(fn, options?)\` signature has been moved to a separate API. ` +
        `Use \`watchEffect(fn, options?)\` instead. \`watch\` now only ` +
        `supports \`watch(source, cb, options?) signature.`
    )
  }
  return doWatch(source as any, cb, options)
}

watch 接收 3 个参数,source 侦听的数据源,cb 回调函数,options 侦听选项。

source 参数

source 的类型以下:

export type WatchSource<T = any> = Ref<T> | ComputedRef<T> | (() => T)
type MultiWatchSources = (WatchSource<unknown> | object)[]

从两个类型定义看出,数据源支持传入单个的 Ref、Computed 响应式对象,或者传入一个返回相同泛型类型的函数,以及 source 支持传入数组,以便能同时监听多个数据源。

cb 参数

在这个最通用的声明中,cb 的类型是 any,可是其实 cb 这个回调函数也有他本身的类型:

export type WatchCallback<V = any, OV = any> = (
  value: V,
  oldValue: OV,
  onInvalidate: InvalidateCbRegistrator
) => any

在回调函数中,会提供最新的 value、旧 value,以及 onInvalidate 函数用以清除反作用。

options

export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
  immediate?: Immediate
  deep?: boolean
}

能够看到 options 的类型 WatchOptions 继承了 WatchOptionsBase,这也就是 watch 除了 immediate 和 deep 这两个特有的参数外,还能够传递 WatchOptionsBase 中的全部参数以控制反作用执行的行为。

分析完参数后,能够看到函数体内的逻辑与 watchEffect 几乎一致,可是多了在开发环境下检测回调函数是不是函数类型,若是回调函数不是函数,就会报警。

执行 doWatch 时的传参与 watchEffect 相比,多了第二个参数回调函数。

下面就让咱们揭开这个终极 boss doWatch 的庐山真面目吧。

doWatch

不论是 watchEffect、watch 仍是组件内的 watch 选项,在执行时最终调用的都是 doWatch 中的逻辑,这个强大的 doWatch 函数为了兼容各个 api 的逻辑源码也是挺长的大约有 200 行,因此老规矩,笔者会将长源码拆分开来说。若想阅读完整源码请戳这里

先从 doWatch 的函数签名看起:

function doWatch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb: WatchCallback | null,
  { immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ,
  instance = currentInstance
): WatchStopHandle

这个函数签名与 watch 基本一致,多了一个 instance 的参数,默认值为 currentInstance,currentInstance 是当前调用组件暴露出来的一个变量,方便该侦听器找到本身对应的组件。

而 source 在这里的类型就比较清晰,支持单个的 source 或者数组,也只是一个普通对象。

接着会建立三个变量,getter 最终会当作反作用的函数参数传入,forceTrigger 标识是否须要强制更新,isMultiSource 标记传入的是单个数据源仍是以数组形式传入的多个数据源。

let getter: () => any
let forceTrigger = false
let isMultiSource = false

而后会开始判断 source 的类型,根据不一样的类型重置这三个参数的值。

  • ref 类型

    • 访问 getter 函数会获取到 source.value 值,直接解包。
    • forceTrigger 标记会根据是不是 shallowRef 来设置。
  • reactive 类型

    • 访问 getter 函数直接返回 source,由于 reactive 的值不须要解包获取。
    • 因为 reactive 中每每有多个属性,因此会将 deep 设置为 true,这里能够看出从外部给 reactive 设置 deep 是无效的。
  • 数组 array 类型

    • 将 isMultiSource 设置为 true。
    • forceTrigger 会根据数组中是否存在 reactive 响应式对象来判断。
    • getter 是一个数组形式,是 source 内各个元素的单个 getter 结果。
  • source 是函数 function 类型

    • 若是有回调函数

      • getter 就是 source 函数执行的结果,这种状况通常是 watch api 中的数据源以函数的形式传入。
    • 若是没有回调函数,那么此时就是 watchEffect api 的场景了。

      • 此时会为 watchEffect 设置 getter 函数,getter 函数逻辑以下:

        • 若是组件实例已经卸载,则不执行,直接返回
        • 不然执行 cleanup 清除依赖
        • 执行 source 函数
  • 若是 source 不是以上的状况,则将 getter 设置为空函数,而且报出 source 不合法的警告⚠️。

相关代码以下,因为逻辑已经完整的一丝不落的在上面分析了,因此就容笔者偷个懒,不加注释了。

if (isRef(source)) { // ref 类型的数据源,更新 getter 与 forceTrigger
  getter = () => (source as Ref).value
  forceTrigger = !!(source as Ref)._shallow
} else if (isReactive(source)) { // reactive 类型的数据源,更新 getter 与 deep
  getter = () => source
  deep = true
} else if (isArray(source)) { // 多个数据源,更新 isMultiSource、forceTrigger、getter
  isMultiSource = true
  forceTrigger = source.some(isReactive)
  // getter 会以数组形式返回数组中数据源的值
  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)) { // 数据源是函数的状况
  if (cb) {
    // 若是有回调,则更新 getter,让数据源做为 getter 函数
    getter = () =>
      callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
  } else {
    // 没有回调即为 watchEffect 场景
    getter = () => {
      if (instance && instance.isUnmounted) {
        return
      }
      if (cleanup) {
        cleanup()
      }
      return callWithAsyncErrorHandling(
        source,
        instance,
        ErrorCodes.WATCH_CALLBACK,
        [onInvalidate]
      )
    }
  }
} else {
  // 其他状况 getter 为空函数,并发出警告
  getter = NOOP
  __DEV__ && warnInvalidSource(source)
}

接着会处理 watch 中的场景,当有回调,而且 deep 选项为 true 时,将使用 traverse 来包裹 getter 函数,对数据源中的每一个属性递归遍历进行监听。

if (cb && deep) {
  const baseGetter = getter
  getter = () => traverse(baseGetter())
}

以后会声明 cleanup 和 onInvalidate 函数,并在 onInvalidate 函数的执行过程当中给 cleanup 函数赋值,当反作用函数执行一些异步的反作用,这些响应须要在其失效时清除,因此侦听反作用传入的函数能够接收一个 onInvalidate 函数做为入参,用来注册清理失效时的回调。当如下状况发生时,这个失效回调会被触发:

  • 反作用即将从新执行时。
  • 侦听器被中止(若是在 setup() 或生命周期钩子函数中使用了 watchEffect,则在组件卸载时)。
let cleanup: () => void
let onInvalidate: InvalidateCbRegistrator = (fn: () => void) => {
  cleanup = runner.options.onStop = () => {
    callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
  }
}

接着会初始化 oldValue 并赋值。

而后声明一个 job 函数,这个函数最终会做为调度器中的回调函数传入,因为是一个闭包形式依赖外部做用域中的许多变量,因此会放在后面讲,避免出现还未声明的变量形成理解困难。

根据是否有回调函数,设置 job 的 allowRecurse 属性,这个设置很重要,可以让 job 做为一个观察者的回调这样调度器就能知道它容许调用自身。

接着声明一个 scheduler 的调度器对象,根据 flush 的传参来肯定调度器的执行时机。

  • 当 flush 为 sync 同步时,直接将 job 赋值给 scheduler,这样这个调度器函数就会直接执行。
  • 当 flush 为 post 须要延迟执行时,将 job 传入 queuePostRenderEffect 中,这样 job 会被添加进一个延迟执行的队列中,这个队列会在组件被挂载后、更新的生命周期中执行。
  • 最后是 flush 为默认的 pre 优先执行的状况,这是调度器会区分组件是否已经挂载,反作用第一次调用时必须是在组件挂载以前,而挂载后则会被推入一个优先执行时机的队列中。

这一部分逻辑的源码以下:

// 初始化 oldValue
let oldValue = isMultiSource ? [] : INITIAL_WATCHER_VALUE
const job: SchedulerJob = () => { /*暂时忽略逻辑*/ } // 声明一个 job 调度器任务,暂时不关注内部逻辑

// 重要:让调度器任务做为侦听器的回调以致于调度器能知道它能够被容许本身派发更新
job.allowRecurse = !!cb

let scheduler: ReactiveEffectOptions['scheduler'] // 声明一个调度器
if (flush === 'sync') {
  scheduler = job as any // 这个调度器函数会当即被执行
} else if (flush === 'post') {
  // 调度器会将任务推入一个延迟执行的队列中
  scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else {
    // 默认状况 'pre'
  scheduler = () => {
    if (!instance || instance.isMounted) {
      queuePreFlushCb(job)
    } else {
      // 在 pre 选型中,第一次调用必须发生在组件挂载以前
      // 因此此次调用是同步的
      job()
    }
  }
}

在处理完以上的调度器部分后,会开始建立反作用。

首先声明一个 runner 变量,它建立一个反作用并将以前处理好的 getter 函数做为反作用函数传入,并在反作用选项中设置了延迟调用,以及设置了对应的调度器。

并经过 recordInstanceBoundEffect 函数将该反作用函数加入组件实例的的 effects 属性中,好让组件在卸载时可以主动得中止这些反作用函数的执行。

接着会开始处理首次执行反作用函数。

  • 若是 watch 有回调函数

    • 若是 watch 设置了 immediate 选项,则当即执行 job 调度器任务。
    • 不然首次执行 runner 反作用,并将返回值赋值给 oldValue。
  • 若是 flush 的刷新时机是 post,则将 runner 放入延迟时机的队列中,等待组件挂载后执行。
  • 其他状况都直接首次执行 runner 反作用。

最后 doWatch 函数会返回一个函数,这个函数的做用是中止侦听,因此你们在使用时能够显式的为 watch、watchEffect 调用返回值以中止侦听。

// 建立 runner 反作用
const runner = effect(getter, {
  lazy: true,
  onTrack,
  onTrigger,
  scheduler
})

// 将 runner 添加进 instance.effects 数组中
recordInstanceBoundEffect(runner, instance)

// 初始化调用反作用
if (cb) {
  if (immediate) {
    job() // 有回调函数且是 imeediate 选项的当即执行调度器任务
  } else {
    oldValue = runner() // 不然执行一次 runner,并将返回值赋值给 oldValue
  }
} else if (flush === 'post') {
     // 若是调用时机为 post,则推入延迟执行队列
  queuePostRenderEffect(runner, instance && instance.suspense)
} else {
  // 其他状况当即首次执行反作用
  runner()
}

// 返回一个函数,用以显式的结束侦听
return () => {
  stop(runner)
  if (instance) {
    remove(instance.effects!, runner)
  }
}

doWatch 函数到这里就所有运行完毕了,如今全部的变量已经声明完毕,尤为是最后声明的 runner 反作用。咱们能够回过头看看被调用了屡次的 job 中究竟作了什么。

调度器任务中作的事情逻辑比较清晰,首先会判断 runner 反作用是否被停用,若是已经被停用则当即返回,再也不执行后续逻辑。

以后区分场景,经过是否存在回调函数判断是 watch api 调用仍是 watchEffect api 调用。

若是是 watch api 调用,则会执行 runner 反作用,将其返回值赋值给 newValue,做为最新的值。若是是 deep 须要深度侦听,或者是 forceTrigger 须要强制更新,或者新旧值发生了改变,这三种状况都须要触发 cb 回调,通知侦听器发生了变化。在调用侦听器以前会先经过 cleanup 清除反作用,接着触发 cb 回调,将 newValue、oldValue、onInvalidate 三个参数传入回调。在回调触发后再去更新 oldValue 的值。

而若是没有 cb 回调函数,即为 watchEffect 的场景,此时调度器任务仅仅须要执行 runner 反作用函数就好。

job 调度器任务中的具体代码逻辑以下:

const job: SchedulerJob = () => {
  if (!runner.active) { // 若是反作用以停用则直接返回
    return
  }
  if (cb) {
    // watch(source, cb) 场景
    // 调用 runner 反作用获取最新的值 newValue
    const newValue = runner()
    // 若是是 deep 或 forceTrigger 或有值更新
    if (
      deep ||
      forceTrigger ||
      (isMultiSource
        ? (newValue as any[]).some((v, i) =>
            hasChanged(v, (oldValue as any[])[i])
          )
        : hasChanged(newValue, oldValue))
    ) {
      // 当回调再次执行前先清除反作用
      if (cleanup) {
        cleanup()
      }
      // 触发 watch api 的回调,并将 newValue、oldValue、onInvalidate 传入
      callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
        newValue,
        // 首次调用时,将 oldValue 的值设置为 undefined
        oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
        onInvalidate
      ])
      oldValue = newValue // 触发回调后,更新 oldValue
    }
  } else {
    // watchEffect 的场景,直接执行 runner
    runner()
  }
}

总结

在本文中,笔者给你们详细讲解了 Vue3 中提供的 watch、watchEffect 两个 api 的实现,而且在组件的 option 选项中的 watch,其实也是经过 doWatch 函数来完成侦听的。在讲解的过程当中,咱们发现 Vue3 中的侦听器也是经过反作用来实现的,因此理解侦听器以前须要先了解透彻反作用究竟作了什么。

咱们看到 watch、watchEffect 的背后都是调用并返回 doWatch 函数,笔者拆解分析了 doWatch 函数,让读者可以清楚的知道 doWatch 每一行代码都作了什么,以便于当咱们的侦听器不如本身预期的工做时,能够从细节之处分析缘由,而不至于瞎猜瞎试。

最后,若是这篇文章可以帮助到你了解更了解 Vue3 中的 watch 的原理以及它的工做方式,但愿能给本文点一个喜欢❤️。若是想继续追踪后续文章,也能够关注个人帐号或 follow 个人 github,再次谢谢各位可爱的看官老爷。