引言
在前端面试中,"红绿灯"问题是一个经典的考题,它不仅考察面试者对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异步编程底层原理和实际应用场景的全面掌握。