前端日志回撈系統(tǒng)的性能優(yōu)化實踐
一、前言
二、核心性能優(yōu)化
1. 優(yōu)化一:智能化數(shù)據(jù)庫清理機制
2. 優(yōu)化二:上傳模塊的異步加載架構(gòu)
3. 優(yōu)化三:JSZip 庫的動態(tài)引入
4. 優(yōu)化四:日志隊列與性能優(yōu)化
三、打包構(gòu)建中的技術(shù)難點與解決方案
1. 難點一:異步加載 import() 打包失敗問題
2. 難點二:process 對象未定義問題
3. 難點三:第三方依賴的 ESM/CJS 兼容性問題
四、性能測試與效果對比
五、總結(jié)
一、前言
在現(xiàn)代前端應用中,日志回撈系統(tǒng)是排查線上問題的重要工具。然而,傳統(tǒng)的日志系統(tǒng)往往面臨著包體積過大、存儲無限膨脹、性能影響用戶體驗等問題。本文將深入分析我們在@dw/log和@dw/log-upload兩個庫中實施的關(guān)鍵性能優(yōu)化,以及改造過程中遇到的技術(shù)難點和解決方案。
核心優(yōu)化策略概覽:
我們的優(yōu)化策略主要圍繞三個核心問題:
- 存儲膨脹問題 - 通過智能清理策略控制本地存儲大小
- 包體積問題 - 通過異步模塊加載實現(xiàn)按需引入
- 性能影響問題 - 通過隊列機制和節(jié)流策略提升用戶體驗
二、核心性能優(yōu)化
優(yōu)化一:智能化數(shù)據(jù)庫清理機制
問題背景
傳統(tǒng)日志系統(tǒng)的一個重大痛點是本地存儲無限膨脹。用戶長期使用后,IndexedDB 可能積累數(shù)萬條日志記錄,不僅占用大量存儲空間,更拖慢了所有數(shù)據(jù)庫查詢和寫入操作。
解決方案:雙重清理策略
我們實現(xiàn)了一個智能清理機制,它結(jié)合了兩種策略,并只在瀏覽器空閑時執(zhí)行,避免影響正常業(yè)務。
- 雙重清理:
按時間清理: 刪除N天前的所有日志。
按數(shù)量清理: 當日志總數(shù)超過閾值時,刪除最舊的日志,直到數(shù)量達標。
/**
* 綜合清理日志(同時處理過期和數(shù)量限制)
* @param retentionDays 保留天數(shù)
* @param maxLogCount 最大日志條數(shù)
*/
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)
}
// 再清理超出數(shù)量限制的日志
if (maxLogCount && maxLogCount > 0) {
await this.clearExcessLogs(maxLogCount)
}
} catch (error) {
// 日志清理失敗不應該影響主流程
console.warn('日志清理失敗:', error)
}
}- 智能調(diào)度:
節(jié)流: 保證清理操作在短時間內(nèi)(如5分鐘)最多執(zhí)行一次。
空閑執(zhí)行: 將清理任務調(diào)度到瀏覽器主線程空閑時執(zhí)行,確保不與用戶交互或頁面渲染爭搶資源。
/**
* 檢查并執(zhí)行清理(節(jié)流版本,避免頻繁清理)
*/
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)
}
}
})()優(yōu)化二:上傳模塊的異步加載架構(gòu)

問題背景
日志上傳功能涉及 OSS 上傳、文件壓縮等重型依賴,如果全部打包到主庫中,會顯著增加包體積。更重要的是,大部分用戶可能永遠不會觸發(fā)日志上傳功能。
解決方案:動態(tài)模塊加載
189KB 的包體積是不可接受的。分析發(fā)現(xiàn),包含文件壓縮(JSZip)和OSS上傳的 @dw/log-upload模塊是體積元兇,但99%的用戶在正常瀏覽時根本用不到它。
我們采取了“核心功能+插件化”的設計思路,將非核心的上傳功能徹底分離。
- 上傳模塊分離: 將上傳邏輯拆分為獨立的@dw/log-upload庫,并通過CDN托管。
- 動態(tài)加載實現(xiàn): 僅在用戶手動觸發(fā)“上傳日志”時,才通過動態(tài)創(chuàng)建script標簽的方式,從CDN異步加載上傳模塊。我們設計了一個單例加載器確保模塊只被請求一次。
/**
* OSS 上傳模塊的遠程 URL
*/
const OSS_UPLOADER_URL = 'https://cdn-jumper.dewu.com/sdk-linker/dw-log-upload.js'
/**
* 動態(tài)加載遠程模塊
* 使用單例模式確保模塊只加載一次
*/
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 {
// 懶加載上傳函數(shù)
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 ''
}
}優(yōu)化三:JSZip庫的動態(tài)引入
我們避免將 JSZip 打包到主庫中,從主包中移除,改為在上傳模塊內(nèi)部動態(tài)引入,優(yōu)先使用業(yè)務側(cè)可能已加載的全局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
}
}
// 在上傳模塊中實現(xiàn)靈活的 JSZip 加載
export const JSZipCreator = async () => {
// 優(yōu)先使用全局 JSZip(如果頁面已經(jīng)加載了)
if (window.JSZip) {
return window.JSZip
}
return JSZip
}優(yōu)化四:日志隊列與性能優(yōu)化
圖片
在某些異常場景下,日志會短時間內(nèi)高頻觸發(fā)(如循環(huán)錯誤),密集的IndexedDB.put()操作會阻塞主線程,導致頁面卡頓。
我們引入了一個日志隊列,將所有日志寫入請求“緩沖”起來,再由隊列控制器進行優(yōu)化處理。
- 限流: 設置每秒最多處理的日志條數(shù)(如50條),超出部分直接丟棄。錯誤(Error)級別的日志擁有最高優(yōu)先級,不受此限制,確保關(guān)鍵信息不丟失。
- 批處理與空閑執(zhí)行: 將隊列中的日志打包成批次,利用requestIdleCallback在瀏覽器空閑時一次性寫入數(shù)據(jù)庫,極大減少了 I/O 次數(shù)和對主線程的占用。
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)
}
}三、打包構(gòu)建中的技術(shù)難點與解決方案
在改造過程中,我們遇到了許多與打包構(gòu)建相關(guān)的技術(shù)難題。這些問題往往隱藏較深,但一旦出現(xiàn)就會阻塞整個開發(fā)流程。以下是我們遇到的主要問題和解決方案:
難點一:異步加載 import() 打包失敗問題
問題描述
await import('./module')語法在 Rollup 打包為 UMD 格式時會直接報錯,因為 UMD 規(guī)范本身不支持代碼分割。
// 這樣的代碼會導致 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',
// 關(guān)鍵配置:內(nèi)聯(lián)動態(tài)導入
inlineDynamicImports: true,
},
{
file: 'dist/cjs/index.js',
format: 'cjs',
// CJS 格式也需要這個配置
inlineDynamicImports: true,
}
],
plugins: [
typescript(),
resolve({ browser: true }),
commonjs(),
]
}配置說明
- inlineDynamicImports: true會將所有動態(tài)導入的模塊內(nèi)聯(lián)到主包中
- 這解決了 UMD 格式不支持動態(tài)導入的問題
難點二:process對象未定義問題
問題描述
打包后的代碼在瀏覽器環(huán)境中運行時出現(xiàn)process is not defined錯誤:
ReferenceError: process is not defined
at Object.<anonymous> (dw-log.umd.js:1234:56)這通常是因為某些 Node.js 模塊或工具庫在代碼中引用了process對象,而瀏覽器環(huán)境中并不存在。
解決方案:插件注入 process 對象
我們使用@rollup/plugin-inject插件,在打包時向代碼中注入一個模擬的process 對象,以滿足這些庫的運行時需求。
- 創(chuàng)建process-shim.js文件提供瀏覽器端的process實現(xiàn)。
- 在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(),
]
}創(chuàng)建 process-shim.js 文件:
// process-shim.js
// 為瀏覽器環(huán)境提供 process 對象的基本實現(xiàn)
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)在不同模塊系統(tǒng)下的導入方式不同,導致打包后出現(xiàn)導入錯誤:
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'
],
// 轉(zhuǎn)換默認導出
defaultIsModuleExports: 'auto'
}),
]
}四、性能測試與效果對比
打包優(yōu)化效果對比:

五、總結(jié)
通過解決這些打包構(gòu)建中的技術(shù)難點,我們不僅成功完成了日志系統(tǒng)的性能優(yōu)化,還積累了工程化經(jīng)驗。這些實踐不僅帶來了日志系統(tǒng)本身的輕量化與高效化,其經(jīng)驗對于任何追求高性能和穩(wěn)定性的前端項目都有部分參考價值。

































