主題: Web platform
瀏覽器 Scheduler:先分清工作優先序,再決定要不要排隊
Scheduler 是給瀏覽器的工作優先序提示,不是背景執行緒。先保留短小同步 UI,量到阻塞再用 postTask、yield 或 Web Worker。
動態迷因(展開/收合)
scheduler 這個名字很容易讓人誤會成:「我終於能接管瀏覽器的排程器了。」其實不是。
它是一組讓網頁對瀏覽器說明工作重要性的 API。瀏覽器仍要處理輸入、動畫、繪製、網路與其他 task;你只能表達「這件事比較急」或「這件事可以晚點」。這個界線很重要:Scheduler 不是另一條執行緒,也不會替錯誤的工作切分自動變快。
我的起點很保守:短小而立即可見的 UI 先同步完成;真的量到主執行緒被 long task 卡住,才開始排程。
先理解它在排什麼
JavaScript 在頁面的主執行緒上通常是 run-to-completion:一段同步程式還沒結束時,瀏覽器無法先插進來處理下一次輸入或畫下一幀。這就是按鈕明明點了,卻像沒有反應的常見原因。
Prioritized Task Scheduling API 提供三個很少、但夠用的 priority:
| Priority | 適合的工作 | 不代表什麼 |
|---|---|---|
user-blocking |
剛輸入搜尋字後、需要立刻反映在畫面上的結果 | 不保證搶在所有瀏覽器工作前完成 |
user-visible |
使用者很快會看到,但不會卡住目前操作的更新 | 不等於可以無限延後 |
background |
telemetry、非關鍵初始化、非可視區整理 | 不會自動移到背景執行緒 |
scheduler.postTask() 未指定 priority 時預設為 user-visible。不過規格刻意讓瀏覽器保有和其他 event-loop task 交錯的裁量;把 priority 當作語意清楚的提示,不要拿它寫成依賴精確順序的程式。
postTask() 是排新工作;yield() 是暫停後再回來
兩個 API 很像,實際角色不同:
scheduler.postTask(callback, options):把一個獨立 callback 交給瀏覽器稍後執行。await scheduler.yield():目前的async函式先讓出主執行緒,之後從同一個位置繼續。
搜尋字改變是 postTask() 的好例子。較新的輸入會取消舊工作,避免慢一點完成的舊結果蓋過新結果:
let activeSearch;
function scheduleSearch(query) {
activeSearch?.abort();
const controller = new AbortController();
activeSearch = controller;
const render = () => {
if (controller.signal.aborted) return;
const matches = products.filter((product) => product.name.includes(query));
if (!controller.signal.aborted) renderResults(matches);
};
if (typeof globalThis.scheduler?.postTask === "function") {
void globalThis.scheduler.postTask(render, {
priority: "user-blocking",
signal: controller.signal,
}).catch((error) => {
if (error.name !== "AbortError") console.error(error);
});
} else {
setTimeout(render, 0);
}
}
這段 fallback 不會複製 Scheduler 的 priority 語意;它只保證搜尋仍能正確完成,而且不會把過期結果畫回去。AbortController 也不能強制中斷已跑到一半的同步迴圈;它最可靠的是阻止尚未開始的 task,或取消支援 signal 的 API,例如 fetch()。
動態迷因(展開/收合)
切得開,才值得 yield()
yield() 適合仍必須留在主執行緒、但可以分段的工作,例如逐批處理目前已在記憶體中的資料。它讓瀏覽器有機會處理輸入與繪製後,再跑下一段。
不要每處理一筆資料就 yield。裝置快慢、每筆資料的成本都不同;以短時間預算切批比較穩。以下範例在每段工作接近 8ms 時讓出執行權,並對不支援的瀏覽器保留可用 fallback:
function yieldToBrowser() {
if (typeof globalThis.scheduler?.yield === "function") {
return globalThis.scheduler.yield();
}
return new Promise((resolve) => setTimeout(resolve, 0));
}
async function filterProducts(products, query) {
const matches = [];
let sliceStartedAt = performance.now();
for (const product of products) {
if (product.name.includes(query)) matches.push(product);
if (performance.now() - sliceStartedAt >= 8) {
await yieldToBrowser();
sliceStartedAt = performance.now();
}
}
return matches;
}
8ms 只是範例,不是通用門檻。yield 會把原本一段工作拆成多個 task,也會增加控制流程複雜度;先用瀏覽器的 Performance 面板確認 long task,再調整切分方式。
需要另一條執行緒時,直接用 Worker
Scheduler 排的仍是主執行緒工作。影像壓縮、大型檔案解析、長時間加密或數秒 CPU 運算,即使分批仍可能拖慢互動;這時該使用 Web Worker。
Worker 的代價是不能直接操作 DOM,資料要以 message 傳遞或 transfer;換來的是計算能離開主執行緒。這比把一個昂貴演算法標成 user-blocking 更誠實。
| 工作 | 優先選擇 |
|---|---|
| 設定按鈕 disabled、顯示 spinner | 同步完成 |
| 與目前輸入直接相關的獨立更新 | postTask() + user-blocking |
| 可切分、但必須保留在主執行緒的工作 | yield() |
| 非關鍵記錄與整理 | postTask() + background |
| 大型 CPU 計算 | Web Worker |
把 Scheduler 當成量測後的工具
發布前我會看三件事:輸入後的視覺回饋是否更快、Performance trace 中的 long task 是否減少,以及 INP 是否改善。原始碼多了 priority 名稱、或 timeout 變少,都不是使用者體驗變好的證據。
Scheduler 最好的用法並不炫技:讓短小互動繼續簡單,把可延後的工作明確延後,並在主執行緒真的扛不住時把重活移走。先把工作分類,排程才不會變成另一層難以理解的 async 狀態。