事件求生術#
JavaScript 採用單執行緒執行模型,深入理解 event loop 的運作方式,對於開發流暢的瀏覽器應用程式至關重要。本章深入探討 event loop、timer 的運用技巧、事件傳播機制,以及自訂事件的建立。
13.1 深入 Event Loop#
Event loop 比第 2 章的簡化說明更為複雜。它至少有兩個佇列,分別處理兩類任務:macrotasks 和 microtasks。
Macrotask vs. Microtask#
- Macrotasks(巨集任務):建立主文件物件、解析 HTML、執行主線 JavaScript、URL 變更、頁面載入事件、輸入事件、網路事件、計時器事件。每個 macrotask 是一個獨立的工作單元。
- Microtasks(微任務):Promise 回呼、DOM mutation 變更。應在瀏覽器繼續其他工作(如重新渲染 UI)之前儘快執行。
Event Loop 的運作原則#
Event loop 基於兩個基本原則:
- 任務一次處理一個
- 任務執行到完成,不會被中斷
單次迭代的流程:
- 檢查 macrotask 佇列,若有任務則執行一個
- 處理 microtask 佇列中的所有任務
- 若需要則更新 UI 渲染
- 回到步驟 1
在單次 event loop 迭代中,最多處理一個 macrotask,但會處理所有 microtasks。Microtask 優先於下一個 macrotask 和頁面渲染。

Figure 13.1: Event loop 至少包含兩個任務佇列:macrotask queue 和 microtask queue
效能考量#
- 瀏覽器通常以 60 fps 渲染頁面,即每 16 ms 渲染一幀
- 一個 macrotask 及其產生的所有 microtasks 理想上應在 16 ms 內完成
- 頁面渲染只能在兩個 macrotask 之間發生(中間沒有 microtask 等待時)
- 若任務執行超過數百毫秒,使用者可能感受到遲鈍;超過數秒則可能出現「Unresponsive script」警告
處理 mouse-move 事件時要特別小心——移動滑鼠會產生大量事件排隊,在 handler 中執行複雜操作會導致應用程式緩慢且不流暢。
只有 Macrotask 的範例#
書中展示了一個包含主線 JavaScript(15ms)和兩個按鈕點擊事件(8ms + 5ms)的範例。即使 secondButton 在第 12ms 被點擊,其 handler 要等到約第 23ms 才會被執行,因為之前的任務必須先完成。

Figure 13.5: 應用啟動 23ms 後,僅剩一個處理 secondButton click 的任務
同時包含 Macro- 和 Microtask 的範例#
在 firstHandler 中加入 Promise.resolve().then(...) 後:
- Promise 回呼被放入 microtask 佇列
firstHandler執行完成後,event loop 立即處理 microtask- Promise 的 microtask 優先於 macrotask 佇列中等待的
secondButton點擊事件 - 頁面渲染可在兩個 macrotask 之間發生,但不能在 macrotask 和 microtask 之間發生
13.2 馴服計時器:setTimeout 和 setInterval#
計時器是 JavaScript 中經常被誤用但功能強大的特性,能非同步延遲程式碼的執行,延遲時間為至少指定的毫秒數。
計時器 API#
| 方法 | 說明 |
|---|---|
setTimeout(fn, delay) | 延遲後執行一次回呼,回傳 ID |
clearTimeout(id) | 取消指定的 timeout |
setInterval(fn, delay) | 每隔指定時間重複執行回呼,回傳 ID |
clearInterval(id) | 取消指定的 interval |
計時器的延遲時間不保證精確。延遲參數只指定任務被加入佇列的最短等待時間,而非實際執行時間。
Event Loop 中的計時器行為#
書中以一個包含 setTimeout(10ms delay, 6ms 執行)、setInterval(10ms delay, 8ms 執行)和點擊事件(10ms 執行)的範例說明:
- 計時器事件與滑鼠事件一樣被放入 macrotask 佇列
- 若 interval 觸發時,該 interval 的任務已在佇列中等待,瀏覽器不會再新增一個(避免佇列堆積)
- 因此 interval 的實際執行間隔可能不均勻,甚至可能緊接著連續執行

Figure 13.10: 計時器在 event loop 中的執行時序圖
setTimeout 與 setInterval 的差異#
// setTimeout 遞迴:上次回呼結束後至少 10ms 才重新排程
setTimeout(function repeatMe() {
/* 程式碼 */
setTimeout(repeatMe, 10);
}, 10);
// setInterval:每 10ms 嘗試執行,不管上次回呼何時結束
setInterval(() => {
/* 程式碼 */
}, 10);setTimeout遞迴版本在上次回呼完成後至少再等 10mssetInterval每 10ms 嘗試排程,不考慮上次執行的時間
處理計算密集的任務#
JavaScript 的單執行緒特性是複雜應用開發中的最大挑戰。長時間執行的程式碼會阻塞 UI 更新和使用者互動。
解決方案:用 timer 拆分長時間任務
const rowCount = 20000;
const divideInto = 4;
const chunkSize = rowCount / divideInto;
let iteration = 0;
setTimeout(function generateRows() {
const base = chunkSize * iteration;
for (let i = 0; i < chunkSize; i++) {
// 建立 DOM 節點...
}
iteration++;
if (iteration < divideInto) setTimeout(generateRows, 0); // 排程下一批
}, 0);使用
setTimeout(..., 0)並不表示回呼會在 0ms 後執行。這是告訴瀏覽器「盡快執行此回呼」,但不同於 microtask,瀏覽器可在執行之間進行頁面渲染,使 UI 保持回應。
13.3 處理事件#
事件在 DOM 中的傳播#
事件在 DOM 樹中的傳播分為兩個階段:
- Capturing phase(捕獲階段):事件從頂層元素向下傳遞到目標元素
- Bubbling phase(冒泡階段):事件從目標元素向上冒泡回頂層元素
透過 addEventListener 的第三個參數控制:
true:在 capturing 階段觸發 handlerfalse(預設):在 bubbling 階段觸發 handler

Figure 13.15: Capturing 階段事件向下傳遞到目標,Bubbling 階段事件向上冒泡

Figure 13.16: 事件先以 capturing 模式向下傳遞,再以 bubbling 模式向上冒泡
this 與 event.target 的差異:
this:指向 handler 被註冊的元素event.target:指向事件實際發生的元素
事件委派(Event Delegation)#
利用事件冒泡,在祖先元素上註冊一個 handler 來處理所有後代元素的事件,而非為每個元素各自註冊:
// 不好的做法:為每個 td 註冊 handler
const cells = document.querySelectorAll("td");
for (let n = 0; n < cells.length; n++) {
cells[n].addEventListener("click", function () {
this.style.backgroundColor = "yellow";
});
}
// 好的做法:在 table 上委派
const table = document.getElementById("someTable");
table.addEventListener("click", function (event) {
if (event.target.tagName.toLowerCase() === "td")
event.target.style.backgroundColor = "yellow";
});自訂事件(Custom Events)#
自訂事件是實現鬆耦合(loose coupling)的利器。觸發事件的程式碼不需要知道有哪些 handler 在監聽,handler 之間也完全獨立。
使用內建的 CustomEvent 建構子和 dispatchEvent 方法:
function triggerEvent(target, eventType, eventDetail) {
const event = new CustomEvent(eventType, {
detail: eventDetail,
});
target.dispatchEvent(event);
}
// 觸發自訂事件
triggerEvent(document, "ajax-start", { url: "my-url" });
// 監聽自訂事件
document.addEventListener("ajax-start", (e) => {
document.getElementById("whirlyThing").style.display = "inline-block";
assert(e.detail.url === "my-url", "We can pass in event data");
});自訂事件的解耦優勢:共用的 Ajax 程式碼不需要知道頁面端會如何處理事件,頁面端也不需要知道共用程式碼的實作細節。這使得程式碼更模組化、更容易維護和除錯。
13.4 本章重點#
- Event loop 任務分為 macrotasks(離散、獨立的瀏覽器動作)和 microtasks(應儘快執行的小任務,如 promise 回呼、DOM mutation)
- 單執行緒模型下,任務逐一處理且不可中斷。Event loop 至少有一個 macrotask 佇列和一個 microtask 佇列
- 計時器以
setTimeout/setInterval建立、以clearTimeout/clearInterval取消,延遲時間是最少等待時間而非精確時間 - 利用 timer 將計算密集的程式碼拆分為不阻塞瀏覽器的小區塊
- DOM 是階層式的元素樹,事件透過 capturing(向下)和 bubbling(向上)兩階段傳播
- 使用
event.target存取事件實際發生的元素;使用this參照 handler 被註冊的元素 - 使用
CustomEvent建構子和dispatchEvent方法建立與觸發自訂事件,實現程式碼解耦