首页
/ Storybook `@storybook/addon-themes`:用 styled-components 实现主题化 Story 与工具栏主题切换

Storybook `@storybook/addon-themes`:用 styled-components 实现主题化 Story 与工具栏主题切换

2026-09-06 13:44:25作者:田桥桑Industrious

本篇基于 Storybook 仓库中 themes add-on 的官方入门文档 styled-components 入门指南,完整讲解如何用 @storybook/addon-themes 为基于 styled-components 的组件库接入多主题支持:从安装包、注册 add-on,到用 withThemeFromJSXProvider 装饰器注入 ThemeProvider 与全局样式,并结合仓库源码说明主题选择的优先级链路、工具栏主题切换器的渲染逻辑,以及 themes 参数与 global 的用法,使读者既能照着落地,也能理解底层实现。

这个 add-on 解决什么问题

themes add-on(包名 @storybook/addon-themes)让组件 Story 可以声明多个主题,并在 Storybook 工具栏中提供一键切换能力。其核心导出位于 src/index.ts:默认导出一个 preview add-on(注入 initialGlobals),同时通过 export * from './decorators/index.ts' 把三个装饰器——withThemeFromJSXProviderwithThemeByClassNamewithThemeByDataAttribute——公开给 preview 端使用。本文聚焦其中与 styled-components 生态配套的 withThemeFromJSXProvider

安装 add-on

按照官方入门文档,先将包作为 dev 依赖安装。三种包管理器任选其一:

# yarn
yarn add -D @storybook/addon-themes
# npm
npm install -D @storybook/addon-themes
# pnpm
pnpm add -D @storybook/addon-themes

在 main 配置中注册 add-on

接下来在 .storybook/main.jsaddons 数组中加入 add-on 标识,diff 如下(官方文档原文):

export default {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
  addons: [
    '@storybook/addon-essentials',
+   '@storybook/addon-themes',
  ],
};

注册后,manager 端会在 src/manager.tsx 中以 story book/themes 作为 add-on ID 注册一个标题为 "Themes" 的 TOOL 类型工具项,其 match 条件为 viewModestorydocs 且未处于子 tab,也就是说该切换器只在普通 Story / Docs 视图中出现。

提供主题:withThemeFromJSXProvider 装饰器

最后一步是把你的主题对象、ThemeProvider 和全局样式组件交给 add-on 提供的 withThemeFromJSXProvider 装饰器。按官方文档修改 .storybook/preview.js(注意 your-renderer 需替换为你实际使用的渲染器包,例如 react 对应 @storybook/react):

-import { Preview } from '@storybook/your-renderer';
+import { Preview, Renderer } from '@storybook/your-renderer';
+import { withThemeFromJSXProvider } from '@storybook/addon-themes';
+import { ThemeProvider } from 'styled-components';
+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;

其中 lightThemedarkTheme 是你的主题 token 对象(例如颜色、字体、间距等),GlobalStyles 是一个把主题变量落到 CSS 变量或全局选择器上的 styled-components 组件。

参数详解

从源码 src/decorators/provider.decorator.tsx 中的 ProviderStrategyConfiguration 接口可以看到完整参数集:

参数 类型 说明
themes Record<string, Theme> 主题名到主题对象的映射,例如 { light, dark };默认 {}。主题列表的 key 即工具栏中可选的主题名
defaultTheme string 默认主题名。若省略,源码回退为 themeNames[0],即 themes 中第一个 key
Provider 任意 React 组件 主题 Provider 组件,例如 styled-components 的 ThemeProvider。装饰器会以 <Provider theme={theme}> 形式包裹 Story
GlobalStyles 任意 React 组件 全局样式组件,可选。渲染在 Provider 内部、storyFn() 之前,用于应用全局 CSS 变量等

主题选择逻辑:优先级与底层实现

阅读 provider.decorator.tsx 的实现,可以看清每次 Story 渲染时主题是如何被解析的:

  1. 装饰器创建阶段const initialTheme = defaultTheme || themeNames[0],随后调用 initializeThemeState(themeNames, initialTheme)。查看 src/decorators/helpers.ts,它会通过 add-on channel 发出 THEMING_EVENTS.REGISTER_THEMES 事件,把主题名列表和默认主题上报给 manager——这正是工具栏切换器数据来源。

  2. 每次渲染阶段:从 context.parameters[PARAM_KEY](即 parameters.themes)读取 themeOverride,从 globals[GLOBAL_KEY](global key 为字符串 theme,定义见 src/constants.ts)读取用户当前选中的主题,最终主题按如下优先级解析:

    const selectedThemeName = themeOverride || selected || initialTheme;
    

    即:故事级 parameters.themes.themeOverride > 工具栏/global 选中值 > 默认主题。解析结果被 useMemo 缓存,仅依赖 selectedthemeOverride 变化时重算。

  3. 单主题快捷路径:若 themes 只有一个条目,直接取该主题对象,不做名称匹配。

  4. 无 Provider 的降级渲染:若未提供 Provider(例如纯 CSS 变量方案),装饰器不渲染包裹层,只输出 {GlobalStyles && <GlobalStyles />}{storyFn()}——这解释了为何 GlobalStyles 对非 Provider 方案同样有价值。

preview 侧的配套初始状态见 src/preview.ts:它为 global theme 设置了空字符串的 initialGlobals,保证未选主题时不会读到 undefined

工具栏主题切换器的行为

manager 端的 UI 实现在 src/theme-switcher.tsx,几个值得注意的行为细节:

  • 形态随主题数量变化:恰好 2 个主题时渲染一个 ghost Button(点击在两个主题间切换,带 PaintBrushIcon 图标);多于 2 个时渲染 Select 下拉框(见 hasTwoThemes / hasMultipleThemes 两个判断函数);少于 2 个则不渲染任何控件。
  • 锁定(isLocked)状态const isLocked = KEY in storyGlobals || !!themeOverride——即某个 Story 通过全局配置固定了主题、或在参数中写了 themeOverride 时,切换器被 disabled,按钮/下拉会显示 "Story override" 与 tooltip "Theme set by story parameters",提示当前主题由 Story 参数决定而非用户切换。
  • 状态同步:切换器通过 useChannel 监听 REGISTER_THEMES 事件更新 themesListthemeDefault;用户选择后调用 updateGlobals({ theme: selected }),把选中值写回 global theme,preview 端装饰器再经 pluckThemeFromContexthelpers.ts)读回,完成一次闭环。
  • 全局禁用:当参数 themes.disabletrue 时,组件直接 return null,同时 src/types.ts 的注释表明该参数的语义是 "Remove the addon panel and disable the addon's behavior"。

themes 参数与 global 的补充用法

装饰器之外,add-on 还暴露了参数与 global 两个控制面(类型定义见 src/types.ts):

// Story 级:固定该 Story 的主题,并锁住工具栏切换
parameters: {
  themes: {
    disable: false,       // 设为 true 时移除面板并停用 add-on 行为
    themeOverride: 'dark', // 覆盖该 Story 使用的主题
  },
},

// 全局/Story 级:预设选中的主题
globals: {
  theme: 'light',
},

仓库中的模板示例 template/stories/parameters.stories.tstemplate/stories/globals.stories.ts 分别演示了 themeOverride: 'b' 覆盖单个 Story 主题、globals: { theme: 'b' } 全局预设主题、以及 themes: { disable: true } 禁用 add-on 的完整写法;template/stories/decorators.stories.ts 则展示了三种装饰器的并存用法,可作为配置参考。

小结与延伸阅读

  • 三步落地:安装 @storybook/addon-themes → 在 main.js 注册 → 在 preview.jswithThemeFromJSXProvider 提供 themes 映射、ThemeProviderGlobalStyles
  • 主题优先级为 themeOverride(Story 参数)> globals.theme(用户切换)> defaultTheme/首个主题名,这一链路直接体现在 provider.decorator.tsx 的一行解析逻辑中。
  • 若项目不使用 styled-components,同一 add-on 还提供 withThemeByClassNamewithThemeByDataAttribute 等替代策略,对应文档位于 code/addons/themes/docs/getting-started/ 目录(emotion、material-ui、tailwind、bootstrap、postcss 等各有专页),参数级细节可进一步参阅 code/addons/themes/docs/api.mdcode/addons/themes/README.md
登录后查看全文
热门项目推荐
相关项目推荐