Rxjs源码解析(四)Scheduler

1,300 阅读12分钟

Scheduler 也是一个开发场景下几乎不会用到,但在 Rxjs 系统中占据重要地位的概念

Scheduler 本身有 调度安排的意思,同步逻辑肯定不需要调度的,也无法调度,按照顺序执行就是了,只有异步操作才需要调度,Scheduler的存在就是为了让 Rxjs 具备处理异步操作的能力

AsyncScheduler & AsyncAction

rxjs 中一共内置了 4Scheduler,其他 3 个都继承自 AsyncScheduler,把这个理解清楚了,其他的就容易了

// /src/internal/scheduler/async.ts
export const asyncScheduler = new AsyncScheduler(AsyncAction);

export const async = asyncScheduler;

rxjs 并没有直接将 AsyncScheduler暴露出来,暴露出来的跟 AsyncScheduler 相关的 apiasync/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);
  }
}

AsyncSchedulerconstructor方法中,直接将参数都传给父类 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 实例,这个其实就是最外层传入的 AsyncActionAsyncAction 的实例化也只是参数的存放,实例化之后调用了 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 抢占的原因,依旧无法做到精准地在预期时间点启动任务,但相比于 setTimeoutsetInterval 肯定会表现地更好,不会因为前面任务耗时的累计,导致后面任务启动执行的时间跟预期时间之间的差距越来越大

// /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,所以这一句也就是继续调用了 AsyncActionschedule

这一步是比较关键的,使用 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

先输出两头的打印语句 startend,然后再输出 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,并且覆写了三个方法:scheduleexecuterequestAsyncId

// /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._activetrue,再调用 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中有哪些异步执行的 apisetTimeoutsetIntervalsetImmediatepostMessageMessageChannelPromiseMutationObserverprocess.nextTick

哪个更快?

这就涉及到 macrotaskmicrotask 的问题了,后者执行的时机会快于前者

console.log('start')
asyncScheduler.schedule(() => console.log('async')); // scheduling 'async' first...
asapScheduler.schedule(() => console.log('asap'));
console.log('end')
// start
// end
// asap
// async

从上述代码的输出结果可以确认,asyncSchedulerasapScheduler 都是异步执行的,但 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不为 0asapScheduler 的表现就和 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

微任务也是有队列的,有先来后到之分,先进入队列的先执行,上述代码在同步执行后,将 3microtask 任务按照顺序放入到微任务队列中:taskPromisetask,当同步任务结束后,开始执行微任务队列,按照先进先出原则,所以依次执行 taskPromisetask 而如果换成 AsapScheduler

asapScheduler.schedule(() => console.log('asapScheduler'));
Promise.resolve().then(() => {
  console.log('Promise')
})
asapScheduler.schedule(() => console.log('asapScheduler'));
// asapScheduler
// asapScheduler
// Promise

可以看到,尽管第二个 asapScheduler.schedulePromise的后面,但在执行时机上,其却在 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)))
}

这个方法和 AsapActionrequestAsyncId 也差不多,唯一的区别是,这里使用 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,但通过阅读其源码,还是能够收获到一些比较巧妙的通用代码编写方式的