Scheduler 也是一个开发场景下几乎不会用到,但在 Rxjs 系统中占据重要地位的概念
Scheduler 本身有 调度、安排的意思,同步逻辑肯定不需要调度的,也无法调度,按照顺序执行就是了,只有异步操作才需要调度,Scheduler的存在就是为了让 Rxjs 具备处理异步操作的能力
AsyncScheduler & AsyncAction
rxjs 中一共内置了 4 个 Scheduler,其他 3 个都继承自 AsyncScheduler,把这个理解清楚了,其他的就容易了
// /src/internal/scheduler/async.ts
export const asyncScheduler = new AsyncScheduler(AsyncAction);
export const async = asyncScheduler;
rxjs 并没有直接将 AsyncScheduler暴露出来,暴露出来的跟 AsyncScheduler 相关的 api 是 async/asyncScheduler,可以看到暴露出去的是一个 AsyncScheduler 的实例,并且在构造实例的时候传入了一个 AsyncAction
关于 async,源码注释里也解释了其作用:Schedule task as if you used setTimeout(task, duration)
// /src/internal/scheduler/async.ts
const task = () => console.log('it works!');
asyncScheduler.schedule(task, 2000);
上述代码执行后,将在 2000ms后输出 it works!,在这个例子里,asyncScheduler.schedule 的表现就和 setTimeout一样
// /src/internal/scheduler/async.ts
function task(this: SchedulerAction<number>, state?: number) {
console.log('task state: ', state);
if (state !== void 0) {
this.schedule(state + 1, 1000);
}
}
asyncScheduler.schedule<number>(task, 1000, 0);
这个例子能更好地体现出 asyncScheduler的效果,所以从这个例子看起
// /src/internal/scheduler/AsyncScheduler.ts
export class AsyncScheduler extends Scheduler {
// ...
constructor(SchedulerAction: typeof Action, now: () => number = Scheduler.now) {
super(SchedulerAction, now);
}
}
AsyncScheduler 在 constructor方法中,直接将参数都传给父类 Scheduler了,那么去这里看下
export class Scheduler implements SchedulerLike {
// ...
constructor(private schedulerActionCtor: typeof Action, now: () => number = Scheduler.now) {
this.now = now;
}
public schedule<T>(work: (this: SchedulerAction<T>, state?: T) => void, delay: number = 0, state?: T): Subscription {
return new this.schedulerActionCtor<T>(this, work).schedule(state, delay);
}
}
Scheduler 也只是将参数存放到实例的属性上而已,紧接着调用的 schedule 方法才是关键
在 schedule 里面,创建了 this.schedulerActionCtor 实例,这个其实就是最外层传入的 AsyncAction,AsyncAction 的实例化也只是参数的存放,实例化之后调用了 AsyncAction 上的 schedule方法
// /src/internal/scheduler/AsyncAction.ts
export class AsyncAction<T> extends Action<T> {
// ...
constructor(protected scheduler: AsyncScheduler, protected work: (this: SchedulerAction<T>, state?: T) => void) {
super(scheduler, work);
}
public schedule(state?: T, delay: number = 0): Subscription {
// ...
// If this action has already an async Id, don't request a new one.
this.id = this.id || this.requestAsyncId(scheduler, this.id, delay);
return this;
}
protected requestAsyncId(scheduler: AsyncScheduler, _id?: any, delay: number = 0): any {
return intervalProvider.setInterval(scheduler.flush.bind(scheduler, this), delay);
}
}
schedule 会调用 requestAsyncId,而 requestAsyncId 中调用了 intervalProvider.setInterval,大部分情况下,intervalProvider.setInterval 就相当于 setInterval,这里暂且也可以这么看,所以也就是启动了一个计时器,每隔 delay 时间就执行一遍 scheduler.flush.bind(scheduler, this),scheduler 就是 AsyncScheduler 的实例
虽然 setInterval 才是真正的轮询 api,但由于众所周知的原因,我们一般会使用串行的 setTimeout 来模拟,可能是考虑到了看到代码的人会有所疑惑,所以这里这么做的原因,官方也专门注释了一下,大概意思就是每个任务之间应当是相互独立的,下一个任务的执行时间不应当被上一个任务影响
例如原本期望启动一个轮询间隔为 1s的任务,那么第二次、第三次任务的预期启动时间就是程序运行后的第 2s、第 3s,但由于任务耗时时间较长,导致每个任务执行时间需要 10s,那么如果使用 setTimeout 来串行轮询,第二次、第三次任务的启动时间就是程序运行后的第 12s、第 23s
而如果采用 setInterval,那么第二次、第三次任务的执行起始时间就会 尽可能 地贴近预期时间(即程序运行后的第 2s、第 3s),尽管因为耗时任务对 cpu 抢占的原因,依旧无法做到精准地在预期时间点启动任务,但相比于 setTimeout,setInterval 肯定会表现地更好,不会因为前面任务耗时的累计,导致后面任务启动执行的时间跟预期时间之间的差距越来越大
// /src/internal/scheduler/AsyncScheduler.ts
public flush(action: AsyncAction<any>): void {
// ...
do {
if ((error = action.execute(action.state, action.delay))) {
break;
}
} while ((action = actions.shift()!)); // exhaust the scheduler queue
// ...
}
// /src/internal/scheduler/AsyncAction.ts
public execute(state: T, delay: number): any {
// ...
const error = this._execute(state, delay);
// ...
}
protected _execute(state: T, _delay: number): any {
// ....
try {
this.work(state);
} catch (e) {
errored = true;
errorValue = e ? e : new Error('Scheduled action threw falsy error');
}
// ...
}
flush 最终调用了 this.work,并将 state当做参数传了进去,而 this.work 就是我们在自己的业务代码中传入的 task 函数,也就是指定了我们自己定义的方法
我们在 task 中再次调用了 this.schedule(state + 1, 1000);,由于调用我们方法的 this 指向 AsyncAction,所以这一句也就是继续调用了 AsyncAction 的 schedule
这一步是比较关键的,使用 setTimeout 执行方法,定时器指定完了之后就结束了,但如果使用 setInterval,那么就必须要主动在合适的时间取消这个定时器
// /src/internal/scheduler/AsyncAction.ts
public execute(state: T, delay: number): any {
// ...
this.pending = false;
const error = this._execute(state, delay);
if (error) {
return error;
} else if (this.pending === false && this.id != null) {
this.id = this.recycleAsyncId(this.scheduler, this.id, null);
}
}
每次进入 execute 方法的时候就会执行 this.pending = false,看着似乎每次都会进入下面逻辑分支的 else if,然而由于在这个过程之间还执行了 this._execute,最终也就是执行我们自定义的 task,我们的 task 如果调用了 this.schedule 那么 this.schedule 又会将 this.pending 置为 true,所以当还会继续有任务执行的时候,实际上是走不到 else if 分支的
连起来看一遍,每次执行 execute,会执行 this.pending = false,然后执行我们自定义的 task,如果我们自定义的 task中执行了 this.schedule,那么就会执行 this.pending = true,所以无法进入 else if 语句;而如果我们的 task方法不再调用 this.schedule,那么 this.pending就是 false,就会进入 else if 分支,就会执行 this.id = this.recycleAsyncId(this.scheduler, this.id, null)
protected recycleAsyncId(_scheduler: AsyncScheduler, id: any, delay: number | null = 0): any {
if (delay != null && this.delay === delay && this.pending === false) {
return id;
}
intervalProvider.clearInterval(id);
return undefined;
}
由于这个时候 recycleAsyncId方法的第三个参数是 null,所以会执行 intervalProvider.clearInterval(id),即清除了轮询定时器
除了这种情况会清除定时器外,如果我们在调用 this.schedule 的时候,传入的 delay 和之前的 delay 不一致,那么也会清除轮询定时器,重新按照现在传入的delay启动一个定时器,这个比较好理解,毕竟无论怎么说,延迟多长时间执行任务是调用者说了算
最后,如果在调度执行任务的过程中发生了错误,也会清除定时器
// /src/internal/scheduler/AsyncAction.ts
protected _execute(state: T, _delay: number): any {
// ...
if (errored) {
this.unsubscribe();
return errorValue;
}
}
unsubscribe() {
if (!this.closed) {
const { id, scheduler } = this;
const { actions } = scheduler;
this.work = this.state = this.scheduler = null!;
this.pending = false;
arrRemove(actions, this);
if (id != null) {
this.id = this.recycleAsyncId(scheduler, id, null);
}
this.delay = null!;
super.unsubscribe();
}
}
unsubscribe 方法主要就是做一些清理工作,例如清除定时器、重置实例属性值
所以,当满足以下 3 个条件之一的时候,轮询定时器会被清除:
- 开发者自定义的
task方法不再调用this.schedule - 开发者自定义的
task方法调用this.schedule的时候,传入的delay跟上一次不一致,这种情况下会清除上一次启动的定时器,而按照现在传入的delay重新启动一个定时器,也就是说轮询定时器还是存在的,只不过换了一个 - 调度任务执行过程中发生错误
QueueScheduler & QueueAction
还是从官方在源码里给的例子看起
queueScheduler.schedule(function(state) {
if (state !== 0) {
console.log('before', state);
this.schedule(state - 1); // `this` references currently executing Action,
// which we reschedule with new state
console.log('after', state);
}
}, 0, 3);
// before 3
// after 3
// before 2
// after 2
// before 1
// after 1
实际上,如果你把这段代码里的 queueScheduler 换成 asyncScheduler,输出结果一模一样,难道 queueScheduler 就是换了个名字?当然不是
对于这段代码,无论是 queueScheduler 还是 asyncScheduler,输出结果确实一样,但如果多加两行代码就看出问题了
console.log('start')
queueScheduler.schedule(function(state) {
if (state !== 0) {
console.log('before', state);
this.schedule(state - 1); // `this` references currently executing Action,
// which we reschedule with new state
console.log('after', state);
}
}, 0, 3);
console.log('end')
// start
// before 3
// after 3
// before 2
// after 2
// before 1
// after 1
// end
在代码段的前后,在各加一句打印语句,可以看到,整个代码是同步按顺序执行的,也就是 queueScheduler.schedule 代码段是同步执行的,但如果换成 asyncScheduler,输出结果就会变成
// start
// end
// before 3
// after 3
// before 2
// after 2
// before 1
// after 1
先输出两头的打印语句 start、end,然后再输出 asyncScheduler.schedule 执行的结果,所以 queueScheduler 是 同步执行 的
但如果 queueScheduler 是同步执行的话,同步方法在方法体内调用自身,是会递归压栈的,输出结果不应该是那样才对,例如,对于下述同步代码:
function work(state: number) {
if (state !== 0) {
console.log('before')
work(state - 1)
console.log('after', state)
}
}
work(3)
输出
// "before", 3
// "before", 2
// "before", 1
// "after", 1
// "after", 2
// "after", 3
那么为什么 queueScheduler.schedule 的输出和预想的不一样呢?来看代码
和 AsyncScheduler 类似,rxjs也没有将 QueueScheduler 直接暴露出来,而是暴露出了它的一个实例
// /src/internal/scheduler/queue.ts
export const queueScheduler = new QueueScheduler(QueueAction);
/**
* @deprecated Renamed to {@link queueScheduler}. Will be removed in v8.
*/
export const queue = queueScheduler;
queueScheduler 上面有一段注释,其中一小段的意思是,如果 delay 参数的值是 0(或者不传入),那么 queueScheduler 的表现就和 AsyncScheduler 一模一样,否则,才会体现 queueScheduler 的不同之处,所以下面默认 delay 不等于 0
QueueScheduler继承自 AsyncScheduler,并且没有任何修改,所以可以将其认为是 AsyncScheduler
// /src/internal/scheduler/QueueScheduler.ts
import { AsyncScheduler } from './AsyncScheduler';
export class QueueScheduler extends AsyncScheduler {
}
存在修改的是 QueueAction
// /src/internal/scheduler/QueueAction.ts
export class QueueAction<T> extends AsyncAction<T> {}
QueueAction 继承自 AsyncAction,并且覆写了三个方法:schedule、execute、requestAsyncId
// /src/internal/scheduler/QueueAction.ts
public schedule(state?: T, delay: number = 0): Subscription {
// ...
this.scheduler.flush(this);
return this;
}
schedule 直接调用了 scheduler.flush 方法,而在 AsyncAction 中,这个方法是通过 requestAsyncId 调用 setInterval,这就是 QueueScheduler 是同步执行而 AsyncScheduler 异步执行的区别所在
// /src/internal/scheduler/AsyncAction.ts
public schedule(state?: T, delay: number = 0): Subscription {
// ...
this.id = this.id || this.requestAsyncId(scheduler, this.id, delay);
return this;
}
protected requestAsyncId(scheduler: AsyncScheduler, _id?: any, delay: number = 0): any {
return intervalProvider.setInterval(scheduler.flush.bind(scheduler, this), delay);
}
那么为什么同步执行的结果跟我们预想的不一样呢?其实这个逻辑是在 AsyncScheduler 里面
// /src/internal/scheduler/AsyncScheduler.ts
public flush(action: AsyncAction<any>): void {
const { actions } = this;
if (this._active) {
actions.push(action);
return;
}
let error: any;
this._active = true;
do {
if ((error = action.execute(action.state, action.delay))) {
break;
}
} while ((action = actions.shift()!)); // exhaust the scheduler queue
this._active = false;
// ...
}
AsyncScheduler 上有个 this._active,如果是 QueueScheduler 同步调用这段逻辑,那么上一个任务执行到 action.execute 这一行的时候,this._active是 true,再调用 this.schedule 继续启动下一个任务,下一个任务同步执行到 if (this._active)的时候,进入分支体,将任务存到了 actions,而不是直接执行,所以是不存在压栈操作的
就像是队列一样,后续任务都被存到了 actions这个数组队列,然后再通过下面的 do...while循环将任务取出来执行,这就是为什么 QueueScheduler 的同步执行跟正常的同步执行不同的原因所在,QueueScheduler在内部还维护了一个队列,任务先进队列再执行,虽然也是同步的,但函数调用栈就不一样了
AsapScheduler & AsapAction
先看下 rxjs 官方是怎么描述这个 Scheduler
// /src/internal/scheduler/asap.ts
// Perform task as fast as it can be performed asynchronously
尽可能快的以异步的方式执行任务
js中有哪些异步执行的 api?setTimeout、setInterval、setImmediate、postMessage、MessageChannel、Promise、MutationObserver、process.nextTick等
哪个更快?
这就涉及到 macrotask 和 microtask 的问题了,后者执行的时机会快于前者
console.log('start')
asyncScheduler.schedule(() => console.log('async')); // scheduling 'async' first...
asapScheduler.schedule(() => console.log('asap'));
console.log('end')
// start
// end
// asap
// async
从上述代码的输出结果可以确认,asyncScheduler 和 asapScheduler 都是异步执行的,但 asapScheduler 的执行时机却快于 asyncScheduler,从上文可知, asyncScheduler 内部借助了 setInterval 实现异步逻辑,而 setInterval 属于 macrotask,那么作为对比,asapScheduler 内部更快的异步执行必然是使用了 microtask
// /src/internal/scheduler/AsapScheduler.ts
export class AsapScheduler extends AsyncScheduler {
public flush(action?: AsyncAction<any>): void {
// ...
const { actions } = this;
// ...
action = action || actions.shift()!;
const count = actions.length;
do {
if ((error = action.execute(action.state, action.delay))) {
break;
}
} while (++index < count && (action = actions.shift()));
// ...
}
}
AsapScheduler 继承自 AsyncScheduler,并且重写了 flush 方法,AsapScheduler 的实例维护了一个 actions 的队列,每次 flush 的时候,会从这个队列中取值(也就是任务)并执行,这个 actions队列数据是在 AsapAction 追加的
// /src/internal/scheduler/AsapAction.ts
export class AsapAction<T> extends AsyncAction<T> {
// ...
protected requestAsyncId(scheduler: AsapScheduler, id?: any, delay: number = 0): any {
if (delay !== null && delay > 0) {
return super.requestAsyncId(scheduler, id, delay);
}
scheduler.actions.push(this);
return scheduler._scheduled || (scheduler._scheduled = immediateProvider.setImmediate(scheduler.flush.bind(scheduler, undefined)));
}
}
requestAsyncId 方法开头的条件分支,是根据 delay 进行了一个判断,如果 delay不为 0,那么就走 AsyncAction的逻辑,也就是说如果 delay不为 0,asapScheduler 的表现就和 asyncScheduler 一样,跟 queueScheduler 差不多,这里就不多说了
如果 scheduler._scheduled 有值就返回这个值,如果没有值就执行后面那一句,其实就是异步执行任务,为什么这里要判断一下呢,直接执行不行吗?
这其实是 rxjs 为了贯彻 Perform task as fast as it can be performed asynchronously 这句话做出的优化
function task() {
Promise.resolve().then(() => {
console.log('task')
})
}
task()
Promise.resolve().then(() => {
console.log('Promise')
})
task()
// task
// Promise
// task
微任务也是有队列的,有先来后到之分,先进入队列的先执行,上述代码在同步执行后,将 3个 microtask 任务按照顺序放入到微任务队列中:task、Promise、task,当同步任务结束后,开始执行微任务队列,按照先进先出原则,所以依次执行 task、Promise、task
而如果换成 AsapScheduler
asapScheduler.schedule(() => console.log('asapScheduler'));
Promise.resolve().then(() => {
console.log('Promise')
})
asapScheduler.schedule(() => console.log('asapScheduler'));
// asapScheduler
// asapScheduler
// Promise
可以看到,尽管第二个 asapScheduler.schedule 在 Promise的后面,但在执行时机上,其却在 Promise 前面执行,这就是 AsapScheduler 在内部维护 actions 队列的作用
另外,这里出现了一个新的计时器方法 immediateProvider.setImmediate
// /src/internal/scheduler/immediateProvider.ts
import { Immediate } from '../util/Immediate';
const { setImmediate, clearImmediate } = Immediate;
// ...
export const immediateProvider: ImmediateProvider = {
setImmediate(...args) {
const { delegate } = immediateProvider;
return (delegate?.setImmediate || setImmediate)(...args);
},
clearImmediate(handle) {
const { delegate } = immediateProvider;
return (delegate?.clearImmediate || clearImmediate)(handle);
},
delegate: undefined,
};
setImmediate 在一般情况下就是 Immediate.setImmediate,看其代码,有个 Promise.resolve,所以 AsapScheduler 是借助了 Promise 实现的 microtask
// /src/internal/util/Immediate.ts
export const Immediate = {
setImmediate(cb: () => void): number {
const handle = nextHandle++;
activeHandles[handle] = true;
if (!resolved) {
resolved = Promise.resolve();
}
resolved.then(() => findAndClearHandle(handle) && cb());
return handle;
},
clearImmediate(handle: number): void {
findAndClearHandle(handle);
},
};
AnimationFrameScheduler & AnimationFrameAction
// /src/internal/scheduler/animationFrame.ts
// Perform task when `window.requestAnimationFrame` would fire
执行时机和 window.requestAnimationFrame 一致
const div = document.querySelector('div') as HTMLDivElement;
animationFrameScheduler.schedule(function(height) {
div.style.height = height + "px";
this.schedule(height + 1); // `this` references currently executing Action,
// which we reschedule with new state
}, 0, 0);
官方在注释里给的这个例子正常运行之后,就可以在页面上看到一个高度逐渐顺滑增加的 div
这里如果你把 animationFrameScheduler 换成 asyncScheduler,表现效果其实也是一样的,只不过 asyncScheduler 用的是 setInterval 实现的异步,所以可能有时候没有 animationFrameScheduler 那么顺滑;queueScheduler 肯定是不行的,这个东西是同步执行,会让页面直接卡死的;asapScheduler 也不行,微任务队列会在页面渲染前一直执行,表现上也是和页面卡死了一样
AnimationFrameScheduler 的代码和 AsapScheduler 一样,就不多说了,而在 AsyncAction 中,值得一看的就是 requestAsyncId方法
// /src/internal/scheduler/AnimationFrameAction.ts
protected requestAsyncId(scheduler: AnimationFrameScheduler, id?: any, delay: number = 0): any {
// ...
return scheduler._scheduled || (scheduler._scheduled = animationFrameProvider.requestAnimationFrame(() => scheduler.flush(undefined)))
}
这个方法和 AsapAction 的 requestAsyncId 也差不多,唯一的区别是,这里使用 animationFrameProvider.requestAnimationFrame 代替了 immediateProvider.setImmediate,而 animationFrameProvider.requestAnimationFrame 就是 window.requestAnimationFrame(正常情况下)
// /src/internal/scheduler/animationFrameProvider.ts
export const animationFrameProvider: AnimationFrameProvider = {
// ...
requestAnimationFrame(...args) {
const { delegate } = animationFrameProvider;
return (delegate?.requestAnimationFrame || requestAnimationFrame)(...args);
},
cancelAnimationFrame(...args) {
const { delegate } = animationFrameProvider;
return (delegate?.cancelAnimationFrame || cancelAnimationFrame)(...args);
},
delegate: undefined,
};
小结
本文分析了 rxjs 中内置的几个 Scheduler,尽管在大多数情况下我们不会在 rxjs 中直接使用 Scheduler,但通过阅读其源码,还是能够收获到一些比较巧妙的通用代码编写方式的