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
内部实现了一个更加完善的调度器,但是大致实现思路是相同的。