首页
/ Storybook 搭配 Bootstrap 的主题切换:@storybook/addon-themes 接入与 withThemeByDataAttribute 源码解析

Storybook 搭配 Bootstrap 的主题切换:@storybook/addon-themes 接入与 withThemeByDataAttribute 源码解析

2026-09-06 13:34:10作者:贡沫苏Truman

本文以 Storybook 仓库中 Bootstrap 主题接入指南 为核心,完整讲解如何将 @storybook/addon-themes 插件接入 Storybook 项目,让基于 Bootstrap 构建的 Story 支持 light/dark 颜色模式一键切换。读完本文,你将掌握该插件的安装注册流程、withThemeByDataAttribute 装饰器的完整参数配置,以及插件通过 channel 事件在 Preview 与 Manager 之间同步主题状态的底层机制。

为什么需要 addon-themes

@storybook/addon-themes 是 Storybook 官方的主题切换插件,用于在 Preview 中切换组件的多个主题。它的典型价值在于:设计系统往往同时存在浅色、深色甚至品牌定制模式,如果每个 Story 都要手工改代码才能预览另一种模式,评审效率会很低。该插件在 Manager 工具栏注入一个带画笔图标的切换器,点击即可切换当前 Story 的主题,并支持在单个 Story 上锁定覆盖。

插件的 package.json 可以看到几个关键事实:

  • 插件名称为 @storybook/addon-themes,displayName 为 Themes
  • README 说明,要求 Storybook 7.0 或更高版本
  • unsupportedFrameworks 配置为 react-native,即 React Native 框架下不受支持。

它提供了三种装饰器策略(withThemeByDataAttributewithThemeByClassNamewithThemeByJSXProvider,见 decorators 目录),本文聚焦 Bootstrap 官方推荐的数据属性(data attribute)策略。

第一步:安装插件

在项目中以 dev dependency 安装插件,支持三种包管理器:

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

同时别忘了安装 Bootstrap 本体(bootstrap 包)——它是 Story 样式的来源,不属于插件的依赖。

第二步:在 main 配置中注册插件

.storybook/main.jsaddons 数组中加入 @storybook/addon-themes

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

注册后,插件会同时加载两部分运行时代码:Manager 侧的工具栏组件(src/theme-switcher.tsx)与 Preview 侧的初始 globals(src/preview.ts)。从 Preview 侧源码看,插件默认初始化了一个空的 theme global:

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

这保证了即使用户没有显式设置 globals.theme,装饰器读取全局状态时也不会遇到 undefined

第三步:在 preview 中引入 Bootstrap 的样式与脚本

要让 Story 能用到 Bootstrap 的样式和交互脚本(如模态框、下拉菜单),需要在 .storybook/preview.js 中导入:

import { Preview } from '@storybook/your-renderer';

+import 'bootstrap/dist/css/bootstrap.min.css';
+import 'bootstrap/dist/js/bootstrap.bundle';

const preview: Preview = {
  parameters: { /* ... */ },
};

export default preview;

bootstrap.bundle 已包含 Popper.js,因此无需额外引入。这一步与插件本身无关,是 Bootstrap 项目本身的常规做法,但放在 preview 中导入能确保所有 Story 共享同一份全局样式。

第四步:用 withThemeByDataAttribute 声明主题策略

Bootstrap 原生支持 light 与 dark 两种颜色模式,也允许自定义模式,切换机制是在某个父元素上设置 data-bs-theme 属性。基于这个机制,只需在 preview 中挂载 withThemeByDataAttribute 装饰器,即可让工具栏的切换动作落到 data-bs-theme 上:

-import { Preview } from '@storybook/your-renderer';
+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;

参数详解

对照装饰器源码 data-attribute.decorator.tsx,完整参数与默认值如下:

参数 类型 默认值 说明
themes Record<string, string> 必填 主题键值对。左侧键是工具栏中显示的“主题名”,右侧值是最终写入 DOM 的属性值
defaultTheme string 必填 初始主题键,未选择任何主题时生效
parentSelector string 'html' 属性挂载的父元素选择器,默认写在 <html>
attributeName string 'data-theme' 要写入的属性名。Bootstrap 必须显式传 data-bs-theme,其他 CSS 方案(如 postcss 定制主题)可用默认值

themes 是“展示名 → 属性值”的映射,这个设计让你可以在工具栏里显示更友好的名字(例如 brand-dark: 'dark'),而 DOM 上实际写入的仍是 Bootstrap 认识的值。

源码级工作机制

装饰器的核心实现只有三步(见 data-attribute.decorator.tsx):

  1. 注册主题清单。装饰器创建时立即调用 initializeThemeState(Object.keys(themes), defaultTheme)。该函数(helpers.ts)通过 addons.getChannel().emit(THEMING_EVENTS.REGISTER_THEMES, ...) 向 Manager 广播主题名与默认主题。Manager 侧的 ThemeSwitcher 组件 监听该事件并更新本地状态——这就是工具栏下拉框里主题选项的来源。事件名常量定义在 constants.ts,为 storybook/themes/REGISTER_THEMES

  2. 计算当前主题。每次 Story 渲染时,按优先级取值:

    const themeKey = themeOverride || selected || defaultTheme;
    

    优先级为:story 级 parameters.themes.themeOverride > 全局 globals.theme(工具栏选择结果)> defaultTheme

  3. 写入 DOM 属性。在 useEffect 中通过 document.querySelector(parentSelector) 找到父元素并执行 setAttribute(attributeName, themes[themeKey]),依赖数组为 [themeOverride, selected]——即仅当覆盖值或全局主题变化时才触发 DOM 更新,避免不必要的重渲染。

对 Bootstrap 场景,效果就是:工具栏点击“dark”后,<html> 上出现 data-bs-theme="dark",Bootstrap 5.3 的颜色模式机制接管其余工作,所有 Story 组件随之换肤。

工具栏切换器的呈现逻辑

了解 theme-switcher.tsx 可以实现,工具栏组件会根据注册的主题数量自适应形态:

  • 恰好 2 个主题(如 Bootstrap 的 light/dark):渲染一个带画笔图标的切换按钮,点击直接切到另一个主题;
  • 多于 2 个主题:渲染一个 Select 下拉框列出全部主题;
  • 0 或 1 个主题:不渲染任何控件(hasMultipleThemes 不成立时返回 null)。

此外还有两个细节值得注意:

  • 锁定态(isLocked):当当前 Story 在 meta 或 story 级设置了 globals: { theme: '...' },或设置了 themeOverride 参数时,工具栏控件被禁用并显示 “Story override” 标签(源码),提示该 Story 的主题已被故事本身接管;
  • 整体禁用:在 parameters.themes 中设置 disable: true 可隐藏工具栏控件并关闭插件行为(参数类型定义见 types.ts)。

在单个 Story 上覆盖主题

虽然工具栏负责全局切换,但有时某个组件只适合固定主题预览(例如深色模式下的告警条)。可以在 meta 或 story 级用 globals.theme 锁定:

export default {
  title: 'Example/Button',
  component: Button,
  globals: { theme: 'dark' }, // meta 级覆盖
};

export const Primary = {
  args: { primary: true, label: 'Button' },
};

export const PrimaryDark = {
  args: { primary: true, label: 'Button' },
  globals: { theme: 'dark' }, // story 级覆盖
};

这正对应装饰器取值优先级中的 selected 来源——pluckThemeFromContexthelpers.ts)从 context.globals 中读取 theme 键。此外 types.ts 还定义了 story 级 parameters.themes.themeOverride,它的优先级高于 globals.theme,适合在参数中做更细粒度的覆盖。

完整配置速查

将上面各步合并后,一个最小可用的 Bootstrap 主题切换配置如下:

// .storybook/main.js
export default {
  stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|ts|tsx)'],
  addons: ['@storybook/addon-essentials', '@storybook/addon-themes'],
};
// .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 = {
  parameters: { /* ... */ },
  decorators: [
    withThemeByDataAttribute<Renderer>({
      themes: { light: 'light', dark: 'dark' },
      defaultTheme: 'light',
      attributeName: 'data-bs-theme',
    }),
  ],
};

export default preview;

最后需要提醒三个适用前提与限制:一是插件要求 Storybook 7.0+ 且不支持 React Native 框架;二是 withThemeByDataAttribute 依赖浏览器 DOM(内部使用 document.querySelector 与 React 的 useEffect),因此仅适用于基于 DOM 的渲染环境;三是它只对 parentSelector 选中的元素生效,若你的组件渲染在独立 Shadow DOM 或 iframe 内,属性不会自动穿透,需要自定义策略(可参考 插件 API 文档 中“编写自定义装饰器”的思路)。

如果你想换用其他主题方案,同一目录下还有 emotionstyled-componentsmaterial-uitailwind 等针对各自工具链的接入指南,其核心装饰器用法与本文一致,只是落地的属性或 Provider 不同。

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