用 Promise 实现可取消的红绿灯循环,怎样避免递归和定时器泄漏?

先理解原理和示例,再练习完整口述与追问。

等待包装 Promise;for/await 顺序;AbortSignal;取消清定时器与监听;有限轮测试;显示与停留语义明确。

题目先约定为“立即显示红灯保持 3 秒,再绿灯 2 秒,再黄灯 1 秒,循环”。Promise 负责表示一次等待,async 循环顺序执行,而不是同步无限递归。取消必须使等待中的 Promise 结束,清除定时器,并移除不再需要的监听,否则外层任务会挂起或泄漏。

js
function abortError() {
  return Object.assign(new Error('任务已取消'), { name: 'AbortError' })
}
function wait(ms, signal) {
  if (!Number.isFinite(ms) || ms < 0) return Promise.reject(new RangeError('时间不合法'))
  return new Promise((resolve, reject) => {
    if (signal?.aborted) { reject(abortError()); return }
    let timer
    function cancel() {
      clearTimeout(timer)
      signal?.removeEventListener('abort', cancel)
      reject(abortError())
    }
    timer = setTimeout(() => {
      signal?.removeEventListener('abort', cancel)
      resolve()
    }, ms)
    signal?.addEventListener('abort', cancel, { once: true })
  })
}
async function runLights(show, {
  signal, rounds = Infinity,
  schedule = [['red', 3000], ['green', 2000], ['yellow', 1000]]
} = {}) {
  if (typeof show !== 'function') throw new TypeError('show 必须是函数')
  if (rounds !== Infinity && (!Number.isInteger(rounds) || rounds < 0)) throw new RangeError('轮数不合法')
  if (!Array.isArray(schedule) || !schedule.length || !schedule.every(item =>
    Array.isArray(item) && item.length === 2 && typeof item[0] === 'string'
    && Number.isFinite(item[1]) && item[1] >= 0)) throw new TypeError('灯序列不合法')
  for (let round = 0; round < rounds; round++) {
    for (const [color, duration] of schedule) {
      if (signal?.aborted) throw abortError()
      show(color)
      await wait(duration, signal)
    }
  }
}

业务持有 AbortController,卸载时 abort;调用者 catch AbortError 区分主动停止与真实失败。测试使用两轮和短 schedule,验证 red→green→yellow 重复,而不是让测试永久等待。每一步只保留必要状态,循环次数增加不应累积同步调用栈;show 在此约定为同步动作。

定时器时长是最低调度门槛,不保证真实秒表准确。若目标是按绝对时间表补偿漂移,需要另记录基准时间;不能一边要求持续时间一边偷偷跳过灯态。

我把等待、显示与循环分开,用 await 保证顺序,用 AbortSignal 终止等待并回收计时器与监听。明确立即显示后停留的语义,支持有限轮测试,区分取消和异常,也说明定时调度误差。