Vue3 源码解读-watch 实现原理


💡 [本系列Vue3源码解读文章基于3.3.4版本](https://github.com/vuejs/core/tree/v3.3.4) 欢迎关注公众号:《前端 Talkking》

1、前言

所谓 watch,其本质就是观测一个响应式数据,当数据发生变化的时通知并执行相应的回调函数。如下示例:

javascript 复制代码
watch(obj, () => {
   console.log("数据变化了")
}))

假设 watch是一个响应数据,使用 watch函数观测它,并传递一个回调函数,当修改响应式数据 obj 的值的时候,会触发该回调函数执行。

实际上,watch的实质本质上就是利用了 effect以及 options.scheduler选项,如下代码所示:

javascript 复制代码
effect(() => {
  console.log(obj.foo)
}, {
scheduler() {
  // 当 obj.foo 的值变化时,就会执行scheduler调度函数
 }
})

如果一个副作用函数存在 scheduler选项,当响应式数据发生变化时,会触发 scheduler调度函数执行,而非直接触发副作用函数执行。从这个角度来看,其实 scheduler调度函数就相当于一个回调函数,而 watch的实现就是利用了这个特性。以下是最简单的 watch函数实现:

javascript 复制代码
function watch(source, cb) {
  // 触发读取操作,从而建立联系
  effect(() => source.foo,
    {
    scheduler() {
      // 当数据变化时,调用回调函数cb
      cb()
    }
  })
}

2、watch 源码实现

watch函数定义在:packages/runtime-core/src/apiWatch.ts文件下。

2.1 函数签名

watch 函数签名源码实现

javascript 复制代码
export function watch<
  T extends MultiWatchSources,
  Immediate extends Readonly<boolean> = false
>(
  sources: [...T],
  cb: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
  options?: WatchOptions<Immediate>
): WatchStopHandle

// overload: multiple sources w/ `as const`
// watch([foo, bar] as const, () => {})
// somehow [...T] breaks when the type is readonly
export function watch<
  T extends Readonly<MultiWatchSources>,
  Immediate extends Readonly<boolean> = false
>(
  source: T,
  cb: WatchCallback<MapSources<T, false>, MapSources<T, Immediate>>,
  options?: WatchOptions<Immediate>
): WatchStopHandle

// overload: single source + cb
export function watch<T, Immediate extends Readonly<boolean> = false>(
  source: WatchSource<T>,
  cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
  options?: WatchOptions<Immediate>
): WatchStopHandle

// overload: watching reactive object w/ cb
export function watch<
  T extends object,
  Immediate extends Readonly<boolean> = false
>(
  source: T,
  cb: WatchCallback<T, Immediate extends true ? T | undefined : T>,
  options?: WatchOptions<Immediate>
): WatchStopHandle

从以上源码中,我们可以发现,watch函数可以监听的数据源有:

  1. 侦听的数据源是一个 ref 类型的数据 或者是一个具有返回值的 getter 函数;
  2. 侦听的数据源是一个数组;

2.2 watch 源码实现

无论哪种函数签名,在 watch函数实现中的源码如下:

javascript 复制代码
// implementation
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 侦听选项。watch 函数最终都调用了 doWatch函数。

2.2.1 source 参数

从定义可知,source 可以是一个 ref 类型的数据,或者是一个具有返回值的 getter 函数,也可以是一个响应式的 obj 对象。当侦听的是多个源时,source 可以是一个数组。

2.2.2 cb 参数

cb 参数的定义如下:

javascript 复制代码
export type WatchCallback<V = any, OV = any> = (
  value: V,
  oldValue: OV,
  onCleanup: OnCleanup
) => any
  • value:最新的值;
  • oldValue: 更新前的值;
  • onCleanup:用于清除副作用。

2.2.3 options 参数

options 参数的类型定义如下:

javascript 复制代码
export interface WatchOptionsBase extends DebuggerOptions {
  flush?: 'pre' | 'post' | 'sync'
}

export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
  immediate?: Immediate
  deep?: boolean
}
  • immediate:控制 watch 的回调是否立即执行;
  • deep:控制 watch 的监听是否是深度的;
  • flush:调整回调函数的刷新时机;

2.3 doWatch 函数实现

doWatch 函数源码实现

doWatch 的函数与 watch 函数的签名基本一致,也是接收三个参数。在 doWatch 函数中,为了便于 options 选项的使用,对 options 进行了解构。

javascript 复制代码
function doWatch(
  source: WatchSource | WatchSource[] | WatchEffect | object,
  cb: WatchCallback | null,
  { immediate, deep, flush, onTrack, onTrigger }: WatchOptions = EMPTY_OBJ
): WatchStopHandle

doWatch函数实现很长,下面只贴出我们需要理解的关键部分。

2.3.1 标准化 source

通过前文我们知道,source可以是 getter函数,也可以是响应式对象甚至是响应式对象数组,因此,我们需要根据传入的 source,生成标准化的 getter函数,处理流程如下:

标准化 source 处理流程

javascript 复制代码
if (isRef(source)) {
    getter = () => source.value
    forceTrigger = isShallow(source)
  } else if (isReactive(source)) {
    getter = () => source
    deep = true
  } 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 traverse(s)
        } else if (isFunction(s)) {
          return callWithErrorHandling(s, instance, ErrorCodes.WATCH_GETTER)
        } else {
          __DEV__ && warnInvalidSource(s)
        }
      })
  } else if (isFunction(source)) {
    if (cb) {
      // getter with cb
      getter = () =>
        callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
    } else {
      // no cb -> simple effect
      getter = () => {
        if (instance && instance.isUnmounted) {
          return
        }
        if (cleanup) {
          cleanup()
        }
        return callWithAsyncErrorHandling(
          source,
          instance,
          ErrorCodes.WATCH_CALLBACK,
          [onCleanup]
        )
      }
    }
  } else {
    getter = NOOP
    __DEV__ && warnInvalidSource(source)
  }

标准化 source的处理流程拆解如下:

  1. 如果 sourceref对象,则创建一个访问 source.valuegetter函数;
  2. 如果 sourcesource对象,则创建一个访问 sourcegetter函数,并设置 deeptrue
  3. 如果 source是一个函数,则会继续判断第二个参数 cb是否存在,然后对 cb 函数做简单的封装处理;
  4. 如果 source是一个数组,则内部会通过 source.map函数映射处一个新的数组,它会判断每个数组元素的类型,映射规则与前面的 source规则一致;
  5. 如果 source不满足上述条件,则在非生产环境下发出警告,提示 source类型不合法;

2.3.2 创建 job

处理完 watch函数的第一个参数 source后,接下来处理第二个参数 cb

我们知道,cb 是一个回调函数,其拥有三个参数:

  • newValue: 代表新值;
  • oldValue:代表旧值;
  • onInvalidate:表示注册无效的回调函数;

那么如何判断值是否发生了变化,如何计算和存储旧值和新值呢?

我们可以在内部创建一个 job,它是对 cb回调函数做的一层封装,维护新值旧值的计算和存储,以及是否需要执行回调函数,当侦听的值发生变化时就会执行 job

创建 job 源码实现

javascript 复制代码
// 注册无效回调函数
let onCleanup: OnCleanup = (fn: () => void) => {
  cleanup = effect.onStop = () => {
    callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
    cleanup = effect.onStop = undefined
  }
}

// 旧值初始值
let oldValue: any = isMultiSource
  ? new Array((source as []).length).fill(INITIAL_WATCHER_VALUE)
  : INITIAL_WATCHER_VALUE
const job: SchedulerJob = () => {
  if (!effect.active) {
    return
  }
  if (cb) {
    // 新值
    const newValue = effect.run()
    // 满足执行回调函数的条件
    if (
      deep ||
      forceTrigger ||
      (isMultiSource
        ? (newValue as any[]).some((v, i) => hasChanged(v, oldValue[i]))
        : hasChanged(newValue, oldValue)) ||
      (__COMPAT__ &&
        isArray(newValue) &&
        isCompatEnabled(DeprecationTypes.WATCH_ARRAY, instance))
    ) {
      // cleanup before running cb again
      // 执行清理函数
      if (cleanup) {
        cleanup()
      }
      // 执行回调函数 cb
      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
          : isMultiSource && oldValue[0] === INITIAL_WATCHER_VALUE
            ? []
            : oldValue,
        onCleanup
      ])
      // 更新旧值
      oldValue = newValue
    }
  } else {
    // watchEffect
    effect.run()
  }
}
// 允许触发自身
job.allowRecurse = !!cb

创建 job函数的处理流程如下:

  1. 判断回调函数 cb是否传入,如果有传入,那么是 watch的调用场景,否则是 watchEffect函数被调用的场景;
    1. 如果是 watch函数被调用的场景,首先执行副作用函数获取最新的值 newValue,然后判断是否需要执行回调函数 cb的情况:
      1. 监听的数据是 reactive类型,即 deep 的值为 true;
      2. 需要强制执行副作用函数,即 forceTrigger为 true;
      3. 新旧值发生了变化; 如果满足上面条件中的一个,那么先清除副作用函数,然后调用 callWithAsyncErrorHandling函数,将新旧值 newValueoldValue传入该函数中,执行完毕后更新旧值 oldValue,避免在下一次执行回调函数 cb时获取到错误的旧值。
    2. 如果是 watchEffect函数被调用的场景,则直接执行副作用函数即可;
    3. 设置 job 的 allowRecurse 属性,它能够让 job 作为侦听器的回调,这样调度器就能知道它允许调用自身。

2.3.3 创建 scheduler

当调用 watch函数时,可以通过 options 的 flush 选项来指定回调函数的执行时机:

  • flush: sync,代表它是一个同步的 watcher,即数据变化时同步执行回调函数;
  • flush: post,代表调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后再执行;
  • flush: pre,即调度器函数默认的执行方式,在组件更新之前执行,如果组件还没有挂载,则在组件挂载之前同步执行回调函数。

创建 scheduler 源码实现

javascript 复制代码
let scheduler: EffectScheduler
  if (flush === 'sync') {
    // 同步执行,将job赋值给调度器
    scheduler = job as any // the scheduler function gets called directly
  } else if (flush === 'post') {
    // 将调度函数job添加到微任务队列中执行
    scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
  } else {
    // default: 'pre'
    job.pre = true
    if (instance) job.id = instance.uid
    scheduler = () => queueJob(job)
  }

2.3.4 创建副作用 effect

初始化 getter函数和调度函数 scheduler后,调用 ReactiveEffect来创建一个副作用函数。然后经过以下步骤处理:

创建副作用 effect

javascript 复制代码
const effect = new ReactiveEffect(getter, scheduler)

  if (__DEV__) {
    effect.onTrack = onTrack
    effect.onTrigger = onTrigger
  }

  // initial run
  // 初次执行
  if (cb) {
    // 选项参数 immediate 来指定回调是否需要立即执行
    if (immediate) {
      // 手动调用副作用函数,拿到的就是旧值
      job()
    } else {
      // 求旧值
      oldValue = effect.run()
    }
  } else if (flush === 'post') {
    queuePostRenderEffect(
      effect.run.bind(effect),
      instance && instance.suspense
    )
  } else {
    // 没有cb并且flush不为post的情况
    effect.run()
  }
  1. 判断传入的回调函数 cb是否存在,如果存在,则根据传入的 options选项中 immediate,如果是否为 true,则会在创建 watch的时候立即执行一次,否则,否则就手动调用副作用函数,并将返回值作为旧值,赋值给 oldValue
  2. 如果 optionsflush的选项的值为 post,需要将副作用函数放入到微任务队列中,等待组件挂载完成后再执行副作用函数;
  3. 其余情况就是立即执行副作用函数。

2.3.5 返回销毁函数

最后,会返回销毁函数,也就是 watch执行后返回的函数,我们可以通过调用它来停止 watcher对数据的监听,如下代码所示:

返回销毁函数源码实现

javascript 复制代码
const unwatch = () => {
  effect.stop()
  if (instance && instance.scope) {
    // 移除组件effects对这个effect的引用
    remove(instance.scope.effects!, effect)
  }
}

销毁函数内部会执行 effect.stop()函数让 effect失效,并清理 effect的相关依赖,这样就可以停止对数据的监听。同时,如果是组件中注册的 watcher,也会移除组件 effects对这个 effect的引用。

3、总结

watch本质上利用了副作用函数重新执行时的可调度性。一个 watch本身会创建一个 effect,当这个 effect依赖的响应式数据发生变化时,会执行该 effect的调度函数,即 scheduler

watch可以侦听单一数据源,也可以侦听多个源。单一的数据源可以是具有返回值的 getter函数,或者一个 ref对象,也可以是 reactive对象。

watch可以通过 flush指定回调函数和副作用函数的执行时机,可指定的参数值有 postsyncpre(默认)。

watch可以通过指定 immediate为 true,这样 watch在创建的时候会立即执行一次回调函数。

4、参考资料

[1]vue官网

[2]vuejs设计与实现

[3]vue3源码

相关推荐
开心工作室_kaic7 分钟前
ssm068海鲜自助餐厅系统+vue(论文+源码)_kaic
前端·javascript·vue.js
有梦想的刺儿26 分钟前
webWorker基本用法
前端·javascript·vue.js
cy玩具1 小时前
点击评论详情,跳到评论页面,携带对象参数写法:
前端
customer081 小时前
【开源免费】基于SpringBoot+Vue.JS周边产品销售网站(JAVA毕业设计)
java·vue.js·spring boot·后端·spring cloud·java-ee·开源
qq_390161771 小时前
防抖函数--应用场景及示例
前端·javascript
John.liu_Test2 小时前
js下载excel示例demo
前端·javascript·excel
Yaml42 小时前
智能化健身房管理:Spring Boot与Vue的创新解决方案
前端·spring boot·后端·mysql·vue·健身房管理
PleaSure乐事2 小时前
【React.js】AntDesignPro左侧菜单栏栏目名称不显示的解决方案
前端·javascript·react.js·前端框架·webstorm·antdesignpro
哟哟耶耶2 小时前
js-将JavaScript对象或值转换为JSON字符串 JSON.stringify(this.SelectDataListCourse)
前端·javascript·json
getaxiosluo2 小时前
react jsx基本语法,脚手架,父子传参,refs等详解
前端·vue.js·react.js·前端框架·hook·jsx