今天想和大家聊聊一個在面試中幾乎必問,但在實(shí)際工作中又容易被忽略的話題——Event Loop(事件循環(huán))。
你可能在面試時被問過:“JavaScript 是單線程的,那它是怎么實(shí)現(xiàn)異步的?”或者“setTimeout(fn, 0)
真的是立即執(zhí)行嗎?”這些問題的答案,都藏在 Event Loop 里。
別擔(dān)心,即使你是第一次接觸這個概念,我也盡量用最通俗的方式,帶你一步步搞懂它。我們不堆術(shù)語,不講玄學(xué),只說人話。
一、JavaScript 的“單線程”到底是什么意思?
我們常說 JavaScript 是“單線程”的,這句話到底意味著什么?
你可以把 JavaScript 的執(zhí)行環(huán)境想象成一個只有一位服務(wù)員的快餐店。這位服務(wù)員(也就是主線程)一次只能處理一件事:點(diǎn)餐、做漢堡、收錢……他不能同時做兩件事。
比如,你讓服務(wù)員“做一份漢堡,同時給我一杯可樂”,他只能先做漢堡,再倒可樂,或者反過來。他不能一邊煎肉餅一邊倒飲料。
在代碼里,這就像這樣:
console.log("第一步");
console.log("第二步");
setTimeout(() => {
console.log("第三步(異步)");
}, 1000);
console.log("第四步");
輸出結(jié)果是:
第一步
第二步
第四步
第三步(異步)
你看,setTimeout
雖然寫在第三步,但它并沒有立刻執(zhí)行,而是被“推遲”了。為什么?因?yàn)橹骶€程要先把當(dāng)前的任務(wù)做完,才能回頭處理它。
這就是“單線程”的核心:同一時間,只能做一件事。
二、那異步是怎么實(shí)現(xiàn)的?總不能一直卡著吧?
既然 JS 是單線程的,那像 setTimeout
、fetch
、addEventListener
這些異步操作是怎么做到不阻塞主線程的呢?
答案是:它們不是 JS 自己做的,而是瀏覽器(或 Node.js 環(huán)境)幫我們做的。
繼續(xù)用快餐店的比喻:
- 服務(wù)員(JS 主線程)負(fù)責(zé)點(diǎn)單和出餐。
- 但廚房里的烤箱、冰箱、飲料機(jī)……這些是“瀏覽器提供的能力”。
- 當(dāng)你點(diǎn)了一個漢堡,服務(wù)員不會自己去煎,而是把訂單交給廚房(異步任務(wù)),然后繼續(xù)服務(wù)下一位顧客。
- 等廚房做好了,會通知服務(wù)員:“你的漢堡好了”,服務(wù)員再把漢堡端給你。
在技術(shù)上,這個“通知”機(jī)制就是通過 任務(wù)隊(duì)列(Task Queue) 實(shí)現(xiàn)的。
三、Event Loop 的三大核心:調(diào)用棧、任務(wù)隊(duì)列、事件循環(huán)
要理解 Event Loop,你需要知道三個關(guān)鍵角色:
1. 調(diào)用棧(Call Stack)
這是 JS 執(zhí)行函數(shù)的地方。你可以把它想象成一個“待辦事項(xiàng)清單”,從上到下依次執(zhí)行。
比如這段代碼:
function a() {
b();
console.log("a 執(zhí)行完了");
}
function b() {
console.log("b 開始執(zhí)行");
}
a();
執(zhí)行過程就像這樣:
a()
被推入調(diào)用棧a
里面調(diào)用 b()
,b()
被推入棧b()
執(zhí)行完,從棧中彈出a()
繼續(xù)執(zhí)行,打印“a 執(zhí)行完了”,然后彈出
調(diào)用棧是“同步任務(wù)”的執(zhí)行場所。
2. 任務(wù)隊(duì)列(Task Queue / Callback Queue)
當(dāng)異步任務(wù)(比如 setTimeout
、setInterval
、DOM 事件
、Ajax 請求
)完成時,它們的回調(diào)函數(shù)不會立刻執(zhí)行,而是被放進(jìn)一個“等待區(qū)”——這就是任務(wù)隊(duì)列。
任務(wù)隊(duì)列是一個先進(jìn)先出(FIFO) 的隊(duì)列。先進(jìn)來的回調(diào),先被執(zhí)行。
3. 事件循環(huán)(Event Loop)
這才是真正的“調(diào)度員”。它的工作非常簡單:
不斷檢查調(diào)用棧是否為空。如果為空,就從任務(wù)隊(duì)列里取出第一個回調(diào),推入調(diào)用棧執(zhí)行。
就這么簡單!它像個永不停歇的循環(huán),一直盯著:
- “??樟藛??”
- “空了?好,看看隊(duì)列里有沒有任務(wù)。”
- “有?拿一個過來執(zhí)行?!?/li>
這就是“事件循環(huán)”名字的由來:它在循環(huán)地處理事件(回調(diào))。
四、宏任務(wù) vs 微任務(wù):你必須知道的細(xì)節(jié)
到這里,你以為 Event Loop 就完了?不,還有一個更精細(xì)的劃分:宏任務(wù)(Macrotask)和微任務(wù)(Microtask)。
1. 宏任務(wù)(Macrotask)
常見的宏任務(wù)包括:
setTimeout
setInterval
setImmediate
(Node.js)- I/O 操作
- UI 渲染(瀏覽器)
script
標(biāo)簽中的整體代碼
2. 微任務(wù)(Microtask)
微任務(wù)的優(yōu)先級更高,常見的有:
Promise.then/catch/finally
MutationObserver
(監(jiān)聽 DOM 變化)queueMicrotask()
process.nextTick()
(Node.js)
關(guān)鍵區(qū)別:執(zhí)行時機(jī)
Event Loop 的執(zhí)行順序是這樣的:
- 執(zhí)行一個宏任務(wù)(比如整個
script
代碼) - 執(zhí)行過程中,遇到異步操作,把回調(diào)放進(jìn)對應(yīng)的隊(duì)列:
setTimeout
→ 宏任務(wù)隊(duì)列Promise.then
→ 微任務(wù)隊(duì)列
- 當(dāng)前宏任務(wù)執(zhí)行完,立即清空微任務(wù)隊(duì)列(全部執(zhí)行完)
- 然后去宏任務(wù)隊(duì)列取下一個宏任務(wù)
- 重復(fù)這個過程
舉個例子,徹底搞懂
來看這段經(jīng)典代碼:
console.log("1");
setTimeout(() => {
console.log("2");
}, 0);
Promise.resolve().then(() => {
console.log("3");
});
console.log("4");
輸出順序是什么?
我們一步步分析:
執(zhí)行全局腳本(宏任務(wù))
- 打印 "1"
- 遇到
setTimeout
,把回調(diào) () => console.log("2")
放入宏任務(wù)隊(duì)列 - 遇到
Promise.then
,把回調(diào) () => console.log("3")
放入微任務(wù)隊(duì)列 - 打印 "4"
- 當(dāng)前宏任務(wù)執(zhí)行完畢
清空微任務(wù)隊(duì)列
- 執(zhí)行
Promise.then
的回調(diào),打印 "3"
取下一個宏任務(wù)
- 執(zhí)行
setTimeout
的回調(diào),打印 "2"
所以輸出是:1 → 4 → 3 → 2
注意:setTimeout(fn, 0)
并不是“立即執(zhí)行”,而是“等當(dāng)前所有同步和微任務(wù)執(zhí)行完后,再執(zhí)行”。
五、更復(fù)雜的例子:嵌套 Promise 和 setTimeout
再看一個稍微復(fù)雜點(diǎn)的例子:
console.log("start");
setTimeout(() => {
console.log("timeout1");
Promise.resolve().then(() => {
console.log("promise in timeout");
});
}, 0);
Promise.resolve().then(() => {
console.log("promise1");
setTimeout(() => {
console.log("timeout in promise");
}, 0);
});
console.log("end");
來,我們一步步走:
執(zhí)行全局宏任務(wù):
- 打印 "start"
setTimeout
→ 宏任務(wù)隊(duì)列Promise.then
→ 微任務(wù)隊(duì)列- 打印 "end"
- 宏任務(wù)結(jié)束
清空微任務(wù)隊(duì)列:
- 執(zhí)行
Promise.then
,打印 "promise1" - 在
then
里又遇到 setTimeout
,把它加入宏任務(wù)隊(duì)列 - 微任務(wù)隊(duì)列清空
取下一個宏任務(wù)(第一個 setTimeout
):
- 執(zhí)行,打印 "timeout1"
- 遇到
Promise.then
,加入微任務(wù)隊(duì)列
清空微任務(wù)隊(duì)列:
- 執(zhí)行
Promise.then
,打印 "promise in timeout"
取下一個宏任務(wù)(Promise.then
里的 setTimeout
):
- 執(zhí)行,打印 "timeout in promise"
最終輸出:
start
end
promise1
timeout1
promise in timeout
timeout in promise
是不是有點(diǎn)繞?多看幾遍,畫個流程圖,就清楚了。
六、為什么要有微任務(wù)?它有什么用?
你可能會問:既然有宏任務(wù)就夠了,為啥還要搞個微任務(wù)?
答案是:為了更精細(xì)的控制和性能優(yōu)化。
比如:
- Promise 的鏈?zhǔn)秸{(diào)用:
.then().then().then()
,我們希望這些回調(diào)能盡快執(zhí)行,而不是等一輪完整的 Event Loop。 - 避免 UI 卡頓:微任務(wù)在當(dāng)前任務(wù)結(jié)束后立即執(zhí)行,不會觸發(fā)頁面重繪,適合做數(shù)據(jù)更新、狀態(tài)同步等操作。
- DOM 觀察:
MutationObserver
用微任務(wù)來批量處理 DOM 變化,避免頻繁重排。
簡單說:微任務(wù) = 高優(yōu)先級、立即執(zhí)行的小任務(wù)。
七、實(shí)際開發(fā)中的影響
理解 Event Loop 不只是應(yīng)付面試,它對實(shí)際開發(fā)也有幫助。
1. 避免長時間同步任務(wù)阻塞 UI
for (let i = 0; i < 1000000; i++) {
}
這種長時間運(yùn)行的同步代碼會阻塞 Event Loop,導(dǎo)致頁面無響應(yīng)。
解決方案:拆分成小任務(wù),用 setTimeout
或 requestIdleCallback
分批執(zhí)行。
2. 正確處理異步依賴
let data;
fetch("/api/data").then(res => res.json()).then(d => data = d);
console.log(data);
因?yàn)?nbsp;fetch
是異步的,console.log
是同步的,它先執(zhí)行了。
正確做法:用 async/await
或確保在回調(diào)中使用數(shù)據(jù)。
3. setTimeout(fn, 0)
的用途
雖然它不是“立即執(zhí)行”,但可以用來:
- 將任務(wù)推遲到下一輪 Event Loop
- 讓 UI 有機(jī)會先更新
- 實(shí)現(xiàn)簡單的“異步批處理”
let queue = [];
function addTask(task) {
queue.push(task);
setTimeout(processQueue, 0);
}
function processQueue() {
if (queue.length > 0) {
queue.forEach(task => task());
queue = [];
}
}
八、總結(jié):Event Loop 的完整流程
最后,我們來梳理一下瀏覽器中 Event Loop 的完整流程:
- 執(zhí)行一個宏任務(wù)(如整個
script
) - 執(zhí)行過程中:
- 遇到
setTimeout
→ 加入宏任務(wù)隊(duì)列 - 遇到
Promise.then
→ 加入微任務(wù)隊(duì)列 - 遇到 DOM 事件 → 加入宏任務(wù)隊(duì)列
- 當(dāng)前宏任務(wù)執(zhí)行完畢
- 立即執(zhí)行所有微任務(wù)(清空微任務(wù)隊(duì)列)
- 嘗試渲染頁面(如果需要)
- 取下一個宏任務(wù),回到第1步
記住這個口訣:
一個宏任務(wù),清空微任務(wù),再來下一個宏任務(wù)。
寫在最后
Event Loop 是 JavaScript 異步編程的基石。它看似復(fù)雜,但核心思想很簡單:用一個循環(huán)不斷檢查任務(wù)隊(duì)列,按順序執(zhí)行任務(wù)。
?轉(zhuǎn)自https://juejin.cn/post/7534907614394482723
該文章在 2025/8/8 10:56:38 編輯過