likes
comments
collection
share

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

作者站长头像
站长
· 阅读数 14

1. 前言

本片文章原文来自 我的个人博客

这是 《vue3 源码学习,实现一个 mini-vue》 系列文章 响应式模块 的最后一章了,在前面几章我们分别介绍了 reactiveref 以及 computed 这三个方法,阅读了 vue 源码并且实现了它们,那么本章我们最后来实现一下 watch 吧~

2. watch 源码阅读

我们可以点击 这里 来查看 watch 的官方文档。

watch 的实现和 computed 有一些相似的地方,但是作用却与 computed 大有不同。watch 可以监听响应式数据的变化,从而触发指定的函数。

2.1 基础的 watch 实例

我们直接从下面的代码开始 vue 源码调试:

<script>
  const { reactive, watch } = Vue

  const obj = reactive({
    name: '张三'
  })

  watch(obj, (value, oldValue) => {
    console.log('watch 监听被触发')
    console.log('value', value)
  })

  setTimeout(() => {
    obj.name = '李四'
  }, 2000)
</scri

以上代码分析:

  1. 首先通过 reactive 函数构建了响应性的实例
  2. 然后触发 watch
  3. 最后触发 proxysetter

摒弃掉之前熟悉的 reactive,我们从 watch 函数开始源码跟踪:

2.2 watch 函数

  1. 我们直接来到 packages/runtime-core/src/apiWatch.ts 中找到 watch 函数,开始 debugger

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. 可以看到 watch 接受三个参数 source cb options,最后返回并调用了 doWatch,我们进入到 doWatch

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. doWatch 方法代码很多,上面有一些警告打印的 if,我们直接来到第 207 行。因为 sourcereactive 类型数据,所以会执行 getter = () => source,目前 sourceproxy 实例,即:getter = () => Proxy{name: '张三'}。紧接着,指定 deep = true,即:sourcereactive 时,默认添加 options.deep = true。我们继续调试 doWatch 这个方法:

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. 执行 if (cb && deep),条件满足:创建新的常量 baseGetter = getter,我们继续调试 doWatch 这个方法:

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. 执行 let oldValue = isMultiSource ? [] : INITIAL_WATCHER_VALUE,将 INITIAL_WATCHER_VALUE 赋值给 oldValueINITIAL_WATCHER_VALUE = {}

  2. 执行 const job: SchedulerJob = () => {...},我们知道 Scheduler 是一个调度器,SchedulerJob 其实就是一个调度器的处理函数,在之前我们接触了一下 Scheduler 调度器,但是并没有进行深入了解,那么这里将涉及到调度器的比较复杂的一些概念,所以后面我们想要实现 watch,还需要 深入的了解下调度器的概念,现在我们暂时先不需要管它。我们继续调试 doWatch 这个方法:

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. 直接执行:let scheduler: EffectScheduler = () => queuePreFlushCb(job),这里通过执行 queuePreFlushCb 函数,将上一步的 job 作为传参,来得到一个完整的调度器函数 scheduler。我们继续调试 doWatch 这个方法:

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. 代码继续执行得到一个 ReactiveEffect 的实例,注意: 该实例包含一个完善的调度器 scheduler,接着调用了 effectrun 方法,实际上是调用了 getter 方法,获取到了 oldValue,最后返回一个回调函数。

  2. 至此 watch 函数的逻辑执行完成。

总结:

  1. watch 函数的代码很长,但是逻辑还算清晰
  2. 调度器 schedulerwatch 中很关键
  3. schedulerReactiveEffect 两者之间存在互相作用的关系,一旦 effect 触发了 scheduler 那么会导致 queuePreFlushCb(job) 执行
  4. 只要 job() 触发,那么就表示 watch 触发了一次

2.3 reactive 触发 setter

等待两秒,reactive 实例将触发 setter 行为,setter 行为的触发会导致 trigger 函数的触发,所以我们可以直接在 trigger 中进行 debugger

  1. 我们直接来到 packages/reactivity/src/effect.ts 中找到 trigger,进行 debugger

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. 根据我们之前的经验可知,trigger 最终会触发到 triggerEffect,所以我们可以 省略中间 步骤,直接进入到 triggerEffect 中:

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. 我们主要来看 triggerEffect

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. 因为 scheduler 存在,所以会直接执行 scheduler,即等同于直接执行 queuePreFlushCb(job)。所以接下来我们 进入 queuePreFlushCb 函数,看看 queuePreFlushCb 做了什么:

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. 触发 queueCb(cb, ..., pendingPreFlushCbs, ...) 函数,此时 cb = job,即:cb() 触发一次,意味着 watch 触发一次,进入 queueCb 函数:

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. 执行 pendingQueue.push(cb)pendingQueue 从语义中看表示 队列 ,为一个 数组,接着执行了 queueFlush 函数,我们进入 queueFlush() 函数:

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. queueFlush 函数内部做了两件事:1. 执行了 isFlushPending = true isFlushPending 是一个 标记,表示 promise 进入 pending 状态。2. 通过 Promise.resolve().then() 这样一种 异步微任务的方式 执行了 flushJobs 函数, flushJobs 是一个 异步函数,它会等到 同步任务执行完成之后 被触发,我们可以 flushJobs 函数内部增加一个断点

  2. 至此整个 trigger 就执行完成

总结:

  1. 整个 trigger 的执行核心是触发了 scheduler 调度器,从而触发 queuePreFlushCb 函数
  2. queuePreFlushCb 函数主要做了以下几点事情:
    1. 构建了任务队列 pendingQueue
    2. 通过 Promise.resolve().thenflushJobs 函数扔到了微任务队列中

同时因为接下来 同步任务已经执行完成,所以 异步的微任务 马上就要开始执行,即接下来我们将会进入 flushJobs 中。

2.4 flushJobs 函数

  1. 进入 flushJobs 函数代码:

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. 执行 flushPreFlushCbs(seen) 函数,这个函数非常关键,我们来看一下:

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. 通过截图代码可知,pendingPreFlushCbs 为一个数组,其中第一个元素就是 job 函数(通过 2.2 watch 函数 第 4 步 下面的截图可以看到传参)

  2. 执行 for 循环,执行 activePreFlushCbs[preFlushIndex](),即从 activePreFlushCbs 这个数组中,取出一个函数,并执行(就是 job 函数!

  3. 到这里,job** 函数被成功执行**,我们知道 job 执行意味着 watch 执行,即当前 watch 的回调 即将被执行

总结:

  1. flushJobs 的主要作用就是触发 job,即:触发 watch

2.5 job 函数

  1. 进入 job 的执行函数,执行 const newValue = effect.run(),此时 effect 为 :

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. 我们知道执行 run,本质上是执行 fn,而 traverse(baseGetter()) 即为 traverse(() => Proxy{name: 'xx'}),结合代码获取到的是 newValue,所以我们可以大胆猜测,测试 fn 的结果等同于:`fn: () => ({name: '李四'})。 接下来执行:callWithAsyncErrorHandling(cb ......):

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. 函数接收的第一个参数 fn 的值为 watch 的第二个参数 cb。接下来执行 callWithErrorHandling(fn ......)。这里的代码就比较简单了,其实就是触发了 fn(...args),即:watch 的回调被触发,此时 args 的值为:

vue3 源码学习,实现一个 mini-vue(五):watch 侦听器

  1. 截止到此时 watch 的回调终于 被触发了

总结:

  1. job 函数的主要作用其实就是有两个:
    1. 拿到 newValueoldValue
    2. 触发 fn 函数执行

2.6 总结

到目前为止,整个 watch 的逻辑就已经全部理完了。整体氛围了四大块:

  1. watch 函数本身
  2. reactivesetter
  3. flushJobs
  4. job

整个 watch 还是比较复杂的,主要是因为 vue 在内部进行了很多的 兼容性处理,使代码的复杂度上升了好几个台阶,我们自己去实现的时候 会简单很多 的。

3. 代码实现

3.1 scheduler 调度系统机制实现

经过了 computed 的代码和 watch 的代码之后,其实我们可以发现,在这两块代码中都包含了同样的一个概念那就是:调度器 scheduler。完整的来说,我们应该叫它:调度系统

整个调度系统其实包含两部分实现:

  1. lazy:懒执行
  2. scheduler:调度器

3.1.1 懒执行

懒执行相对比较简单,我们来看 packages/reactivity/src/effect.ts 中第 183 - 185 行的代码:

if (!options || !options.lazy) {
    _effect.run()
}

这段代码比较简单,其实就是如果存在 options.lazy 则 不立即 执行 run 函数。

我们可以直接对这段代码进行实现:

export interface ReactiveEffectOptions {
  lazy?: boolean
  scheduler?: EffectScheduler
}

/**
 * effect 函数
 * @param fn 执行方法
 * @returns 以 ReactiveEffect 实例为 this 的执行函数
 */
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
  // 生成 ReactiveEffect 实例
  const _effect = new ReactiveEffect(fn)
  // !options.lazy 时
  if (!options || !options.lazy) {
    // 执行 run 函数
    _effect.run()
  }
}

那么此时,我们就可以新建一个测试案例来测试下 lazy,创建 packages/vue/examples/reactivity/lazy.html

<script>
  const { reactive, effect } = Vue

  const obj = reactive({
    count: 1
  })

  // 调用 effect 方法
  effect(
    () => {
      console.log(obj.count)
    },
    {
      lazy: true
    }
  )

  obj.count = 2

  console.log('代码结束')
</script>

当不存在 lazy 时,打印结果为:

1
2
代码结束

lazytrue 时,因为不在触发 run,所以不会进行依赖收集,打印结果为:

代码结束

3.1.2 scheduler:调度器

调度器比懒执行要稍微复杂一些,整体的作用分成两块:

  1. 控制执行顺序
  2. 控制执行规则

1. 控制执行顺序

  1. packages/reactivity/src/effect.ts 中:
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
  // 生成 ReactiveEffect 实例
  const _effect = new ReactiveEffect(fn)

  // 存在 options,则合并配置对象
  + if (options) {
  +  extend(_effect, options)
  + }
  
  // !options.lazy 时
  if (!options || !options.lazy) {
    // 执行 run 函数
    _effect.run()
  }
}
  1. packages/shared/src/index.ts 中,增加 extend 函数:
/**
 * Object.assign
 */
export const extend = Object.assign
  1. 创建测试案例 packages/vue/examples/reactivity/scheduler.html
<script>
  const { reactive, effect } = Vue

  const obj = reactive({
    count: 1
  })

  // 调用 effect 方法
  effect(
    () => {
      console.log(obj.count)
    },
    {
      scheduler() {
        setTimeout(() => {
          console.log(obj.count)
        })
      }
    }
  )

  obj.count = 2

  console.log('代码结束')
</script>

最后执行结果为:

1
代码结束
2

说明我们实现了 控制执行顺序

2. 控制执行规则

  1. 创建 packages/runtime-core/src/scheduler.ts
// 对应 promise 的 pending 状态
let isFlushPending = false

/**
 * promise.resolve()
 */
const resolvedPromise = Promise.resolve() as Promise<any>
/**
 * 当前的执行任务
 */
let currentFlushPromise: Promise<void> | null = null

/**
 * 待执行的任务队列
 */
const pendingPreFlushCbs: Function[] = []

/**
 * 队列预处理函数
 */
export function queuePreFlushCb(cb: Function) {
  queueCb(cb, pendingPreFlushCbs)
}

/**
 * 队列处理函数
 */
function queueCb(cb: Function, pendingQueue: Function[]) {
  // 将所有的回调函数,放入队列中
  pendingQueue.push(cb)
  queueFlush()
}

/**
 * 依次处理队列中执行函数
 */
function queueFlush() {
  if (!isFlushPending) {
    isFlushPending = true
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

/**
 * 处理队列
 */
function flushJobs() {
  isFlushPending = false
  flushPreFlushCbs()
}

/**
 * 依次处理队列中的任务
 */
export function flushPreFlushCbs() {
  if (pendingPreFlushCbs.length) {
    let activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
    pendingPreFlushCbs.length = 0
    for (let i = 0; i < activePreFlushCbs.length; i++) {
      activePreFlushCbs[i]()
    }
  }
}
  1. 创建 packages/runtime-core/src/index.ts ,导出 queuePreFlushCb 函数:
export { queuePreFlushCb } from './scheduler'
  1. packages/vue/src/index.ts 中,新增导出函数:
export { queuePreFlushCb } from '@vue/runtime-core'
  1. 创建测试案例 packages/vue/examples/reactivity/scheduler-2.html
<script>
  const { reactive, effect, queuePreFlushCb } = Vue

  const obj = reactive({
    count: 1
  })

  // 调用 effect 方法
  effect(
    () => {
      console.log(obj.count)
    },
    {
      scheduler() {
        queuePreFlushCb(() => {
          console.log(obj.count)
        })
      }
    }
  )

  obj.count = 2
  obj.count = 3
</script>

最后执行结果为:

1
3
3

说明我们实现了 控制执行规则

3.2.3 总结

懒执行相对比较简单,所以我们的总结主要针对调度器来说明。

调度器是一个相对比较复杂的概念,但是它本身并不具备控制 执行顺序执行规则 的能力。

想要完成这两个能力,我们需要借助一些其他的东西来实现,这整个的一套系统,我们把它叫做 调度系统

那么到目前,我们调度系统的代码就已经实现完成了,这个代码可以在我们将来实现 watch 的时候直接使用。

3.2 初步实现 watch 数据监听器

  1. 创建 packages/runtime-core/src/apiWatch.ts 模块,创建 watchdoWatch 函数:
/**
 * watch 配置项属性
 */
export interface WatchOptions<Immediate = boolean> {
  immediate?: Immediate
  deep?: boolean
}

/**
 * 指定的 watch 函数
 * @param source 监听的响应性数据
 * @param cb 回调函数
 * @param options 配置对象
 * @returns
 */
export function watch(source, cb: Function, options?: WatchOptions) {
  return doWatch(source as any, cb, options)
}

function doWatch(
  source,
  cb: Function,
  { immediate, deep }: WatchOptions = EMPTY_OBJ
) {
  // 触发 getter 的指定函数
  let getter: () => any

  // 判断 source 的数据类型
  if (isReactive(source)) {
    // 指定 getter
    getter = () => source
    // 深度
    deep = true
  } else {
    getter = () => {}
  }

  // 存在回调函数和deep
  if (cb && deep) {
    // TODO
    const baseGetter = getter
    getter = () => baseGetter()
  }

  // 旧值
  let oldValue = {}
  // job 执行方法
  const job = () => {
    if (cb) {
      // watch(source, cb)
      const newValue = effect.run()
      if (deep || hasChanged(newValue, oldValue)) {
        cb(newValue, oldValue)
        oldValue = newValue
      }
    }
  }

  // 调度器
  let scheduler = () => queuePreFlushCb(job)

  const effect = new ReactiveEffect(getter, scheduler)

  if (cb) {
    if (immediate) {
      job()
    } else {
      oldValue = effect.run()
    }
  } else {
    effect.run()
  }

  return () => {
    effect.stop()
  }
}

  1. packages/reactivity/src/reactive.tsreactive 类型的数据,创建 标记
 export const enum ReactiveFlags {
   	IS_REACTIVE = '__v_isReactive'
   }
   
   function createReactiveObject(
   	...
   ) {
   	...
   	// 未被代理则生成 proxy 实例
   	const proxy = new Proxy(target, baseHandlers)
   	// 为 Reactive 增加标记
   	proxy[ReactiveFlags.IS_REACTIVE] = true
   ...
   }

   /**
    * 判断一个数据是否为 Reactive
    */
   export function isReactive(value): boolean {
   	return !!(value && value[ReactiveFlags.IS_REACTIVE])
   }

  1. packages/shared/src/index.ts 中创建 EMPTY_OBJ
/**
 * 只读的空对象
 */
export const EMPTY_OBJ: { readonly [key: string]: any } = {}
  1. packages/runtime-core/src/index.tspackages/vue/src/index.ts 中导出 watch 函数

  2. 创建测试实例 packages/vue/examples/reactivity/watch.html

<script>
  const { reactive, watch } = Vue

  const obj = reactive({
    name: '张三'
  })

  watch(
    obj,
    (value, oldValue) => {
      console.log('watch 监听被触发')
      console.log('value', value)
    }
  )

  setTimeout(() => {
    obj.name = '李四'
  }, 2000)
</script>

此时运行项目,却发现,当前存在一个问题,那就是 watch 监听不到 reactive 的变化。

这个问题的原因是 我们在 setTimeout 中,触发了 触发依赖 操作。但是我们并没有做 依赖收集 的操作导致的。

不知道大家还记不记得,我们之前在看源码的时候,看到过一个 traverse 方法。

之前的时候,我们一直没有看过该方法,那么现在我们可以来说一下它了。

它的源码在 packages/runtime-core/src/apiWatch.ts 中:

查看源代码可以发现,这里面的代码其实有些 莫名其妙,他好像什么都没有做,只是在 循环的进行 xxx.value 的形式,我们知道 xxx.value 这个行为,我们把它叫做 getter 行为。并且这样会产生 副作用,那就是 依赖收集!。

所以我们知道了,对于 traverse 方法而言,它就是一个不断在触发响应式数据 依赖收集 的方法。

我们可以通过该方法来触发依赖收集,然后在两秒之后,触发依赖,完成 scheduler 的回调。

3.3 完成 watch 数据监听器的依赖收集

  1. packages/runtime-core/src/apiWatch.ts 中,创建 traverse 方法:
/**
 * 依次执行 getter,从而触发依赖收集
 */
export function traverse(value: unknown) {
  if (!isObject(value)) {
    return value
  }

  for (const key in value as object) {
    traverse((value as any)[key])
  }
  return value
}
  1. 在 doWatch 中通过 traverse 方法,构建 getter:
// 存在回调函数和deep
if (cb && deep) {
  // TODO
  const baseGetter = getter
  getter = () => traverse(baseGetter())
}

此时再次运行测试实例, watch 成功监听。

同时因为我们已经处理了 immediate 的场景:

if (cb) {
  if (immediate) {
    job()
  } else {
    oldValue = effect.run()
  }
} else {
  effect.run()
}

所以,目前 watch 也支持 immediate 的配置选项。

3.4 总结

对于 watch 而言本质上还是依赖于 ReactiveEffect 来进行的实现。

本质上依然是一个 依赖收集触发依赖 的过程。只不过区别在于此时的依赖收集是被 “被动触发” 的。

除此之外,还有一个调度器的概念,对于调度器而言,它起到的的主要作用就是 控制执行顺序、控制执行规则 ,但是大家也需要注意调度器本身只是一个函数,想要完成调度功能,还需要其他的东西来配合才可以。

4. 最后总结

到这里,mini-vue 的整个 响应系统 就完成了,响应系统分成了:

  1. reactive
  2. ref
  3. computed
  4. watch

四大块来进行分别的实现。

通过之前的学习可以知道,响应式的核心 APIProxy。整个 reactive 都是基于此来进行实现。

但是 Porxy 只能代理 复杂数据类型,所以延伸除了 get valueset value 这样 以属性形式调用的方法refcomputed 之所以需要 .value 就是因为这样的方法。

响应系统 终于结束,接下来可以开始学习新的模块 渲染系统 喽~

转载自:https://juejin.cn/post/7184088829014310971
评论
请登录