首页
/ Storybook client-logger:客户端统一日志包的使用方法与源码解析

Storybook client-logger:客户端统一日志包的使用方法与源码解析

2026-09-06 15:13:35作者:裘晴惠Vivianne

本文围绕 Storybook 核心包中的 client-logger 模块展开:它是 Storybook 中所有客户端日志输出的统一入口。读完本文,你能掌握 logger / once / pretty / deprecate 各组 API 的正确用法、日志级别(LOGLEVEL)的过滤机制,以及该包在 Storybook manager 与 preview 运行时中被内部模块大量引用的具体方式。

为什么 Storybook 的客户端日志必须走 client-logger

README 中的一句话定下了该模块的职责:任何经由 Storybook 进行的客户端侧日志,都应该通过本包完成。这背后的动机很直接——Storybook 的 manager 界面(左侧导航、addon 面板等)与 preview 运行时(渲染 story 的环境)中散落着大量框架内部逻辑,如果各处直接调用 console.*,将带来三个问题:

  1. 日志无法按级别统一开关:开发者无法控制 Storybook 自身输出多少噪音;
  2. 相同的警告/弃用提示可能在一次会话中重复刷屏;
  3. 无法利用控制台样式能力(%c 格式化)做品牌化输出。

因此官方推荐的用法(摘自 README)是通过内部入口导入统一的 logger 对象:

import { logger } from 'storybook/internal/client-logger';

logger.info('Info message');
logger.warn('Warning message');
logger.error('Error message');

在仓库内部,这个约定得到了严格执行。以 code/core/src 为范围统计,直接引用 internal/client-logger 的模块遍布 manager 与 preview 两条链路,例如 addons 模块globals 模块预览端 WebViewinstrumentermanager 全局运行时preview 全局运行时 等十余处。换句话说,你在浏览器控制台看到的 Storybook 自身提示,几乎都来自这个包。

logger:带级别过滤的六个方法

完整实现只有百余行,位于 index.ts。先看级别体系(index.ts 第 7~19 行):

type LogLevel = 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent';

const levels: Record<LogLevel, number> = {
  trace: 1,
  debug: 2,
  info: 3,
  warn: 4,
  error: 5,
  silent: 10,
};

const currentLogLevelString: LogLevel = LOGLEVEL as LogLevel;
const currentLogLevelNumber: number = levels[currentLogLevelString] || levels.info;

要点有两条:

  • 级别是数值化阈值trace(1) < debug(2) < info(3) < warn(4) < error(5) < silent(10)。每个 logger.xxx 方法在输出前比较 currentLogLevelNumber <= levels.xxx,当前级别不高于该方法的级别才放行。也就是说,把级别调到 error 时,trace/debug/info/warn 全部静默,只剩 error;调到 silent 时,连 error 也被屏蔽(只有 log 方法用 < levels.silent 判断,见下文)。
  • 默认级别是 info:当全局变量 LOGLEVEL 不存在或取值不在六个合法值之内时,levels[currentLogLevelString] 得到 undefined,回退到 levels.info。即默认不输出 tracedebug,但输出 info 及以上级别——这与 index.test.ts 中通过 vi.mockLOGLEVEL 显式设为 'debug' 再断言各方法会调用对应 console 方法的测试逻辑互相印证。

logger 对象本身(index.ts 第 23~54 行)暴露六个方法,一一对应转发到原生 console

方法 转发目标 放行条件
logger.trace console.trace 当前级别 ≤ trace
logger.debug console.debug 当前级别 ≤ debug
logger.info console.info 当前级别 ≤ info
logger.warn console.warn 当前级别 ≤ warn
logger.error console.error 当前级别 ≤ error
logger.log console.log 当前级别 < silent

注意 log 是特殊成员:它不参与 levels 的阈值映射(levels 中没有 log),只在“未静音”时输出。测试用例 index.test.tsLOGLEVEL='debug' 的 mock 环境下逐一验证了 debug/log/info/warn/error 分别调用到 global.console 的对应方法,可作为行为契约的权威依据。

LOGLEVEL 从哪里来:manager 模板注入

index.ts 第 1~5 行从 @storybook/global 取全局变量:

import { global } from '@storybook/global';

// @ts-ignore
const { LOGLEVEL } = global;

它不依赖 window,而是通过 @storybook/global 解析当前运行时的全局对象,因此同一份代码在浏览器 manager、iframe preview 与 SSR 场景下行为一致。那么 global.LOGLEVEL 是谁写进去的?在仓库中可以找到注入点:builder-manager 的 HTML 模板 在构建 manager 页面时把日志级别序列化后挂到全局作用域(template.ts 第 65 行附近):

LOGLEVEL: JSON.stringify(await logLevel, null, 2),

即:日志级别在构建期就被烧进 manager 的 HTML 入口client-logger 在模块初始化时读取一次并固化为 currentLogLevelNumber,之后所有方法共享这个阈值。这也意味着运行中改变 LOGLEVEL 全局变量不会影响已加载模块的判断——从源码结构看,阈值只在模块顶层计算一次。

once 与 deprecate:同一消息只打一次

Storybook 内部的很多警告来自重复执行的路径(如 addon 注册、模块合并等),如果每次都打印会刷屏。index.ts 第 56~75 行提供了去重工具:

const logged = new Set();
export const once =
  (type: keyof typeof logger) =>
  (message: any, ...rest: any[]) => {
    if (logged.has(message)) {
      return undefined;
    }
    logged.add(message);
    return loggertype;
  };

once.clear = () => logged.clear();
once.trace = once('trace');
once.debug = once('debug');
// ... info / warn / error / log 同理

export const deprecate = once('warn');

工作机制:

  • once 内部维护一个 Set,以消息体本身作为去重键。同一 message 第二次及以后传入时直接返回 undefined,不再打印;
  • 工厂形式 once(type) 允许绑定任意日志级别,并预置了 once.traceonce.debugonce.infoonce.warnonce.erroronce.log 六个变体;
  • once.clear() 清空去重集合,供需要重置(例如热更新后重新提示)的场景使用;
  • deprecate 是弃用提示的专用出口,等价于 once('warn')——用警告级别输出,且同一条弃用信息整个会话只出现一次。在 manager/preview 的 API 模块(如 addons.tsevents.ts)中可以看到这类对历史 API 的弃用处理正是通过 client-logger 完成。

一个细节值得注意:去重键是 message 而不是调用栈,所以如果希望同一提示因不同上下文分别展示一次,需要让 message 本身携带差异信息(例如拼接组件名或 story id)。

pretty:用 %c 给控制台日志上色

浏览器控制台支持 %c 占位符来指定后续文本的 CSS 样式,但 Storybook 内部习惯用 JSX 风格的 <span style="color: #663399">...</span> 描述品牌色。pretty(index.ts 第 77~109 行)负责把前者翻译成后者:

export const pretty =
  (type: keyof typeof logger) =>
  (...args: Parameters<LoggingFn>) => {
    const argArray: Parameters<LoggingFn> = [] as any;

    if (args.length) {
      const startTagRe = /<span\s+style=(['"])([^'"]*)\1\s*>/gi;
      const endTagRe = /<\/span>/gi;

      let reResultArray;
      argArray.push(args[0].replace(startTagRe, '%c').replace(endTagRe, '%c'));

      while ((reResultArray = startTagRe.exec(args[0]))) {
        argArray.push(reResultArray[2]);
        argArray.push('');
      }
      // ... 后续参数原样透传
    }
    logger[type].apply(logger, argArray);
  };

转换规则可以拆成三步:

  1. 用正则把首个参数中的 <span style="..."></span> 全部替换为 %c
  2. 每出现一个开标签,就往参数列表追加两个参数:该标签的 style 值(作为 %c 的样式串)和一个空串(作为闭合 %c 的样式,避免后续文字被染色);
  3. 第二个及以后的实参原样透传。源码中留有一条注释说明了限制:Chrome DevTools 尚不支持 console.log('%cBlue!', 'color: blue;', '%cRed!', 'color: red;') 这种多个独立着色段的写法,所以 pretty 只处理 args[0]

同样地,pretty 也预置了 pretty.trace/debug/info/warn/error 变体,用法与 once 对称。

测试如何锁定行为

index.test.ts 是整个包的行为契约,值得作为引用依据浏览:

  • 通过 vi.mock('@storybook/global', () => ({ global: { ...global, LOGLEVEL: 'debug' } })) 显式把级别设为 debug,验证在该级别下 debugloginfowarnerror 都会穿透到对应的 console 方法;
  • beforeEach 中用 vi.fn() 替换全部 console 方法,afterAll 恢复原始 console,保证断言只针对本模块的行为;
  • 断言形式为 expect(global.console.debug).toHaveBeenCalledWith(message),即“消息原样透传、不做二次包装”。

需要说明的是,当前测试只覆盖了 LOGLEVEL='debug' 这一档;info 默认档、silent 屏蔽 error 等分支是从源码实现直接读出的行为(见上文级别阈值表),引用时请以 index.ts 的实现为准。

适用前提与注意事项

  • 入口约定:Storybook 内部使用 storybook/internal/client-logger 这个内部子路径导入(见 manager/globals/exports.tspreview/globals/runtime.ts 等文件),它属于 @storybook/core 的 internal 表面,并非面向业务项目稳定导出的公共 API;业务代码如需日志,直接在自身应用中使用 console 即可。
  • 级别时机LOGLEVEL 由 manager 构建模板注入并在模块加载时读取一次,运行时改写全局变量不改变已判定好的阈值(从源码结构看,这是顶层一次性求值的结果)。
  • 去重键once/deprecate 以消息体为键,跨模块复用完全相同的字符串时,后到者会被静默;需要重置时调用 once.clear()
  • 样式边界pretty 仅转换首个参数中的 span 样式,依赖 Chrome DevTools 对单个 %c 段的支持,其他浏览器控制台的渲染效果可能退化。

小结

client-logger 用不到一百五十行代码回答了三个工程问题:日志级别怎么统一开关(LOGLEVEL 阈值 + 模板注入)、重复提示怎么收敛(once/deprecate 去重)、控制台输出怎么带上品牌样式(pretty%c 翻译)。对维护 Storybook 内部模块或在其之上扩展 manager/preview 逻辑的开发者来说,凡是涉及客户端日志,正确姿势就是导入 logger(或 once/pretty/deprecate),而不是直接触碰 console

登录后查看全文
热门项目推荐
相关项目推荐