Storybook addon-themes 实战:用 withThemeByClassName 在预览中按 CSS 类切换主题
本文基于 Storybook 仓库的 addon-themes 官方片段 与 Themes 指南,系统讲解基于 CSS 类(CSS class)策略的主题切换方案。文章不仅给出可直接复制到.storybook/preview.*的完整配置,还会深入 class-name.decorator.tsx 源码,剖析类名是如何被添加到父元素、默认值与全局状态优先级如何取舍,帮助你在依赖html(或其他容器元素)上挂 CSS 类来切换亮色/暗色等主题的组件库中,把主题能力无缝集成进 Storybook。
背景:三种主题装饰器,按主题机制对号入座
@storybook/addon-themes的用途是在 Storybook 预览 iframe 内为组件切换多套主题。仓库的 Themes 指南 指出,它暴露了三个装饰器,分别对应三种主流的主题实现方式:
withThemeFromJSXProvider:主题通过 Provider(如 Material UI、Styled-components、Emotion)注入组件树时使用;withThemeByClassName:主题依赖父元素上的 CSS 类(class)来决定时使用,即本文主角;withThemeByDataAttribute:主题依赖父元素上的 data 属性(如data-theme)来决定时使用,配置方式可参考 storybook-addon-themes-data-attribute-decorator。
如果你的样式体系是经典写法——例如全局 CSS 里定义了html.dark { ... }或body.theme-dark这类作用域规则,那么withThemeByClassName就是最贴合的方案:它会在渲染每个 story 时,把所选主题对应的类名挂到指定父元素上,让组件直接继承主题作用域。
安装与在 preview 中的完整配置
安装依赖
在项目里安装@storybook/addon-themes(需先安装并配置好 Storybook 及对应框架,如 react-vite、vue3-vite、nextjs 等),并在.storybook/main.*的addons数组中注册:
module.exports = { addons: ['@storybook/addon-themes'], };仓库在
code/addons/themes维护该插件(源码见 index.ts、manager.tsx 等),并配有 postinstall 自动注册逻辑(postinstall.ts),多数场景按官方安装向导即可。
CSF 3 写法:.storybook/preview.js | preview.jsx
这是最经典、适用于绝大多数框架的配置。导入全局样式后,把withThemeByClassName放进preview.decorators:
import { withThemeByClassName } from '@storybook/addon-themes'; import '../src/index.css'; // Your application's global CSS file const preview = { decorators: [ withThemeByClassName({ themes: { light: '', dark: 'dark', }, defaultTheme: 'light', }), ], }; export default preview;CSF 3 写法:.storybook/preview.ts | preview.tsx(带类型)
TypeScript 项目用Preview类型给配置加约束。注意把@storybook/your-framework替换为你实际使用的框架包,例如@storybook/react-vite、@storybook/nextjs、@storybook/vue3-vite:
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc. import { Preview, Renderer } from '@storybook/your-framework'; import { withThemeByClassName } from '@storybook/addon-themes'; import '../src/index.css'; // Your application's global CSS file const preview: Preview = { decorators: [ withThemeByClassName<Renderer>({ themes: { light: '', dark: 'dark', }, defaultTheme: 'light', }), ], }; export default preview;这里显式传了泛型withThemeByClassName<Renderer>。从源码看,该泛型的默认值正是Renderer本身(class-name.decorator.tsx),因此不写泛型也能通过类型推导,写出来则是为了跨框架时获得更精确的类型检查。
CSF Next 🧪 写法:.storybook/preview.tsx
Storybook 新一代的definePreviewAPI 下,插件以addonThemes()形式作为addons项注册,再通过addonThemes.withThemeByClassName挂装饰器:
// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) import { Renderer, definePreview } from '@storybook/your-framework'; import addonThemes from '@storybook/addon-themes'; import '../src/index.css'; // Your application's global CSS file export default definePreview({ addons: [addonThemes()], decorators: [ addonThemes.withThemeByClassName<Renderer>({ themes: { light: '', dark: 'dark', }, defaultTheme: 'light', }), ], });CSF Next 🧪 写法:.storybook/preview.jsx
CSF Next 对应的 JavaScript 版本无需泛型:
// Replace your-framework with the framework you are using (e.g., react-vite, nextjs, nextjs-vite) import { definePreview } from '@storybook/your-framework'; import addonThemes from '@storybook/addon-themes'; import '../src/index.css'; // Your application's global CSS file export default definePreview({ addons: [addonThemes()], decorators: [ addonThemes.withThemeByClassName({ themes: { light: '', dark: 'dark', }, defaultTheme: 'light', }), ], });CSF Next 分支可从插件默认导出得到addonThemes函数(源码默认导出即definePreviewAddon包装,见 index.ts),addonThemes.withThemeByClassName实际就是同一份导出,API 与 CSF 3 保持一致。
配置项详解:themes、defaultTheme 与隐藏的 parentSelector
withThemeByClassName接受的配置结构,在源码中定义为ClassNameStrategyConfiguration(class-name.decorator.tsx):
| 配置项 | 类型 | 默认值 | 作用 |
|---|---|---|---|
themes | Record<string, string> | 必填 | 主题名 → CSS 类名的映射表,如{ light: '', dark: 'dark' } |
defaultTheme | string | 必填 | 未通过工具栏/全局值指定主题时回退使用的主题名 |
parentSelector | string | 'html' | 添加/移除类名的父元素 CSS 选择器 |
themes:主题名到类名的映射
themes的键是主题名(会展示在 addon 的主题切换 UI 上),值是切换到该主题时应作用在父元素上的类名。示例中light对应空字符串'',表示“亮色主题不需要额外类”,这正是把 light 当作基础样式的常见约定;而dark对应'dark',切换后html元素会获得class="dark",配合html.dark { ... }这类 CSS 规则生效。
主题键的顺序会被原样保留,并同步给 addon 面板用于渲染下拉/切换按钮(见下文initializeThemeState)。类的数量不受限:映射值可以包含多个类名,例如themes: { dark: 'theme-dark high-contrast' },运行时会被拆分为独立类逐个处理。
defaultTheme:兜底主题
在“既没有用户手动选择、也没有 story 级 override”的情况下,defaultTheme决定初始挂到父元素上的类。它是多分支渲染的兜底值,应确保始终是themes中存在的一个键。
parentSelector:类挂在哪里
默认挂在document.querySelector('html')上。源码常量DEFAULT_ELEMENT_SELECTOR = 'html'(class-name.decorator.tsx)写得很清楚。如果你的 CSS 主题作用域不在html而在其他容器(例如 Storybook 渲染 story 的根节点#storybook-root内部某个包裹层),可传任意有效选择器,例如仓库模板测试中的parentSelector: '#storybook-root > *'(见 decorators.stories.ts)。
源码视角:切换时到底发生了什么
理解了配置后,再看 class-name.decorator.tsx 的核心实现,可以帮助你预判各种边界情况。整体流程分两步。
第一步:注册主题清单给 Manager 面板
装饰器工厂被调用时立即执行initializeThemeState(Object.keys(themes), defaultTheme)。该函数(helpers.ts)通过addons.getChannel().emit(THEMING_EVENTS.REGISTER_THEMES, { defaultTheme, themes: themeNames }),把“有哪些主题、默认哪个”通过 Storybook 通信 channel 广播给 Manager 侧的主题切换 UI(theme-switcher.tsx)。也就是说,你的themes键一旦配置,就会自动出现在预览工具栏的主题下拉里,无需额外注册。
第二步:按选中值增删类名
真正给组件换肤的装饰器逻辑在useEffect内(依赖数组为[themeOverride, selected]),执行顺序是:
- 计算最终生效主题:
themeOverride || selected || defaultTheme。优先级最高的是当前 story 参数里的themeOverride,其次是从 globals 读到的用户选择selected,最后才是defaultTheme。 const selectedThemeName = themeOverride || selected || defaultTheme;确定胜出主题。const parentElement = document.querySelector(parentSelector);查找挂载点;若找不到直接 return,不做任何操作。- 遍历
themes映射,凡键名不等于选中主题的,都把其类名从父元素上移除(类名先经classStringToArray按空格切分成数组、过滤空项)。 - 最后把选中主题的类名数组
classList.add(...)到父元素上。若选中主题值为空字符串,则不添加任何类,天然回到无类的基础样式。
由此可得到几个有用的工程结论:
- 空字符串值即“去类”:
light: ''表示亮色主题时不附加任何类,切换走时会移除其他主题的类,因此它是纯 CSS 基础样式场景下最省心的写法; - 切换是差量而非全量重建:插件只操作那些出现在
themes映射值里的类名,你手动写在html上的其他类不会被误删; - 多类名被支持:值里的多个类名会被拆分逐个 add/remove,映射值使用空格分隔即可。
全局值从哪来:globals 与 story 级参数
选中值selected来自pluckThemeFromContext(context),它读取context.globals[GLOBAL_KEY](helpers.ts)。常量定义(constants.ts)标明:GLOBAL_KEY = 'theme'、PARAM_KEY = 'themes'。也就是说:
- 通过工具栏 UI选择的主题会写入名为
theme的 global,跨 story 保持; - 想在单个 story/meta 内固定主题,可设置
parameters.themes.themeOverride,它优先级最高,适合“这个故事专测暗色”的场景。类型定义见 types.ts:
// 对应 parameters.themes interface ThemesParameters { themes?: { disable?: boolean; // 关闭该 story 的主题切换行为 themeOverride?: string; // 强制覆盖为该主题 }; } // 对应 globals.theme interface ThemesGlobals { theme?: string; }仓库自己的模板验证了这种组合用法:在 decorators.stories.ts 中,story 通过globals预设初始主题,并用themes: { a: 'theme-a', b: 'theme-b' }、parentSelector: '#storybook-root > *'演示挂载点在默认html之外的情况;同文件的样式装饰器则把.theme-a、.theme-b的背景与前景色写进动态<style>,构成一个完整的“类名换肤”可运行样例,可供参考或复刻进自己的项目验证配置。
实操场景:Story 级主题控制
withThemeByClassName在 preview 里配置一次后全局生效。针对“某个 story 想固定暗色、某些 story 禁掉主题切换”的细分需求,无需改 decorator,直接在 CSF 中声明即可:
export const Dark = { parameters: { themes: { themeOverride: 'dark', }, }, }; export const Default = { parameters: { themes: { // 无需关闭:未设置 themeOverride 时跟随工具栏选择/defaultTheme }, }, };配合本文开头配置,Dark渲染时html会被强制挂上dark类,不受工具栏当前选择影响;其余 story 继续跟随全局主题。想要彻底禁用某一 story 的主题行为时,则设置parameters.themes.disable: true。工具栏选中的主题作为 global(键theme)在 story 间持久共享,这正是调试“同一组件在不同主题下的表现差异”的标准工作流。
常见注意事项
- 全局样式必须能被 preview 加载:映射的类名要在组件渲染前已随全局 CSS(示例中的
../src/index.css)进入 iframe,否则html.dark选择器匹配不到任何样式; parentSelector命中失败时静默返回:源码在document.querySelector未命中时直接return(class-name.decorator.tsx),不会报错——若主题没生效,优先确认选择器在 Storybook 预览 DOM 里真实存在;- 主题名与类名不必一致:主题键面向用户(UI 展示与代码语义),类名面向 CSS,二者解耦使得一套类名可同时服务多个主题别名;
- CSF Next 与 CSF 3 只是接入方式不同:一个走
definePreview+addons数组,一个走preview.decorators,装饰器本体与配置项完全共享同一实现与类型。
把上述配置落地到.storybook/preview.*后,Storybook 工具栏即会出现主题切换入口,每次切换都会以差量方式更新html(或你指定的容器)上的类名,让你的组件在亮色、暗色等主题间即时预览。若你的项目走 data 属性换肤,可对照同目录下 storybook-addon-themes-data-attribute-decorator;若走 JSX Provider 换肤,则参考 storybook-addon-themes-jsx-provider-decorator。
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考