vue3学习与my-vue3实现06:调度执行

38 阅读4分钟

可调度性是响应系统非常重要的特性,它指的是当trigger动作触发副作用函数执行时,有能力决定副作用函数执行的时机、次数以及方式。

控制执行时机

以下面的代码为例:

  const obj = reactive({
    foo: 1,
  })
  effect(() => {
    console.log(obj.foo)
  })
  obj.foo++
  console.log('end')

我们可以很容易看出这段代码的输出结果如下:

01 1
02 2
03 end

而现在我们假设需求有变,输出顺序需要调整为:

01 1
02 end
03 2

根据打印结果我们可以很容易的想到,把语句obj.foo++和语句console.log('end')的位置互换就可以了。而如果想要在不调整代码的情况下实现需求,就需要响应系统支持调度

我们可以为effect函数设计一个选项参数options,允许用户指定调度器:

effect(() => {
  console.log(obj.foo)
}, {
  // 调度器
  scheduler(fn) {
    ...
  },
})

如上面代码所示,用户在调用effect函数注册副作用函数时,可以传递第二个参数options。它是一个对象,允许指定scheduler调度函数,同时在effect函数内部我们需要把options选项挂载到对应的ReactiveEffect实例上。

// share/src/index.ts
export const extend = Object.assign
// reactivity/src/effect.ts
export type EffectScheduler = (...args: any[]) => any

export interface ReactiveEffectOptions {
  scheduler?: EffectScheduler
}

export class ReactiveEffect<T = any> {
  constructor(
    public fn: () => T,
    // 新增
    public scheduler: EffectScheduler | null = null,
  ) {}
}

export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
  const _effect = new ReactiveEffect(fn)

  if (options)
    extend(_effect, options)

  _effect.run()
}

有了调度函数,我们在trigger函数中触发副作用函数执行时,就可以直接调用用户传递的调度器函数,从而把控制权交给用户:

export function trigger(target: object, key: unknown) {
  const depsMap = targetMap.get(target)
  if (!depsMap)
    return
  // 修改
  const deps = depsMap.get(key as string)
  // 新增
  const effects: ReactiveEffect[] = []
  deps?.forEach((dep) => {
    if (dep)
      effects.push(dep)
  })
  effects.forEach(effect => triggerEffect(effect)) //修改
}
// 新增
export function triggerEffect(effect: ReactiveEffect) {
  //如果一个副作用函数存在调度器,则调用该调度器
  if (effect.scheduler)
    effect.scheduler()
  else
    // 否则直接执行副作用函数(默认行为)
    effect.run()
}

如上面代码所示,在trigger动作触发副作用函数执行时,我们优先判断该副作用函数是否存在调度器,如果存在,则直接调用调度器函数;否则直接执行副作用函数。

这样我们就可以实现前文的需求了,如以下代码:

  const obj = reactive({
    foo: 1,
  })
  effect(() => {
    console.log(obj.foo)
  }, {
    // 调度器
    scheduler() {
      setTimeout(() => {
        console.log(obj.foo)
      })
    },
  })
  obj.foo++
  console.log('end')

我们使用setTimeout开启一个宏任务来执行副作用函数,这样就是实现期望的打印顺序了:

01 1
02 end
03 2

执行次数

我们查看如下例子:

  const obj = reactive({
    foo: 1,
  })
  effect(() => {
    console.log(obj.foo)
  })
  obj.foo++
  obj.foo++

首先在副作用函数中打印obj.foo的值,接着连续对其执行两次自增操作,在没有指定调度器的情况下,它的输出如下:

01 1
02 2
03 3

查看输出,字段obj.foo的值会从1自增到3,2则是它的过渡装填,如果我们只关心最终结果,那么应该只打印两次就够了,我们期望的打印结果是:

01 1
02 3

基于调度器我们就可以实现:

// reactivity/src/deferredComputed.ts
const tick = Promise.resolve()
// 自定义一个任务队列
const queue: any[] = []
// 标志是否正在刷新队列
let queued = false

const flush = () => {
  for (let i = 0; i < queue.length; i++)
    queue[i]()
  // 清空队列任务
  queue.length = 0
  // 表示队列未刷新
  queued = false
}
export const scheduler = (fn: any) => {
  // 如果队列中有相同的任务,则不加入
  if (!queue.includes(fn)) {
    // 任务放入队列
    queue.push(fn)
    // 队列正在刷新,什么都不做
    if (!queued) {
      // 设置队列正在刷新
      queued = true
      // 在微任务中刷新queue队列
      tick.then(flush)
    }
  }
}
// vue/examples/reactivity/times.html
const obj = reactive({
  foo: 1,
})
function printFoo() {
  console.log(obj.foo)
}

effect(() => {
  printFoo()
}, {
  scheduler() {
    scheduler(printFoo)
  },
})
obj.foo++
obj.foo++

上面的代码,我们首先定义了一个任务队列queue,可以在scheduler方法中看到,每次加入新的任务时,我们会做去重操作。而scheduler函数通过queued标志判断是否需要执行,只有当其为false时才需要执行,而一旦flush开始执行,queued就会被设置为true,代表着无论调用多少次scheduler,在一个周期内都只会执行一次。需要注意的是,在scheduler内通过tick.then将一个函数添加到为微任务队列,在微任务队列内完成对queue的遍历执行。

而我们上面示例代码的效果就是,连续对obj.foo++执行两次自增操作,会同步且连续地执行两次scheduler调度函数,这意味着同一个副作用函数会被scheduler添加两次,但由于我们的去重操作,最终queue中只会有一项当前的副作用函数且只会执行一次,当它执行的时候,字段obj.foo的值已经是3了,这样就实现了我们期望的输出:

01 1
02 3

如果查看过源码的朋友,会发现这里的scheduler函数实现并不相同,其实去重的能力,源码放在了runtime-core/src/apiWatchdoWatch中,这里我只做了简单的复现,原理基本相同。

代码仓库

github.com/KoiraCMT/my…