首页
/ Storybook @storybook/addon-themes:@mui/material 主题切换配置全解

Storybook @storybook/addon-themes:@mui/material 主题切换配置全解

2026-09-06 13:39:13作者:薛曦旖Francesca

本篇指南基于 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 条件限制了该工具仅在 storydocs 视图模式下显示:

// 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.jsonexports 字段可以印证这种双入口结构:./manager./preview 分别指向独立的构建产物,源码分别对应 src/manager.tsxsrc/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> 主题名到主题对象的映射,键即工具栏中显示的主题名(如 lightdark
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/首个主题。当只配置了一个主题时会走快捷分支直接取该主题。pluckThemeFromContextcontext.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 外,还提供 withThemeFromClassNamewithThemeFromDataAttribute 等替代策略,可分别查看 class-name.decorator.tsxdata-attribute.decorator.tsx
  • 其它工具配方styled-componentsemotiontailwindbootstrappostcss 的对应配置方式,与本文 Material UI 配方互为对照;
  • 示例故事:该扩展自带的 Storybook 示例,展示 decorators、globals、parameters 三种维度的主题配置。
登录后查看全文
热门项目推荐
相关项目推荐