Storybook 搭配 Bootstrap 的主题切换:@storybook/addon-themes 接入与 withThemeByDataAttribute 源码解析
本文以 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 框架下不受支持。
它提供了三种装饰器策略(withThemeByDataAttribute、withThemeByClassName、withThemeByJSXProvider,见 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.js 的 addons 数组中加入 @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):
-
注册主题清单。装饰器创建时立即调用
initializeThemeState(Object.keys(themes), defaultTheme)。该函数(helpers.ts)通过addons.getChannel().emit(THEMING_EVENTS.REGISTER_THEMES, ...)向 Manager 广播主题名与默认主题。Manager 侧的 ThemeSwitcher 组件 监听该事件并更新本地状态——这就是工具栏下拉框里主题选项的来源。事件名常量定义在 constants.ts,为storybook/themes/REGISTER_THEMES。 -
计算当前主题。每次 Story 渲染时,按优先级取值:
const themeKey = themeOverride || selected || defaultTheme;优先级为:story 级
parameters.themes.themeOverride> 全局globals.theme(工具栏选择结果)>defaultTheme。 -
写入 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 来源——pluckThemeFromContext(helpers.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 文档 中“编写自定义装饰器”的思路)。
如果你想换用其他主题方案,同一目录下还有 emotion、styled-components、material-ui、tailwind 等针对各自工具链的接入指南,其核心装饰器用法与本文一致,只是落地的属性或 Provider 不同。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00