你好,未来的前端大师!
你是否曾经对 setTimeout(fn, 0)
为什么不是"立即"执行感到困惑?或者在面试中被问到 Promise 和 setTimeout 的执行顺序时,心里有点发虚?
别担心,这些问题都指向同一个核心概念------Event Loop(事件循环)。这不仅仅是面试高频题,更是理解 JavaScript 异步编程的基石。它决定了你的代码以何种顺序执行,直接关系到应用的性能和用户的体验。
今天,就让我们用最通俗易懂的语言,结合实战代码,彻底征服这个"磨人的小妖精",让你对 JS 的执行机制有一个全新的、透彻的理解!
一、为什么要有事件循环?JS 的"单线程"宿命
我们都知道,JavaScript 是一门单线程语言。这意味着在任何一个时刻,它只能做一件事。这就像一个专心致志的工匠,一次只能打磨一个零件。
这种设计的初衷是为了简单和安全。想象一下,如果 JS 是多线程的,一个线程在删除某个 DOM 节点,而另一个线程同时在修改这个节点的样式,那页面岂不是要"精神分裂"了?
但"专一"也带来了挑战:如果一个任务非常耗时(比如一次网络请求),那么整个程序都会被阻塞,页面会卡住不动,无法响应用户的任何操作。这显然是无法接受的。
为了解决这个问题,JavaScript 的设计者们引入了一套聪明的异步执行机制,而事件循环正是这套机制的核心与灵魂。
二、事件循环的核心:宏任务与微任务
为了实现异步,JS 把任务分成了两类:同步任务 和异步任务。同步任务在主线程上排队执行,而异步任务则进入了"任务队列"。
关键在于,这个"任务队列"又被细分为两种,它们的执行时机完全不同:
1. 宏任务 (Macro Task)
可以理解为比较"宏观"的、独立的任务单元。每次事件循环都从一个宏任务开始。
常见的宏任务:
- 整个
<script>
代码块setTimeout()
和setInterval()
- DOM 事件监听 (
addEventListener
的回调)- I/O 操作,如
fetch
requestAnimationFrame()
(比较特殊,与渲染紧密相关)
2. 微任务 (Micro Task)
微任务则更加"紧急"和"精细"。它们通常在当前宏任务执行过程中产生,并且需要在当前宏任务执行结束之后、下一个宏任务开始之前立即"插队"执行完毕。
常见的微任务:
Promise.prototype.then()
,.catch()
,.finally()
MutationObserver
: 它可以在 DOM 发生变化时,在页面重新渲染前得到通知。queueMicrotask()
: 一个专门用来创建微任务的现代 API。process.nextTick()
(Node.js 环境专属,最高优先级的微任务)
三、事件循环的完整流程:一次完美的轮转
好了,了解了宏任务和微任务,我们现在可以完整地描绘出事件循环的执行过程了:
- 执行宏任务 :从宏任务队列中取出一个任务(首次是
<script>
脚本)开始执行。 - 执行同步代码:执行这个宏任务中的所有同步代码。
- 清空微任务队列 :当宏任务的同步代码执行完毕,立即检查微任务队列。如果队列不为空,则循环执行其中的所有微任务,直到队列被完全清空。
- UI 渲染 (浏览器环境):微任务队列清空后,浏览器会判断是否需要进行 UI 渲染(重绘/重排)。
- 开始下一个宏任务:结束本轮循环,回到第一步,从宏任务队列中取出下一个任务。
核心要义 :一次 Event Loop = 执行一个宏任务 + 清空所有产生的微任务
四、实战演练:从入门到进阶
理论说再多,不如代码跑一波!
入门篇:宏任务与微任务的基础碰撞
来看这段代码:
javascript
console.log('script start');
Promise.resolve().then(() => console.log('Promise 1'));
Promise.resolve().then(() => console.log('Promise 2'));
setTimeout(() => {
console.log('setTimeout');
}, 0);
console.log('script end');
分析:
- 宏任务1 (
script
) 开始 :- 打印
script start
。 - 遇到两个
Promise.then
,将它们的回调(微任务)依次放入微任务队列。 - 遇到
setTimeout
,将其回调(一个新的宏任务)放入宏任务队列。 - 打印
script end
。
- 打印
- 清空微任务 :
- 执行微任务队列,依次打印
Promise 1
和Promise 2
。
- 执行微任务队列,依次打印
- 宏任务2 (
setTimeout
) 开始 :- 打印
setTimeout
。
- 打印
所以,最终输出 : script start
-> script end
-> Promise 1
-> Promise 2
-> setTimeout
。
进阶篇:宏任务中产生微任务
这个例子开始变得有趣了,它展示了宏任务内部如何"孕育"出新的微任务。
javascript
const p4 = new Promise((resolve, reject) => {
// 这里的代码是 Promise 的一部分,同步执行
console.log('Promise constructor');
setTimeout(() => {
// 这是未来的一个宏任务
console.log('setTimeout body');
resolve(1000); // resolve 时,才会把 .then 的回调推入微任务队列
}, 0);
});
p4.then((res) => {
console.log('p4.then 1, res:', res);
}).then(() => {
console.log('p4.then 2');
});
console.log('script end');
分析:
- 宏任务1 (
script
) 开始 :new Promise
的构造函数是同步的,打印Promise constructor
。- 遇到
setTimeout
,将其回调注册为宏任务2。 p4.then
被注册,但此时p4
状态还是pending
,所以什么都不做。- 打印
script end
。
- 清空微任务: 微任务队列为空。
- 宏任务2 (
setTimeout
回调) 开始 :- 打印
setTimeout body
。 - 执行
resolve(1000)
。此时p4
的状态变为fulfilled
,第一个.then
的回调被推入微任务队列。
- 打印
- 清空微任务 :
- 执行第一个
.then
的回调,打印p4.then 1, res: 1000
。 - 这个
.then
执行完后,第二个.then
的回调也被推入微任务队列。 - 执行第二个
.then
的回调,打印p4.then 2
。
- 执行第一个
最终输出 : Promise constructor
-> script end
-> setTimeout body
-> p4.then 1, res: 1000
-> p4.then 2
。
五、解密浏览器渲染:JS 和 UI 的微妙关系
一个很多开发者会忽略的关键点:页面渲染(UI Rendering)本身不属于宏任务或微任务!
关于 UI 渲染的关键:UI 渲染发生在"当前宏任务执行完毕,且所有微任务都已清空"之后,到"下一个宏任务开始"之前这个间隙。
这意味着:
- JS 执行会阻塞渲染:如果你的同步代码或微任务队列执行时间过长,浏览器就没机会去渲染页面,导致卡顿。
MutationObserver
和queueMicrotask
可以在下次渲染前执行代码,这是它们的重要应用场景。
看这个 html
的例子:
html
<script>
const target = document.createElement('div');
document.body.appendChild(target);
// MutationObserver 的回调是微任务
const observer = new MutationObserver(() => {
console.log('微任务: MutaionObserver - DOM 变了,但在渲染前通知我');
});
observer.observe(target, { attributes: true });
// 同步修改DOM
target.setAttribute('data-set', '123');
// 控制台会先打印 '微任务...', 然后你才能在Elements面板看到data-set属性
</script>
六、深入幕后:浏览器的多线程协作
你可能会好奇,既然 JS 是单线程,那 setTimeout
的计时和 fetch
的下载是在哪里完成的呢?
答案是:浏览器是多进程的,而一个渲染进程(通常对应一个 Tab 页)是多线程的!
除了我们熟知的 JS 主线程,还有这些"幕后英雄":
- GUI 渲染线程 :负责解析 HTML、CSS,布局和绘制页面。它与 JS 主线程是互斥的。
- 定时器触发线程 :
setTimeout
和setInterval
就在这里计时。时间到了,它会把回调函数放入宏任务队列。 - 事件触发线程:管理用户的点击、滚动等事件,并将回调放入宏任务队列。
- 异步 HTTP 请求线程:处理网络请求,完成后将回调放入宏任务队列。
正是这些线程与我们的 JS 主线程通过事件循环机制完美协作,才构建出了流畅而强大的 Web 应用。
最终总结:你需要记住的
- 一个中心:JavaScript 是单线程的。
- 两个基本点:任务分为宏任务和微任务。
- 一个核心流程:一次事件循环就是【执行一个宏任务 -> 清空所有微任务 -> (可能进行UI渲染)】的重复。
- 一个关键推论 :
Promise.then
(微任务) 总是在setTimeout
(下一个宏任务) 之前执行。- 一个性能忠告:保持你的同步代码和微任务简短,以避免阻塞渲染。
现在,你应该能清晰地解释为什么 setTimeout(fn, 0)
不会立即执行了------因为它需要等待当前宏任务和所有微任务执行完毕后,才有机会从宏任务队列中被取出。
掌握了事件循环,你不仅能从容应对面试,更能写出性能更优、逻辑更严谨的 JavaScript 代码。希望这篇文章能成为你前端进阶路上的一个坚实台阶!