解密大厂红绿灯考题:从异步流程到中断机制的底层逻辑拆解🤔

177 阅读11分钟

"实现一个红绿灯" 看似是道送分题:红、黄、绿依次点亮,循环往复。有人用setInterval写几行代码就交差,有人却能从异步控制讲到内存管理,甚至延伸到AbortController的底层实现。

这道题的背后藏着对异步编程、流程控制、中断机制的深层考察。能写出 "会亮" 的红绿灯只是入门,能让红绿灯 "可控"(暂停、重启)才是进阶,而理解其中的底层逻辑,才能真正通过这道题的 "隐藏关卡"。

从一道 "送命题" 开始:基础红绿灯的坑

先看最基础的需求:实现一个红绿灯,按照 "红→黄→绿" 的顺序循环切换,每种颜色显示对应时间(比如红 1 秒、黄 3 秒、绿 2 秒)。

很多初学者看到这个需求,第一反应是用setInterval

// 错误示范:用setInterval实现
let currentColor = 'red';
const lightEl = document.getElementById('light');

// 初始显示红色
lightEl.style.backgroundColor = currentColor;

setInterval(() => {
  if (currentColor === 'red') {
    currentColor = 'yellow';
  } else if (currentColor === 'yellow') {
    currentColor = 'green';
  } else {
    currentColor = 'red';
  }
  lightEl.style.backgroundColor = currentColor;
}, 1000); // 这里有问题:无法设置不同颜色的显示时间

20250820-1443-53.9938980.gif 这段代码的问题很明显:setInterval的间隔是固定的,无法满足 "红 1 秒、黄 3 秒、绿 2 秒" 的不同时长需求。

这时候有人会想到用setTimeout嵌套:

const lightEl = document.getElementById('light');

function red() {
  lightEl.style.backgroundColor = 'red';
  setTimeout(yellow, 1000); // 1秒后切黄色
}

function yellow() {
  lightEl.style.backgroundColor = 'yellow';
  setTimeout(green, 3000); // 3秒后切绿色
}

function green() {
  lightEl.style.backgroundColor = 'green';
  setTimeout(red, 2000); // 2秒后切红色,形成循环
}

red(); 

20250820-1445-53.4284355.gif

这段代码能实现基础功能,但有个致命问题:异步流程嵌套过深,无法控制。如果需求增加 "暂停" 功能,你会发现根本无从下手 —— 因为每个setTimeout的引用都被淹没在函数里,无法手动清除。

这就是大厂面试的第一个坑:基础实现容易,但能否写出可扩展、可控制的异步流程,才是关键

从 "会亮" 到 "有序":异步变同步的核心逻辑

1. 为什么需要 "异步变同步"?

JavaScript 是单线程语言,所有任务默认在主线程队列中按顺序执行。但像setTimeout、网络请求这类耗时操作,若阻塞主线程会导致页面卡顿,因此被设计为 "异步任务":主线程执行时遇到异步任务,会将其放入 "任务队列",待主线程空闲后再执行。

红绿灯的颜色切换依赖时间延迟(本质是setTimeout),属于异步任务。但 "红→黄→绿" 的顺序是强依赖的:必须等红灯结束,才能执行黄灯;必须等黄灯结束,才能执行绿灯。这种 "异步任务需按顺序执行" 的场景,就是 "异步变同步" 的典型需求。

2. 用 Promise 封装 sleep:异步变同步的基石

要实现顺序执行,首先需要将 "等待指定时间" 这个异步操作,转化为可被 "同步逻辑" 调度的单元。这就需要用Promise来封装setTimeout,得到一个sleep函数:

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

这行代码看似简单,却藏着 Promise 的核心逻辑:

  • Promise是一个状态容器,有 "pending(等待)"、"fulfilled(完成)"、"rejected(拒绝)" 三种状态
  • setTimeoutms毫秒后执行resolve时,Promise 状态从 "pending" 变为 "fulfilled"
  • 外部可以通过.then()await监听状态变化,实现 "等待异步操作完成后再执行后续逻辑"

3. 用 async/await 实现顺序执行

有了sleep函数,我们可以用async/await语法糖将异步流程 "同步化":

const seq = [
  { color: 'red', ms: 1000 },
  { color: 'yellow', ms: 3000 },
  { color: 'green', ms: 2000 }
];

async function trafficLight() {
  while (true) { // 循环执行红绿灯逻辑
    for (const { color, ms } of seq) {
      console.log(color); // 输出当前颜色
      await sleep(ms); // 等待指定时长后再执行下一次循环
    }
  }
}

trafficLight();

这段代码的执行逻辑值得逐行拆解:

  • async声明的函数会返回一个 Promise,内部可以使用await
  • 当执行到await sleep(ms)时,函数会 "暂停",等待sleep返回的 Promise 变为 "fulfilled"
  • 等待期间,主线程不会被阻塞,可以处理其他任务(比如用户交互)
  • sleep的 Promise 完成后,函数从暂停处继续执行,进入下一次循环

这种写法相比嵌套setTimeout(回调地狱),逻辑更清晰,且符合人类对 "顺序执行" 的直觉理解。

4. Promise 链式调用:async/await 的底层替代方案

async/await出现前,开发者用 Promise 的链式调用实现同样的逻辑。比如用then方法串联异步任务:

function light(color, ms) {
  console.log(color);
  return new Promise(resolve => setTimeout(resolve, ms));
}

function loop() {
  light('red', 1000)
    .then(() => light('yellow', 3000)) // 红灯结束后执行黄灯
    .then(() => light('green', 2000)) // 黄灯结束后执行绿灯
    .then(loop); // 绿灯结束后重新开始循环
}

loop();

链式调用的核心是 "返回值透传":每个.then()的回调返回的 Promise,会成为下一个.then()的等待对象。这种方式虽然能实现顺序执行,但当流程复杂时,链条会变得冗长,不如async/await直观。

小结:无论是async/await还是 Promise 链式调用,核心都是通过 Promise 的状态管理,将原本分散的异步任务串联成 "同步式" 的执行流程。这是红绿灯实现的第一层逻辑,也是大厂对 "异步流程控制" 能力的基础考察。

从 "失控" 到 "可控":用 AbortController 实现中断机制

基础版红绿灯能按顺序切换,但有个致命缺陷:一旦启动就无法暂停。在实际场景中(比如用户切换页面、组件卸载),我们需要能随时中止正在执行的异步流程。这就需要引入 "中断机制",而浏览器提供的AbortController正是为解决这个问题而生。

1. AbortController:浏览器的 "中断信号" 解决方案

AbortController是浏览器提供的 API,用于给异步任务发送 "中止信号"。它的核心组成是:

  • controller:控制器实例,通过abort()方法发送中止信号
  • signal:信号对象,作为controller的属性存在,用于传递信号状态
// 创建控制器实例
const controller = new AbortController();
// 获取信号对象
const signal = controller.signal;

// 监听信号的中止事件
signal.addEventListener('abort', () => {
  console.log('收到中止信号');
});

// 发送中止信号
controller.abort();
// 此时signal.aborted为true
console.log(signal.aborted); // 输出true

AbortController的底层是 "发布 - 订阅模式":signal是订阅者,controller.abort()是发布操作。当调用abort()时,signalaborted属性变为true,同时触发abort事件,所有监听该事件的回调都会执行。

 改造 sleep 函数:让等待可中止

要让红绿灯能暂停,首先需要改造sleep函数,使其能接收signal信号,并在收到中止信号时提前结束等待:

const sleep = (ms, signal) => new Promise((resolve, reject) => {
  // 启动定时器,正常情况下ms后执行resolve
  const timer = setTimeout(resolve, ms);
  
  // 监听中止信号
  signal.addEventListener('abort', () => {
    // 清除定时器,避免resolve被执行
    clearTimeout(timer);
    // 用reject抛出中止错误,通知外部流程
    reject(new DOMException("操作已中止", "AbortError"));
  }, { once: true }); // once: true确保回调只执行一次,避免内存泄漏
});

这里的关键细节:

  • signal通过参数传入sleep,建立信号传递通道
  • 监听abort事件时,必须清除定时器(clearTimeout(timer)),否则即使触发了rejectsetTimeout仍会在后续执行resolve,导致状态混乱
  • 抛出DOMException而非普通错误,是为了遵循浏览器规范(AbortError是标准错误类型)
  • { once: true }是性能优化:abort事件只会触发一次,监听一次后自动移除,避免多余的事件监听占用内存

4. 改造 trafficLight:让流程响应中止信号

有了可中止的sleep,下一步是改造红绿灯主逻辑,使其能在收到中止信号时退出循环:

async function trafficLight(signal) {
  // 循环执行,直到信号被中止
  while (!signal.aborted) {
    for (const { color, ms } of seq) {
      // 每次切换颜色前检查信号,若已中止则退出
      if (signal.aborted) return;
      
      console.log(color); // 输出当前颜色
      try {
        // 等待指定时长,若期间收到中止信号,会抛出AbortError
        await sleep(ms, signal);
      } catch (err) {
        // 捕获中止错误,退出函数
        if (err.name === "AbortError") return;
        // 非中止错误则重新抛出,交给上层处理
        throw err;
      }
    }
  }
}

这里的防御性检查非常重要:

  • while (!signal.aborted)确保整个循环能响应中止信号
  • 每个颜色切换前的if (signal.aborted) return,避免在信号已中止后仍执行颜色输出
  • try/catch捕获sleep抛出的AbortError,优雅退出流程,而非让错误冒泡导致程序崩溃

5. 绑定按钮事件:实现用户交互控制

最后,通过按钮点击事件创建 / 销毁控制器,实现 "开始" 和 "暂停" 功能:

let controller = null;

document.getElementById('start').addEventListener('click', () => {
  // 若已有控制器且未中止,则不重复启动
  if (controller && !controller.signal.aborted) return;
  
  // 创建新控制器
  controller = new AbortController();
  // 启动红绿灯,传入信号
  trafficLight(controller.signal);
});

// 点击"暂停"按钮
document.getElementById('pause').addEventListener('click', () => {
  // 若控制器存在,发送中止信号
  if (controller) controller.abort();
});

20250820-1508-14.5067164.gif 这段代码解决了 "重复启动" 问题:当红绿灯正在运行时(controller存在且未中止),点击 "开始" 不会创建新实例,避免多个流程同时执行导致的状态混乱。

从红绿灯到工程实践:异步中断的扩展应用

红绿灯的中断逻辑看似简单,但在实际开发中,类似的场景无处不在。最典型的就是 "组件卸载时中止未完成的网络请求",避免内存泄漏。

 用 AbortController 中断 fetch 请求

fetchAPI 原生支持signal参数,我们可以用AbortController中断未完成的请求(比如用户切换路由时):

// React组件中中断fetch请求的示例
import { useEffect } from 'react';

function Banner() {
  useEffect(() => {
    // 创建控制器
    const controller = new AbortController();
    const { signal } = controller;

    // 发起请求时传入signal
    fetch('/api/banners', { signal })
      .then(res => res.json())
      .then(data => console.log('请求成功', data))
      .catch(err => {
        if (err.name === 'AbortError') {
          console.log('请求被中断');
        }
      });

    // 组件卸载时中断请求(关键:避免内存泄漏)
    return () => controller.abort();
  }, []);

  return <div> banners </div>;
}

为什么要中断请求?

如果组件卸载时请求还未完成,请求的回调函数依然会等待结果,导致内存泄漏(回调函数持有组件相关的引用,无法被 GC 回收)。用controller.abort()可以直接终止请求,避免这种情况。

中断机制的底层共性:信号传递

无论是红绿灯的sleep函数,还是fetch请求,它们能被中断的核心是信号的传递与监听

  1. 生产者(sleep/fetch)接收signal,并监听abort事件;
  2. 消费者(控制器)调用abort()发送信号;
  3. 生产者收到信号后,执行清理逻辑(清除定时器 / 终止请求),并通过reject告知上层。

这种模式和前端的 "事件总线"、"发布 - 订阅" 完全一致,只是AbortController是浏览器内置的标准化实现。

边界情况处理

能写出基础的红绿灯不难,但能处理各种边界情况,同样也是面试官真正想考察的能力。我们来梳理几个容易忽略的细节:

1. 重复点击 "开始" 按钮

如果用户快速点击 "开始",可能会创建多个控制器,导致红绿灯混乱。解决方法是在启动前检查当前是否已有运行中的控制器:

document.getElementById('start').addEventListener('click', () => {
  // 如果已有控制器且未中断,直接返回
  if (controller && !controller.signal.aborted) {
    return;
  }
  // 否则创建新控制器
  controller = new AbortController();
  trafficLight(controller.signal);
});

2. 中断时的状态清理

暂停红绿灯后,最好将当前颜色重置(比如灰色),避免用户误以为还在运行:

// 在pause按钮的事件中添加状态清理
document.getElementById('pause').addEventListener('click', () => {
  if (controller) {
    controller.abort();
    lightEl.style.backgroundColor = 'gray'; // 重置颜色
    lightEl.textContent = '已暂停';
  }
});

5.3 错误处理的颗粒度

trafficLight函数中,try/catch需要精准捕获AbortError,而不是吞掉所有错误:

try {
  await sleep(ms, signal);
} catch (err) {
  // 只处理中断错误,其他错误继续抛出
  if (err.name === 'AbortError') {
    return;
  }
  throw err; // 比如网络错误、定时器错误等,需要上层处理
}

这种颗粒度的错误处理,能避免隐藏代码中的其他问题。

结语

从 "让红绿灯亮起来" 到 "让红绿灯可控",看似只是功能的增加,实则涉及异步编程、设计模式、工程实践等多层知识。大厂的面试题从不局限于 "怎么做",而是探究 "为什么这么做"—— 这要求我们不仅要会用 API,更要理解 API 背后的底层逻辑。