事件循环与宏微任务:为啥有些 log 顺序跟你想的不一样?

0 阅读6分钟

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱“面向搜索引擎写代码”的尴尬。

一、开篇:一个让你怀疑人生的 console.log

先看这段代码:

console.log('1');

setTimeout(() => console.log('2'), 0);

Promise.resolve().then(() => console.log('3'));

console.log('4');

实际输出是:1 4 3 2,而不是 1 2 3 4

很多人会懵:setTimeout 延迟是 0,按理说应该“马上执行”,为什么反而比 Promise 还晚?这就涉及到 JavaScript 的事件循环和宏任务、微任务。

二、概念扫盲

先把几个核心概念搞清楚:

概念一句话
调用栈同步代码执行的地方,先进后出
事件循环不断检查「调用栈空了没 → 执行微任务 → 执行一个宏任务」的循环
宏任务scriptsetTimeoutsetIntervalI/O
微任务Promise.then/catch/finallyqueueMicrotaskMutationObserver

核心要点:每执行完一个宏任务,会先清空当前所有微任务,再执行下一个宏任务。

大白话:可以把 JS 想象成一个办事员——先把手头的事(同步代码)干完,再去处理“小纸条”(微任务),最后才去处理“排队的人”(下一个宏任务)。微任务永远插队在下一个宏任务前面。

三、核心规则(3 条记住就够用)

  1. 同步代码先执行完
  2. 同步代码执行完后,先执行当前所有微任务
  3. 微任务清空后,再执行下一个宏任务,然后重复 2~3。

记住:同步 > 微任务 > 下一个宏任务

四、典型例子

1:setTimeout vs Promise(最基础)

console.log('1');           // 同步

setTimeout(() => {
  console.log('2');         // 宏任务
}, 0);

Promise.resolve()
  .then(() => console.log('3'))   // 微任务
  .then(() => console.log('5'));  // 微任务

console.log('4');           // 同步

输出:1 4 3 5 2

分步讲解:

阶段发生了什么输出
主脚本执行执行 14;把 setTimeout 回调放进宏任务队列;把 Promise.then 回调放进微任务队列1, 4
微任务阶段主脚本(第一个宏任务)执行完,清空微任务队列3, 5
下一个宏任务执行 setTimeout 回调2

同步先跑完,微任务插队在下一个宏任务前面,所以 2 一定在最后。


2:嵌套场景(Promise 里套 setTimeout)

console.log('start');

setTimeout(() => {
  console.log('timer1');
  Promise.resolve().then(() => console.log('promise1'));
}, 0);

Promise.resolve()
  .then(() => {
    console.log('promise2');
    setTimeout(() => console.log('timer2'), 0);
  });

console.log('end');

输出:start end promise2 timer1 promise1 timer2

分步讲解:

阶段发生了什么输出
主脚本同步执行 start、end;注册 timer1、promise2start, end
微任务执行 promise2,其中又注册了 timer2promise2
宏任务 timer1执行 timer1,又注册了 promise1timer1
微任务timer1 执行完后,清空微任务,执行 promise1promise1
宏任务 timer2执行 timer2timer2

要点:宏任务里新产生的微任务,会在当前宏任务结束后立即执行,不会等到下一轮。所以 timer1 跑完 → 立刻执行 promise1 → 再执行 timer2。很多面试题的“怪顺序”都是这种嵌套造成的。


3:async/await 的本质

async function foo() {
  console.log('foo start');
  await bar();
  console.log('foo end');   // 等价于 Promise.then 里的代码
}

async function bar() {
  console.log('bar');
}

console.log('script start');

foo();

console.log('script end');

输出:script startfoo startbarscript endfoo end

等价理解await 之后的代码会被包进微任务:

// await bar() 等价于:
// Promise.resolve(bar()).then(() => { console.log('foo end'); })

为什么 foo endscript end 之后? 因为 await bar() 会把后面的 console.log('foo end') 包装进 Promise.then 的回调,本质是微任务。微任务要等当前宏任务(主脚本)执行完才跑,所以 script end 先输出,foo end 后输出。

五、日常怎么选、踩坑在哪

1.选型建议

场景建议原因
希望“尽快”执行Promise.thenqueueMicrotask微任务在下一宏任务之前执行
需要推迟到“下一轮”setTimeout(fn, 0)作为宏任务,排在当前微任务之后
不想阻塞 UI长计算用 requestIdleCallback 或分片setTimeout 0 仍可能卡顿

为什么“尽快执行”不推荐 setTimeout(fn, 0)? 即使延迟为 0,回调也会进入宏任务队列,必须等当前所有微任务都执行完才会轮到自己。如果中间有很多微任务,你的回调会被推迟。

2.常见踩坑

  1. 以为 setTimeout(fn, 0) 是“马上执行”:其实是“当前宏任务 + 微任务都跑完后”才执行;
  2. 以为 await 是同步await 之后的代码是微任务;
  3. 微任务里再注册微任务:会在同一轮微任务阶段继续执行,可能造成长微任务链,阻塞渲染;
  4. 在微任务里做耗时计算:微任务会一口气执行完,中间不会让出主线程,可能卡住页面。

六、小结

例子核心考点一句话记住
例子 1同步 > 微任务 > 宏任务1 4 先,3 5 再,2 最后
例子 2宏任务里产生的微任务,当前宏任务结束后马上清空timer1 跑完 → 立刻执行 promise1 → 再执行 timer2
例子 3await 后面的代码是微任务foo end 不会在 script end 之前

记住四句话

  • 同步 > 微任务 > 下一个宏任务;
  • Promise.thenasync/await 的回调是微任务;
  • setTimeoutsetInterval 是宏任务;
  • 需要“尽快但不阻塞”时用微任务;需要“让出本轮”时用 setTimeout(fn, 0)

延伸阅读:HTML 规范 Event loops、Node.js 中 process.nextTick vs setImmediate vs setTimeout(与浏览器有差异)。


学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~