请先看下面这段代码,在心里默默给个执行结果:
javascript
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
new Promise((resolve) => {
console.log('3');
resolve();
}).then(() => {
console.log('4');
});
console.log('5');
如果你的答案是 1 3 5 4 2,恭喜你------你对事件循环已经有了基本认知;如果不是,读完这篇文章,你将成为人肉 JS 执行器,任何异步面试题都不过是一道逻辑题。本文会用最通俗的语言,从回调地狱一路讲到宏任务/微任务,让你彻底看懂 JavaScript 的执行机制。

一、说好的一行一行执行呢?
JavaScript因为要解决DOM冲突被设计成单线程语言,这意味着它一次只能做一件事。理论上,代码就应该按照书写顺序一行一行执行:
javascript
let a = '1';
console.log(a); // 1
let b = '2';
console.log(b); // 2
然而,我们经常看到这样的代码:
javascript
setTimeout(function() {
console.log('定时器开始啦');
});
new Promise(function(resolve) {
console.log('马上执行for循环啦');
for (var i = 0; i < 10000; i++) {
i == 99 && resolve();
}
}).then(function() {
console.log('执行then函数啦');
});
console.log('代码执行结束');
如果你以为是"定时器开始啦 → 马上执行for循环啦 → 执行then函数啦 → 代码执行结束",那控制台会让你怀疑人生。实际输出是:
text
马上执行for循环啦
代码执行结束
执行then函数啦
定时器开始啦
原因就是:JavaScript 有同步任务和异步任务之分,并且有一套精确的调度规则------事件循环。
二、同步与异步,就像银行的叫号系统
想象一个只有一个窗口的银行,所有客户都必须排队。这就是 JavaScript 的单线程模型。
但银行不会让一个存钱需要 30 分钟的客户堵住后面所有只取 100 块钱的人。所以,他们会分两类业务:
- 同步任务:快速处理,在主窗口立即办理。
- 异步任务:耗时较久,先去旁边填表,填好了再回到等候区排队等叫号。
JavaScript的解决方式也类似:
- 同步任务直接进入主线程执行栈。
- 异步任务会被扔进一个"注册中心"(Event Table),绑定好回调函数。一旦异步操作完成(比如定时器到时间了,或者请求返回了),就把对应的回调函数放入一个事件队列(Event Queue)等待执行。
主线程会先把执行栈里的所有同步任务清空,然后去事件队列里取出第一个回调,放进执行栈执行。执行完再去取下一个,如此循环往复------这就是事件循环(Event Loop)。
举个现实中的 AJAX 例子:
javascript
let data = [];
$.ajax({
url: 'https://api.example.com',
data: data,
success: () => {
console.log('发送成功!');
}
});
console.log('代码执行结束');
执行过程:
- ajax 进入 Event Table,注册回调函数
success。 - 主线程继续执行,打印
'代码执行结束'。 - 某刻请求完成,
success回调被放入事件队列。 - 主线程执行栈清空后,读取事件队列,执行
console.log('发送成功!')。
因此输出顺序一定是:先 '代码执行结束',后 '发送成功!'。
三、setTimeout 的真相:定时器并不准时
我们常用 setTimeout(fn, delay) 来延迟执行,但 delay 很多时候并不准确。
javascript
setTimeout(() => {
console.log('task');
}, 3000);
console.log('执行console');
根据异步规则,上面代码会先输出 '执行console',3 秒后输出 'task',这很容易理解。
但如果中间插入一个很慢的同步操作呢?
javascript
setTimeout(() => {
task();
}, 3000);
// 模拟一个超级慢的函数
slowTask(10000000); // 假设这个函数要跑 10 秒
执行过程变成:
task进入 Event Table,开始计时 3 秒。- 主线程开始执行
slowTask,耗时 10 秒。 - 3 秒到了,
task被放入事件队列,但主线程还在忙slowTask,只能继续等。 - 10 秒后
slowTask结束,主线程去事件队列取出task执行。
最终 task 的实际延迟是 10 秒,而不是 3 秒。这就是 setTimeout 的"不准时"根源:它只保证在指定时间后将回调放入队列,但不保证立刻执行,前面还有多少同步任务或排队的回调,它就要等多久。
setTimeout(fn, 0) 的含义也清楚了:不是立即执行 ,而是把 fn 放在队列的最后,等所有同步任务完成后立刻执行。不过根据 HTML 标准,0 毫秒最低实际上是 4ms。
四、宏任务与微任务:事件循环的"VIP 插队机制"
上面我们只区分了同步和异步,但异步任务内部还有"三六九等"。
4.1 两大类异步任务
- 宏任务(Macro Task) :整体 script 代码、
setTimeout、setInterval、I/O、UI 渲染等。 - 微任务(Micro Task) :
Promise.then、MutationObserver,以及 Node.js 的process.nextTick。
事件循环的精确规则是:
- 执行一个宏任务(首次就是整个 script)。
- 宏任务执行完后,立即清空所有微任务。
- 重新渲染 UI(如果需要)。
- 再取下一个宏任务,回到步骤 1。
关键:每个宏任务之后,所有微任务会一次性清空,且微任务可以插队,不会让其他宏任务先行。
4.2 一个经典的例子
回到开篇那段代码,详细拆解一下:
javascript
console.log('1');
setTimeout(() => {
console.log('2');
}, 0);
new Promise((resolve) => {
console.log('3');
resolve();
}).then(() => {
console.log('4');
});
console.log('5');
第一轮(当前 script 宏任务):
console.log('1')→ 输出 1setTimeout→ 回调放入宏任务队列,记作setTimeout1new Promise内的函数立即执行:输出 3 ,resolve()把.then回调放入微任务队列,记作then1console.log('5')→ 输出 5
现在,当前宏任务执行完毕。微任务队列里有 then1,执行它输出 4。
第一轮结束,输出 1 3 5 4。
第二轮(下一个宏任务): 取出 setTimeout1 执行,输出 2。没有微任务。循环结束。
最终输出:1 3 5 4 2。
五、综合实战:一道面试大题,三步拆穿
下面是一道经典的大厂面试题,我们用刚学到的规则一步步剖析:
javascript
console.log('1');
setTimeout(function() {
console.log('2');
process.nextTick(function() {
console.log('3');
});
new Promise(function(resolve) {
console.log('4');
resolve();
}).then(function() {
console.log('5');
});
});
process.nextTick(function() {
console.log('6');
});
new Promise(function(resolve) {
console.log('7');
resolve();
}).then(function() {
console.log('8');
});
setTimeout(function() {
console.log('9');
process.nextTick(function() {
console.log('10');
});
new Promise(function(resolve) {
console.log('11');
resolve();
}).then(function() {
console.log('12');
});
});
第一轮事件循环(当前 script 宏任务)
console.log('1')→ 输出 1。- 遇到第一个
setTimeout,回调进入宏任务队列(记作time1)。 - 遇到
process.nextTick,回调进入微任务队列(记作pro1)。 - 遇到
new Promise:执行构造函数输出 7 ,.then回调进入微任务队列(记作then1)。 - 遇到第二个
setTimeout,回调进入宏任务队列(记作time2)。
第一轮宏任务结束,此时的队列状态:
| 宏任务队列 | 微任务队列 |
|---|---|
time1 |
pro1 |
time2 |
then1 |
清空微任务队列:
- 执行
pro1→ 输出 6 - 执行
then1→ 输出 8
第一轮输出:1, 7, 6, 8
第二轮事件循环(宏任务 time1)
执行 time1:
console.log('2')→ 输出 2- 遇到
process.nextTick,回调进入微任务队列(记作pro2) new Promise:输出 4 ,.then回调进入微任务队列(记作then2)
time1 执行完毕,队列状态:
| 宏任务队列 | 微任务队列 |
|---|---|
time2 |
pro2 |
then2 |
清空微任务:
- 执行
pro2→ 输出 3 - 执行
then2→ 输出 5
第二轮输出:2, 4, 3, 5
第三轮事件循环(宏任务 time2)
执行 time2:
console.log('9')→ 输出 9process.nextTick回调进入微任务队列(pro3)new Promise:输出 11 ,.then回调进入微任务队列(then3)
清空微任务:
- 执行
pro3→ 输出 10 - 执行
then3→ 输出 12
第三轮输出:9, 11, 10, 12
完整输出
text
1, 7, 6, 8, 2, 4, 3, 5, 9, 11, 10, 12
如果你能独立推到这一步,事件循环对你已无秘密。
六、async/await 的时序------语法糖背后的微任务
async/await 是建立在 Promise 之上的语法糖,它也遵循微任务规则。看一个例子:
javascript
async function foo() {
console.log('a');
await bar();
console.log('b');
}
async function bar() {
console.log('c');
}
console.log('d');
foo();
console.log('e');
拆解:
console.log('d')→ 输出 d- 调用
foo():- 输出 a
await bar():bar()同步执行,输出 c,并返回一个 resolved 的 Promiseawait把下面的console.log('b')包装成微任务,放入队列
- 主线程继续执行
console.log('e')→ 输出 e - 同步代码完毕,清空微任务:输出 b
最终输出:d, a, c, e, b
记住:await 右边的表达式是同步执行的,下面的代码被当作微任务延迟执行。
七、日常开发中的事件循环应用
1. Vue 的 nextTick
Vue 更新 DOM 是异步的。当你修改响应式数据后,Vue 会把 DOM 更新任务推入微任务队列,所以此时直接读取 DOM 还是旧的。
javascript
count.value = 10;
console.log(document.getElementById('counter').textContent); // 旧值
await nextTick();
console.log(document.getElementById('counter').textContent); // 新值
nextTick 内部就是利用了微任务(Promise 或 MutationObserver),保证你在队列清空后拿到最新 DOM。
2. React 的批量更新
React 18 之前的版本会将多个 setState 合并为一个更新,背后的调度也利用了事件循环和微任务,让渲染在合适的时机执行。
3. 分片执行,防止页面卡死
如果一段同步计算要循环 10 万次,主线程会被彻底堵塞。可以将每一小段计算用 setTimeout 切分为多个宏任务:
javascript
function batchProcess(items, index = 0) {
if (index >= items.length) return;
heavyWork(items[index]);
setTimeout(() => batchProcess(items, index + 1), 0);
}
这样每次只处理一项,然后让出主线程去响应用户交互,页面就不会卡死。
八、总结
JavaScript因为单线程,使得它必须依靠事件轮询来处理异步。理解事件循环,只需要牢牢把握两条铁律:
- 同步任务优先,异步任务靠边站。
- 一个宏任务后,所有微任务清空,再取下一个宏任务。
宏任务(setTimeout、setInterval、script 代码)是一栋栋房子,微任务(Promise.then、nextTick)是每栋房子里的紧急快递------先派送完快递,才轮到下一栋房子。
当你能把一道异步面试题的输出顺序像讲推理故事一样讲述出来,你就真正掌握了这门语言的核心执行机制。希望本文能帮你打通这"最后一公里",让异步不再神秘。