瀏覽器任務排程深度解析
為何 `setTimeout(0)` 不可靠,以及如何選擇現代且穩健的替代方案
核心結論(1分鐘速覽)
本頁將深入探討瀏覽器對計時器的節流機制,並提供具體的程式碼實踐。以下是關鍵摘要,幫助您快速掌握核心概念,並了解為何需要更精確的任務排程 API。
- ▶瀏覽器為保護系統資源與使用者體驗,會對 setTimeout 進行節流(clamping),使其最小延遲通常不低於 4ms。
- ▶若需要在 microtasks 完成後「立即」執行下一個 task,應優先使用 scheduler.postTask。
- ▶在不支援
scheduler
的環境,重用單一 MessageChannel 是高效且能保證順序的 fallback 選擇。 - ▶為應對 Safari 等瀏覽器的實作差異,穩健的 library 應實作包含劣化保護與可配置的 fallback 策略。
問題解析:為何 `setTimeout` 會被節流?
瀏覽器的主要職責之一是作為「使用者代理」,在網頁功能與系統資源之間取得平衡。過於頻繁的計時器會持續喚醒 CPU,增加耗電,並可能導致介面卡頓。因此,瀏覽器透過「節流」機制來限制這種行為,保護使用者裝置與體驗。
Clamping 行為詳解
HTML 規格定義,在特定情境下,計時器的最小延遲會被「夾住(clamp)」在一個較高的值(通常是 4ms)。這個值在背景分頁、省電模式或低刷新率螢幕上會更高。這意味著 setTimeout(fn, 0)
並不保證零延遲。
事件循環與任務層級
要理解排程,必須知道簡化的事件循環順序:
- 1. 當前 Task:執行同步腳本。
- 2. Microtasks:執行所有
Promise.then
。 - 3. 下一個 Task:從任務佇列中取出一個執行,如 MessageChannel 的回呼。
- 4. (可能延遲) setTimeout 的回呼。
互動式範例:眼見為憑
點擊下方按鈕,觀察在您目前的瀏覽器中,不同排程 API 的實際執行順序。您會清楚地看到 setTimeout
的回呼通常會被延遲到最後執行。
排程順序示範
點擊按鈕開始...
解決方案:現代排程 API
為了給予開發者更精確的控制權,現代瀏覽器提供了更專門的 API。它們語意更清晰,且通常不會被節流,是 setTimeout(0)
的理想替代品。
首選:`scheduler.postTask`
這是最新、語意最清晰的 API,專為精細的任務排程設計。它允許指定優先級(如 'user-blocking'),並與瀏覽器的渲染週期整合得更好。
- ✓優點:語意明確,可設定優先級,不會被節流。
- ✗缺點:較新,部分舊版瀏覽器不支援。
最佳實踐:穩健的 `nextTask` 實作
在 library 或大型應用中,建立一個抽象的排程函式是最佳實踐。以下是一個可以直接使用的 nextTask
輔助函式,它會自動選擇最佳可用 API,並包含了針對 Safari 等特殊情況的保護機制。
// nextTask(fn, ...args) -> 安排 fn 在當前 microtasks 後立即執行。
const nextTask = (() => {
// 1) 優先使用 scheduler.postTask
if (typeof scheduler !== 'undefined' && scheduler.postTask) {
return (fn, ...args) => {
const handle = { cancelled: false };
scheduler.postTask(() => { if (!handle.cancelled) fn(...args); }, { priority: 'user-visible' });
return { cancel: () => { handle.cancelled = true; } };
};
}
// 2) MessageChannel + queue 作為 fallback
try {
const channel = new MessageChannel();
const q = [];
channel.port1.onmessage = () => {
const item = q.shift();
if (!item) return;
const [fn, args] = item;
try { fn(...args); } catch (e) { setTimeout(() => { throw e; }, 0); }
};
return (fn, ...args) => {
// 安全閥:佇列過長時退回 setTimeout,避免極端情況下卡死
if (q.length > 10000) {
const t = setTimeout(fn, 0, ...args);
return { cancel: () => clearTimeout(t) };
}
q.push([fn, args]);
channel.port2.postMessage(null);
// ... (此處可加入更精確的 cancel 邏輯)
};
} catch (e) {
// MessageChannel 不可用時(如某些舊環境),繼續往下
}
// 3) 最後的保險:setTimeout(0)
return (fn, ...args) => {
const t = setTimeout(fn, 0, ...args);
return { cancel: () => clearTimeout(t) };
};
})();