瀏覽器任務排程深度解析

為何 `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. 1. 當前 Task:執行同步腳本。
  2. 2. Microtasks執行所有 Promise.then
  3. 3. 下一個 Task:從任務佇列中取出一個執行,如 MessageChannel 的回呼。
  4. 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) };
  };
})();