调度执行
为什么要调度执行
可调度性是响应式系统的重要特性。所谓可调度性,指的是trigger动作触发副作用函数重新执行的时候,有能力决定副作用函数执行的时机、次数以及方式。
如何决定副作用函数执行方式
以下面代码为例:
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(() => {
console.log(obj.foo)
})
obj.foo++
console.log('结束了')
输出结果如下:
1
2
'结束了'
现在假设需求有变,输出顺序需要调整为:
1
'结束了'
2
实现思路
在不修改编码顺序的情况下实现需求,这时候就需要可调度执行。
那么我们可以设计一个选项参数options,允许用户指定调度器:
effect(
() => {
console.log(obj.foo)
},
// options
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// ...
}
}
)
effect函数中,可以传递第二个参数options,他是一个对象,其中允许指定一个 scheduler 调度函数,同时在effect函数内部我们把options挂载在副作用函数的options选项上:
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压栈
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 将 options 挂载到 effectFn 上
effectFn.options = options // 新增
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
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() // 新增
}
})
}
在遍历执行副作用函数时,我们应当判断当前执行的副作用函数是否有调度器,如果存在,需直接调用调度器,把当前副作用函数当作参数传进去调度器中。那么此时,可以实现上边的需求了:
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将副作用函数推入宏任务队列,那么此时可以得到期望的输出顺序了:
1
'结束了'
2
决定函数的执行次数
当我们运行如下代码:
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(() => {
console.log(obj.foo)
})
obj.foo++
obj.foo++
由于做了两次自增操作,如果没有调度器,那么会连续输出两次:
1
2
3
但是从结果可知,foo的最初状态是1,foo的最终状态是3,那么中间的2可以看作过渡状态。那么我们期望的输出结果是:
1
3
实现思路
其实还是基于调度器:
// 定义一个任务队列
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
jobQueue.clear(); // 清空 jobQueue 避免下一次调度的时候执行上次遗留的副作用函数
})
}
effect(() => {
console.log(obj.foo)
}, {
scheduler(fn) {
// 每次调度时,将副作用函数添加到 jobQueue 队列中
jobQueue.add(fn)
// 调用 flushJob 刷新队列
flushJob()
}
})
obj.foo++
obj.foo++
其中定义一个jobQueue的集合,集合的去重特性,在同一轮事件循环内加入相同的函数时,会被去重,只保留一个副作用函数。那么在这个调度设置中,宏任务的每一次设置都不会立刻执行副作用函数,而是统一等到清空微任务队列时再遍历执行副作用函数。虽然有一定的延迟,但是正是这个延迟,正好避免了中间过渡状态时不需要的重复的副作用函数调用。
另一个关键在于控制位isFlushing,用于阻止正在刷新队列函数的重复执行。
这个例子就是调度器的另一种应用,本质上,响应式的各种额外的实现都是基于使用调度器传入自定义函数。