Storybook addon-themes 完全指南:在 Storybook 工具栏中实现组件多主题切换
本篇指南以 Storybook 官方 @storybook/addon-themes 插件(源码位于 code/addons/themes)为主体,系统讲解它的安装注册、三种内置主题装饰器、主流 UI 工具链(styled-components、Emotion、MUI、Tailwind、Bootstrap、PostCSS)的接入配方,以及如何用 globals.theme 覆盖单个 Story 的主题。读完并结合仓库源码后,你将掌握从工具栏一键切换主题到编写完全自定义装饰器的完整能力,并理解 preview 与 manager 之间基于 channel 事件的主题状态同步机制。
插件定位:在预览区一键切换主题
@storybook/addon-themes 的作用是在 Storybook 的预览区(preview)中支持在多个主题之间切换。其 package.json 中的描述为 "Storybook Themes addon: Switch between themes from the toolbar",即主题切换入口直接呈现在 manager 的顶部工具栏中。
从 package.json 可以看出该插件的发布结构:
- 当前仓库中的版本为
10.6.0-beta.1,以 workspace 形式与 Storybook 主包联动,peerDependencies为storybook: workspace:^; exports暴露了主入口(装饰器导出)、./manager(工具栏 UI)、./preview(全局初始值)与./postinstall四个子路径;storybook.unsupportedFrameworks声明了react-native不支持;- 唯一运行时依赖是
ts-dedent(用于生成弃用提示信息)。
插件的核心机制分为两侧:manager 侧注册一个工具栏工具(theme switcher),preview 侧提供一组装饰器把“当前选中的主题”应用到你的组件上。两侧的协作细节将在后文结合源码展开。
安装与注册
该插件要求 Storybook 7.0 或更高版本。安装命令如下:
npm i -D @storybook/addon-themes
然后在 .storybook/main.js(或 main.ts)中注册插件:
export default {
addons: ['@storybook/addon-themes'],
};
从 src/index.ts 可以看到插件以 definePreviewAddon 的方式导出 preview 注解:
export default () => definePreviewAddon<ThemesTypes>(addonAnnotations);
export * from './decorators/index.ts';
其中 preview 注解内容定义在 src/preview.ts,它初始化了主题全局状态的默认值:
export const initialGlobals: ProjectAnnotations<Renderer>['initialGlobals'] = {
[KEY]: '', // KEY 即 GLOBAL_KEY = 'theme'
};
也就是说,插件会在所有 globals 中注入一个 theme 键,初始为空字符串。后续无论用户在工具栏选什么、还是 Story 里通过 globals.theme 覆盖,最终都写入这个键。
三种内置装饰器与完整参数表
插件对外导出三个开箱即用的装饰器(见 src/decorators/index.ts),分别对应三种主题落地策略:通过 JSX Provider 注入、通过父元素 class 名切换、通过父元素 data 属性切换。三者都接受 themes 与 defaultTheme,并都会调用 initializeThemeState 向 manager 注册可用主题列表,从而让工具栏出现切换控件。
withThemeFromJSXProvider:Provider 注入式
适用于 styled-components、Emotion、Material UI 这类通过 React Context/Provider 分发主题配置的库:
import { withThemeFromJSXProvider } from '@storybook/addon-themes';
export const decorators = [
withThemeFromJSXProvider({
themes: {
light: lightTheme,
dark: darkTheme,
},
defaultTheme: 'light',
Provider: ThemeProvider,
GlobalStyles: CssBaseline,
}),
];
可选参数(引自 docs/api.md):
| 参数 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
themes |
Record<string, any> |
主题配置对象,键为主题名、值为主题对象。提供多个主题时,工具栏会自动出现切换项 | |
defaultTheme |
string |
默认使用的主题名 | |
Provider |
用于提供主题的 JSX 组件 | ||
GlobalStyles |
包含全局 CSS 的 JSX 组件 |
从 src/decorators/provider.decorator.tsx 的实现可以看到几个值得注意的细节:
- 若未提供
defaultTheme,取themes的第一个键作为初始主题(const initialTheme = defaultTheme || themeNames[0]); - 主题选择优先级为
themeOverride || selected || initialTheme,即“Story 级参数覆盖 > 工具栏全局选择 > 默认主题”; - 若只配置了一个主题,无论选什么都直接使用唯一主题对象;
- 若未提供
Provider,会退化为仅渲染GlobalStyles与storyFn(),因此Provider并非硬性必填。
withThemeByClassName:class 名切换式
适用于通过给祖先元素加 class 来启用主题的 CSS 方案(典型如 Tailwind 的 dark 类、PostCSS 工具生成的 .is-dark 等):
import { withThemeByClassName } from '@storybook/addon-themes';
export const decorators = [
withThemeByClassName({
themes: {
light: 'light-theme',
dark: 'dark-theme',
},
defaultTheme: 'light',
}),
];
| 参数 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
themes |
Record<string, string> |
✅ | 主题名到主题 class 名的映射 |
defaultTheme |
string |
✅ | 默认主题名 |
parentSelector |
string |
要应用主题 class 的父元素选择器,默认 "html" |
src/decorators/class-name.decorator.tsx 中的实现逻辑是:在 useEffect 中先移除所有“非当前主题”对应的 class(class 值按空格分割,支持一个主题对应多个 class),再把当前主题 class 添加到 document.querySelector(parentSelector) 命中的元素上。若选择器未命中元素则直接跳过,不会报错。这也解释了为什么在仓库自带的 template/stories/decorators.stories.ts 示例中,parentSelector 被显式设置为 '#storybook-root > *'——把 class 加到 Story 根节点而非整个 html 上,避免污染 Storybook 自身界面。
withThemeByDataAttribute:data 属性切换式
适用于通过 data 属性切换主题的方案(典型如 Bootstrap 5 的 data-bs-theme):
import { withThemeByDataAttribute } from '@storybook/addon-themes';
export const decorators = [
withThemeByDataAttribute({
themes: {
light: 'light',
dark: 'dark',
},
defaultTheme: 'light',
attributeName: 'data-bs-theme',
}),
];
| 参数 | 类型 | 是否必填 | 说明 |
|---|---|---|---|
themes |
Record<string, string> |
✅ | 主题名到 data 属性值的映射 |
defaultTheme |
string |
✅ | 默认主题名 |
parentSelector |
string |
父元素选择器,默认 "html" |
|
attributeName |
string |
设置在父元素上的 data 属性名,默认 "data-theme" |
src/decorators/data-attribute.decorator.tsx 的实现很简洁:在 useEffect 中对命中的父元素执行 parentElement.setAttribute(attributeName, themes[themeKey])。
主流工具链接入配方
插件随仓库提供了 6 份工具专属配方文档(位于 code/addons/themes/docs/getting-started/),安装与注册步骤与上文一致,差异都在 .storybook/preview.js 的装饰器配置与样式导入上。下面按文档脉络逐一整理。
styled-components / @emotion/styled:Provider 注入
两者配方几乎相同(styled-components.md、emotion.md),区别仅在 ThemeProvider 的来源:
// .storybook/preview.js
import { Preview, Renderer } from '@storybook/your-renderer';
import { withThemeFromJSXProvider } from '@storybook/addon-themes';
import { ThemeProvider } from 'styled-components'; // 或 '@emotion/react'
import { GlobalStyles, lightTheme, darkTheme } from '../src/themes'; // 你自定义的主题配置
const preview: Preview = {
parameters: { /* ... */ },
decorators: [
withThemeFromJSXProvider<Renderer>({
themes: {
light: lightTheme,
dark: darkTheme,
},
defaultTheme: 'light',
Provider: ThemeProvider,
GlobalStyles: GlobalStyles,
}),
],
};
export default preview;
@mui/material:Provider 注入 + 字体导入
MUI 配方 额外要求导入 Roboto 与 Material Icon 字体(文档建议用 fontsource 作为版本锁定的依赖而非 CDN),然后把 @mui/material 的 ThemeProvider 与 CssBaseline 交给 withThemeFromJSXProvider:
// .storybook/preview.js(节选)
import '@fontsource/roboto/300.css';
import '@fontsource/roboto/400.css';
import '@fontsource/roboto/500.css';
import '@fontsource/roboto/700.css';
import '@fontsource/material-icons';
import { withThemeFromJSXProvider } from '@storybook/addon-themes';
import { CssBaseline, ThemeProvider } from '@mui/material';
import { lightTheme, darkTheme } from '../src/themes';
const preview: Preview = {
parameters: { /* ... */ },
decorators: [
withThemeFromJSXProvider<Renderer>({
themes: { light: lightTheme, dark: darkTheme },
defaultTheme: 'light',
Provider: ThemeProvider,
GlobalStyles: CssBaseline,
}),
],
};
export default preview;
Tailwind:class 切换(或 data 属性切换)
Tailwind 配方 分两步:先把 Tailwind 样式表导入 preview,再按你在 tailwind.config 中配置的 dark mode 策略选择装饰器。
class 策略(默认,.dark 类):
// .storybook/preview.js
import { Preview, Renderer } from '@storybook/your-renderer';
import { withThemeByClassName } from '@storybook/addon-themes';
import '../src/index.css';
const preview: Preview = {
parameters: { /* ... */ },
decorators: [
withThemeByClassName<Renderer>({
themes: {
light: '', // light 无 class
dark: 'dark', // 父元素挂 .dark 即进入暗色模式
},
defaultTheme: 'light',
}),
],
};
export default preview;
如果配置为 data 属性策略,则换用 withThemeByDataAttribute 并指定 attributeName: 'data-theme'。
Bootstrap 5:data-bs-theme 属性切换
Bootstrap 配方 先导入 Bootstrap 的 CSS 与 JS bundle,再用 data-bs-theme 属性装饰器:
// .storybook/preview.js
import { Preview, Renderer } from '@storybook/your-renderer';
import { withThemeByDataAttribute } from '@storybook/addon-themes';
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.bundle';
const preview: Preview = {
parameters: { /* ... */ },
decorators: [
withThemeByDataAttribute<Renderer>({
themes: {
light: 'light',
dark: 'dark',
},
defaultTheme: 'light',
attributeName: 'data-bs-theme',
}),
],
};
export default preview;
PostCSS:prefers-color-scheme 转 class
PostCSS 配方 适合纯 CSS 项目。核心思路是利用 CSS 原生的 @media (prefers-color-scheme: dark) 书写暗色样式,再用 postcss-dark-theme-class 插件把媒体查询内容复制为 .is-dark 选择器下的规则:
# 额外需要安装
npm install -D @storybook/addon-themes postcss-dark-theme-class
PostCSS 配置中加入插件:
// postcss.config.js
module.exports = {
plugins: [
require('postcss-dark-theme-class'),
require('autoprefixer'),
],
};
CSS 中继续用媒体查询写暗色变量:
:root {
--text-color: black;
}
@media (prefers-color-scheme: dark) {
html {
--text-color: white;
}
}
然后在 preview 中用 withThemeByClassName 挂 is-light / is-dark class,并在 preview 中 import "../src/index.css" 引入样式。
覆盖单个 Story 的主题:globals.theme 与 parameters.themes
README 的核心用法之一:如果你想为某个组件或某个 Story 固定主题,可以在 meta 或 story 层级设置 globals.theme:
import React from 'react';
import { Button } from './Button';
export default {
title: 'Example/Button',
component: Button,
// meta level override:该组件下所有 story 都用 dark
globals: { theme: 'dark' },
};
export const Primary = {
args: {
primary: true,
label: 'Button',
},
};
export const PrimaryDark = {
args: {
primary: true,
label: 'Button',
},
// story level override:仅该 story 用 dark
globals: { theme: 'dark' },
};
仓库自带的模板 Story template/stories/globals.stories.ts 就是这一机制的最小示例:SetGlobal story 在 meta defaultTheme: 'a' 的基础上,通过 globals: { theme: 'b' } 强制切到主题 b。
从 src/decorators/provider.decorator.tsx 中可以看到三种来源的取值优先级:
const selectedThemeName = themeOverride || selected || initialTheme;
即:parameters.themes.themeOverride(Story 参数级)> globals.theme(工具栏全局选择 / Story globals 覆盖)> 装饰器里的默认主题。
除 globals.theme 外,插件还定义了完整的参数类型,见 src/types.ts:
export interface ThemesParameters {
themes?: {
/** 移除工具栏面板并禁用插件行为 */
disable?: boolean;
/** 为该 story 覆盖的主题 */
themeOverride?: string;
};
}
export interface ThemesGlobals {
/** 为该 story 覆盖的主题 */
theme?: string;
}
也就是说,你还可以通过 parameters: { themes: { themeOverride: 'dark' } } 精细地覆盖某个 Story,或用 themes: { disable: true } 在 meta/story 层面直接关闭整个插件(工具栏控件会返回 null,见下文)。
源码解析:工具栏切换器与 channel 状态同步
理解了“装饰器如何应用主题”,再看 manager 侧就顺理成章了。整个插件的关键常量集中在 src/constants.ts:
export const PARAM_KEY = 'themes'; // 参数键
export const ADDON_ID = 'storybook/themes'; // 插件 ID
export const GLOBAL_KEY = 'theme'; // 全局状态键
export const THEME_SWITCHER_ID = 'storybook/themes/theme-switcher';
export const THEMING_EVENTS = {
REGISTER_THEMES: 'storybook/themes/REGISTER_THEMES', // 主题注册事件
};
manager 侧:工具栏控件如何出现
src/manager.tsx 在 manager 中注册工具:
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,
});
});
match 条件表明:控件只在 story 或 docs 视图下显示,且当前没有打开 tab 时才渲染。
切换器 UI:两主题用按钮,多主题用下拉
src/theme-switcher.tsx 的 ThemeSwitcher 组件负责工具栏 UI,几个关键点:
- 通过
useParameter(PARAM_KEY, ...)读取当前 story 的themes参数,其中disable: true时直接不渲染任何控件; - 通过
useChannel监听REGISTER_THEMES事件,把装饰器注册上来的主题列表与默认主题存入useAddonState; - 计算
isLocked = KEY in storyGlobals || !!themeOverride——只要该 story 用globals.theme或themeOverride固定了主题,工具栏控件就会被禁用,并显示 “Story override” 的标签与提示(theme-switcher.tsx#L43-L67); - 恰好两个主题时渲染一个切换
Button(点一下即在两者间切换),多于两个主题时渲染一个Select下拉; - 用户选择后调用
updateGlobals({ theme: selected }),把选中的主题写回globals.theme,preview 侧的装饰器随后在下次渲染时读取到新值——这就完成了“工具栏点击 → 全局状态更新 → Story 重渲染换肤”的闭环。
preview 侧:主题如何被“注册”与“读取”
装饰器调用 initializeThemeState 时,本质是向 channel 发出 REGISTER_THEMES 事件(src/decorators/helpers.ts#L34-L39):
export function initializeThemeState(themeNames: string[], defaultTheme: string) {
addons.getChannel().emit(THEMING_EVENTS.REGISTER_THEMES, {
defaultTheme,
themes: themeNames,
});
}
而读取当前主题的 helper pluckThemeFromContext 就是直接取 context.globals.theme(src/decorators/helpers.ts#L16-L18):
export function pluckThemeFromContext({ globals }: StoryContext): string {
return globals[GLOBAL_KEY] || '';
}
由此可以推断整个数据流:装饰器注册主题列表 → manager 渲染切换控件 → 用户选择写入 globals.theme → 装饰器从 context.globals.theme 读取并应用。这也解释了为什么 globals.theme 既是 Story 覆盖机制,又是工具栏选择机制——两者共用同一个全局键。
编写自定义装饰器:DecoratorHelpers
如果你的主题方案无法被上面三个装饰器覆盖(比如 Vuetify 依赖自己的全局主题状态),插件提供了 DecoratorHelpers 集合让你自建装饰器,详见 docs/api.md 的 "Writing a custom decorator" 一节:
pluckThemeFromContext(context):从 Storybook 全局状态读取当前选中的主题名;initializeThemeState(themeNames, defaultTheme):把主题列表与默认主题注册给插件,让工具栏出现切换控件;useThemeParameters:已弃用,官方明确建议直接通过context.parameters.themes访问参数(src/decorators/helpers.ts#L20-L32 中该函数会调用deprecate打印弃用提示)。
以 Vuetify 为例(api.md 给出的完整示例),自定义装饰器 .storybook/withVuetifyTheme.decorator.js:
import { DecoratorHelpers } from '@storybook/addon-themes';
import { useTheme } from 'vuetify';
const { initializeThemeState, pluckThemeFromContext } = DecoratorHelpers;
export const withVuetifyTheme = ({ themes, defaultTheme }) => {
initializeThemeState(Object.keys(themes), defaultTheme);
return (story, context) => {
const selectedTheme = pluckThemeFromContext(context);
const { themeOverride } = context.parameters.themes ?? {};
const selected = themeOverride || selectedTheme || defaultTheme;
return {
components: { story },
setup() {
const theme = useTheme();
theme.global.name.value = themes[selected];
return { theme };
},
template: `<v-app><story /></v-app>`,
};
};
};
再在 .storybook/preview.js 中挂到 decorators:
// .storybook/preview.js
import { setup } from '@storybook/vue3';
import { registerPlugins } from '../src/plugins';
import { withVuetifyTheme } from './withVuetifyTheme.decorator';
setup((app) => {
registerPlugins(app);
});
export const decorators = [
withVuetifyTheme({
// 键是工具栏切换器中显示的标签,值必须与 VuetifyOptions 的主题键对应
themes: {
light: 'light',
dark: 'dark',
'high contrast': 'highContrast',
},
defaultTheme: 'light',
}),
];
注意这里 themes 的键(工具栏显示名)与值(Vuetify 内部主题键)是解耦的一对映射,这正是自定义装饰器相对内置装饰器多出来的表达能力。
小结
@storybook/addon-themes通过.storybook/main.js一行注册,即可在工具栏获得主题切换能力,要求 Storybook 7.0+;- 三个内置装饰器覆盖三类主题落地方式:
withThemeFromJSXProvider(Provider 注入)、withThemeByClassName(class 切换)、withThemeByDataAttribute(data 属性切换),选型取决于你的 UI 技术栈; - 主题优先级为
parameters.themes.themeOverride>globals.theme(工具栏选择或 Story globals 覆盖)>defaultTheme;themes.disable: true可在任意层级关闭插件; - 从源码看,manager 与 preview 通过
storybook/themes/REGISTER_THEMES事件与globals.theme全局键协作,Story 一旦锁定主题,工具栏控件会自动禁用并标注 “Story override”; - 对内置装饰器无法覆盖的方案,用
DecoratorHelpers(initializeThemeState+pluckThemeFromContext)编写自定义装饰器,useThemeParameters已弃用,请直接读context.parameters.themes。
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 StartedRust0624
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