Storybook Themes 插件实战:为 @emotion/styled 项目配置 withThemeFromJSXProvider 主题切换
本文为 emotion.md 这篇官方入门文档的深度扩写版,介绍如何在 Storybook 中安装并注册 @storybook/addon-themes 插件,并用 withThemeFromJSXProvider 装饰器接入 @emotion/react 的 ThemeProvider,使你在预览工具栏中一键切换 Light/Dark 等多套主题。读完后,你将掌握:三步完成插件接入、理解装饰器内部的主题选择优先级(themeOverride > 全局 theme > defaultTheme),以及如何按 Story 粒度锁定主题、禁用插件面板。
一、插件总览:Themes Addon 解决什么问题
@storybook/addon-themes(见 package.json,描述为 “Switch between themes from the toolbar”)让组件在预览区按主题切换渲染:插件在 Manager 工具栏注册一个 “Themes” 切换控件,在 Preview 侧通过装饰器把选中的主题注入到组件树中。其架构由三部分组成:
- Manager 侧:manager.tsx 通过
addons.register(ADDON_ID, ...)注册一个types.TOOL,仅在story或docs视图且无 tab 时显示(match: ({ viewMode, tabId }) => !!(viewMode && viewMode.match(/^(story|docs)$/)) && !tabId); - Preview 侧:装饰器(如本文的
withThemeFromJSXProvider)通过 channel 事件REGISTER_THEMES把可用主题列表上报给工具栏,并在渲染时根据当前选中主题包裹组件; - 状态载体:选中的主题存在全局状态
globals.theme中(键名定义见 constants.ts:PARAM_KEY = 'themes'、GLOBAL_KEY = 'theme'、ADDON_ID = 'storybook/themes')。
插件要求 Storybook 7.0 及以上版本(见 README.md);当前仓库中的包版本为 10.6.0-beta.1,以下配置以该代码库为准。
二、安装插件
第一步:以 dev dependency 安装包。 文档给出了三种包管理器的命令:
yarn:
yarn add -D @storybook/addon-themes
npm:
npm install -D @storybook/addon-themes
pnpm:
pnpm add -D @storybook/addon-themes
从 package.json 的 exports 字段可以看到该包对外暴露了 .(预览侧装饰器 API)、./manager、./preview 三个入口——Storybook 框架在解析插件路径 @storybook/addon-themes 时,会自动把 manager.js/manager.tsx 加载到 Manager、preview.js/preview.ts 加载到 Preview,因此注册 addon 后无需手动 import 这两个入口文件。
三、在 .storybook/main.js 中注册 Addon
第二步:把 addon 路径加入 addons 数组。 对 .storybook/main.js 做如下修改(+ 为新增行):
export default {
stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
addons: [
'@storybook/addon-essentials',
+ '@storybook/addon-themes',
],
};
注册之后,manager.tsx 中的 addons.add(THEME_SWITCHER_ID, { title: 'Themes', type: types.TOOL, ... }) 才会生效,工具栏随即出现主题切换入口(控件 ID 为 storybook/themes/theme-switcher,见 constants.ts)。
四、提供主题:用 withThemeFromJSXProvider 接入 Emotion
第三步:在预览配置中提供主题与全局样式。 文档要求在 .storybook/preview.js 中使用 withThemeFromJSXProvider 装饰器,把你的主题对象、ThemeProvider 和 GlobalStyles 组件一起传入:
-import { Preview } from '@storybook/your-renderer';
+import { Preview, Renderer } from '@storybook/your-renderer';
+import { withThemeFromJSXProvider } from '@storybook/addon-themes';
+import { ThemeProvider } from '@emotion/react';
+import { GlobalStyles, lightTheme, darkTheme } from '../src/themes'; // Import your custom theme configs
const preview: Preview = {
parameters: { /* ... */ },
+ decorators: [
+ withThemeFromJSXProvider<Renderer>({
+ themes: {
+ light: lightTheme,
+ dark: darkTheme,
+ },
+ defaultTheme: 'light',
+ Provider: ThemeProvider,
+ GlobalStyles: GlobalStyles,
+ }),
+ ]
};
export default preview;
四个配置项的含义:
| 配置项 | 说明 |
|---|---|
themes |
主题映射表:键是主题名(如 light/dark),值是传给 Provider theme 属性的主题对象。主题名会直接显示在工具栏切换控件中 |
defaultTheme |
默认主题名。省略时源码会回退到 themes 的第一个键(Object.keys(themes)[0]),见 provider.decorator.tsx |
Provider |
你的样式库的主题 Provider 组件,Emotion 场景传 @emotion/react 的 ThemeProvider |
GlobalStyles |
可选的全局样式组件,会在 Provider 内部、故事渲染之前挂载 |
源码解读:主题选择与渲染流程
withThemeFromJSXProvider 的完整实现见 provider.decorator.tsx,可以分两段理解:
1)注册阶段(装饰器工厂执行时)
const themeNames = Object.keys(themes);
const initialTheme = defaultTheme || themeNames[0];
initializeThemeState(themeNames, initialTheme);
其中 initializeThemeState(见 helpers.ts)通过 addons channel 发出 THEMING_EVENTS.REGISTER_THEMES(即 storybook/themes/REGISTER_THEMES)事件,携带 themes 名称列表和默认主题。Manager 侧的 theme-switcher.tsx 正是通过 channel.last(THEMING_EVENTS.REGISTER_THEMES) 与 useChannel 监听拿到这份列表,从而渲染出正确的切换控件。也就是说:你传给装饰器的 themes 键,就是工具栏里可切换的主题名。
2)渲染阶段(每个 Story 渲染时)
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—— Story/meta 级显式覆盖;globals.theme—— 工具栏切换后写入的全局主题(pluckThemeFromContext从context.globals['theme']读取,见 helpers.ts);initialTheme(defaultTheme或themes的第一个键)。
两个值得注意的实现细节:
- 单主题直通:当
themes只有一项时,无论当前选中什么,都直接取那一项(pairs.length === 1 ? ... : themes[selectedThemeName]),因此单主题配置不会因全局值不匹配而拿到undefined; Provider可省略:若未传Provider,装饰器只渲染<GlobalStyles />+ 故事内容,退化为纯全局样式方案(provider.decorator.tsx)。对 Emotion 项目来说,常规做法仍是传入ThemeProvider,让themeprop 驱动styled组件。
五、工具栏切换控件的两种形态
Manager 侧的 ThemeSwitcher(theme-switcher.tsx)会根据主题数量自适应:
- 恰好 2 个主题:渲染为单按钮,点击即在两个主题间翻转(
alternateTheme = themesList.find((t) => t !== currentTheme)),本文 light/dark 场景正是这种形态; - 3 个及以上主题:渲染为带图标的下拉
Select,选项即themes的全部键名。
此外还有两个状态行为:
- 锁定(locked):当
globals.theme已显式设置,或故事传了themeOverride时,isLocked为true,控件被禁用并显示 “Theme set by story parameters” 提示,表示该故事的展示主题由故事配置接管,避免工具栏误切换; - 禁用面板:故事参数
parameters: { themes: { disable: true } }会让控件直接返回null(if (disable) return null)。参数与全局状态的完整类型定义见 types.ts:ThemesParameters.themes支持disable?: boolean与themeOverride?: string,ThemesGlobals支持theme?: string。
六、按 Story 粒度覆盖主题
如果你只想让某个(或某些)故事固定展示某一主题,文档与 README 提供了两个层级的手段:
方式一:parameters.themes.themeOverride(装饰器内优先读取,见上文优先级链)
export const PrimaryDark = {
args: { primary: true, label: 'Button' },
parameters: {
themes: { themeOverride: 'dark' },
},
};
仓库自带的 parameters.stories.ts 模板即演示了 themeOverride: 'b' 的写法,同一文件的 Disabled 故事还演示了 themes: { disable: true } 隐藏切换控件。
方式二:globals.theme 覆盖(meta 级或 story 级均可,见 README.md 的 “Overriding theme” 一节):
export default {
title: 'Example/Button',
component: Button,
// meta level override
globals: { theme: 'dark' },
};
export const PrimaryDark = {
args: { primary: true, label: 'Button' },
// story level override
globals: { theme: 'dark' },
};
仓库模板 globals.stories.ts 中的 SetGlobal 故事同样演示了 globals: { theme: 'b' } 的用法——此时工具栏控件进入 locked 状态,与上文第五节的锁定行为对应。
七、其他接入策略与延伸阅读
withThemeFromJSXProvider 是 “Provider 策略”(用样式库的 React Context 下发主题)。同一插件还内置了基于 DOM 的属性策略装饰器(withThemeByClassName、withThemeByDataAttribute,源码见 decorators/ 目录,入口导出见 decorators/index.ts),适用于 Bootstrap、Tailwind 等无需 Provider 的方案。
仓库内与本文强相关的延伸阅读:
- 同一 “Getting started” 系列的其它框架接入配方:material-ui.md、styled-components.md、bootstrap.md、tailwind.md、postcss.md;
- 参数/全局状态 API 参考:api.md,其中 “Writing a custom decorator” 一节教你为 Emotion 之外的任意主题方案自写装饰器;
- 插件 README:code/addons/themes/README.md(含安装、注册、覆盖主题的简要说明);
- 预览侧入口:preview.ts、Manager 侧入口:manager.tsx。
小结:接入 Emotion 主题的完整路径是「安装 @storybook/addon-themes → 在 main.js 的 addons 注册 → 在 preview.js 用 withThemeFromJSXProvider 传入 themes/defaultTheme/Provider/GlobalStyles」三步;主题切换通过 channel 事件 storybook/themes/REGISTER_THEMES 打通 Manager 与 Preview,最终主题取值遵循 themeOverride > globals.theme > defaultTheme 的优先级,配合 globals.theme 覆盖与 themes.disable 参数,即可满足从全局演示到单故事锁定的全部场景。
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