一、前言
在現代前端應用中,日誌回撈系統是排查線上問題的重要工具。然而,傳統的日誌系統往往面臨着包體積過大、存儲無限膨脹、性能影響用户體驗等問題。本文將深入分析我們在@dw/log和@dw/log-upload兩個庫中實施的關鍵性能優化,以及改造過程中遇到的技術難點和解決方案。
核心優化策略概覽:
我們的優化策略主要圍繞三個核心問題:
- 存儲膨脹問題 - 通過智能清理策略控制本地存儲大小
- 包體積問題 - 通過異步模塊加載實現按需引入
- 性能影響問題 - 通過隊列機制和節流策略提升用户體驗
二、核心性能優化
優化一:智能化數據庫清理機制
問題背景
傳統日誌系統的一個重大痛點是本地存儲無限膨脹。用户長期使用後,IndexedDB 可能積累數萬條日誌記錄,不僅佔用大量存儲空間,更拖慢了所有數據庫查詢和寫入操作。
解決方案:雙重清理策略
我們實現了一個智能清理機制,它結合了兩種策略,並只在瀏覽器空閒時執行,避免影響正常業務。
- 雙重清理:
-
- 按時間清理: 刪除N天前的所有日誌。
- 按數量清理: 當日志總數超過閾值時,刪除最舊的日誌,直到數量達標。
<!---->
/**
* 綜合清理日誌(同時處理過期和數量限制)
* @param retentionDays 保留天數
* @param maxLogCount 最大日誌條數
*/
async cleanupLogs(retentionDays?: number, maxLogCount?: number): Promise<void> {
if (!this.db) {
throw new Error('Database not initialized')
}
try {
// 先清理過期日誌
if (retentionDays && retentionDays > 0) {
await this.clearExpiredLogs(retentionDays)
}
// 再清理超出數量限制的日誌
if (maxLogCount && maxLogCount > 0) {
await this.clearExcessLogs(maxLogCount)
}
} catch (error) {
// 日誌清理失敗不應該影響主流程
console.warn('日誌清理失敗:', error)
}
}
- 智能調度:
-
- 節流: 保證清理操作在短時間內(如5分鐘)最多執行一次。
- 空閒執行: 將清理任務調度到瀏覽器主線程空閒時執行,確保不與用户交互或頁面渲染爭搶資源。
<!---->
/**
* 檢查並執行清理(節流版本,避免頻繁清理)
*/
private checkAndCleanup = (() => {
let lastCleanup = 0
const CLEANUP_INTERVAL = 5 * 60 * 1000 // 5分鐘最多清理一次
return () => {
const now = Date.now()
if (now - lastCleanup > CLEANUP_INTERVAL) {
lastCleanup = now
executeWhenIdle(() => {
this.performCleanup()
}, 1000)
}
}
})()
優化二:上傳模塊的異步加載架構
問題背景
日誌上傳功能涉及 OSS 上傳、文件壓縮等重型依賴,如果全部打包到主庫中,會顯著增加包體積。更重要的是,大部分用户可能永遠不會觸發日誌上傳功能。
解決方案:動態模塊加載
189KB 的包體積是不可接受的。分析發現,包含文件壓縮(JSZip)和OSS上傳的 @dw/log-upload模塊是體積元兇,但99%的用户在正常瀏覽時根本用不到它。
我們採取了“核心功能+插件化”的設計思路,將非核心的上傳功能徹底分離。
- 上傳模塊分離: 將上傳邏輯拆分為獨立的@dw/log-upload庫,並通過CDN託管。
- 動態加載實現: 僅在用户手動觸發“上傳日誌”時,才通過動態創建script標籤的方式,從CDN異步加載上傳模塊。我們設計了一個單例加載器確保模塊只被請求一次。
<!---->
/**
* OSS 上傳模塊的遠程 URL
*/
const OSS_UPLOADER_URL = 'https://cdn-jumper.dewu.com/sdk-linker/dw-log-upload.js'
/**
* 動態加載遠程模塊
* 使用單例模式確保模塊只加載一次
*/
const loadRemoteModule = async (): Promise<LogUploadModule> => {
if (!moduleLoadPromise) {
moduleLoadPromise = (async () => {
try {
await loadScript(OSS_UPLOADER_URL)
return window.DWLogUpload
} catch (error) {
moduleLoadPromise = null
throw error
}
})()
}
return moduleLoadPromise
}
/**
* 上傳文件到 OSS
*/
export const uploadToOss = async (file: File, curEnv?: string, appId?: string): Promise<string> => {
try {
// 懶加載上傳函數
if (!ossUploader) {
const module = await loadRemoteModule()
ossUploader = module.uploadToOss
}
const result = await ossUploader(file, curEnv, appId)
return result
} catch (error) {
console.info('Failed to upload file to OSS:', error)
return ''
}
}
優化三:JSZip庫的動態引入
我們避免將 JSZip 打包到主庫中,從主包中移除,改為在上傳模塊內部動態引入,優先使用業務側可能已加載的全局window.JSZip。
/**
* 獲取 JSZip 實例
*/
export const getJSZip = async (): Promise<JSZip | null> => {
try {
if (!JSZipCreator) {
const module = await loadRemoteModule()
JSZipCreator = module.JSZipCreator
}
zipInstance = new window.JSZip()
return zipInstance
} catch (error) {
console.info('Failed to create JSZip instance:', error)
return null
}
}
// 在上傳模塊中實現靈活的 JSZip 加載
export const JSZipCreator = async () => {
// 優先使用全局 JSZip(如果頁面已經加載了)
if (window.JSZip) {
return window.JSZip
}
return JSZip
}
優化四:日誌隊列與性能優化
在某些異常場景下,日誌會短時間內高頻觸發(如循環錯誤),密集的IndexedDB.put()操作會阻塞主線程,導致頁面卡頓。
我們引入了一個日誌隊列,將所有日誌寫入請求“緩衝”起來,再由隊列控制器進行優化處理。
- 限流: 設置每秒最多處理的日誌條數(如50條),超出部分直接丟棄。錯誤(Error)級別的日誌擁有最高優先級,不受此限制,確保關鍵信息不丟失。
- 批處理與空閒執行: 將隊列中的日誌打包成批次,利用requestIdleCallback在瀏覽器空閒時一次性寫入數據庫,極大減少了 I/O 次數和對主線程的佔用。
<!---->
export class LogQueue {
private readonly MAX_LOGS_PER_SECOND = 50
/**
* 檢查限流邏輯
*/
private checkRateLimit(entry: LogEntry): boolean {
// 錯誤日誌總是被接受
if (entry.level === 'error') {
return true
}
const now = Date.now()
if (now - this.lastResetTime > 1000) {
this.logCount = 0
this.lastResetTime = now
}
if (this.logCount >= this.MAX_LOGS_PER_SECOND) {
return false
}
this.logCount++
return true
}
}
空閒時間處理機制:
export function executeWhenIdle(callback: () => void, timeout: number = 2000): void {
if (typeof window !== 'undefined' && 'requestIdleCallback' in window) {
window.requestIdleCallback(() => {
callback()
}, { timeout })
} else {
setTimeout(callback, 50)
}
}
三、打包構建中的技術難點與解決方案
在改造過程中,我們遇到了許多與打包構建相關的技術難題。這些問題往往隱藏較深,但一旦出現就會阻塞整個開發流程。以下是我們遇到的主要問題和解決方案:
難點一:異步加載 import()
打包失敗問題
問題描述
await import('./module')語法在 Rollup 打包為 UMD 格式時會直接報錯,因為 UMD 規範本身不支持代碼分割。
// 這樣的代碼會導致 UMD 打包失敗
const loadModule = async () => {
const module = await import('./upload-module')
return module
}
錯誤信息:
Error: Dynamic imports are not supported in UMD builds
[!] (plugin commonjs) RollupError: "import" is not exported by "empty.js"
解決方案:inlineDynamicImports 配置
通過在 Rollup 配置中設置inlineDynamicImports: true來解決這個問題:
// rollup.config.js
export default {
input: 'src/index.ts',
output: [
{
file: 'dist/umd/dw-log.js',
format: 'umd',
name: 'DwLog',
// 關鍵配置:內聯動態導入
inlineDynamicImports: true,
},
{
file: 'dist/cjs/index.js',
format: 'cjs',
// CJS 格式也需要這個配置
inlineDynamicImports: true,
}
],
plugins: [
typescript(),
resolve({ browser: true }),
commonjs(),
]
}
配置説明
- inlineDynamicImports: true會將所有動態導入的模塊內聯到主包中
- 這解決了 UMD 格式不支持動態導入的問題
難點二:process對象未定義問題
問題描述
打包後的代碼在瀏覽器環境中運行時出現process is not defined錯誤:
ReferenceError: process is not defined
at Object.<anonymous> (dw-log.umd.js:1234:56)
這通常是因為某些 Node.js 模塊或工具庫在代碼中引用了process對象,而瀏覽器環境中並不存在。
解決方案:插件注入 process 對象
我們使用@rollup/plugin-inject插件,在打包時向代碼中注入一個模擬的process 對象,以滿足這些庫的運行時需求。
- 創建process-shim.js文件提供瀏覽器端的process實現。
- 在rollup.config.js中配置插件:
<!---->
// rollup.config.js
import inject from '@rollup/plugin-inject'
import path from 'path'
export default {
// ... 其他配置
plugins: [
// 注入 process 對象
inject({
// 使用文件導入方式注入 process 對象
process: path.join(__dirname, 'process-shim.js'),
}),
typescript(),
resolve({ browser: true }),
commonjs(),
]
}
創建 process-shim.js 文件:
// process-shim.js
// 為瀏覽器環境提供 process 對象的基本實現
export default {
env: {
NODE_ENV: 'production'
},
browser: true,
version: '',
versions: {},
platform: 'browser',
argv: [],
cwd: function() { return '/' },
nextTick: function(fn) {
setTimeout(fn, 0)
}
}
高級解決方案:條件注入
為了更精確地控制注入,我們還可以使用條件注入:
inject({
// 只在需要的地方注入 process
process: {
id: path.join(__dirname, 'process-shim.js'),
// 可以添加條件,只在特定模塊中注入
include: ['**/node_modules/**', '**/src/utils/**']
},
// 同時處理 global 對象
global: 'globalThis',
// 處理 Buffer 對象
Buffer: ['buffer', 'Buffer'],
})
難點三:第三方依賴的
ESM/CJS兼容性問題
問題描述
某些第三方庫(如 JSZip、@poizon/upload)在不同模塊系統下的導入方式不同,導致打包後出現導入錯誤:
TypeError: Cannot read property 'default' of undefined
解決方案:混合導入處理
// 處理 JSZip 的兼容性導入
let JSZipModule: any
try {
// 嘗試 ESM 導入
JSZipModule = await import('jszip')
// 檢查是否有 default 導出
JSZipModule = JSZipModule.default || JSZipModule
} catch {
// 降級到全局變量
JSZipModule = (window as any).JSZip || require('jszip')
}
// 處理 @poizon/upload 的導入
import PoizonUploadClass from '@poizon/upload'
// 兼容不同的導出格式
const PoizonUpload = PoizonUploadClass.default || PoizonUploadClass
在 Rollup 配置中加強兼容性處理:
export default {
plugins: [
resolve({
browser: true,
preferBuiltins: false,
// 解決模塊導入問題
exportConditions: ['browser', 'import', 'module', 'default']
}),
commonjs({
// 處理混合模塊
dynamicRequireTargets: [
'node_modules/jszip/**/*.js',
'node_modules/@poizon/upload/**/*.js'
],
// 轉換默認導出
defaultIsModuleExports: 'auto'
}),
]
}
四、性能測試與效果對比
打包優化效果對比:
五、總結
通過解決這些打包構建中的技術難點,我們不僅成功完成了日誌系統的性能優化,還積累了工程化經驗。這些實踐不僅帶來了日誌系統本身的輕量化與高效化,其經驗對於任何追求高性能和穩定性的前端項目都有部分參考價值。
往期回顧
- 得物靈犀搜索推薦詞分發平台演進3.0
- R8疑難雜症分析實戰:外聯優化設計缺陷引起的崩潰|得物技術
- 可擴展系統設計的黃金法則與Go語言實踐|得物技術
- 營銷會場預覽直通車實踐|得物技術
- 基於TinyMce富文本編輯器的客服自研知識庫的技術探索和實踐|得物技術
文 / 沸騰
關注得物技術,每週更新技術乾貨
要是覺得文章對你有幫助的話,歡迎評論轉發點贊~
未經得物技術許可嚴禁轉載,否則依法追究法律責任。