浏览器日志实践:模块命名空间与级别过滤

47 阅读4分钟

浏览器原生的 console.log 虽然简单易用,但在大型应用中会遇到诸多问题:所有模块的日志混在一起、无法按需开关、生产环境难以调试。本文实现了一个基于模块命名空间级别过滤的日志系统。

浏览器原生日志能力

浏览器的 Console API 提供了一系列强大的日志方法,这些方法在所有现代浏览器中都有良好的支持:

基础 API

// 基础日志输出
console.log('普通日志')
console.info('信息日志')
console.warn('警告日志')
console.error('错误日志')
console.debug('调试日志')

// 调用栈追踪
console.trace('追踪调用链')

// 分组显示
console.group('分组标题')
console.log('分组内容')
console.groupEnd()
console.groupCollapsed('折叠分组')  // 默认折叠

// 表格展示
console.table([{ name: 'Alice', age: 25 }, { name: 'Bob', age: 30 }])

// 计时器
console.time('操作耗时')
// ... 一些操作
console.timeEnd('操作耗时')  // 输出: 操作耗时: 123.45ms

// 断言
console.assert(value > 0, 'Value must be positive')

// 清空控制台
console.clear()

浏览器日志的痛点

尽管浏览器提供了丰富的 API,但在实际开发中仍存在诸多问题:

1. 缺乏级别控制

// 问题:无法根据环境动态控制日志输出
console.log('开发时需要,生产环境不需要的调试信息')

// 只能通过手动注释或删除代码
// console.log('临时注释掉的日志')

生产环境中,大量 console.log 会:

  • 影响性能(序列化对象、格式化输出)
  • 暴露敏感信息(用户数据、业务逻辑)
  • 产生噪音干扰(用户打开 DevTools 看到大量无关日志)

2. 缺乏模块隔离

console.log('来自认证模块')
console.log('来自网络模块')
console.log('来自渲染模块')

// 在控制台中:
// 来自认证模块
// 来自网络模块
// 来自渲染模块
// ❌ 无法快速定位特定模块的问题

当应用规模扩大时:

  • 数百个模块的日志交织在一起
  • 无法只查看特定模块的日志
  • 难以追踪问题的来源

3. 生产环境难以调试

// 问题:生产环境出现问题时
// - 所有 console.log 都被删除了 → 无法排查
// - 所有 console.log 都保留了 → 找不到有用信息

// 希望能够:
// ✅ 动态开启特定模块的日志

我们需要什么

一个实用的日志系统需要两大核心能力:

1. 模块命名空间(Namespace)

  • 每条日志都知道来自哪个模块
  • 可以针对特定模块开启详细日志
  • 支持模块级别的独立控制

2. 级别过滤(Level Filtering)

  • 开发环境看所有日志,生产环境只看关键信息
  • 运行时动态调整,无需重启应用
  • 避免不必要的性能开销

核心实现

机制一:级别过滤

enum Level {
    Debug = 'debug',
    Info = 'info',
    Warn = 'warn',
    Error = 'error',
}

const LOG_LEVEL_PRIORITY = {
    [Level.Debug]: 0,
    [Level.Info]: 1,
    [Level.Warn]: 2,
    [Level.Error]: 3,
}

// 核心过滤逻辑
shouldLog(level: Level): boolean {
    return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[logger.level]
}

使用效果:

const log = createLogger('auth', Level.Info)

log.debug('不显示')  // Debug < Info
log.info('用户登录', { userId: 123 })  // 显示
log.error('登录失败')  // 显示

// 动态调整
log.level = Level.Debug
log.debug('现在显示了')

机制二:模块命名空间

const loggers: Record<string, Logger> = {}

function createLogger(moduleName: string, defaultLevel = Level.Info): Logger {
    if (loggers[moduleName]) {
        return loggers[moduleName]
    }

    const logger = {
        level: defaultLevel,
        debug: wrapMethod(console.log, Level.Debug),
        info: wrapMethod(console.info, Level.Info),
        warn: wrapMethod(console.warn, Level.Warn),
        error: wrapMethod(console.error, Level.Error),
    }

    function wrapMethod(method: (...args: unknown[]) => void, level: Level) {
        return (...args: unknown[]) => {
            if (!logger.shouldLog(level)) return

            const prefix = `[${level.toUpperCase()[0]}][${moduleName}]`
            method(prefix, ...args)
        }
    }

    loggers[moduleName] = logger
    return logger
}

使用效果:

const authLog = createLogger('auth')
const networkLog = createLogger('network')

authLog.info('User login')       // [I][auth] User login
networkLog.debug('API called')   // [D][network] API called

机制三:全局控制接口

globalThis.__logger = {
    list: () => Object.keys(loggers),
    setLevel: (name: string, level: Level) => {
        if (loggers[name]) {
            loggers[name].level = level
        }
    },
}

控制级别

// 浏览器控制台
__logger.list()                      // ['auth', 'network']
__logger.setLevel('payment', 'debug') // 开启支付模块详细日志

源代码

enum Level {
    Debug = 'debug',
    Info = 'info',
    Warn = 'warn',
    Error = 'error',
}

const LOG_LEVEL_PRIORITY = {
    [Level.Debug]: 0,
    [Level.Info]: 1,
    [Level.Warn]: 2,
    [Level.Error]: 3,
}

interface Logger {
    level: Level
    debug: (...args: unknown[]) => void
    info: (...args: unknown[]) => void
    warn: (...args: unknown[]) => void
    error: (...args: unknown[]) => void
    debugGroup: (...args: unknown[]) => void
    debugGroupEnd: () => void
    shouldLog: (level: Level) => boolean
}

const loggers: Record<string, Logger> = {}

/**
 * 创建或获取一个模块的日志记录器
 * 解决痛点2:缺乏模块隔离
 */
function createLogger(moduleName: string, defaultLevel = Level.Info): Logger {
    if (loggers[moduleName]) {
        return loggers[moduleName]
    }

    const logger = {
        level: defaultLevel,

        shouldLog(level: Level): boolean {
            return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[logger.level]
        },

        debug: wrapMethod(console.log, Level.Debug),
        info: wrapMethod(console.info, Level.Info),
        warn: wrapMethod(console.warn, Level.Warn),
        error: wrapMethod(console.error, Level.Error),
        debugGroup: wrapMethod(console.group, Level.Debug),
        debugGroupEnd: wrapMethod(console.groupEnd, Level.Debug),
    }

    function wrapMethod(method: (...args: unknown[]) => void, level: Level): (...args: unknown[]) => void {
        return (...args: unknown[]) => {
            // 解决痛点1:缺乏级别控制
            if (!logger.shouldLog(level)) {
                return
            }

            // 解决痛点3:格式不统一
            // 部分解决痛点4:添加模块名上下文(但时间戳等仍需手动传入)
            const prefix = `[${level.toUpperCase()[0]}][${moduleName}]`
            method(prefix, ...args)
        }
    }

    loggers[moduleName] = logger
    return logger
}

globalThis.__logger = {
    list: () => Object.keys(loggers),
    get: (name: string) => loggers[name],
    setLevel: (name: string, level: Level) => {
        if (loggers[name]) {
            loggers[name].level = level
            console.log(`✅ ${name} 日志级别已设置为 ${level}`)
        } else {
            console.warn(`⚠️  模块 ${name} 不存在`)
        }
    },
}