"实现一个红绿灯" 看似是道送分题:红、黄、绿依次点亮,循环往复。有人用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); // 这里有问题:无法设置不同颜色的显示时间
这段代码的问题很明显:
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();
这段代码能实现基础功能,但有个致命问题:异步流程嵌套过深,无法控制。如果需求增加 "暂停" 功能,你会发现根本无从下手 —— 因为每个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(拒绝)" 三种状态- 当
setTimeout在ms毫秒后执行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()时,signal的aborted属性变为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)),否则即使触发了reject,setTimeout仍会在后续执行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();
});
这段代码解决了 "重复启动" 问题:当红绿灯正在运行时(
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请求,它们能被中断的核心是信号的传递与监听:
- 生产者(
sleep/fetch)接收signal,并监听abort事件; - 消费者(控制器)调用
abort()发送信号; - 生产者收到信号后,执行清理逻辑(清除定时器 / 终止请求),并通过
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 背后的底层逻辑。