首页
/ Storybook addon-themes 完全指南:在 Storybook 工具栏中实现组件多主题切换

Storybook addon-themes 完全指南:在 Storybook 工具栏中实现组件多主题切换

2026-09-06 13:28:31作者:乔或婵

本篇指南以 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 主包联动,peerDependenciesstorybook: 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 属性切换。三者都接受 themesdefaultTheme,并都会调用 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,会退化为仅渲染 GlobalStylesstoryFn(),因此 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.mdemotion.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/materialThemeProviderCssBaseline 交给 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 中用 withThemeByClassNameis-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.tsxThemeSwitcher 组件负责工具栏 UI,几个关键点:

  • 通过 useParameter(PARAM_KEY, ...) 读取当前 story 的 themes 参数,其中 disable: true 时直接不渲染任何控件;
  • 通过 useChannel 监听 REGISTER_THEMES 事件,把装饰器注册上来的主题列表与默认主题存入 useAddonState
  • 计算 isLocked = KEY in storyGlobals || !!themeOverride——只要该 story 用 globals.themethemeOverride 固定了主题,工具栏控件就会被禁用,并显示 “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.themesrc/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 覆盖)> defaultThemethemes.disable: true 可在任意层级关闭插件;
  • 从源码看,manager 与 preview 通过 storybook/themes/REGISTER_THEMES 事件与 globals.theme 全局键协作,Story 一旦锁定主题,工具栏控件会自动禁用并标注 “Story override”;
  • 对内置装饰器无法覆盖的方案,用 DecoratorHelpersinitializeThemeState + pluckThemeFromContext)编写自定义装饰器,useThemeParameters 已弃用,请直接读 context.parameters.themes
登录后查看全文
热门项目推荐
相关项目推荐