1. 可调度性
可调度性指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。
js
const data = {foo: 1}
const obj = new Proxy(data, { /*...*/ }
effect(() => {
console.log(obj.foo)
})
obj.foo++
console.log('end')
在副作用函数中打印 obj.foo,完成了依赖收集过程。随后对 obj.foo 进行自增操作,触发依赖会使副作用函数再次执行。因此最终打印结果为:
arduino
1
2
end
假如我们想在不调整代码顺序的情况下让输出结果变成:
arduino
1
end
2
换言之,我们想让副作用函数的触发延迟执行,此时我们可以为 effect 函数设计一个选项参数 options:
js
effect(
() => {
console.log(obj.foo)
},
// options
{
// 调度器 scheduler
scheduler(fn) {
// ...
}
}
)
2. 调度器实现
options 是一个对象,其中有 scheduler 调度器属性,对象形式方便后期继续扩展其他属性。scheduler 是一个函数,可以用于自行决定副作用函数的执行形式与执行时机。在 effect 注册副作用函数过程中,将 options 对象作为第二个参数传入,我们需要将其作为一个属性挂载在副作用函数上,在后续触发响应的时候可以获取到 options。因此我们需要对 effect 函数进行相应的修改:
js
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 函数中触发副作用函数重新执行时,就可以直接调用用户传递的调度器函数,从而把控制权交给用户:
js
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 调度器,如果存在,则把当前副作用函数作为参数传递给调度器函数并执行调度器函数,由用户自己控制副作用函数的执行形式与执行时机;如果不存在调度器属性,则直接执行副作用函数。
有了调度器的设置,我们就能实现之前所描述的需求,改变代码的执行顺序:
js
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,这样第二次副作用函数的执行就会在所有同步代码执行完成后,就能获得我们预期的输出:
js
1
end
2
4. 调度器改变代码执行次数
在下面这段代码中,我们在副作用函数中读取了 obj.foo,随后对 obj.foo 进行了两次自增操作:
js
const data = {foo: 1}
const obj = new Proxy(data, { /*...*/ }
effect(() => {
console.log(obj.foo)
})
obj.foo++
obj.foo++
显而易见,副作用函数会执行三次,分别是在 effect 中注册的时候初次调用,两次自增操作都会触发依赖调用副作用函数,因此输出结果为:
js
1
2
3
以上的输出结果显然符合预期,但是仔细思考其实我们对于 obj.foo 中间的自增操作并不关心,无论 obj 的 foo 属性自增了多少次,我们所关心的都是他的最终值。因此我们希望所有中间的自增操作都不会触发副作用函数的执行。这同样需要借助调度器来实现:
js
// 定义一个任务队列
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.finally 将 isFlushing 变量值复原为 false,使得后续 flushJob 函数能正常进入。
整段代码的效果是,连续对 obj.foo 执行两次自增操作,会同步 且连续地执行两次 scheduler 调度函数,这意味着同一个副作用函数会被 jobQueue.add(fn) 语句添加两次,但由于 Set 数据结构的 去重能力,最终 jobQueue 中只会有一项,即当前副作用函数。类似地,flushJob 也会同步且连续地执行两次,但由于 isFlushing 标志的存在,实际上 flushJob 函数在一个事件循环内只会执行一次,即在微任务队列内执行一次。当微任务队列开始执行时,就会遍历 jobQueue 并执行里面存储的副作用函数。由于此时 jobQueue 队列内只有一个副作用函数,所以只会执行一次,并且当它执行时,字段 obj.foo 的值已经是 3 了,这样我们就实现了期望的输出:
1
3
这个功能有点类似于在 Vue.js 中连续多次修改响应式数据但只会触发一次更新,实际上 Vue.js 内部实现了一个更加完善的调度器,但是大致实现思路是相同的。