为什么setTimeout明明是0毫秒,却不是立即执行?为什么Promise.then比setTimeout先执行?今天我们来揭开JS异步执行的底层秘密——事件循环。看完这篇,你就能像背口诀一样记住各种异步任务的执行顺序,面试再也不怕被问“输出顺序”了。
前言
还记得昨天我们说的异步吗?JS把耗时任务丢给浏览器去干,自己继续往下跑。但问题是:异步任务完成后,回调函数是怎么被调用的?谁来决定先执行哪个回调?
这就要说到今天的主角——事件循环(Event Loop)。它就像一个外卖调度中心,管理着所有的“订单”(异步任务)和“配送员”(回调函数)。弄懂了它,你就弄懂了JS的异步执行机制。
一、为什么需要事件循环?
JS是单线程的,这意味着只有一个“执行员”在处理代码。如果这个执行员被卡住了,整个页面就挂了。
但浏览器给了JS一套“外挂”——Web APIs(比如setTimeout、DOM事件、网络请求)。JS遇到这些任务时,会交给浏览器去后台处理,自己继续执行同步代码。
问题来了:后台任务完成后,回调函数怎么回来执行?这就需要事件循环来调度——它负责盯着“任务队列”,一旦主线程空闲了,就把队列里的任务拉出来执行。
二、事件循环的三大角色
事件循环就像一家外卖店,有三个核心角色:
1. 主线程:厨师
厨师(主线程)只做一件事:按顺序做菜(执行同步代码)。他一次只能做一个菜,做完一个才能做下一个。
2. 任务队列:待处理订单
顾客下的订单(异步回调)被送到这里排队。厨师忙完手里的活,就会来这里取下一个订单。
3. 事件循环:调度员
调度员(事件循环)的任务很简单:不断盯着厨师,看他忙完没。忙完了,就从任务队列里取一个订单交给厨师。这个过程无限循环,所以叫“事件循环”。
三、宏任务 vs 微任务:VIP通道和普通通道
任务队列并不是只有一个,而是分两条通道:
- 宏任务队列:普通通道,包括setTimeout、setInterval、I/O操作、UI渲染、事件回调等。
- 微任务队列:VIP通道,包括Promise.then、MutationObserver、queueMicrotask等。
调度员有个规则:每次从宏任务队列里取一个任务执行完后,要把当前所有微任务都执行完,然后再去取下一个宏任务。
console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');
// 输出顺序:1,4,3,2
为什么是这个顺序?
- 同步代码先执行(1,4)
- 执行完后,微任务队列里有Promise.then(3),全部执行
- 然后取下一个宏任务setTimeout(2)
四、事件循环的完整流程
用代码来模拟一下事件循环的执行过程:
// 循环流程示意
while (true) {
// 1. 执行一个宏任务(从宏任务队列取)
const macroTask = macroQueue.shift();
execute(macroTask);
// 2. 执行所有微任务
while (microQueue.length > 0) {
const microTask = microQueue.shift();
execute(microTask);
}
// 3. 可能进行一次UI渲染(浏览器)
// 4. 回到步骤1
}
这个循环就是事件循环的核心逻辑。记住这个顺序,就能推断出任何异步代码的执行顺序。
五、经典面试题:输出顺序大挑战
来几个经典题目,看看你掌握了没有。
题目一:基础版
setTimeout(() => console.log('A'), 0);
Promise.resolve().then(() => console.log('B'));
console.log('C');
// 输出:C B A
题目二:嵌套版
setTimeout(() => {
console.log('A');
Promise.resolve().then(() => console.log('B'));
}, 0);
Promise.resolve().then(() => console.log('C'));
setTimeout(() => console.log('D'), 0);
console.log('E');
// 输出:E C A B D
分析:
- 同步代码:E
- 微任务:C
- 第一个宏任务(第一个setTimeout):A,然后它的微任务B
- 第二个宏任务(第二个setTimeout):D
题目三:async/await版
async function test() {
console.log('1');
await console.log('2');
console.log('3');
}
console.log('4');
test();
console.log('5');
// 输出:4 1 2 5 3
等等,为什么是这个顺序?await后面的代码相当于被包在Promise.then里,是微任务。所以:
- 同步代码:4
- 进入test函数:1
await console.log('2'):先执行console.log('2'),然后await后面的代码(3)变成微任务- 继续同步:5
- 同步结束,执行微任务:3
题目四:复杂混合
setTimeout(() => console.log('A'), 0);
Promise.resolve()
.then(() => {
console.log('B');
setTimeout(() => console.log('C'), 0);
})
.then(() => console.log('D'));
console.log('E');
// 输出:E B D A C
分析:
- 同步:E
- 微任务:Promise.then链。第一个then输出B,并把setTimeout(C)加入宏任务;然后第二个then输出D(因为第一个then返回的Promise会立即触发第二个then)
- 宏任务:A
- 下一个宏任务:C
六、UI渲染在哪儿?
浏览器会在适当的时机进行UI渲染,通常是在一个宏任务执行完、所有微任务执行完后,下一个宏任务开始前。
setTimeout(() => {
document.body.style.background = 'red';
});
Promise.resolve().then(() => {
document.body.style.background = 'blue';
});
上面的代码,背景会先变成蓝色,再变成红色。因为微任务先执行,然后UI渲染,然后下一个宏任务执行。
七、Node.js的事件循环不一样
Node.js的事件循环和浏览器不同,它有六个阶段:timers、pending、idle/prepare、poll、check、close。简单来说,Node里的setImmediate和process.nextTick有自己的顺序:
process.nextTick:不属于事件循环的任何阶段,会在当前阶段结束后立即执行(比微任务还早)。setImmediate:在check阶段执行。
setImmediate(() => console.log('setImmediate'));
setTimeout(() => console.log('setTimeout'), 0);
process.nextTick(() => console.log('nextTick'));
// 输出:nextTick, setTimeout/setImmediate(顺序不一定)
在Node里,setTimeout和setImmediate的执行顺序取决于事件循环的当前阶段,不一定谁先。
八、实际开发中的应用
了解事件循环有什么用?不只是为了面试,实际开发中也很有帮助:
1. 用setTimeout分割长任务
如果你有一个很耗时的循环,会阻塞页面,可以用setTimeout分片执行。
function processLargeArray(items, chunkSize = 100) {
let index = 0;
function process() {
const chunk = items.slice(index, index + chunkSize);
chunk.forEach(item => {
// 处理每个item
});
index += chunkSize;
if (index < items.length) {
setTimeout(process, 0); // 让出主线程,让页面有机会渲染
}
}
process();
}
2. 用微任务做“刚刚好”的异步
有时候你想让代码异步执行,但又想让它尽快执行,可以用微任务。
function doSomethingAsync(callback) {
// 确保回调是异步执行的
queueMicrotask(callback);
// 或者 Promise.resolve().then(callback)
}
3. 用Promise优化“同步变异步”
如果某个操作可能是同步的也可能是异步的,用Promise包装可以保证执行顺序一致。
function fetchData() {
if (cachedData) {
// 用微任务确保异步执行
return Promise.resolve(cachedData);
}
return fetch('/api/data');
}
// 调用方不需要担心是同步还是异步
fetchData().then(data => console.log(data));
九、总结:外卖调度员的日常
事件循环就是JS的“外卖调度员”,它的工作流程是:
- 厨师(主线程)做完一道菜(同步代码)
- 调度员看看VIP通道(微任务队列)有没有菜要上
- 有就全上完
- 再从普通通道(宏任务队列)取一道菜给厨师
- 重复
记住这个口诀:先同步,再微任务,最后宏任务。宏任务里嵌套的微任务,在下一次微任务循环中执行。
掌握了事件循环,你就掌握了JS异步的执行规律。那些看似复杂的异步面试题,不过是这个规则的排列组合。
明天我们将进入JavaScript的另一个重要话题——Promise源码实现,手写一个符合Promise/A+规范的Promise,让你彻底理解它的内部机制。
如果你觉得今天的“外卖调度”讲得清楚明白,点个赞让更多人看到。有疑问评论区见,我们明天见!