在為部落格新增音訊播放器功能後不久,我遇到了一個看似簡單卻頗具啟發性的 bug:播放按鈕點擊後毫無反應。這篇文章記錄完整的偵錯過程,以及一個容易被忽略的 JavaScript 陷阱。
問題現象
使用者回報:「Claude nerf 文章的音訊播放器點擊播放沒有反應」。
初步觀察:
- 播放器 UI 正常顯示
- 按鈕可以點擊,但沒有任何視覺或聽覺反饋
- 沒有明顯的錯誤訊息彈出
偵錯過程
第一步:使用 Chrome MCP 開啟網站
我使用 Chrome MCP(Model Context Protocol)直接在瀏覽器中進行偵錯:
# 開啟目標頁面
chrome_navigate("https://b-log.to/ai-analysis/claude-nerf-analysis/")
第二步:檢查 Console 錯誤
透過 chrome_console 工具擷取錯誤訊息,發現兩個關鍵線索:
錯誤 1:404 網路請求失敗
Failed to load resource: the server responded with a status of 404 ()
URL: /content/audio/Claude_AI_變笨還是被Nerf?...陷阱-part0.m4a
這個錯誤是誤報——系統嘗試載入分割檔案 -part0.m4a,但實際上只有完整的 .m4a 檔案存在。播放清單偵測邏輯正常返回原始檔案,這不是主要問題。
錯誤 2:未捕獲的 Promise 錯誤
Uncaught (in promise)
URL: https://b-log.to/assets/main.js
Line: 243, Column: 14
這才是真正的罪魁禍首!
第三步:深入測試 Audio 元素
我注入測試腳本直接呼叫 audio.play():
const audio = document.querySelector('.audio-player audio');
try {
await audio.play();
console.log('✅ Play succeeded!');
} catch (error) {
console.error('❌ Play failed:', error);
}
結果揭曉:
❌ Play failed: NotAllowedError: play() failed because
the user didn't interact with the document first.
Error name: NotAllowedError
根本原因分析
問題出在 assets/main.js 的第 243 行:
// 播放/暫停
playPauseBtn.addEventListener('click', () => {
if (audio.paused) {
audio.play(); // ❌ 沒有處理 Promise 錯誤
} else {
audio.pause();
}
});
為什麼會失敗?
現代瀏覽器的 HTMLMediaElement.play() 方法返回一個 Promise,用於處理以下情況:
- 自動播放政策限制:未經使用者互動前不允許播放
- 媒體載入失敗:檔案不存在或格式不支援
- 權限問題:使用者拒絕媒體播放權限
在我的案例中,雖然使用者確實點擊了按鈕(符合互動要求),但 Promise 仍然可能因為其他原因被拒絕。未處理的 Promise rejection 會導致靜默失敗——程式碼不會崩潰,但功能完全無法運作。
解決方案
修復非常簡單,為所有 audio.play() 調用加上 .catch() 錯誤處理:
修復 1:播放/暫停按鈕(main.js:243)
playPauseBtn.addEventListener('click', () => {
if (audio.paused) {
audio.play().catch(error => {
console.error('播放失敗:', error);
});
} else {
audio.pause();
}
});
修復 2:播放清單自動播放(main.js:445)
// 自動播放下一個片段
audio.play().catch(error => {
console.error('自動播放失敗:', error);
});
技術要點總結
1. Promise 必須處理錯誤
JavaScript 的 Promise 有三種狀態:pending、fulfilled、rejected。任何返回 Promise 的方法都應該處理 rejection,否則會導致:
- 未捕獲的 Promise 錯誤(Uncaught in promise)
- 靜默失敗(功能不運作但沒有明顯錯誤)
- 難以偵錯(沒有堆疊追蹤)
2. 瀏覽器自動播放政策
Chrome、Firefox、Safari 等現代瀏覽器都實施嚴格的自動播放政策:
- ✅ 允許:使用者互動後的播放(點擊、觸控、鍵盤)
- ❌ 禁止:頁面載入時自動播放
- ⚠️ 例外:靜音影片或已授權的網站
即使符合政策,play() 仍可能因為其他原因失敗,因此錯誤處理不是可選的,而是必須的。
3. 偵錯工具的價值
這次偵錯過程中,Chrome MCP 發揮了關鍵作用:
- 遠端偵錯:無需開啟本機 DevTools
- 腳本注入:即時測試假設
- Console 擷取:完整的錯誤記錄
這類工具對於在生產環境中快速定位問題非常有幫助。
經驗教訓
所有異步操作都應該有錯誤處理
- 不只是
audio.play(),任何返回 Promise 的方法都一樣 - 使用
.catch()或try-catch(async/await)
- 不只是
靜默失敗是最糟的失敗
- 至少在 console 輸出錯誤訊息
- 考慮使用錯誤追蹤服務(Sentry、Rollbar)
測試真實使用者場景
- 本機開發時可能沒有自動播放限制
- 生產環境的政策可能更嚴格
善用偵錯工具
- Chrome DevTools、MCP、遠端偵錯
- 不要只依賴
console.log
修復驗證
提交修復後(commit 51ce01e),透過以下步驟驗證:
- 清除快取並重新載入頁面
- 點擊播放按鈕
- 確認音訊正常播放
- 檢查 console 無錯誤訊息
✅ 播放器現已完全正常運作!
後續修復:自動略過內容問題(2025-10-19)
在 Promise 錯誤處理修復後,又發現了一個更隱蔽的問題:播放時會自動略過一些內容,但進度條顯示正常,原始檔案也正常。這個問題源於多個時序競爭條件(race condition)和事件處理器衝突。
發現的核心問題
經過深度分析(commit dde7489),找出了 5 個相互關聯的問題:
1. 播放進度還原時機錯誤(高嚴重性)
// ❌ 錯誤:在音訊元數據載入前就設定 currentTime
const savedTime = localStorage.getItem(storageKey + '-time');
if (savedTime && parseFloat(savedTime) > 0) {
audio.currentTime = parseFloat(savedTime); // 太早設定!
}
如果在 loadedmetadata 事件觸發前設定 currentTime,可能會失效或產生競爭條件,導致播放進度跳過或重置。
2. 進度儲存觸發條件不可靠(中嚴重性)
// ❌ 錯誤:使用 % 5 判斷觸發時機
if (Math.floor(audio.currentTime) % 5 === 0) {
localStorage.setItem(storageKey + '-time', audio.currentTime.toString());
}
timeupdate 事件並非每秒精確觸發一次(通常是每 250ms),使用 % 5 === 0 判斷可能永遠不會觸發,尤其是在播放速度改變時。
3. 播放清單片段切換衝突(高嚴重性)
播放清單模式下,loadPart() 會呼叫 audio.load(),這會觸發 loadedmetadata 事件,但此時播放進度可能已經被設定,導致進度被重置或跳到錯誤位置。
4. ended 事件處理器重複綁定(中嚴重性)
init() 中已經綁定了 ended 事件,initPlaylist() 又綁定了另一個處理器,兩者可能互相衝突,導致播放清單模式下行為異常。
5. 缺少 seeking/seeked 事件處理(低嚴重性)
拖曳進度條時會觸發 timeupdate 事件,可能導致儲存錯誤的播放進度。
修復方案
修復 1:正確的播放進度還原時機
// ✅ 正確:在元數據載入完成後還原播放進度
audio.addEventListener('loadedmetadata', () => {
durationEl.textContent = this.formatTime(audio.duration);
const savedTime = localStorage.getItem(storageKey + '-time');
if (savedTime && parseFloat(savedTime) > 0) {
const time = parseFloat(savedTime);
if (time < audio.duration) {
audio.currentTime = time; // 確保音訊已載入
}
}
});
修復 2:改用時間戳記節流機制
// ✅ 正確:使用節流避免過度儲存
let lastSaveTime = 0;
const SAVE_INTERVAL = 5000; // 5 秒
audio.addEventListener('timeupdate', () => {
// ... 更新 UI ...
if (!isSeeking) {
const now = Date.now();
if (now - lastSaveTime >= SAVE_INTERVAL) {
localStorage.setItem(storageKey + '-time', audio.currentTime.toString());
lastSaveTime = now;
}
}
});
修復 3:新增 seeking/seeked 事件處理
// ✅ 正確:拖曳時暫停儲存,拖曳完成後立即儲存
let isSeeking = false;
audio.addEventListener('seeking', () => {
isSeeking = true;
});
audio.addEventListener('seeked', () => {
isSeeking = false;
localStorage.setItem(storageKey + '-time', audio.currentTime.toString());
lastSaveTime = Date.now();
});
修復 4:使用 CSS class 標記播放清單模式
// ✅ 正確:統一處理 ended 事件
audio.addEventListener('ended', () => {
if (!audioPlayer.classList.contains('playlist-mode')) {
localStorage.removeItem(storageKey + '-time');
progressBar.value = 0;
}
});
// 在 initPlaylist() 中
audioPlayer.classList.add('playlist-mode');
audio.addEventListener('ended', () => {
if (!audioPlayer.classList.contains('playlist-mode')) return;
// ... 播放清單邏輯 ...
});
技術要點
時序競爭條件(Race Condition):異步操作必須確保正確的執行順序,尤其是媒體元素的載入和狀態設定。
節流(Throttling)vs 去抖(Debouncing):對於高頻事件(如
timeupdate),使用時間戳記節流比條件判斷更可靠。事件處理器管理:避免重複綁定同一事件,使用狀態標記(如 CSS class)區分不同模式。
狀態同步:拖曳進度條時需要暫停自動儲存,避免儲存中間狀態。
測試場景
修復後應測試以下場景:
- ✅ 首次播放(無儲存進度)
- ✅ 還原播放(有儲存進度)
- ✅ 拖曳進度條
- ✅ 播放速度變更
- ✅ 播放清單模式(多個片段)
- ✅ 頁面重新載入
結語
這個看似簡單的 bug 揭示了一個重要教訓:JavaScript 的 Promise 機制要求開發者主動處理錯誤。在異步操作日益普遍的現代 Web 開發中,忽略 Promise rejection 就像忽略 try-catch 一樣危險。
記住:如果一個函數返回 Promise,你就有責任處理它的 rejection。這不僅能讓程式碼更健壯,也能讓偵錯過程更加順暢。
相關資源: