前端 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 的時機:
- 更新頻率不高:資料每 30 秒才更新一次,使用 WebSocket 維持持久連線完全不划算。
- 基礎設施限制:某些公司的防火牆或 API 閘道不支援 WebSocket 升級,Polling 是最保險的選擇。
- 後端是 RESTful API:現有的後端就是純 REST API,不想為了推播而引入額外的 WebSocket 服務。
- 開發時程緊迫:Polling 是最快能實作完成的方案,可以先上線,之後有需要再優化成 WebSocket。
- 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 完整解析