前端知库

怎样用 Promise 实现红绿灯循环和 LazyMan 这类按顺序延时执行的题?

这类题考的是把“等一段时间再做下一件事”串起来。先把 setTimeout 包成返回 Promise 的 sleep,再用 async/await 在循环里依次等待;LazyMan 这种链式调用,则先把每个方法要做的事放进任务队列,等同步代码跑完后再按顺序执行,sleepFirst 就是把任务插到队头。

参考答案

简答

红绿灯题是:红灯亮 3 秒,绿灯亮 2 秒,黄灯亮 1 秒,然后一直循环。直接嵌套 setTimeout 能写出来,但层层回调很难读,也没法无限循环。更好的做法是先写一个 sleep(ms),它返回一个在 ms 毫秒后完成的 Promise,然后在 async 函数里用 while (true) 循环,依次 await sleep(3000)、await sleep(2000)、await sleep(1000)。

LazyMan 题要求支持 LazyMan('Tom').eat('午饭').sleep(1000).eat('晚饭') 这样的链式调用,而且 sleep 要真的让后面的输出等一会儿。难点在于链式调用是同步执行完的,每个方法只能返回 this,不能自己等。所以每个方法只负责把要做的事包成一个任务函数,放进队列;构造时安排一个异步的“开始”,等整条链都调用完,再从队头逐个取出任务执行。

sleepFirst 要求不管写在哪,都最先执行,它只需要把任务插到队列开头而不是末尾。这就是为什么要等同步代码全部执行完再开始:只有那时,队列里的任务才是完整的。

要注意,开始执行的时机要用 setTimeout 或 queueMicrotask 放到当前同步代码之后。如果在构造函数里立刻开始执行,后面链式调用加进来的任务就可能错过。

红绿灯

JavaScript
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

async function trafficLight(log, rounds = Infinity) {
  const lights = [
    ['红', 3000],
    ['绿', 2000],
    ['黄', 1000],
  ];
  for (let round = 0; round < rounds; round++) {
    for (const [color, ms] of lights) {
      log(color);
      await sleep(ms);
    }
  }
}

await sleep(ms) 做了什么?sleep 返回的 Promise 在 ms 毫秒后才完成,await 让 async 函数暂停在这里,把控制权还给事件循环,时间到了再从下一行继续。所以这段代码虽然写成了普通的循环,实际是一个灯亮完再亮下一个。

rounds 参数是为了测试和演示方便,默认无限循环。真正的页面里,组件卸载时要能停下来,可以在循环条件里检查一个停止标记,或者用 AbortController 取消。

LazyMan

JavaScript
class LazyMan {
  #tasks = [];

  constructor(name, log) {
    this.log = log;
    this.#tasks.push(() => this.log(`我是 ${name}`));
    // 等整条链式调用同步执行完,再开始按队列执行
    setTimeout(() => this.#run(), 0);
  }

  async #run() {
    for (const task of this.#tasks) await task();
  }

  eat(food) {
    this.#tasks.push(() => this.log(`吃 ${food}`));
    return this;
  }

  sleep(ms) {
    this.#tasks.push(() => new Promise((resolve) => setTimeout(resolve, ms)));
    return this;
  }

  sleepFirst(ms) {
    this.#tasks.unshift(() => new Promise((resolve) => setTimeout(resolve, ms)));
    return this;
  }
}

const logs = [];
new LazyMan('Tom', (text) => logs.push(text)).eat('午饭').sleep(1000).eat('晚饭').sleepFirst(500);

执行顺序是:等 500 毫秒,输出“我是 Tom”“吃 午饭”,再等 1000 毫秒,输出“吃 晚饭”。

为什么 sleepFirst 写在最后,却最先执行?链式调用是同步代码,new LazyMan(...) 到 .sleepFirst(500) 一口气执行完,这期间只是往 #tasks 里放任务:先是“我是 Tom”,然后“吃 午饭”、等 1000、“吃 晚饭”,最后 sleepFirst 用 unshift 把等待 500 放到了最前面。直到这时,构造函数里的 setTimeout 回调才执行 #run,按队列顺序跑。

#run 里的 await task() 为什么对普通任务也能用?log 返回 undefined,await undefined 会立即继续;sleep 任务返回 Promise,await 就会等它完成。所以同一个循环能同时处理同步任务和异步任务。

常见追问

不用 async/await 怎么写?

用 reduce 把任务串成一条 Promise 链:tasks.reduce((chain, task) => chain.then(task), Promise.resolve())。每个 then 等上一个任务完成,返回 Promise 的任务会让链等待它。

怎样限制同时执行的异步任务数?

这是并发控制,和按顺序执行不同,要维护一个“正在执行”的计数,完成一个再补一个,见《怎样实现有明确失败语义的异步并发池?》。