Vue3 Watch API 到底是怎么实现的?

简介: Vue3 Watch API 到底是怎么实现的?

前言


在之前的文章,我们已经介绍过 vue3 的响应式原理。如果还没看过的同学,强烈建议先看看《六千字详解!vue3 响应式是如何实现的?》,该文章用 vue3 ref 的例子,详细地介绍了响应式原理的实现。

而这篇则是在响应式原理的基础上,进一步介绍 Vue3 的另外一个 API —— watch


watch 用法


Vue3  的 watchApi 主要有两类:watch 和 watchEffect。(watchPostEffect  和 watchSyncEffect 只是 watchEffect 的不同参数 flush 的别名)


watch 的用法


  1. 侦听单一源

typescript

复制代码

// 侦听一个 getter 函数
const state = reactive({ count: 0 })
watch(
  () => state.count,
  (count, prevCount) => {
    /* ... */
  }
)
// 直接侦听一个 ref
const count = ref(0)
watch(count, (count, prevCount) => {
  /* ... */
})
  1. 侦听多个源


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 函数主要分为以下几个部分:

  1. 标准化 source,组装成为 getter 函数
  2. 组装 job 函数。判断侦听的值是否有变化,有变化则执行 getter 函数和 cb 回调
  3. 组装 scheduler 函数,scheduler 负责在合适的时机调用 job 函数(根据 options.flush,即副作用刷新的时机),默认在组件更新前执行
  4. 开启侦听
  5. 返回停止侦听函数

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 对象的作用:

  1. ReactiveEffect,接受 fn 和 scheduler 参数。ReactiveEffect 被创建时,会立即执行 fn
  2. 当 fn 函数中使用到响应式变量(如 ref)时,该响应式变量就会用数组收集 ReactiveEffect 对象的引用
  3. 1686386223629.png
  4. 响应式变量被改变时,会触发所有的 ReactiveEffect 对象,触发规则如下:
  • 如果没有 scheduler 参数,则执行ReactiveEffect 的 fn
  • 如果有 scheduler 参数,则执行 scheduler,这时需要在 scheduler 中手动调用 fn
  1. 执行 fn 时,使用到响应式变量,依赖又会被重新收集

接下来,我们会从 ReactiveEffect 作为切入点,进行介绍(并非按照代码顺序介绍)


开启侦听


// 开启侦听,侦听的是 getter 函数
const effect = new ReactiveEffect(getter, scheduler)

这里会立即调用 getter 函数,进行依赖收集。

如果依赖有变化,则执行 scheduler 函数

1686386200613.png

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()
    }
  }
}

queuePostRenderEffectqueuePreFlushCb 在该文章不会详细介绍,只需要知道,这两个函数是在 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


其他阅读


最后


如果这篇文章对您有所帮助,请帮忙点个赞👍,您的鼓励是我创作路上的最大的动力。

目录
相关文章
|
17天前
|
JavaScript 前端开发 API
Vue 3新特性详解:Composition API的威力
【10月更文挑战第25天】Vue 3 引入的 Composition API 是一组用于组织和复用组件逻辑的新 API。相比 Options API,它提供了更灵活的结构,便于逻辑复用和代码组织,特别适合复杂组件。本文将探讨 Composition API 的优势,并通过示例代码展示其基本用法,帮助开发者更好地理解和应用这一强大工具。
21 1
|
1月前
|
缓存 JavaScript API
Vue 3的全新Reactivity API:解锁响应式编程的力量
Vue 3引入了基于Proxy的全新响应式系统,提升了性能并带来了更强大的API。本文通过示例详细介绍了`reactive`、`ref`、`computed`、`watch`等核心API的使用方法,帮助开发者深入理解Vue 3的响应式编程。无论你是初学者还是资深开发者,都能从中受益,构建更高效的应用程序。
19 1
|
2月前
|
JavaScript 前端开发 API
花了一天的时间,地板式扫盲了vue3中所有API盲点
这篇文章全面介绍了Vue3中的API,包括组合式API、选项式API等内容,旨在帮助开发者深入了解并掌握Vue3的各项功能。
花了一天的时间,地板式扫盲了vue3中所有API盲点
|
1月前
|
缓存 JavaScript API
Vue 3的全新Reactivity API:解锁响应式编程的力量
【10月更文挑战第9天】Vue 3的全新Reactivity API:解锁响应式编程的力量
16 3
|
1月前
|
缓存 JavaScript 前端开发
深入理解 Vue 3 的 Composition API 与新特性
本文详细探讨了 Vue 3 中的 Composition API,包括 setup 函数的使用、响应式数据管理(ref、reactive、toRefs 和 toRef)、侦听器(watch 和 watchEffect)以及计算属性(computed)。我们还介绍了自定义 Hooks 的创建与使用,分析了 Vue 2 与 Vue 3 在响应式系统上的重要区别,并概述了组件生命周期钩子、Fragments、Teleport 和 Suspense 等新特性。通过这些内容,读者将能更深入地理解 Vue 3 的设计理念及其在构建现代前端应用中的优势。
31 0
深入理解 Vue 3 的 Composition API 与新特性
|
1月前
|
JavaScript API
|
22天前
|
API
《vue3第四章》Composition API 的优势,包含Options API 存在的问题、Composition API 的优势
《vue3第四章》Composition API 的优势,包含Options API 存在的问题、Composition API 的优势
25 0
|
22天前
|
JavaScript 前端开发 API
《vue3第六章》其他,包含:全局API的转移、其他改变
《vue3第六章》其他,包含:全局API的转移、其他改变
20 0
|
1月前
|
存储 前端开发 JavaScript
深入理解Vue3的组合式API及其实践应用
【10月更文挑战第5天】深入理解Vue3的组合式API及其实践应用
79 0
|
1月前
|
JavaScript 前端开发 安全