列車即時定位功能上線後的第一天,我在手機上反覆測試時注意到一個不太對勁的現象:偶爾會有某列車的圖示在畫面上突然跳回前一站的位置,過了幾秒又跳回正確位置。起初我以為是後端資料延遲的問題,但用瀏覽器的 Network 面板仔細觀察後才發現,問題的根源並不在後端,而是出在前端的輪詢機制本身。
當時我使用的是最直覺的 setInterval 來定時拉取列車位置資料。邏輯很簡單:每隔 5 秒發出一次 API 請求,拿到資料後更新介面。但這個寫法有一個隱含的假設——每次請求都會在下一次觸發之前完成。在辦公室的穩定 Wi-Fi 環境下,這個假設確實成立。然而當使用者處於行動網路環境中(例如在地鐵隧道裡訊號不穩),單次請求的回應時間可能遠超 5 秒。
結果就是:第一次請求還在等待回應的時候,setInterval 已經觸發了第二次、甚至第三次請求。當這些請求最終以不同的順序返回時,較早發出但較晚回應的過時資料會覆蓋掉較新的正確資料。這就是典型的競態條件 (Race Condition)。在 Chrome DevTools 的 Network 面板中,我清楚地看到了多個並行的 fetch 請求,它們的回應順序完全混亂——第三次請求的回應先到了,然後才收到第二次的回應,接著介面被第二次的舊資料刷了回去。
為了確認這就是問題所在,我在瀏覽器的開發工具中開啟了 Network Throttling,將網路模擬為 Slow 3G 模式。果然,問題立刻高頻率復現。每一輪 setInterval 觸發時,前一輪的請求尚未完成,導致畫面上的列車圖示像打了彈簧一樣來回跳動。我在 Console 裡印出每次回應攜帶的時間戳後,確認了這些回應確實發生了亂序交錯。
另一個衍生問題也值得記錄:由於 setInterval 不會因為錯誤而停止,當網路完全斷線時,系統會持續累積大量失敗的請求。這些請求在底層引發了大量未處理的 Promise rejection,瀏覽器控制台充斥著紅色的錯誤訊息,在極端情況下甚至會影響頁面的回應速度。
解決方案在概念上非常簡單——確保上一次請求完成之後,才排程下一次請求。我將 setInterval 替換為基於 async/await 搭配遞迴 setTimeout 的串聯模式。這種寫法保證了在任何時間點,系統中最多只有一個進行中的請求,從根本上杜絕了競態條件的可能性。同時,我在 try...catch 區塊中加入了重試延遲的機制,當請求失敗時不會立即重試,而是等待一段較長的間隔後再嘗試,避免對伺服器產生不必要的壓力。
// 使用 setInterval 的固定間隔輪詢
function startPolling() {
setInterval(function() {
fetch('/api/train-positions')
.then(res => res.json())
.then(data => {
updateTrainPositions(data);
});
}, 5000);
}
startPolling();
// 使用遞迴 setTimeout 的序列化輪詢
async function pollTrainData() {
try {
const res = await fetch('/api/train-positions');
const data = await res.json();
updateTrainPositions(data);
// 成功後正常間隔排程下一次
setTimeout(pollTrainData, 5000);
} catch (err) {
console.warn('資料拉取失敗,延遲重試', err);
// 失敗時延長等待時間再重試
setTimeout(pollTrainData, 15000);
}
}
pollTrainData();
除了核心的輪詢修改之外,我也利用了 navigator.onLine 屬性與 online/offline 事件來監聽網路狀態。當偵測到裝置斷線時,輪詢會自動暫停並在介面上顯示一個不擾人的提示條,告知使用者目前處於離線狀態。一旦網路恢復連線,系統會自動重新啟動輪詢循環並立即拉取最新資料,讓使用者無需手動重新整理頁面。
這次重構帶來的改變是立竿見影的。修改上線後,列車位置的顯示變得穩定可靠。即使使用者處於訊號不佳的行動環境中,列車圖示也不會再出現異常的位置回跳。取而代之的是一個平穩、可預期的更新節奏:系統在每次成功取得資料後才開始計算下一次的更新間隔,資料永遠是按照時序正確呈現的。此外,當網路恢復連線時那個自動同步的體驗也讓整體產品顯得更加成熟可靠。對使用者而言,他們可能不會察覺到底層發生了什麼變化,但他們會明確感受到「這個應用變得更穩了」——而這正是我追求的效果。