前端日誌管理模塊的設計與實現

給團隊封裝一個簡單統一的日誌管理工具 / 模塊,來替換掉項目中野蠻生長的 console.log()吧!

一、問題背景 ⛰️

在項目中,我們會頻繁用到 console.log() 來輸出一些關鍵信息到控制檯中,有助於開發調試,以及問題的排查,待項目上線後,這些調試日誌又得及時清除。

同時在前端質量要求下,我們會做 “前端埋點”,用於遠程上報一些關鍵行爲信息,用於在出問題時還原用戶的操作路徑,復現 BUG,從而解決問題,而各種各樣的上報若是能在業務開發中抹平差異,也有助於研發提效。

因此,有必要在團隊中封裝日誌工具(Logger),用於統一管理日誌輸出和格式化上報,降低開發者對多平臺上報差異的心智負擔。

二、需求概述 🧾

預期日誌管理工具(Logger)需要有如下能力:

  1. 支持區分 infowarnerror 三種本地調試類型日誌

  2. 支持遠程上報自定義日誌 report()

  3. 支持設置 namespace,用於區分代碼執行的 scope

  4. 支持鏈式操作

  5. 區分生產環境和開發環境,生產環境禁止輸出日誌到控制檯

  6. 支持功能可擴展

三、方案設計 🖌️

在閱讀完 Axios 的源碼後,個人認爲 Axios 裏對於設計模式的應用是非常靈活,同理,一個好的日誌工具也應當遵守着一定的軟件設計模式原則。

作爲項目中用到的日誌工具,單例模式應當是更適合的選擇!

Logger 的打印輸出能力,本質上還是藉助了 window.console 對象中的方法:

Console 對象

在面向對象編程中,我們可以認爲 console 是一個已經初始化的實例,同時也是一個單例,因爲它是全局唯一。

而單例模式的最大好處就是全局唯一,對於做日誌統一管理有着天然的友好支持基礎。

四、實現細節 🔍

接下來通過具體的代碼,來逐一實現並完善我們的 Logger 日誌工具類。

4.1 ES Module 下的單例模式

在 ESM 規範下,我們可以直接通過直接導出實例方式(export default new ClassName()),來實現單例模式。

Logger 的基礎結構就有了:

/**
 * 日誌打印工具,統一管理日誌輸出&上報
 */
class Logger {
  /** 命名空間(scope),用於區分所在執行文件 */
  private namespace: string

  constructor(namespace = 'unknown') {
    this.namespace = namespace
  }
}

export default new Logger()

4.2 可擴展的單例模式

參考 Axios 的設計 [1],因此我們還提供 create() 方法,爲創建新實例留一個入口方法。

/**
 * 創建新的 Logger 實例
 * 
 * @param namespace 命名空間
 * @returns Logger
 */
public create(namespace = 'unknown') {
  return new Logger(namespace);
}

當需要重新定義一個 logger 實例時,就可以參考如下方式:

import logger from '@/utils/logger'

const newLogger = logger.create('custom')
logger.info(newLogger === logger) // [unknown] false

4.3 定義 “打印” 類日誌方法

需要區分 infowarnerror 三種類型的日誌,實現如下:

定義日誌枚舉類型:

const enum LogLevel {
  /** 普通日誌 */
  Log,
  /** 警告日誌 */
  Warning,
  /** 錯誤日誌 */
  Error,
}

const Styles = ['color: green;''color: orange;''color: red;']
const Methods = ['info''warn''error'] as const
private _log(level: LogLevel, args: unknown[]) {
  if (!__DEV__) return
  console[Methods[level]](`%c${this.namespace}`, Styles[level], ...args)
}

/**
 * 打印輸出信息 🐛
 *
 * @param args 任意參數
 */
public info(...args: unknown[]) {
  this._log(LogLevel.Log, args)
  return this
}

/**
 * 打印輸出警告信息 ❕
 *
 * @param args 任意參數
 */
public warn(...args: unknown[]) {
  this._log(LogLevel.Warning, args)
  return this
}

/**
 * 打印輸出錯誤信息 ❌
 *
 * @param args 任意參數
 */
public error(...args: unknown[]) {
  this._log(LogLevel.Error, args)
  return this
}

_log() 方法中,通過 __DEV__ 環境變量區分 “生產” 和“開發”:

if (!__DEV__) return

這種變量可以理解爲 “開關”:

生產環境則控制檯不輸出信息,在實際應用中,可以擴展 “是否輸出信息” 的變量,來針對性擴展,例如線上需要通過特定參數展示調試日誌,用於線上定位問題,那麼就可以綜合多個條件來決定是否輸出控制檯,畢竟編程最核心的問題是解決需求。

在開發模式下,針對不同的信息類型,會標註不同的顏色:

Chrome 瀏覽器下的效果

與此同時,在每個 “輸出” 方法中都返回了 this(當前實例),因而便可以爲鏈式調用方法提供了使用基礎。

4.4 支持修改 namespace

namespace 最重要的作用是:區分在不同組件或文件下的日誌,便於問題定位排查。

由於 Logger 將所有的輸出集中到了統一文件,在 console.log() 中文件定位永遠是 Logger 類定義實現所在文件,因此需要 namespace 來區分。

新增 setNamespace() 方法:

/**
 * 設置命名空間(日誌前綴)
 * @param namespace
 */
public setNamespace(namespace = '') {
  this.namespace = `[${namespace}]`
  return this
}

TypeScript 環境下,會提供代碼提示,例如某個文件下輸出錯誤信息的方式:

setNamespace() 方法,並不是每次都需要調用的,只需在文件中調用一次即可。

4.5 埋點遠程上報

在一些關鍵時機,例如進入頁面、點擊 “付費按鈕” 等一些關鍵操作上,一般會加上一些上報到遠程,用於記錄用戶操作路徑,以此便於在出現問題後,復現 BUG 並“對症下藥”。

而埋點上報一般有三類:代碼埋點可視化埋點無痕埋點

我們這裏通過給 Logger 增加遠程上報的方式就是代碼埋點

一般情況下,埋點上報屬於 “前端監控” 方面,前端監控是一個獨立的管理系統,它的職能是負責前端項目的監控、異常報警等,因此通常會有用於項目集成的前端 SDK

有了 Logger 實例,我們可以在 Logger 中直接統一集成 “前端監控 SDK” 的主動上報方法即可!

在 Logger 類中新增三個方法:

/**
 * 遠程上報
 * TODO: 根據基建環境自定義擴展
 */
public reportLog() {
  this.info() // 用於在本地輸出
}
public reportEvent() {
  this.info()
}
public reportException() {
  this.error()
}

至於爲什麼添加着兩個方法,實際是根據 “前端監控 SDK” 提供的 API 來決定

例如常見的 “Sentry - 應用監控錯誤溯源 [2]” 平臺,針對主動上報,提供了三種方法,通常爲了保持一致性,降低心智負擔,因此新增對應的三個上報方法。

具體的上報參數和邏輯,則需要大家根據自己的業務區擴展。

五、Logger 的可擴展性 ⚙️

從上面 Logger 類的實現,可以發現一個明顯的問題,如果業務需要擴展功能,則需要修改 Logger 類內部的方法,Logger 類中的方法和邏輯,我們可以理解爲是所有業務都通用的,業務定製化的功能應該通過額外擴展方式來完善。

那有沒有什麼辦法,可以實現不修改方法,而擴展 Logger 的功能吶?

5.1 擴展方案

有幾個方案:

  1. 繼承 Logger 類擴展

  2. 增加回調函數作爲參數

個人推薦第二個方案,但如果每一次調用,都按照如下方式:

logger.info('message'() ={})

但這種設計比較粗糙

5.2 攔截器

參考 Axios 的攔截器設計,也就是 AOP(面向切面編程模式)的設計思想,來擴展 _log() 方法。

新增類型申明:

/**
 * 日誌的配置類型
 */
type LoggerConfigType = {
  /** 命名空間 */
  namespace?: string
}

/**
 * 攔截器函數類型
 */
type InterceptorFuncType = (config: LoggerConfigType) => void

將 Logger 的配置集中的 config 私有變量中,並新增 addBeforeFunc()addAfterFunc() 兩個方法,用於新增自定義 “攔截器” 函數

其中一個細節是,日誌打印之後的攔截器,按照 FCLS(First Come Last Serve,先到後服務)的策略,和 Axios 的響應攔截器執行順序對齊,與此同時,攔截器函數中會注入當前 Logger 的 config 配置。

通過簡單的 “攔截器”,即可實現功能的擴展,這種方式的功能擴展不會影響到主體功能,後期的維護升級是無侵入性的,還算比較優雅的,是吧!

5.3 其他方案

這裏還可以考慮更多設計,例如參考發佈訂閱設計模式來改造,通過生命週期的關鍵點,被動觸發,主動通知並執行所有訂閱了對應消息的事件,可以參閱《聊一聊發佈訂閱設計模式 [3]》

也可以用插件模式方式來實現擴展,類似發佈訂閱模式,給 _log() 函數添加執行的鉤子函數🪝(回調函數),例如這種設計下,把 “埋點上報” 等功能拆分成插件,再實現一個簡單的事件隊列模型,集成一下子!

六、總結

至此,一個基本的日誌工具就實現完成了,但並未完完全全遵守設計原則,這裏在生產實踐中還需要封裝、抽離相應 “職責”,增加可維護性。

在團隊中以此作爲基礎結構,然後針對團隊、項目、業務的特點做適當的擴展,構建符合當前團隊特性的通用日誌工具模塊,應該也不是什麼難事!

參考資料

[1]

Axios 源碼閱讀筆記: https://juejin.cn/post/7031945393826955294

[2]

Sentry - 應用監控錯誤溯源: https://sentry.io/welcome/

[3]

聊一聊發佈訂閱設計模式: https://juejin.cn/post/6991749405686628365

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/WdtLHiVAcQ95Ncf5bXZexw