首页
/ Storybook Themes 插件实战:为 @emotion/styled 项目配置 withThemeFromJSXProvider 主题切换

Storybook Themes 插件实战:为 @emotion/styled 项目配置 withThemeFromJSXProvider 主题切换

2026-09-06 13:36:13作者:丁柯新Fawn

本文为 emotion.md 这篇官方入门文档的深度扩写版,介绍如何在 Storybook 中安装并注册 @storybook/addon-themes 插件,并用 withThemeFromJSXProvider 装饰器接入 @emotion/reactThemeProvider,使你在预览工具栏中一键切换 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,仅在 storydocs 视图且无 tab 时显示(match: ({ viewMode, tabId }) => !!(viewMode && viewMode.match(/^(story|docs)$/)) && !tabId);
  • Preview 侧:装饰器(如本文的 withThemeFromJSXProvider)通过 channel 事件 REGISTER_THEMES 把可用主题列表上报给工具栏,并在渲染时根据当前选中主题包裹组件;
  • 状态载体:选中的主题存在全局状态 globals.theme 中(键名定义见 constants.tsPARAM_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.jsonexports 字段可以看到该包对外暴露了 .(预览侧装饰器 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 装饰器,把你的主题对象、ThemeProviderGlobalStyles 组件一起传入:

-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/reactThemeProvider
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]);

从源码结构看,主题的最终取值遵循明确的优先级链:

  1. parameters.themes.themeOverride —— Story/meta 级显式覆盖;
  2. globals.theme —— 工具栏切换后写入的全局主题(pluckThemeFromContextcontext.globals['theme'] 读取,见 helpers.ts);
  3. initialThemedefaultThemethemes 的第一个键)。

两个值得注意的实现细节:

  • 单主题直通:当 themes 只有一项时,无论当前选中什么,都直接取那一项(pairs.length === 1 ? ... : themes[selectedThemeName]),因此单主题配置不会因全局值不匹配而拿到 undefined
  • Provider 可省略:若未传 Provider,装饰器只渲染 <GlobalStyles /> + 故事内容,退化为纯全局样式方案(provider.decorator.tsx)。对 Emotion 项目来说,常规做法仍是传入 ThemeProvider,让 theme prop 驱动 styled 组件。

五、工具栏切换控件的两种形态

Manager 侧的 ThemeSwitchertheme-switcher.tsx)会根据主题数量自适应:

  • 恰好 2 个主题:渲染为单按钮,点击即在两个主题间翻转(alternateTheme = themesList.find((t) => t !== currentTheme)),本文 light/dark 场景正是这种形态;
  • 3 个及以上主题:渲染为带图标的下拉 Select,选项即 themes 的全部键名。

此外还有两个状态行为:

  • 锁定(locked):当 globals.theme 已显式设置,或故事传了 themeOverride 时,isLockedtrue,控件被禁用并显示 “Theme set by story parameters” 提示,表示该故事的展示主题由故事配置接管,避免工具栏误切换;
  • 禁用面板:故事参数 parameters: { themes: { disable: true } } 会让控件直接返回 nullif (disable) return null)。参数与全局状态的完整类型定义见 types.tsThemesParameters.themes 支持 disable?: booleanthemeOverride?: stringThemesGlobals 支持 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 的属性策略装饰器(withThemeByClassNamewithThemeByDataAttribute,源码见 decorators/ 目录,入口导出见 decorators/index.ts),适用于 Bootstrap、Tailwind 等无需 Provider 的方案。

仓库内与本文强相关的延伸阅读:

小结:接入 Emotion 主题的完整路径是「安装 @storybook/addon-themes → 在 main.jsaddons 注册 → 在 preview.jswithThemeFromJSXProvider 传入 themes/defaultTheme/Provider/GlobalStyles」三步;主题切换通过 channel 事件 storybook/themes/REGISTER_THEMES 打通 Manager 与 Preview,最终主题取值遵循 themeOverride > globals.theme > defaultTheme 的优先级,配合 globals.theme 覆盖与 themes.disable 参数,即可满足从全局演示到单故事锁定的全部场景。

登录后查看全文
热门项目推荐
相关项目推荐