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官网](https://link.juejin.cn?target=https%3A%2F%2Fcn.vuejs.org%2F "https://cn.vuejs.org/") \[2\][vuejs设计与实现](https://link.juejin.cn?target=https%3A%2F%2Fwww.ituring.com.cn%2Fbook%2F2953 "https://www.ituring.com.cn/book/2953") \[3\][vue3源码](https://link.juejin.cn?target=https%3A%2F%2Fgithub.com%2Fvuejs%2Fcore%2Fblob%2Fv3.3.4 "https://github.com/vuejs/core/blob/v3.3.4")

相关推荐
3Katrina几秒前
前端面试之防抖节流(二)
前端·javascript·面试
前端进阶者7 分钟前
天地图编辑支持删除编辑点
前端·javascript
江号软件分享15 分钟前
无接触服务的关键:二维码生成识别技术详解
前端
江号软件分享16 分钟前
如何利用取色器实现跨平台色彩一致性
前端
灰海20 分钟前
封装WebSocket
前端·网络·websocket·网络协议·vue
前端小巷子30 分钟前
深入理解TCP协议
前端·javascript·面试
万少32 分钟前
鸿蒙外包的十大生存法则
前端·后端·面试
顽疲1 小时前
从零用java实现 小红书 springboot vue uniapp(13)模仿抖音视频切换
java·vue.js·spring boot
江号软件分享1 小时前
有效保障隐私,如何安全地擦除电脑上的敏感数据
前端
web守墓人2 小时前
【前端】ikun-markdown: 纯js实现markdown到富文本html的转换库
前端·javascript·html