前端 Polling 輪詢技術:JavaScript 實作完整教學

2024/01/08 2026/05/16
前端 Polling 輪詢技術:JavaScript 實作完整教學

前端輪詢(Frontend Polling) 是一種讓客戶端定期向伺服器詢問是否有新資料的通訊方式。雖然比不上 WebSocket 的即時性,但 Polling 實作簡單、不需要特殊的伺服器設定,在許多實際應用場景中仍然是最務實的選擇。

什麼是 Polling?

Polling(輪詢) 的核心概念很直覺:由客戶端主動、定期地向伺服器發送請求,詢問是否有新的資料或狀態更新。

試想你在等一個快遞。如果你每隔 10 分鐘就跑去門口看一次,這就是 Polling 的概念——主動、定期地去「輪詢」狀態。

Polling 的常見應用場景

Polling 在以下情境中特別適合:

  • 訂單狀態追蹤:電商平台的「訂單處理中 → 已出貨 → 配送中」狀態更新,不需要極低延遲,每隔 30 秒查詢一次完全夠用。
  • 任務進度條:後端執行耗時的影片轉檔、報表產生任務,前端每隔幾秒輪詢進度百分比。
  • 通知系統:不需要即時推播的通知(例如「有新訊息」提示),定時輪詢即可,實作成本遠低於 WebSocket。
  • 儀表板資料更新:監控面板每隔 1 分鐘自動刷新一次數據。
  • 老舊環境整合:當後端 API 不支援 WebSocket 或 SSE,或需要相容某些代理伺服器(Proxy)限制時,Polling 是最相容的方案。

Short Polling(短輪詢)

Short Polling(短輪詢) 是最基本的實作方式:客戶端每隔固定的時間間隔就發送一次請求,不論伺服器有沒有新資料,都會立即回傳(即使回傳「沒有新資料」)。

原理

客戶端                    伺服器
  │── 請求 ────────────────→│
  │←─ 回應(無新資料)──────│  t=0s
  │
  │ (等待 3 秒)
  │
  │── 請求 ────────────────→│
  │←─ 回應(有新資料!)────│  t=3s
  │
  │ (等待 3 秒)
  │
  │── 請求 ────────────────→│  t=6s
  ...

使用 setInterval 實作

setInterval 是最直接的實作方式,每隔固定時間執行一次:

// 使用 setInterval 實作短輪詢
const POLL_INTERVAL = 3000; // 每 3 秒輪詢一次

const intervalId = setInterval(async () => {
  try {
    const response = await fetch('/api/order-status?orderId=123');
    const data = await response.json();

    console.log('訂單狀態:', data.status);

    // 如果任務完成,停止輪詢
    if (data.status === 'completed' || data.status === 'failed') {
      clearInterval(intervalId);
      console.log('輪詢結束,最終狀態:', data.status);
    }
  } catch (error) {
    console.error('輪詢請求失敗:', error.message);
  }
}, POLL_INTERVAL);

// 如需手動停止輪詢
// clearInterval(intervalId);

使用 setTimeout 遞迴實作(推薦)

雖然 setInterval 看起來更簡單,但實務上更推薦用 遞迴 setTimeout 的方式。原因是:setInterval 是以固定間隔觸發請求,不管上一次請求是否已完成。如果網路慢或伺服器回應慢,可能造成多個請求同時在飛。

用遞迴 setTimeout 則是在收到回應後才設定下一次輪詢,保證請求之間的間隔,不會堆積。

// 使用遞迴 setTimeout 實作短輪詢(推薦)
let isPolling = false;
let timeoutId = null;

async function poll() {
  if (!isPolling) return; // 允許外部控制停止

  try {
    const response = await fetch('/api/task-progress?taskId=456');

    if (!response.ok) {
      throw new Error(`伺服器回應錯誤:${response.status}`);
    }

    const data = await response.json();
    updateProgressBar(data.progress); // 更新 UI

    // 任務未完成,繼續輪詢
    if (data.progress < 100) {
      timeoutId = setTimeout(poll, 3000); // 收到回應後,3 秒後再次輪詢
    } else {
      console.log('任務完成!');
      isPolling = false;
    }
  } catch (error) {
    console.error('輪詢失敗:', error.message);
    // 出錯後,5 秒後重試
    timeoutId = setTimeout(poll, 5000);
  }
}

// 開始輪詢
function startPolling() {
  isPolling = true;
  poll();
}

// 停止輪詢
function stopPolling() {
  isPolling = false;
  if (timeoutId) clearTimeout(timeoutId);
}

// 啟動
startPolling();

Short Polling 優缺點

優點:

  • 實作簡單,任何支援 HTTP 的環境都適用。
  • 不需要維持長時間連線,對伺服器資源佔用較少(每次請求完成後連線即關閉)。
  • 容易除錯(每次請求都是獨立的 HTTP 請求,可在 Network tab 查看)。

缺點:

  • 資料延遲等於輪詢間隔(間隔 3 秒,最壞情況延遲 3 秒)。
  • 即使沒有新資料,也會持續發送請求,造成不必要的網路流量和伺服器負載。
  • 高頻率輪詢 + 大量使用者 = 伺服器壓力倍增。

Long Polling(長輪詢)

Long Polling(長輪詢) 是對 Short Polling 的改良:客戶端發出請求後,如果伺服器沒有新資料,它不會立即回應,而是保持連線開啟,直到有新資料時才回傳。客戶端收到回應後立刻發起下一次請求,如此循環。

原理

客戶端                    伺服器
  │── 請求 ────────────────→│
  │                         │(伺服器等待,保持連線)
  │                         │(... 等了 8 秒 ...)
  │←─ 回應(有新資料!)────│  t=8s
  │── 立即再次請求 ─────────→│
  │                         │(繼續等待下一筆資料)
  │←─ 回應(有新資料!)────│  t=12s
  ...

使用 async/await + fetch 實作

// Long Polling 實作
let isLongPolling = false;

async function longPoll() {
  while (isLongPolling) {
    try {
      // 使用 AbortController 設定超時,避免永遠等待
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), 30000); // 30 秒超時

      const response = await fetch('/api/notifications/long-poll', {
        signal: controller.signal,
      });
      clearTimeout(timeoutId);

      if (!response.ok) {
        throw new Error(`伺服器回應錯誤:${response.status}`);
      }

      const data = await response.json();

      if (data.type === 'NEW_MESSAGE') {
        displayNotification(data.message); // 處理新訊息
      }

      // 收到回應後,立刻發起下一次 Long Poll(幾乎沒有間隔)

    } catch (error) {
      if (error.name === 'AbortError') {
        // 請求超時(伺服器 30 秒內沒有新資料),這是正常情況
        console.log('Long Poll 超時,立即重新發起請求...');
      } else {
        // 真正的錯誤,等待一段時間再重試
        console.error('Long Poll 失敗:', error.message);
        await sleep(5000); // 等 5 秒後再重試
      }
    }
  }
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

// 開始 Long Polling
function startLongPolling() {
  isLongPolling = true;
  longPoll();
}

// 停止 Long Polling
function stopLongPolling() {
  isLongPolling = false;
}

Long Polling 優缺點

優點:

  • 即時性遠優於 Short Polling(伺服器有資料時幾乎立即推送)。
  • 相比 Short Polling,減少了大量無效的請求(沒有資料時不回應)。
  • 不需要特殊的伺服器協定,標準 HTTP 即可。

缺點:

  • 每個連線在等待期間會佔用伺服器的一個執行緒/資源(視後端框架而定)。
  • 實作比 Short Polling 複雜(需要處理超時、重連邏輯)。
  • 對 HTTP 代理和防火牆的相容性問題(長時間連線可能被中斷)。

Short Polling vs Long Polling vs WebSocket

面向 Short Polling Long Polling WebSocket
連線方式 每次請求建立新連線 每次請求保持到有資料 持久雙向連線
即時性 低(受輪詢間隔限制) 中(伺服器有資料即推送) 高(真正的即時)
伺服器負載 高(大量無效請求) 中(等待期間佔用連線) 低(持久連線,推播效率高)
實作複雜度 高(需要 WebSocket 伺服器)
瀏覽器相容性 最好 現代瀏覽器皆支援(IE10+)
防火牆/代理 無問題 可能有問題 可能有問題(需 WSS)
適用場景 訂單狀態、進度條、不頻繁更新 通知系統、聊天室(小規模) 聊天室、線上遊戲、即時協作

選擇建議:

  • 更新頻率低(> 30 秒一次)→ Short Polling
  • 需要較即時但不想建設 WebSocket 基礎設施 → Long Polling
  • 真正需要即時雙向通訊(聊天、遊戲、協作工具)→ WebSocket

實戰:實作帶 Backoff 的 Polling

在生產環境中,純粹的固定間隔輪詢有一個潛在問題:當伺服器出現問題時,大量客戶端同時重試,可能讓已經過載的伺服器雪上加霜(雪崩效應)。

解決方法是結合 指數退避(Exponential Backoff):每次請求失敗後,等待時間指數增長,而不是固定間隔立即重試。

// 帶指數退避的 Polling 實作(完整可執行程式碼)

const POLL_CONFIG = {
  url: '/api/task-status?taskId=789',
  interval: 3000,       // 正常輪詢間隔(毫秒)
  maxRetries: 5,        // 最大重試次數
  baseDelay: 1000,      // 退避基礎延遲(毫秒)
  maxDelay: 30000,      // 最大退避延遲(毫秒)
};

// 計算指數退避延遲(加入 Jitter 隨機性,避免所有客戶端同時重試)
function calculateBackoffDelay(retryCount, baseDelay, maxDelay) {
  const exponentialDelay = baseDelay * Math.pow(2, retryCount);
  // Full Jitter:在 0 到指數延遲之間隨機取一個值
  const jitter = Math.random() * exponentialDelay;
  return Math.min(jitter, maxDelay);
}

function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}

async function pollWithBackoff(config) {
  let retryCount = 0;
  let isRunning = true;

  // 回傳停止函式,供外部呼叫
  const stop = () => { isRunning = false; };

  while (isRunning) {
    try {
      const response = await fetch(config.url);

      if (!response.ok) {
        // 5xx 或 429 才退避重試,4xx 通常是客戶端問題不重試
        if (response.status >= 500 || response.status === 429) {
          throw new Error(`伺服器錯誤:${response.status}`);
        }
        console.error(`請求失敗(${response.status}),停止輪詢`);
        break;
      }

      const data = await response.json();
      retryCount = 0; // 成功後重設重試計數

      // 更新 UI
      console.log('取得資料:', data);
      updateUI(data);

      // 任務完成,停止輪詢
      if (data.status === 'done' || data.status === 'error') {
        console.log('任務結束,狀態:', data.status);
        isRunning = false;
        break;
      }

      // 正常輪詢間隔
      await sleep(config.interval);

    } catch (error) {
      retryCount++;
      console.error(`輪詢第 ${retryCount} 次失敗:`, error.message);

      if (retryCount >= config.maxRetries) {
        console.error('已達最大重試次數,停止輪詢');
        isRunning = false;
        break;
      }

      // 計算退避延遲並等待
      const delay = calculateBackoffDelay(
        retryCount,
        config.baseDelay,
        config.maxDelay
      );
      console.log(`將在 ${Math.round(delay / 1000)} 秒後重試...`);
      await sleep(delay);
    }
  }

  return stop;
}

// 更新 UI 的函式(模擬)
function updateUI(data) {
  const progressEl = document.querySelector('#progress');
  if (progressEl) progressEl.textContent = `進度:${data.progress}%`;
}

// 啟動輪詢
pollWithBackoff(POLL_CONFIG);

關於退避策略的完整說明,請參考:Backoff 退避策略完整教學


最佳實踐

在生產環境中實作 Polling 時,以下幾點能大幅提升可靠性和效能:

1. 設定最大重試次數

永遠要有終止條件,避免無限輪詢消耗資源:

const MAX_RETRIES = 10;
let retryCount = 0;

async function safePoll() {
  if (retryCount >= MAX_RETRIES) {
    console.error('已達最大重試次數,請手動重整頁面');
    showErrorMessage('連線失敗,請稍後再試');
    return;
  }
  // ... 輪詢邏輯
}

2. 使用 AbortController 取消請求

當使用者離開頁面或執行其他操作需要取消輪詢時,應該同時取消正在進行中的 HTTP 請求:

let abortController = null;

async function pollWithAbort() {
  // 取消上一次還未完成的請求
  if (abortController) abortController.abort();
  abortController = new AbortController();

  try {
    const response = await fetch('/api/data', {
      signal: abortController.signal,
    });
    const data = await response.json();
    processData(data);
  } catch (error) {
    if (error.name === 'AbortError') {
      console.log('請求已被取消');
      return; // 不繼續重試
    }
    // 其他錯誤才處理
    handleError(error);
  }
}

// 在頁面卸載時清理
window.addEventListener('beforeunload', () => {
  if (abortController) abortController.abort();
});

3. 利用 Page Visibility API 在頁面隱藏時暫停輪詢

當使用者切換到其他頁籤時,繼續輪詢完全是浪費資源。Page Visibility API 讓我們可以在頁面被隱藏時暫停,顯示時再繼續:

let pollingTimeoutId = null;

function startPolling() {
  pollingTimeoutId = setTimeout(doPoll, 5000);
}

function stopPolling() {
  if (pollingTimeoutId) {
    clearTimeout(pollingTimeoutId);
    pollingTimeoutId = null;
  }
}

// 監聽頁面可見性變化
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    // 頁面被隱藏(切換頁籤)→ 暫停輪詢
    console.log('頁面隱藏,暫停輪詢');
    stopPolling();
  } else {
    // 頁面重新可見 → 恢復輪詢
    console.log('頁面顯示,恢復輪詢');
    startPolling();
  }
});

常見問題(FAQ)

Q1:什麼情況下應該選擇 Polling,而不是 WebSocket?

選擇 Polling 的時機:

  1. 更新頻率不高:資料每 30 秒才更新一次,使用 WebSocket 維持持久連線完全不划算。
  2. 基礎設施限制:某些公司的防火牆或 API 閘道不支援 WebSocket 升級,Polling 是最保險的選擇。
  3. 後端是 RESTful API:現有的後端就是純 REST API,不想為了推播而引入額外的 WebSocket 服務。
  4. 開發時程緊迫:Polling 是最快能實作完成的方案,可以先上線,之後有需要再優化成 WebSocket。
  5. Serverless 環境:AWS Lambda 等 Serverless 函式有執行時間限制,不適合維持長時間的 WebSocket 連線。

Q2:輪詢的最佳間隔時間是多少?

沒有放之四海皆準的答案,需要根據以下因素決定:

考量因素 建議
資料更新頻率 間隔不應短於資料本身的更新週期
使用者對延遲的容忍度 訂單狀態可容忍 30 秒,但進度條應 2-5 秒
伺服器的承載能力 估算 每秒請求數 = 使用者數 / 輪詢間隔(秒)
API 的費用 使用收費 API 時,頻繁輪詢會顯著增加成本

一般建議:

  • 進度條、即時資料:2-5 秒
  • 狀態更新(訂單、任務):10-30 秒
  • 儀表板、統計數據:60 秒以上

當使用者數量增加時,應考慮結合 Backoff 退避策略 來動態調整輪詢間隔,或在尖峰時段增加間隔。

想了解 Node.js 後端如何搭建支援 Polling 的 API,可以參考:NVM vs NPM vs Node.js 完整解析

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。