可调度性是响应系统非常重要的特性,它指的是当trigger动作触发副作用函数执行时,有能力决定副作用函数执行的时机、次数以及方式。
控制执行时机
以下面的代码为例:
const obj = reactive({
foo: 1,
})
effect(() => {
console.log(obj.foo)
})
obj.foo++
console.log('end')
我们可以很容易看出这段代码的输出结果如下:
01 1
02 2
03 end
而现在我们假设需求有变,输出顺序需要调整为:
01 1
02 end
03 2
根据打印结果我们可以很容易的想到,把语句obj.foo++
和语句console.log('end')
的位置互换就可以了。而如果想要在不调整代码的情况下实现需求,就需要响应系统支持调度。
我们可以为effect函数设计一个选项参数options,允许用户指定调度器:
effect(() => {
console.log(obj.foo)
}, {
// 调度器
scheduler(fn) {
...
},
})
如上面代码所示,用户在调用effect函数注册副作用函数时,可以传递第二个参数options。它是一个对象,允许指定scheduler调度函数,同时在effect函数内部我们需要把options选项挂载到对应的ReactiveEffect
实例上。
// share/src/index.ts
export const extend = Object.assign
// reactivity/src/effect.ts
export type EffectScheduler = (...args: any[]) => any
export interface ReactiveEffectOptions {
scheduler?: EffectScheduler
}
export class ReactiveEffect<T = any> {
constructor(
public fn: () => T,
// 新增
public scheduler: EffectScheduler | null = null,
) {}
}
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
const _effect = new ReactiveEffect(fn)
if (options)
extend(_effect, options)
_effect.run()
}
有了调度函数,我们在trigger函数中触发副作用函数执行时,就可以直接调用用户传递的调度器函数,从而把控制权交给用户:
export function trigger(target: object, key: unknown) {
const depsMap = targetMap.get(target)
if (!depsMap)
return
// 修改
const deps = depsMap.get(key as string)
// 新增
const effects: ReactiveEffect[] = []
deps?.forEach((dep) => {
if (dep)
effects.push(dep)
})
effects.forEach(effect => triggerEffect(effect)) //修改
}
// 新增
export function triggerEffect(effect: ReactiveEffect) {
//如果一个副作用函数存在调度器,则调用该调度器
if (effect.scheduler)
effect.scheduler()
else
// 否则直接执行副作用函数(默认行为)
effect.run()
}
如上面代码所示,在trigger动作触发副作用函数执行时,我们优先判断该副作用函数是否存在调度器,如果存在,则直接调用调度器函数;否则直接执行副作用函数。
这样我们就可以实现前文的需求了,如以下代码:
const obj = reactive({
foo: 1,
})
effect(() => {
console.log(obj.foo)
}, {
// 调度器
scheduler() {
setTimeout(() => {
console.log(obj.foo)
})
},
})
obj.foo++
console.log('end')
我们使用setTimeout开启一个宏任务来执行副作用函数,这样就是实现期望的打印顺序了:
01 1
02 end
03 2
执行次数
我们查看如下例子:
const obj = reactive({
foo: 1,
})
effect(() => {
console.log(obj.foo)
})
obj.foo++
obj.foo++
首先在副作用函数中打印obj.foo的值,接着连续对其执行两次自增操作,在没有指定调度器的情况下,它的输出如下:
01 1
02 2
03 3
查看输出,字段obj.foo的值会从1自增到3,2则是它的过渡装填,如果我们只关心最终结果,那么应该只打印两次就够了,我们期望的打印结果是:
01 1
02 3
基于调度器我们就可以实现:
// reactivity/src/deferredComputed.ts
const tick = Promise.resolve()
// 自定义一个任务队列
const queue: any[] = []
// 标志是否正在刷新队列
let queued = false
const flush = () => {
for (let i = 0; i < queue.length; i++)
queue[i]()
// 清空队列任务
queue.length = 0
// 表示队列未刷新
queued = false
}
export const scheduler = (fn: any) => {
// 如果队列中有相同的任务,则不加入
if (!queue.includes(fn)) {
// 任务放入队列
queue.push(fn)
// 队列正在刷新,什么都不做
if (!queued) {
// 设置队列正在刷新
queued = true
// 在微任务中刷新queue队列
tick.then(flush)
}
}
}
// vue/examples/reactivity/times.html
const obj = reactive({
foo: 1,
})
function printFoo() {
console.log(obj.foo)
}
effect(() => {
printFoo()
}, {
scheduler() {
scheduler(printFoo)
},
})
obj.foo++
obj.foo++
上面的代码,我们首先定义了一个任务队列queue
,可以在scheduler
方法中看到,每次加入新的任务时,我们会做去重操作。而scheduler
函数通过queued
标志判断是否需要执行,只有当其为false
时才需要执行,而一旦flush
开始执行,queued
就会被设置为true
,代表着无论调用多少次scheduler
,在一个周期内都只会执行一次。需要注意的是,在scheduler
内通过tick.then
将一个函数添加到为微任务队列,在微任务队列内完成对queue
的遍历执行。
而我们上面示例代码的效果就是,连续对obj.foo++
执行两次自增操作,会同步且连续地执行两次scheduler
调度函数,这意味着同一个副作用函数会被scheduler
添加两次,但由于我们的去重操作,最终queue
中只会有一项当前的副作用函数且只会执行一次,当它执行的时候,字段obj.foo
的值已经是3了,这样就实现了我们期望的输出:
01 1
02 3
如果查看过源码的朋友,会发现这里的scheduler
函数实现并不相同,其实去重的能力,源码放在了runtime-core/src/apiWatch
的doWatch
中,这里我只做了简单的复现,原理基本相同。