前言
戰友們,我們已經走到了這趟旅程的終點線前。我們的「通用 BLE 瑞士刀」已經打造完成,它鋒利、功能完備,足以應對絕大多數的 BLE 探索與互動任務。
但是,一個好的工具和一個偉大的工具之間,往往存在著一道名為「效能」的鴻溝。
想像一下,當我們訂閱一個高頻率的感測器(例如陀螺儀或加速計)時,通知數據可能會以每秒數十甚至上百次的頻率瘋狂湧入。我們目前的 log
函式和 UI 更新邏輯,每一次收到數據,都會去操作一次 DOM(appendChild
或更新 textContent
)。這種頻繁的 DOM 操作,是網頁效能的頭號殺手,它會很快地佔滿瀏覽器的運算資源,導致介面卡頓、甚至崩潰。
一個專業級的偵錯工具,必須能夠從容地應對這種極端情況。因此,在我們為這趟旅程畫上句點之前,今天的最後一課,就是要為我們的工具進行一次效能最佳化手術。我們將學習前端開發中至關重要的最佳化技巧,確保我們的工具即使在數據的驚濤駭浪中,也能保持流暢與穩定。
內文
每次我們用 JavaScript 修改 DOM(例如 appendChild
),瀏覽器都需要做很多背後工作:
重新計算佈局 (Reflow):計算元素的新尺寸和位置。
重新繪製 (Repaint):將元素的新外觀重新畫出來。
如果每秒做幾百次這樣的工作,瀏覽器的主執行緒就會被完全佔用,沒有時間去回應使用者的點擊或滾動,從而導致頁面「凍結」。
我們的策略是:不要每來一條日誌就去敲一次門(操作 DOM),而是先把日誌存起來(放在一個陣列緩衝區裡),等存到一定數量,或者過了一定時間後,再把這一批日誌一次性地敲門送進去。
修改 app.js
中的日誌系統:
// app.js
// --- [2] 日誌記錄函式 (重構版) ---
let logBuffer = []; // 新增一個日誌緩衝區陣列
let logTimer = null; // 新增一個計時器變數
function log(message) {
// 1. 在開發者控制台總是即時印出
console.log(message);
// 2. 將訊息加入緩衝區
const now = new Date();
const timeString = now.toLocaleTimeString();
logBuffer.push(`<strong>[${timeString}]</strong> ${message}`);
// 3. 如果計時器不在運行中,就啟動一個
if (!logTimer) {
logTimer = setTimeout(() => {
flushLogBuffer(); // 500 毫秒後,將緩衝區的內容一次性寫入 DOM
}, 500); // 延遲時間可以根據需求調整
}
}
function flushLogBuffer() {
if (logBuffer.length > 0) {
const logFragment = document.createDocumentFragment(); // 使用 DocumentFragment 提升效能
logBuffer.forEach(message => {
const logEntry = document.createElement('p');
logEntry.innerHTML = message;
logFragment.appendChild(logEntry);
});
logContainer.appendChild(logFragment);
logContainer.scrollTop = logContainer.scrollHeight; // 滾動到底部
}
// 清空緩衝區並重置計時器
logBuffer = [];
logTimer = null;
}
程式碼解析:
緩衝區:log
函式現在只負責將訊息推入 logBuffer
陣列。
計時器:setTimeout
確保了 flushLogBuffer
函式最多每 500 毫秒才會被執行一次。
DocumentFragment
:這是一個輕量級的「DOM 容器」,我們可以先把所有新的 p
元素都 appendChild
到這個 fragment 上(這是在記憶體中操作,非常快),然後再把這個 fragment 一次性地 appendChild
到真實的 logContainer
中。這樣,無論我們有多少條日誌,都只會引發一次昂貴的 Reflow 和 Repaint。
對於 Subscribe
收到的高頻數據,我們不需要顯示每一個中間值。人眼也無法分辨每秒更新 100 次和每秒更新 10 次的區別。我們可以使用「節流」技術來限制 UI 更新的頻率。
在 app.js
中新增一個 throttle
輔助函式:
// app.js
/**
* 節流函式:確保一個函式在一定時間內最多只被執行一次
* @param {Function} func - 要被節流的函式
* @param {number} limit - 時間限制 (毫秒)
* @returns {Function} - 包裝後的節流函式
*/
function throttle(func, limit) {
let inThrottle;
return function() {
const args = arguments;
const context = this;
if (!inThrottle) {
func.apply(context, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
}
}
接著,修改 renderCharacteristic
中 handleNotifications
的部分:
// 在 renderCharacteristic 函式內部
// ...
let isSubscribed = false;
// 將 UI 更新的部分單獨抽離
const updateValueUI = (value) => {
valueSpan.textContent = value;
// (可選) 更新中央資料庫
gattProfile.services[serviceUuid].characteristics[charInfo.uuid].value = value;
};
// 使用節流函式包裝我們的 UI 更新函式,限制為每 200 毫秒最多更新一次
const throttledUpdateUI = throttle(updateValueUI, 200);
const handleNotifications = (event) => {
const valueDataView = event.target.value;
// 我們的 parseValue 函式在這裡依然很有用!
const parsedValue = parseValue(valueDataView);
const message = `[Notification] ${charInfo.uuid}: ${parsedValue}`;
log(message); // 日誌依然可以即時記錄 (因為它已經被最佳化了)
throttledUpdateUI(parsedValue); // 但 UI 的更新是經過節流的
};
// ...
程式碼解析:
我們定義了一個經典的 throttle
函式。
我們將真正更新 UI 的邏輯 updateValueUI
包裝成了一個節流版本 throttledUpdateUI
。
在 handleNotifications
中,我們總是呼叫 throttledUpdateUI
。它會確保即使通知每秒來 100 次,valueSpan.textContent
也最多只會被修改 5 次(1000ms / 200ms)。
後續
今天,我們為這趟精彩的旅程,進行了最後、也是最重要的一次打磨。
明天,我們將迎來這趟 30 天鐵人賽的最終章。我們將不再撰寫新的程式碼,而是會進行一次全面的專案總結與回顧。我們將審視我們所建立的一切,鞏固所學的知識,並一起探討這把瑞士刀未來還能有哪些更酷的升級方向,為這段精彩的旅程,畫上一個完美的句號。