Vue响应式原理(6)-调度器实现

1. 可调度性

可调度性指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。

const data = {foo: 1}
const obj = new Proxy(data, { /*...*/ }

effect(() => {
    console.log(obj.foo)
})
obj.foo++

console.log('end')

在副作用函数中打印 obj.foo,完成了依赖收集过程。随后对 obj.foo 进行自增操作,触发依赖会使副作用函数再次执行。因此最终打印结果为:

1
2
end

假如我们想在不调整代码顺序的情况下让输出结果变成:

1
end
2

换言之,我们想让副作用函数的触发延迟执行,此时我们可以为 effect 函数设计一个选项参数 options

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

2. 调度器实现

options 是一个对象,其中有 scheduler 调度器属性,对象形式方便后期继续扩展其他属性。scheduler 是一个函数,可以用于自行决定副作用函数的执行形式与执行时机。在 effect 注册副作用函数过程中,将 options 对象作为第二个参数传入,我们需要将其作为一个属性挂载在副作用函数上,在后续触发响应的时候可以获取到 options。因此我们需要对 effect 函数进行相应的修改:

function effect(fn, options = {}) {
    const effectFn = () => {
        cleanup(effectFn)
        activeEffect = effectFn

        effectStack.push(effectFn)
        fn()

        effectStack.pop()
        activeEffect = effectStack[effectStack.length - 1]
    }

    // 将 options 挂载到 effectFn 上
    effectFn.options = options
    effectFn.deps = []
    effectFn()
}

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

function trigger(target, key) {
    const depsMap = bucket.get(target);
    if (!depsMap) return;
    // 根据 key 取得所有副作用函数 effects
    const effects = depsMap.get(key);
    const effectsToRun = new Set();
    effects && effects.forEach((effectFn) => {
        if (effectFn !== activeEffect) {
            effectsToRun.add(effectFn)
        }
    });
    
    effectsToRun.forEach((effectFn) => {
        // 如果副作用函数存在options.scheduler调度器属性,
        // 则调用该调度器,并将副作用函数作为参数传递给调度器
        if (effectFn.options.scheduler) {
            effectFn.options.scheduler(effectFn)
        } else {
            effectFn()
        }
    })
}

3. 调度器改变副作用函数执行顺序

如上面的代码所示,在 trigger 动作触发副作用函数执行时,我们优先判断该副作用函数是否存在options.scheduler 调度器,如果存在,则把当前副作用函数作为参数传递给调度器函数并执行调度器函数,由用户自己控制副作用函数的执行形式与执行时机;如果不存在调度器属性,则直接执行副作用函数。

有了调度器的设置,我们就能实现之前所描述的需求,改变代码的执行顺序:

const data = {foo: 1}
const obj = new Proxy(data, { /*...*/ }

effect(() => {
    console.log(obj.foo)
},{
    options: {
        scheduler(fn) {
            // 将副作用函数作为参数传递给setTimeout,使其变为宏任务,在同步任务执行完后执行
            setTimeout(fn)
        }
    }
})
obj.foo++

console.log('end')

scheduler 中,我们将取到的副作用函数 fn 传递给 setTimeout 函数,就可以开启一个宏任务来执行副作用函数 fn,这样第二次副作用函数的执行就会在所有同步代码执行完成后,就能获得我们预期的输出:

1
end
2

4. 调度器改变代码执行次数

在下面这段代码中,我们在副作用函数中读取了 obj.foo,随后对 obj.foo 进行了两次自增操作:

const data = {foo: 1}
const obj = new Proxy(data, { /*...*/ }

effect(() => {
    console.log(obj.foo)
})
obj.foo++
obj.foo++

显而易见,副作用函数会执行三次,分别是在 effect 中注册的时候初次调用,两次自增操作都会触发依赖调用副作用函数,因此输出结果为:

1
2
3

以上的输出结果显然符合预期,但是仔细思考其实我们对于 obj.foo 中间的自增操作并不关心,无论 objfoo 属性自增了多少次,我们所关心的都是他的最终值。因此我们希望所有中间的自增操作都不会触发副作用函数的执行。这同样需要借助调度器来实现:

// 定义一个任务队列
const jobQueue = new Set();
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
const p = Promise.resolve();
// 一个标志代表是否正在刷新队列
let isFlushing = false;
function flushJob() {
  // 如果队列正在刷新,则什么都不做
  if (isFlushing) return;
  // 设置为 true,代表正在刷新
  isFlushing = true;
  // 在微任务队列中刷新 jobQueue 队列
  p.then(() => {
    jobQueue.forEach((job) => job());
  }).finally(() => {
    // 结束后重置 isFlushing
    isFlushing = false;
  });
}

effect(
  () => {
    console.log(obj.foo);
  },
  {
    scheduler(fn) {
      // 每次调度时,将副作用函数添加到 jobQueue 队列中
      jobQueue.add(fn);
      // 调用 flushJob 刷新队列
      flushJob();
    },
  }
);

在这里我们首先设置了一个 Set 类型的变量 jobQueue,其主要作用是存储所有需要执行的副作用函数,选用 Set 类型是为了利用其自动去重的能力,防止相同的副作用函数重复添加。随后我们在全局添加了一个 isFlushing 变量来表示队列是否在刷新中。同时我们设置了 flushJob 函数,在该函数中,我们首先对 isFlushing 变量值进行判断:如果值为 true 则表明当前队列在刷新中,因此直接退出不做任何操作;如果值为 false 则可以进入后续代码,首先将 isFlushing 变量置为 true,防止后续 flushJob 函数重复执行,这就表明我们无论重复多少次 flushJob 调用都只会在一个周期内执行一次。我们设置了一个 promise 实例变量 p,在 flushJob 内通过 p.then 将一个函数添加到微任务队列,在微任务队列内完成对 jobQueue 的遍历执行,并在 p.finallyisFlushing 变量值复原为 false,使得后续 flushJob 函数能正常进入。

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

1
3

这个功能有点类似于在 Vue.js 中连续多次修改响应式数据但只会触发一次更新,实际上 Vue.js 内部实现了一个更加完善的调度器,但是大致实现思路是相同的。

原文链接:https://juejin.cn/post/7323090513750769703 作者:明教教主张5G

(0)
上一篇 2024年1月13日 下午4:27
下一篇 2024年1月13日 下午4:37

相关推荐

发表回复

登录后才能评论