引言
在前端面试中,"红绿灯"问题是一个经典的考题,它不仅考察面试者对JavaScript异步编程基础的掌握,更深入地检验其将异步操作同步化、控制流程以及解决实际问题的能力。这个问题通常要求实现一个模拟交通信号灯按顺序(红灯亮N秒,黄灯亮M秒,绿灯亮P秒,然后循环)亮起的逻辑。其核心在于如何将基于定时器的异步行为,通过现代JavaScript的异步机制(Promise, async/await)进行优雅的同步化控制。
核心概念:异步变同步与sleep函数
JavaScript是单线程的,其异步操作(如定时器、网络请求)通常通过事件循环机制在后台执行,并在完成后将回调函数放入任务队列等待主线程空闲时执行。然而,"红绿灯"问题要求我们按照严格的顺序和时间间隔执行一系列操作,这需要我们将异步行为"同步化"地串联起来。
1. 实现一个sleep函数
JavaScript本身没有内置的sleep(或delay)函数来暂停主线程的执行(因为这会阻塞UI,导致页面卡死)。但我们可以利用Promise和setTimeout来模拟一个异步的sleep函数,使其在async/await的上下文中表现出同步的暂停效果。
sleep函数实现:
javascript
// 1.js
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// 示例用法:
(async () => {
console.log('begin');
await sleep(2000); // 暂停2秒
console.log('end');
})();
底层原理:
setTimeout(resolve, ms):setTimeout是一个异步API,它会在指定毫秒数ms后将resolve函数放入宏任务队列。resolve函数被调用时,会改变Promise的状态为fulfilled。new Promise(resolve => ...):创建一个Promise实例。当setTimeout的回调执行并调用resolve()时,这个Promise就会被解决(resolved)。await sleep(ms):await关键字会暂停async函数的执行,直到其后面的Promise被解决。一旦sleep返回的Promise被解决,async函数就会从暂停处继续执行。这样,就实现了"等待"ms毫秒的效果,将异步操作通过语法糖变得像同步一样。
红绿灯问题的两种实现方案
有了sleep函数作为基础,我们就可以着手解决红绿灯问题了。这里介绍两种常见的实现方案。
1. 方案一:async/await与循环
这种方案利用async/await的同步化特性,结合while(true)循环和for...of迭代,清晰地表达了红绿灯的顺序和循环逻辑。
代码实现:
javascript
// 2.js
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
async function trafficLight() {
const sequence = [
{ color: 'red', time: 1000 },
{ color: 'yellow', time: 3000 },
{ color: 'green', time: 2000 }
];
while (true) { // 无限循环,模拟红绿灯持续运行
for (const { color, time } of sequence) {
console.log(color); // 打印当前灯的颜色
await sleep(time); // 等待指定时间
}
}
}
trafficLight(); // 启动红绿灯
底层分析:
async function trafficLight(): 声明一个异步函数,允许在函数内部使用await。while (true): 创建一个无限循环,确保红绿灯持续交替亮起。for (const { color, time } of sequence): 遍历红绿灯的颜色和时间序列。每次迭代都会获取当前灯的颜色和持续时间。console.log(color): 模拟灯亮起。await sleep(time): 这是关键。await会暂停当前trafficLight函数的执行,直到sleep(time)返回的Promise被解决(即time毫秒后)。在此期间,JavaScript主线程是空闲的,可以响应其他事件(如用户交互),避免了阻塞。当sleep完成后,trafficLight函数会从await处恢复执行,进入下一个循环迭代。
这种方案的优点是代码可读性高,逻辑清晰,非常符合人类的思维习惯,将异步流程以同步的方式表达出来。
2. 方案二:Promise链式调用与递归
在async/await普及之前,Promise链式调用是处理异步操作序列的常见方式。红绿灯问题也可以通过Promise的.then()方法进行链式调用,并结合递归实现循环。
代码实现:
scss
// 3.js
function light(color, ms) {
console.log(color);
return new Promise(resolve => setTimeout(resolve, ms));
}
function loop() {
light('red', 1000) // 红灯亮1秒
.then(() => light('yellow', 3000)) // 接着黄灯亮3秒
.then(() => light('green', 2000)) // 接着绿灯亮2秒
.then(() => loop()); // 递归调用loop,实现循环
}
loop(); // 启动红绿灯
底层分析:
function light(color, ms): 这是一个辅助函数,用于模拟单个灯亮起并返回一个Promise,该Promise在ms毫秒后解决。.then(() => ...): Promise的.then()方法允许我们在前一个Promise解决后执行回调函数,并返回一个新的Promise。这使得我们可以将一系列异步操作串联起来,形成一个"链"。- 链式调用:
light('red', 1000).then(...)表示红灯亮起1秒后,再执行.then()中的回调,即黄灯亮起。以此类推,形成红 -> 黄 -> 绿的顺序。 return new Promise(...): 在.then()的回调中返回一个新的Promise,确保链式调用能够继续。then(() => loop()): 在绿灯亮起并等待结束后,递归调用loop()函数,从而实现红绿灯的无限循环。这种递归是异步的,不会导致栈溢出,因为每次递归调用都是在前一个Promise解决后才发生的。
这种方案虽然不如async/await直观,但它展示了Promise链式调用的强大能力,以及如何通过递归处理循环异步流程。
扩展思考:fetch请求的中止
面试中,面试官可能会将话题引申到其他异步场景,例如fetch请求的中止。这与红绿灯问题中"控制异步流程"的思路是相通的。
当用户切换路由或组件卸载时,如果存在未完成的fetch请求,可能会导致内存泄漏或不必要的资源消耗。fetch API提供了AbortController来中止请求。
AbortController使用:
ini
const controller = new AbortController();
const signal = controller.signal;
fetch('/api/data', { signal })
.then(response => response.json())
.then(data => console.log(data))
.catch(error => {
if (error.name === 'AbortError') {
console.log('Fetch aborted');
} else {
console.error('Fetch error:', error);
}
});
// 在需要中止请求时调用:
// controller.abort();
底层原理:
AbortController提供了一个signal属性,可以将其作为fetch请求的选项传入。当调用controller.abort()方法时,signal会触发一个abort事件,fetch请求会接收到这个信号并中止。中止后,fetch返回的Promise会以AbortError拒绝,我们可以在.catch()中捕获并处理这个错误。
这体现了通过外部信号(signal)来控制异步任务生命周期的模式,与红绿灯问题中通过await控制时间流逝异曲同工。
总结
"红绿灯"问题是前端面试中一个经典的异步编程考题。它要求面试者不仅理解setTimeout等异步API,更要掌握Promise、async/await等现代JavaScript异步机制,以及如何利用它们将异步操作进行同步化控制。通过实现sleep函数、使用async/await循环或Promise链式递归,我们可以优雅地解决这个问题。同时,对fetch请求中止等相关异步控制机制的理解,也体现了面试者对JavaScript异步编程底层原理和实际应用场景的全面掌握。