最折磨人的不是「一定會壞」的 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 === 997user.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 之類延遲掩蓋競態,要找出根因。