Storybook @storybook/addon-themes 主题插件 API 深度解析:三个内置装饰器与自定义主题装饰器的完整实现
本文系统讲解 Storybook 主题插件 @storybook/addon-themes 的完整 API:包括 withThemeFromJSXProvider、withThemeByClassName、withThemeByDataAttribute 三个内置装饰器的配置参数与适用场景,以及基于 DecoratorHelpers 编写自定义主题装饰器(以 Vuetify 为例)的完整流程。结合仓库中的装饰器实现与 toolbar 切换器源码,你将理解主题选择优先级、preview 与 manager 之间的通道通信机制,并掌握单个 story 主题覆盖(globals.theme / themeOverride)的实战用法。
安装与注册
@storybook/addon-themes 需要 Storybook 7.0 及以上版本(见 code/addons/themes/README.md)。安装并注册方式如下:
npm i -D @storybook/addon-themes
在 .storybook/main.js 中启用该 addon:
export default {
addons: ['@storybook/addon-themes'],
};
注册后,该 addon 会在 manager 侧注册一个标题为 "Themes" 的 toolbar 工具(仅在 story/docs 视图下显示),同时向 preview 侧注入 initialGlobals,将全局变量 theme 初始化为空字符串。对应的实现分别在 manager.tsx 和 preview.ts 中:
// code/addons/themes/src/preview.ts
export const initialGlobals: ProjectAnnotations<Renderer>['initialGlobals'] = {
[KEY]: '', // KEY 即 GLOBAL_KEY = 'theme'
};
仓库内还针对主流主题工具提供了配方文档,可作为不同技术栈的参照:
@mui/material@emotion/styledbootstrappostcssstyled-componentstailwindvuetify@3.x:使用下文“编写自定义装饰器”方案
内置装饰器一:withThemeFromJSXProvider
适用于通过 JSX Provider + 全局样式 注入主题方案的库(如 MUI 的 ThemeProvider、styled-components 的 ThemeProvider)。它接收主题对象映射、Provider 组件与全局样式组件,并据此包裹 story:
import { withThemeFromJSXProvider } from '@storybook/addon-themes';
export const decorators = [
withThemeFromJSXProvider({
themes: {
light: lightTheme,
dark: darkTheme,
},
defaultTheme: 'light',
Provider: ThemeProvider,
GlobalStyles: CssBaseline,
}),
];
可用选项(引自 code/addons/themes/docs/api.md):
| option | type | 必填 | 说明 |
|---|---|---|---|
themes |
Record<string, any> |
否 | 主题配置对象,key 为主题名,value 为主题对象。提供多个主题时,toolbar 会出现主题切换项 |
defaultTheme |
string |
否 | 默认使用的主题名 |
Provider |
组件 | 否 | 用于提供主题的 JSX 组件 |
GlobalStyles |
组件 | 否 | 包含全局 CSS 样式的 JSX 组件 |
源码中的主题选择优先级
从 provider.decorator.tsx 的实现看,装饰器工厂函数在创建时即调用 initializeThemeState(themeNames, initialTheme) 向 manager 注册主题列表,其中 initialTheme = defaultTheme || themeNames[0]——即未显式指定 defaultTheme 时,默认取 themes 中第一个 key。
每次 story 渲染时的实际主题按以下优先级解析(provider.decorator.tsx):
const { themeOverride } = context.parameters[PARAM_KEY] ?? {}; // story 参数
const selected = pluckThemeFromContext(context); // toolbar 全局状态
const selectedThemeName = themeOverride || selected || initialTheme;
即:parameters.themes.themeOverride > toolbar 选择的 globals.theme > 默认主题。此外还有一个值得注意的细节:当 themes 只有一个条目时,直接使用唯一主题,忽略切换状态(pairs.length === 1 ? pluckThemeFromKeyPairTuple(pairs[0]) : themes[selectedThemeName]),保证单主题场景下不需要用户做任何选择。若未提供 Provider,装饰器退化为仅渲染 GlobalStyles 加 story 本身。
内置装饰器二:withThemeByClassName
适用于通过 CSS 类名 启用主题的库(如 Bootstrap 5、Tailwind 的 class 策略等)。它接收“主题名 → 类名”的映射,并自动把类名应用到父元素上:
import { withThemeByClassName } from '@storybook/addon-themes';
export const decorators = [
withThemeByClassName({
themes: {
light: 'light-theme',
dark: 'dark-theme',
},
defaultTheme: 'light',
}),
];
| option | type | 必填 | 说明 |
|---|---|---|---|
themes |
Record<string, string> |
✅ | 主题名 → 主题类名的映射 |
defaultTheme |
string |
✅ | 默认主题名 |
parentSelector |
string |
否 | 应用主题类的父元素选择器,默认 "html" |
实现细节
class-name.decorator.tsx 中的关键行为:
parentSelector默认值为'html'(DEFAULT_ELEMENT_SELECTOR),通过document.querySelector(parentSelector)定位元素;- 类名字符串会按空格拆分(
classStringToArray),因此 单个主题可以配置多个类名,例如themes: { dark: 'theme-dark font-serif' }; - 切换主题时先移除其他主题对应的全部类名,再添加当前主题的类名,避免残留类互相冲突;
- 逻辑包裹在
useEffect中,依赖项为[themeOverride, selected],仅在主题变化时执行 DOM 操作,不阻塞首帧渲染(story 本体直接return storyFn())。
内置装饰器三:withThemeByDataAttribute
适用于通过 data 属性 切换主题的现代库,典型代表是 Bootstrap 5.3(data-bs-theme):
import { withThemeByDataAttribute } from '@storybook/addon-themes';
export const decorators = [
withThemeByDataAttribute({
themes: {
light: 'light',
dark: 'dark',
},
defaultTheme: 'light',
attributeName: 'data-bs-theme',
}),
];
| option | type | 必填 | 说明 |
|---|---|---|---|
themes |
Record<string, string> |
✅ | 主题名 → data 属性值的映射 |
defaultTheme |
string |
✅ | 默认主题名 |
parentSelector |
string |
否 | 设置 data 属性的父元素选择器,默认 "html" |
attributeName |
string |
否 | 要设置的 data 属性名,默认 "data-theme" |
data-attribute.decorator.tsx 中,attributeName 默认值为 'data-theme';主题解析优先级与其余两个装饰器完全一致(themeOverride || selected || defaultTheme),最终在 useEffect 中执行 parentElement.setAttribute(attributeName, themes[themeKey])。
toolbar 主题切换器的工作机制
三个内置装饰器在工厂阶段都会调用 initializeThemeState,把主题列表与默认主题通过 addon 通道广播给 manager,从而驱动 toolbar 上的 "Themes" 工具。从 theme-switcher.tsx 的源码看,切换器有几种形态:
- 恰好 2 个主题:渲染为单个
Button(画笔图标),点击即在两个主题间切换; - 3 个及以上主题:渲染为
Select下拉框,列出全部主题; - 仅 1 个主题:不渲染任何控件;
- 锁定状态:当 story 的
globals声明了theme,或参数中存在themeOverride时(isLocked),控件置为 disabled 并显示 "Story override" 标签,提示该 story 的主题已被 story 本身锁定。
此外,parameters.themes.disable: true 会移除工具栏控件并整体停用 addon 行为(该选项定义在 types.ts 的 ThemesParameters 中)。
相关常量定义在 constants.ts:ADDON_ID = 'storybook/themes'、GLOBAL_KEY = 'theme'、通道事件 THEMING_EVENTS.REGISTER_THEMES,是理解 preview 与 manager 通信的关键。
覆盖单个 Story 的主题
若只希望某个组件或 story 固定使用特定主题,而不跟随 toolbar 切换,可以在 meta 或 story 级别声明 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' },
};
story 级别的 globals 优先级高于 meta 级别与 toolbar 选择,同时会让 toolbar 切换器进入锁定态(显示 "Story override")。另一种更细粒度、不锁定 story 全局语义的方式是参数 parameters.themes.themeOverride,它只在装饰器内部参与优先级解析(见上文 themeOverride || selected || initialTheme)。
编写自定义装饰器:DecoratorHelpers
如果现有三个装饰器都不适配你的主题库,addon 还导出了一组 DecoratorHelpers,让你复用主题切换状态来自建装饰器。这些辅助函数定义在 helpers.ts 中,从 @storybook/addon-themes 的 DecoratorHelpers 命名空间访问。
pluckThemeFromContext
从 Storybook 全局状态中读取当前选中的主题名。源码实现非常直接——读取 context.globals[GLOBAL_KEY](即 globals.theme),未设置时返回空字符串:
import { DecoratorHelpers } from '@storybook/addon-themes';
const { pluckThemeFromContext } = DecoratorHelpers;
export const myCustomDecorator =
({ themes, defaultState, ...rest }) =>
(storyFn, context) => {
const selectedTheme = pluckThemeFromContext(context);
// Snipped
};
useThemeParameters(已废弃)
⛔ Deprecated:不要再使用这个 hook,改为直接从 context 访问参数,例如
context.parameters.themes。
返回本 addon 的主题参数。源码中该函数在调用时会发出 deprecation 警告,并优先使用 context.parameters[PARAM_KEY] ?? DEFAULT_THEME_PARAMETERS:
import { DecoratorHelpers } from '@storybook/addon-themes';
const { useThemeParameters } = DecoratorHelpers;
export const myCustomDecorator =
({ themes, defaultState, ...rest }) =>
(storyFn, context) => {
const { themeOverride } = useThemeParameters();
// Snipped
};
initializeThemeState
用于向 addon 状态注册主题列表与默认主题。实现上是通过 addons.getChannel().emit(THEMING_EVENTS.REGISTER_THEMES, { defaultTheme, themes }) 向 manager 广播(helpers.ts)——这正是 toolbar 切换器能感知到主题列表的原因。
import { DecoratorHelpers } from '@storybook/addon-themes';
const { initializeThemeState } = DecoratorHelpers;
export const myCustomDecorator = ({ themes, defaultState, ...rest }) => {
initializeThemeState(Object.keys(themes), defaultTheme);
return (storyFn, context) => {
// Snipped
};
};
完整示例:Vuetify
Vuetify 使用自己的全局状态决定渲染哪个主题,三个内置装饰器都无法直接适配。以下自定义装饰器演示了“注册主题 + 读取选择 + 写入 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 中提供给 Storybook:
// .storybook/preview.js
import { setup } from '@storybook/vue3';
import { registerPlugins } from '../src/plugins';
import { withVuetifyTheme } from './withVuetifyTheme.decorator';
setup((app) => {
registerPlugins(app);
});
/* snipped for brevity */
export const decorators = [
withVuetifyTheme({
// 这些 key 将作为 toolbar 主题切换器中显示的标签,
// value 必须与你 VuetifyOptions 中的主题 key 对应
themes: {
light: 'light',
dark: 'dark',
'high contrast': 'highContrast',
},
defaultTheme: 'light', // 默认主题的 key
}),
];
注意 themes 中 key 与 value 的语义分离:key 是展示在 toolbar 中的标签(可含空格,如 'high contrast'),value 必须能映射到你主题库自身的主题标识。
模板故事中的实战参考
仓库内建模板附带了三个装饰器的对照演示故事 decorators.stories.ts,其中展示了两个值得借鉴的写法:
- 在沙盒模板中使用
parentSelector: '#storybook-root > *'代替默认的html,把主题类/属性应用到 story 根节点上; withThemeFromJSXProvider的Provider被实现为任意接收{ theme, children }的函数,注释说明该模式需要在非 React 环境中也能工作,因此不能依赖useEffect,改用setTimeout在渲染完成后操作 DOM。
小结与关键路径索引
@storybook/addon-themes 的核心设计可以概括为三层:装饰器层负责把“选中主题”转译为各主题库能理解的注入方式(Provider / class / data attribute);通道层通过 REGISTER_THEMES 事件与 globals.theme 在 preview 和 manager 间同步状态;toolbar 层根据主题数量自适应呈现 Button 或 Select,并尊重 story 级锁定与 disable 参数。
| 关注点 | 文件路径 |
|---|---|
| API 参考文档 | code/addons/themes/docs/api.md |
| 使用说明与覆盖示例 | code/addons/themes/README.md |
| Provider 装饰器实现 | code/addons/themes/src/decorators/provider.decorator.tsx |
| 类名装饰器实现 | code/addons/themes/src/decorators/class-name.decorator.tsx |
| data 属性装饰器实现 | code/addons/themes/src/decorators/data-attribute.decorator.tsx |
| 自定义装饰器辅助函数 | code/addons/themes/src/decorators/helpers.ts |
| toolbar 切换器实现 | code/addons/themes/src/theme-switcher.tsx |
| addon 常量与事件定义 | code/addons/themes/src/constants.ts |
| 类型定义(parameters/globals) | code/addons/themes/src/types.ts |
| 模板演示故事 | code/addons/themes/template/stories/decorators.stories.ts |
如需快速上手某一具体主题库,可优先查阅 docs/getting-started 下的配方文档;当你所用库不在配方列表中时,本文的自定义装饰器一节提供了可直接套用的实现范式。
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