Storybook client-logger:客户端统一日志包的使用方法与源码解析
本文围绕 Storybook 核心包中的 client-logger 模块展开:它是 Storybook 中所有客户端日志输出的统一入口。读完本文,你能掌握 logger / once / pretty / deprecate 各组 API 的正确用法、日志级别(LOGLEVEL)的过滤机制,以及该包在 Storybook manager 与 preview 运行时中被内部模块大量引用的具体方式。
为什么 Storybook 的客户端日志必须走 client-logger
README 中的一句话定下了该模块的职责:任何经由 Storybook 进行的客户端侧日志,都应该通过本包完成。这背后的动机很直接——Storybook 的 manager 界面(左侧导航、addon 面板等)与 preview 运行时(渲染 story 的环境)中散落着大量框架内部逻辑,如果各处直接调用 console.*,将带来三个问题:
- 日志无法按级别统一开关:开发者无法控制 Storybook 自身输出多少噪音;
- 相同的警告/弃用提示可能在一次会话中重复刷屏;
- 无法利用控制台样式能力(
%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 模块、预览端 WebView、instrumenter、manager 全局运行时、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。即默认不输出trace和debug,但输出info及以上级别——这与 index.test.ts 中通过vi.mock将LOGLEVEL显式设为'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.ts 在 LOGLEVEL='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.trace、once.debug、once.info、once.warn、once.error、once.log六个变体; once.clear()清空去重集合,供需要重置(例如热更新后重新提示)的场景使用;deprecate是弃用提示的专用出口,等价于once('warn')——用警告级别输出,且同一条弃用信息整个会话只出现一次。在 manager/preview 的 API 模块(如 addons.ts、events.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);
};
转换规则可以拆成三步:
- 用正则把首个参数中的
<span style="...">与</span>全部替换为%c; - 每出现一个开标签,就往参数列表追加两个参数:该标签的
style值(作为%c的样式串)和一个空串(作为闭合%c的样式,避免后续文字被染色); - 第二个及以后的实参原样透传。源码中留有一条注释说明了限制: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,验证在该级别下debug、log、info、warn、error都会穿透到对应的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.ts、preview/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。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00