最折磨人的不是「一定會壞」的 bug,而是「有時會壞」的 bug:重新整理就好了、在你電腦上不會、加了中斷點就消失。這類間歇性(intermittent)與一觀察就變樣的 Heisenbug,需要的不是更用力地重現,而是不同的策略。
為什麼難重現#
間歇性 bug 通常源於「時序」與「環境」這兩類不確定因素:
| 來源 | 前端常見例子 |
|---|---|
| 競態條件 | 兩個非同步請求回來的順序不固定;事件監聽器重複綁定 |
| 時序敏感 | 動畫與資料載入賽跑;setTimeout 假設了某個耗時 |
| 殘留狀態 | localStorage/快取殘留;前一次操作沒清乾淨 |
| 環境差異 | 慢網路才出現;特定瀏覽器版本;低階裝置 CPU 較慢 |
「不可重現」不等於「不存在」。它只是說你還沒找到觸發它的那個隱藏變因。目標就是把那個變因揪出來,並讓它「穩定地」發生。
策略一:放大觸發條件#
與其等它偶然發生,不如刻意製造它需要的極端條件,逼它「穩定壞」。
- 放大時序壓力:用 DevTools 的 Network throttling 模擬 3G/慢速,讓「快網路藏住的競態」現形;用 Performance panel 的 CPU throttling 模擬低階裝置。
- 反覆執行:把可疑操作包進迴圈跑上千次,或快速連點、快速切換路由,把低機率事件的次數堆上去。
- 製造殘留:故意不清快取、保留舊的
localStorage,重現「第二次才壞」的情境。
// 反覆觸發以放大競態的出現機率
for (let i = 0; i < 1000; i++) {
fireTheSuspiciousAction();
}策略二:用「不改變時序」的觀察手段#
既然中斷點會改變時序、讓 Heisenbug 消失,就改用對執行時序干擾最小的觀察方式。
- Logpoint:不暫停、不動原始碼,避免中斷點凍結時序而讓 bug 隱形。
- 條件式中斷點:只在
i === 997、user.id === null等特定條件才停,免得在迴圈裡停上千次。 - 命中次數:只在第 N 次命中才中斷,精準鎖定出問題的那一次迭代。
- Event Listener 中斷點:懷疑是事件重複綁定或非預期觸發時,直接對事件類型下斷。
策略三:留下證據,等它再來#
如果就是無法當場重現,那就佈署「陷阱」,等它在真實環境再次發生時自動帶回情報。
- 在關鍵路徑加上結構化、可開關的防禦性日誌(進入/離開、狀態變化、關鍵資料)。
- 全域捕捉未處理的錯誤與 Promise rejection,連同環境元資訊一起回報:
window.addEventListener("error", (e) => report(e.error));
window.addEventListener("unhandledrejection", (e) => report(e.reason));
function report(err) {
// 連同脈絡一起送回監控系統
sendToMonitoring({
message: String(err),
ua: navigator.userAgent,
url: location.href,
time: Date.now(),
});
}競態類 bug 修好後,務必想清楚「為什麼之前會錯」,而不是靠「加個
setTimeout延遲一下」把它壓下去。用延遲掩蓋競態,只是把 bug 變得更罕見、更難抓——它總會在更慢的機器上回來。
小結#
- 間歇性 bug 多源於時序、殘留狀態、環境差異;不可重現只是變因未明。
- 放大觸發條件(節流、反覆執行、製造殘留),逼它穩定壞。
- 用 Logpoint/條件中斷點等低干擾手段觀察,避免 Heisenbug 隱形。
- 真的無法當場重現,就佈署日誌與全域錯誤捕捉,等它帶情報回來。
- 別用
setTimeout之類延遲掩蓋競態,要找出根因。