Stories

Detail Return Return

前端日誌回撈系統的性能優化實踐|得物技術 - Stories Detail

一、前言

在現代前端應用中,日誌回撈系統是排查線上問題的重要工具。然而,傳統的日誌系統往往面臨着包體積過大、存儲無限膨脹、性能影響用户體驗等問題。本文將深入分析我們在@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)
    }
  }
})()

優化二:上傳模塊的異步加載架構

image.png

問題背景

日誌上傳功能涉及 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
}

優化四:日誌隊列與性能優化

image.png

在某些異常場景下,日誌會短時間內高頻觸發(如循環錯誤),密集的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'
    }),
  ]
}

四、性能測試與效果對比

打包優化效果對比:
image.png

五、總結

通過解決這些打包構建中的技術難點,我們不僅成功完成了日誌系統的性能優化,還積累了工程化經驗。這些實踐不僅帶來了日誌系統本身的輕量化與高效化,其經驗對於任何追求高性能和穩定性的前端項目都有部分參考價值。

往期回顧

  1. 得物靈犀搜索推薦詞分發平台演進3.0
  2. R8疑難雜症分析實戰:外聯優化設計缺陷引起的崩潰|得物技術
  3. 可擴展系統設計的黃金法則與Go語言實踐|得物技術
  4. 營銷會場預覽直通車實踐|得物技術
  5. 基於TinyMce富文本編輯器的客服自研知識庫的技術探索和實踐|得物技術

文 / 沸騰

關注得物技術,每週更新技術乾貨

要是覺得文章對你有幫助的話,歡迎評論轉發點贊~

未經得物技術許可嚴禁轉載,否則依法追究法律責任。

user avatar tianmiaogongzuoshi_5ca47d59bef41 Avatar zero_dev Avatar chaoshenjinghyperai Avatar lovecola Avatar _wss Avatar mincloud Avatar bygpt Avatar fulade Avatar rtedevcomm Avatar zread_ai Avatar elegantdevil Avatar smallhuifei Avatar
Favorites 26 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.