跳到正文

手写练习/异步控制

GitHub

可重启异步任务 createRestartableTask

中等异步Promise闭包版本号重启

代码实现

JavaScript42 行
function createRestartableTask(task, maxRestarts = 1) {
  let restartCount = 0
  let version = 0
  let resolveOuter, rejectOuter

  const promise = new Promise((resolve, reject) => {
    resolveOuter = resolve
    rejectOuter = reject
  })

  async function run() {
    const currentVersion = ++version

    try {
      const result = await task()

      // 只有当前最新一轮,才可以提交结果。
      if (currentVersion === version) {
        resolveOuter(result)
      }
    } catch (error) {
      if (currentVersion === version) {
        rejectOuter(error)
      }
    }
  }

  function restart() {
    restartCount++

    if (restartCount > maxRestarts) {
      rejectOuter(new Error('Restart limit'))
      return
    }

    run()
  }

  run()

  return { promise, restart }
}

笔记记录

可重启异步任务 createRestartableTask

要解决什么问题

一个任务还没完成时,允许主动重新执行,并忽略旧轮次晚到的结果。调用方始终等待同一个外层 promise,通过 restart() 启动新一轮。

核心是一个版本号:每轮执行前递增 version,并把当时的值保存在局部变量 currentVersion 中。任务完成后,只有版本仍然相等的那一轮,才会调用外层 Promise 的 resolve 或 reject。

这里保留原实现的行为,只统一变量命名与排版。它屏蔽旧结果,不会取消旧任务。

什么时候执行

  • 调用 createRestartableTask(task) 时立即执行第一轮 task()。
  • 调用 restart() 时先累计重启次数;未超限则立即执行新一轮,无需等待旧轮次结束。
  • 读取返回对象的 promise、调用 .then() 或 await promise,都不会启动新任务。
  • 最新一轮成功或失败,会尝试结束外层 Promise;失败不会自动触发重试。

maxRestarts 表示首次执行之外,允许额外启动几轮。约定它是非负整数,源码没有额外校验参数:默认 1 时,最多执行 task() 两次。

看一遍时序

假设第一轮较慢,在它完成前调用一次 restart():

时刻发生的事情局部版本 / 当前版本对外层 Promise 的影响
1创建任务,第一轮开始1 / 1等待结果
2调用 restart(),第二轮开始2 / 2继续等待
3第一轮成功或失败1 / 2版本不匹配,忽略
4第二轮成功2 / 2以第二轮结果完成

即使第二轮先完成,第一轮随后到达,也无法覆盖它的结果。若第二轮失败,外层 Promise 会失败,不会回头采用第一轮的成功结果。

使用示例

let calls = 0
 
const { promise, restart } = createRestartableTask(() => {
  const round = ++calls
  const delay = round === 1 ? 100 : 20
 
  return new Promise(resolve => {
    setTimeout(() => resolve(`第 ${round} 轮结果`), delay)
  })
})
 
promise.then(console.log).catch(console.error)
restart()
 
// 第二轮先完成:输出「第 2 轮结果」
// 第一轮仍会完成,但它的结果不会交给调用方。

每一轮都需要执行一次新的操作,所以传入的是 task 函数。若函数每次只是返回同一个已经创建的 Promise,并不会重新发起底层操作。

代码里各自负责什么

  • restartCount:记录调用 restart() 的次数,首次执行不计入。
  • version:记录最近启动的轮次,所有轮次共享这个变量。
  • currentVersion:某一次 run() 独有的版本快照,不会随新轮次改变。
  • resolveOuter / rejectOuter:让内部不同轮次能够尝试结束同一个外层 Promise。
  • try / catch:同时处理 task() 同步抛错和返回的 Promise 拒绝;旧轮次的失败同样会被捕获并忽略。

run() 虽然是异步函数,调用它时仍会先递增版本号并调用 task(),遇到 await 后才等待结果。restart() 不返回这一轮的 Promise,调用方继续等待最初返回的 promise。

超过重启上限会怎样

默认最多允许重启一次。如果外层 Promise 仍在等待,第二次调用 restart() 会用 Error('Restart limit') 将其拒绝,并且不会启动第三轮。

此时已启动的任务依然运行。即使最新一轮随后调用了 resolveOuter,也不能改变外层 Promise 已经拒绝的结果。

maxRestarts = 0 时仍会执行第一轮,但不允许额外重启。

需要记住的边界

屏蔽结果不等于取消任务

版本号只能控制哪个结果被接收。旧请求、计时器或其他操作仍可能继续,已经发生的副作用也不会撤销。如果需要真正取消,必须让底层任务配合,例如接收并响应 AbortSignal。

Promise 已结束后,不能重新变成等待状态

外层只有一个 Promise,一旦成功或失败,结果就固定了。

原实现没有结束状态检查。因此,如果还没用完重启次数,在外层 Promise 结束后调用 restart(),仍会执行新的 task();只是新结果无法再通过同一个 Promise 交给调用方。若此时已经超限,再调用 rejectOuter 也不会改变已有结果。

如果希望结束后不再启动任务,可以增加 settled 标记:在接受最新结果、接受最新错误或超限时置为 true,并在 restart() 入口直接返回。这里没有把这项改动混入参考源码。

重启可能让多轮同时运行

新一轮不会等待旧一轮结束,版本号也不是并发限制器。这里约束的是最多启动多少轮,不是同时运行多少个任务。

和 retry 有什么区别

对比当前实现失败重试 retry
新一轮的触发调用方主动调用 restart()当前轮失败后按策略触发
是否等待旧轮结束不等待通常等待失败后再重试
旧结果处理通过版本号过滤通常串行执行,没有旧轮次竞争
当前轮失败外层 Promise 直接拒绝可能继续下一轮

现有的可配置异步重试器可以用来对照这两种控制方式。

面试时怎么讲

我用一个外层 Promise 暴露最终结果,用递增版本号识别最新执行轮次。每次重启都会调用任务函数并记录新版本,旧轮次完成时因为版本不匹配而被忽略。它支持手动重启和次数上限,但不取消旧任务,也不能让已结束的 Promise 重新等待;如果不希望结束后继续执行任务,还需要显式维护结束状态。