主題: Web platform

瀏覽器 Scheduler:先分清工作優先序,再決定要不要排隊

Scheduler 是給瀏覽器的工作優先序提示,不是背景執行緒。先保留短小同步 UI,量到阻塞再用 postTask、yield 或 Web Worker。

動態迷因(展開/收合)
看到 Scheduler 不必急著把每個函式排隊;先分清這段工作是現在就要完成,還是可以讓瀏覽器稍後處理。 · 來源:GIPHY

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()

動態迷因(展開/收合)
工作不是愈早執行愈好:先問它是否真的擋住使用者,再選同步、Scheduler 或 Worker。 · 來源:GIPHY

切得開,才值得 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 狀態。


外部參考資料