07.vue3之基于computed和watch实现的调度机制原理

70 阅读4分钟

前言:理解什么可调度性。所谓的可调度,就是再程序在触发时(trigger动作触发副作用函数时),我们有能力决定副作用函数的执行时机,次数和方式。

那我们就按照一下代码来看看如何决定副作用函数的执行方式。

const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(() => {
    console.log(obj.foo)
})
 obj.foo++
 console.log('结束了')

再副作用函数执行中,我们的执行的顺序肯定是先执行副作用函数里面的东西,然后再执行自增的操作,最后才是结束了执行,那我们的输出结果就是

1
2
'结束了'

那如果我们想要的执行顺序变化,希望他的输出是1,结束了,2,那我们可能首先想到的就是把上述代码的顺序改变了,也可以实现,但是我们如何在不调整代码的情况下实现这个功能呢,那我们这个时候就需要响应系统支持调度器功能了,根据上面的思考,那我们就可以为effect设计一个options选项,来实现调度器功能

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

就像上面代码一样,我们可以再调用effect函数的时候,传递一个options参数,他是一个scheduler调度函数,同事再effect函数内部我们需要把options选项挂到对应的副作用函数上

function effect(fn,options ={}){
    const effectFn = () => {
       ------
       //将options挂到effectFn函数上
       effect.options = options
       
       effectFn.deps = []
       //执行副作用函数
       effectFn()
    }
}

有了调度函数,我们在 trigger 函数中触发副作用函数重新执行时,这样我们就可以自己把控调度时机

function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap) return
  const effects = depsMap.get(key)

  const effectsToRun = new Set()
  effects && effects.forEach(effectFn => {
    if (effectFn !== activeEffect) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach(effectFn => {
    if (effectFn.options.scheduler) {  //关键逻辑
      effectFn.options.scheduler(effectFn)
    } else {
      effectFn()
    }
  })
  // effects && effects.forEach(effectFn => effectFn())
}

如上面的代码所示,在 trigger 动作触发副作用函数执行时,我们优先判断该副作用函数是否存在调度器,如果存在,则直接调用调度器函数,并把当前副作用函数作为参数传递过去,由用户自己控制如何执行;否则保留之前的行为,即直接执行副作用函数。有了这些基础设施之后,我们就可以实现前文的需求了,如以下

代码所示:

 const data = { foo: 1 }
 const obj = new Proxy(data, { /* ... */ })
 effect(
     () => {
       console.log(obj.foo)
     },
     // options
     {
     // 调度器 scheduler 是一个函数
       scheduler(fn) {
     // 将副作用函数放到宏任务队列中执行
       setTimeout(fn)
     }
 })
 
obj.foo++
console.log('结束了')

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

1
结束了
2

除了控制副作用函数的执行顺序,通过调度器还可以做到控制它

的执行次数,这一点也尤为重要。我们思考如下例子:

 const data = { foo: 1 }
 const obj = new Proxy(data, { /* ... */ })
 effect(() => {
  console.log(obj.foo)
 })
 obj.foo++
 obj.foo++

首先在副作用函数中打印obj.foo,然后再两次自增,正常情况下输入如下

1
2
3

由输出可知,字段一定会从1自增到3,2只是过渡的状态,那我们只关心结果而不关心过程,那么我们期待的结果是:

1
3

其中不包括过渡的状态,基于调度器那我们可以很容易实现此功能

const jobQueue = new Set()
const p = Promise.resolve()
//一个标志代表正在刷新队列
let isFlushing = false
function flushJob(){
    if(isFlushing) return
    //设置为true,代表正在刷新
    isFlushing = true
    p.then(() => {
        jobQueue.forEach(job => job())
    }).finally(() => {
        isFlushing = false
    }) 
}

effect(() => {
    console.log(obj.foo)
},{
    scheduler(fn){
        jobQueue.add(fn)
        //调用flushJob刷新队列
        flushJob()    
    }
})
obj.foo++
obj.foo++

整段代码的效果是,连续对 obj.foo 执行两次自增操作,会同步且连续地执行两次 scheduler 调度函数,这意味着同一个副作用函数会被 jobQueue.add(fn) 语句添加两次,但由于 Set 数据结构的去重能力,最终 jobQueue 中只会有一项,即当前副作用函数。类似地,flushJob 也会同步且连续地执行两次,但由于 isFlushing 标志的存在,实际上 flushJob 函数在一个事件循环内只会执行一次,即在微任务队列内执行一次。当微任务队列开始执行时,就会遍历jobQueue 并执行里面存储的副作用函数。由于此时 jobQueue 队列内只有一个副作用函数,所以只会执行一次,并且当它执行时,字段obj.foo 的值已经是 3 了,这样我们就实现了期望的输出:

1
3

这个功能就类似于vue中连续多次修改响应是数据但只会触发一次的道理,这就是常说的vue的异步执行道理