首页
/ Storybook @storybook/addon-themes 主题插件 API 深度解析:三个内置装饰器与自定义主题装饰器的完整实现

Storybook @storybook/addon-themes 主题插件 API 深度解析:三个内置装饰器与自定义主题装饰器的完整实现

2026-09-06 13:30:53作者:裴麒琰

本文系统讲解 Storybook 主题插件 @storybook/addon-themes 的完整 API:包括 withThemeFromJSXProviderwithThemeByClassNamewithThemeByDataAttribute 三个内置装饰器的配置参数与适用场景,以及基于 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.tsxpreview.ts 中:

// code/addons/themes/src/preview.ts
export const initialGlobals: ProjectAnnotations<Renderer>['initialGlobals'] = {
  [KEY]: '', // KEY 即 GLOBAL_KEY = 'theme'
};

仓库内还针对主流主题工具提供了配方文档,可作为不同技术栈的参照:

内置装饰器一: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.tsThemesParameters 中)。

相关常量定义在 constants.tsADDON_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-themesDecoratorHelpers 命名空间访问。

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 根节点上;
  • withThemeFromJSXProviderProvider 被实现为任意接收 { 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 下的配方文档;当你所用库不在配方列表中时,本文的自定义装饰器一节提供了可直接套用的实现范式。

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