Storybook @storybook/addon-themes:@mui/material 主题切换配置全解
本篇指南基于 Storybook 仓库中 Material UI 主题配方文档,完整讲解如何在 Storybook 中安装 @storybook/addon-themes 扩展、注册到配置、加载 Material UI 字体,并用 withThemeFromJSXProvider 装饰器接入自定义主题,最终在工具栏中实现多主题一键切换。同时结合该扩展的源码实现,说明主题状态如何在 preview 与 manager 之间传递、主题优先级如何裁定,帮助你既会用、又知其所以然。
背景:addon-themes 的组成与工作机制
@storybook/addon-themes 用于在预览区(preview)中切换组件的多套主题。从 package.json 可以看到,该扩展要求 Storybook 7.0 或更高版本,并且明确将 react-native 列为不受支持的框架(unsupportedFrameworks 字段)。
整个扩展由两部分协作构成:
- Preview 侧:提供
withThemeFromJSXProvider等装饰器,负责把选中的主题注入到故事组件的渲染上下文中; - Manager 侧:注册一个标题为 "Themes" 的工具栏控件,负责展示切换 UI。
从 src/manager.tsx 源码可见,manager 入口调用 addons.register(ADDON_ID, ...) 注册工具,其中 match 条件限制了该工具仅在 story 或 docs 视图模式下显示:
// src/manager.tsx
addons.register(ADDON_ID, () => {
addons.add(THEME_SWITCHER_ID, {
title: 'Themes',
type: types.TOOL,
match: ({ viewMode, tabId }) => !!(viewMode && viewMode.match(/^(story|docs)$/)) && !tabId,
render: ThemeSwitcher,
paramKey: PARAM_KEY,
});
});
两侧的通信依赖常量 src/constants.ts 中定义的几个键名与事件名:
export const PARAM_KEY = 'themes' as const; // stories 中 parameters 的键
export const ADDON_ID = `storybook/${PARAM_KEY}` as const; // 扩展 ID
export const GLOBAL_KEY = 'theme' as const; // globals 中主题名的键
export const THEMING_EVENTS = {
REGISTER_THEMES: `${ADDON_ID}/REGISTER_THEMES`,
} as const;
而 src/preview.ts 会为预览端初始化 globals.theme = '',为后续的工具栏联动提供初始值。
第一步:安装扩展
按原文档要求,将包作为开发依赖安装。三种包管理器分别对应:
yarn:
yarn add -D @storybook/addon-themes
npm:
npm install -D @storybook/addon-themes
pnpm:
pnpm add -D @storybook/addon-themes
如果你使用的 Storybook 版本低于 7.0,需要先升级 Storybook 本体,否则该扩展无法正常工作(依据 README 中 "Requires Storybook 7.0 or later" 的说明)。
第二步:在 main.js 中注册扩展
安装完成后,把扩展加入 .storybook/main.js(.storybook/main.ts 同理)的 addons 数组:
export default {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-essentials',
+ '@storybook/addon-themes',
],
};
注册后,Storybook 构建时会同时加载该包的 manager 入口(提供工具栏 UI)与 preview 入口(提供 globals.theme 初始状态)。package.json 的 exports 字段可以印证这种双入口结构:./manager 与 ./preview 分别指向独立的构建产物,源码分别对应 src/manager.tsx 与 src/preview.ts。
第三步:在 preview.js 中导入字体
@mui/material 依赖 Google 的 Roboto 字体与 Material Icons 图标字体,否则组件无法按预期渲染。原文档建议通过 fontsource 方案以 npm 包形式引入字体,这样字体就是版本锁定的依赖,不依赖任何 CDN,可保证离线构建与渲染一致性。
这些导入放在 .storybook/preview.js 中,使字体对全部故事生效:
import { Preview } from '@storybook/your-renderer';
+// Load Material UI fonts
+import '@fontsource/roboto/300.css';
+import '@fontsource/roboto/400.css';
+import '@fontsource/roboto/500.css';
+import '@fontsource/roboto/700.css';
+import '@fontsource/material-icons';
const preview: Preview = {
parameters: { /* ... */ },
};
export default preview;
其中 Roboto 的 300/400/500/700 四个字重分别对应 Material UI 文本层级常用的 Light、Regular、Medium、Bold,与 @mui/material 默认主题的 fontFamily/fontWeight 设定相配合;material-icons 包则提供 <IconButton>、<Tooltip> 等组件中用到的字形图标。
第四步:用 withThemeFromJSXProvider 提供主题
Material UI 自带一套开箱即用的默认主题,但实际项目通常会有多套品牌主题。@storybook/addon-themes 提供了 withThemeFromJSXProvider 装饰器,通过 JSX Provider 机制把指定主题注入故事。
对 .storybook/preview.js 做如下修改:
-import { Preview } from '@storybook/your-renderer';
+import { Preview, Renderer } from '@storybook/your-renderer';
+import { withThemeFromJSXProvider } from '@storybook/addon-themes';
+import { CssBaseline, ThemeProvider } from '@mui/material';
+import { lightTheme, darkTheme } from '../src/themes'; // Import your custom theme configs
// Load Roboto fonts
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import '@fontsource/material-icons';
const preview: Preview = {
parameters: { /* ... */ },
+ decorators: [
+ withThemeFromJSXProvider<Renderer>({
+ themes: {
+ light: lightTheme,
+ dark: darkTheme,
+ },
+ defaultTheme: 'light',
+ Provider: ThemeProvider,
+ GlobalStyles: CssBaseline,
+ }),
+ ],
};
export default preview;
四个配置项的完整定义见 src/decorators/provider.decorator.tsx 中的 ProviderStrategyConfiguration 接口:
| 配置项 | 类型 | 说明 |
|---|---|---|
themes |
Record<string, Theme> |
主题名到主题对象的映射,键即工具栏中显示的主题名(如 light、dark) |
defaultTheme |
string |
默认主题名;省略时回退为 themes 中第一个键(initialTheme = defaultTheme || themeNames[0]) |
Provider |
任意组件 | 主题 Provider 组件,Material UI 场景传 ThemeProvider;装饰器会把当前选中的主题以 theme prop 传入 |
GlobalStyles |
任意组件 | 全局样式组件,Material UI 场景传 CssBaseline,随主题一起渲染 |
装饰器内部机制:主题如何被选中并上报
withThemeFromJSXProvider 的实现(provider.decorator.tsx#L24-L63)分三步工作:
1. 初始化并注册主题清单。 装饰器创建时立即计算主题名列表与初始主题,并通过 initializeThemeState 向 manager 上报:
// provider.decorator.tsx
const themeNames = Object.keys(themes);
const initialTheme = defaultTheme || themeNames[0];
initializeThemeState(themeNames, initialTheme);
initializeThemeState(定义于 src/decorators/helpers.ts)通过 addon channel 发出 REGISTER_THEMES 事件:
// helpers.ts
export function initializeThemeState(themeNames: string[], defaultTheme: string) {
addons.getChannel().emit(THEMING_EVENTS.REGISTER_THEMES, {
defaultTheme,
themes: themeNames,
});
}
manager 侧的 src/theme-switcher.tsx 通过 useChannel 监听该事件,把主题清单写入 addon 状态——这就是工具栏中下拉选项的来源。
2. 按优先级裁定当前主题。 每次故事渲染时,装饰器按如下优先级选取主题名:
// provider.decorator.tsx
const { themeOverride } = context.parameters[PARAM_KEY] ?? {};
const selected = pluckThemeFromContext(context); // 读取 globals.theme
const theme = useMemo(() => {
const selectedThemeName = themeOverride || selected || initialTheme;
const pairs = Object.entries(themes);
return pairs.length === 1 ? pluckThemeFromKeyPairTuple(pairs[0]) : themes[selectedThemeName];
}, [selected, themeOverride]);
优先级为:故事级 parameters.themes.themeOverride > 全局 globals.theme(工具栏选择)> defaultTheme/首个主题。当只配置了一个主题时会走快捷分支直接取该主题。pluckThemeFromContext 从 context.globals.theme 取值(GLOBAL_KEY = 'theme')。
3. 条件渲染 Provider。 如果未提供 Provider,装饰器只渲染 GlobalStyles 包裹的故事(仍可通过 class-name / data-attribute 等其它装饰器策略使用);提供时则以 <Provider theme={theme}> 包裹,GlobalStyles 渲染在 Provider 内部,保证 CSS 变量等作用域正确。
可选:故事级主题覆盖与禁用
当某个组件或故事需要固定展示某套主题时,可以使用 globals.theme 覆盖(此用法在 README 中有完整示例):
import React from 'react';
import { Button } from './Button';
export default {
title: 'Example/Button',
component: Button,
// meta level override
globals: { theme: 'dark' },
};
export const Primary = {
args: {
primary: true,
label: 'Button',
},
};
export const PrimaryDark = {
args: {
primary: true,
label: 'Button',
},
// story level override
globals: { theme: 'dark' },
};
与参数相关的类型定义集中在 src/types.ts:
export interface ThemesParameters {
themes?: {
/** Remove the addon panel and disable the addon's behavior */
disable?: boolean;
/** Which theme to override for the story */
themeOverride?: string;
};
}
export interface ThemesGlobals {
/** Which theme to override for the story */
theme?: string;
}
即可以在 parameters.themes 中使用 themeOverride 指定故事固定主题,或使用 disable: true 移除工具栏面板并禁用整个扩展行为。
从 theme-switcher.tsx 的源码可以推断工具栏的联动细节:当检测到 globals.theme 已被故事设置(KEY in storyGlobals)或存在 themeOverride 时,工具会显示 "Story override" 并禁用切换(isLocked);disable 参数为真时直接返回 null 不渲染任何 UI。
工具栏交互形态:按钮、下拉与隐藏
ThemeSwitcher 会根据注册的主题数量自适应呈现不同 UI,理解这一点有助于调试"为什么工具栏没出现切换控件":
- 两个主题:渲染一个切换按钮(
<Button>),点击即在两个主题间往返切换(updateGlobals({ theme: alternateTheme })); - 三个及以上主题:渲染一个带画笔图标的下拉选择框(
<Select>),选项即themes的键名; - 只有一个主题:不渲染任何控件(返回
null)——若你只配了一套 Material UI 主题却期望看到工具栏,这就是原因; parameters.themes.disable为真:同样不渲染。
验证与延伸
完成上述四步后,运行 storybook dev,打开任意使用 MUI 组件的故事,顶部工具栏应出现 "Themes" 控件(按钮或下拉,取决于主题数量),切换后故事会立即以新主题的 ThemeProvider 重新渲染,且字体由 fontsource 本地依赖提供。
如需进一步深入,可继续阅读仓库中的相关资源:
- Themes 扩展 README:通用用法、故事级覆盖示例与工具配方索引;
- API 参考文档:完整参数说明与"编写自定义装饰器"章节(适用于 Vuetify 等未列出的 UI 库);
- decorators 源码目录:除
withThemeFromJSXProvider外,还提供withThemeFromClassName、withThemeFromDataAttribute等替代策略,可分别查看class-name.decorator.tsx、data-attribute.decorator.tsx; - 其它工具配方:
styled-components、emotion、tailwind、bootstrap、postcss的对应配置方式,与本文 Material UI 配方互为对照; - 示例故事:该扩展自带的 Storybook 示例,展示 decorators、globals、parameters 三种维度的主题配置。
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