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

2024/01/08

前端輪詢(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 PollingLong PollingWebSocket
連線方式每次請求建立新連線每次請求保持到有資料持久雙向連線
即時性低(受輪詢間隔限制)中(伺服器有資料即推送)高(真正的即時)
伺服器負載高(大量無效請求)中(等待期間佔用連線)低(持久連線,推播效率高)
實作複雜度高(需要 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 完整解析