首页
/ Storybook 明暗主题一键切换实战:addon-themes 配合 PostCSS dark-theme-class 的完整落地方案

Storybook 明暗主题一键切换实战:addon-themes 配合 PostCSS dark-theme-class 的完整落地方案

2026-09-06 13:42:37作者:瞿蔚英Wynne

本文聚焦 Storybook 官方 @storybook/addon-themes 插件的 PostCSS 接入方案,对应仓库文档 postcss.md。如果你的项目使用 prefers-color-scheme 媒体查询来编写暗色样式,读完本文你将掌握:如何用 postcss-dark-theme-class 插件把媒体查询规则改写为类名选择器、如何在 .storybook/preview.js 中接入 withThemeByClassName 装饰器,以及主题切换工具在 Manager 与 Preview 两侧的完整实现原理——从类名切换逻辑到 channel 事件通信,全部有源码佐证。

方案总览:三方协作的明暗主题机制

PostCSS 方案的核心思路是把"跟随系统自动切换的暗色样式"改造成"跟随类名切换的暗色样式",由三方协作完成:

  1. @storybook/addon-themes:提供 Manager 工具栏中的主题切换按钮,以及 Preview 侧的主题类名装饰器;
  2. postcss-dark-theme-class:一个 PostCSS 插件,负责把 @media (prefers-color-scheme: dark) 规则块的内容拷贝到 .is-dark 选择器下,使暗色样式脱离系统媒体查询、改由类名控制;
  3. withThemeByClassName 装饰器:在渲染故事前,根据当前选中的主题,在父元素(默认是 html)上增删 is-light / is-dark 类名。

这样浏览器中的故事就能通过类名命中暗色样式,而切换动作只需点击工具栏一次。

第一步:安装依赖

按官方文档 postcss.md 的说明,需要同时安装插件与 PostCSS 处理器两个包(作为 dev dependency):

yarn:

yarn add -D @storybook/addon-themes postcss-dark-theme-class

npm:

npm install -D @storybook/addon-themes postcss-dark-theme-class

pnpm:

pnpm add -D @storybook/addon-themes postcss-dark-theme-class

两个包各司其职:前者来自本仓库 code/addons/themes(当前仓库版本为 10.x,插件要求 Storybook 7.0 及以上,见 addons/themes 的 README);后者是外部 PostCSS 生态插件,仅参与样式编译阶段,不依赖 Storybook。

第二步:在 main 配置中注册 Addon

将插件加入 .storybook/main.jsaddons 数组:

module.exports = {
  stories: [
    "../stories/**/*.stories.mdx",
    "../stories/**/*.stories.@(js|jsx|ts|tsx)",
  ],
  addons: [
    "@storybook/addon-essentials",
+   "@storybook/addon-themes"
  ],
};

注册之后,插件的 manager 入口 会通过 addons.register 向 Manager 侧注册一个 type: types.TOOL 的工具,标题为 ThemesparamKeythemes。其中 match 条件限定了工具的显示时机:

match: ({ viewMode, tabId }) => !!(viewMode && viewMode.match(/^(story|docs)$/)) && !tabId,

也就是说,只有在 story 视图或 docs 视图(且未打开具体子标签页)时,工具栏中才会出现主题切换器——这与主题切换作用于"当前故事"的语义一致。

第三步:配置 postcss-dark-theme-class,把媒体查询改写为类名

CSS 为暗色模式提供了专门媒体 at-rule:@media (prefers-color-scheme: dark)。按原文档的说明,postcss-dark-theme-class 的作用就是把这些 at-rule 中的内容拷贝到 .is-dark 选择器下,从而让暗色样式可以由 .is-dark 类名激活,而不是依赖用户系统偏好。

配置前先检查项目中是否已有 PostCSS 配置,可能是以下三种位置之一:

  • 项目根目录的 postcss.config.js
  • package.json 中的 "postcss" 字段;
  • bundler 配置中内联的 postcss 配置。

然后在插件列表中追加:

module.exports = {
  plugins: [
+   require('postcss-dark-theme-class'),
    require('autoprefixer')
  ]
}

配置完成后,你的 CSS 可以照常用 prefers-color-scheme 媒体查询书写暗色样式:

:root {
  --text-color: black;
}
@media (prefers-color-scheme: dark) {
  html {
    --text-color: white;
  }
}

编译后,.is-dark 下会存在等价的规则块,暗色变量即可通过给 html 元素添加 is-dark 类来生效。

第四步:在 preview.js 中引入样式

为了让故事能访问到这些全局样式,需要把 CSS 导入 .storybook/preview.js

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

+import "../src/index.css";

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

export default preview;

第五步:用 withThemeByClassName 装饰器启用一键切换

最后,把 withThemeByClassName 装饰器加入 decorators,并声明"主题名 → 类名"的映射:

-import { Preview } from "@storybook/your-renderer";
+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: "is-light",
+      dark: "is-dark",
+    },
+    defaultTheme: "light",
+  }),
+ ]
};

export default preview;

参数说明(与源码中 ClassNameStrategyConfiguration 接口 完全一致):

参数 类型 必填 说明
themes Record<string, string> 主题名到父元素类名的映射,如 { light: "is-light", dark: "is-dark" };值也支持空格分隔的多个类名
defaultTheme string 初始选中的主题名,必须是 themes 的键之一
parentSelector string 类名挂载的父元素选择器,默认值为 'html'(见 源码常量

仓库中官方的示例故事 decorators.stories.ts 展示了自定义 parentSelector 的用法:当样式作用域不在 <html> 上时,可以把类名挂到 #storybook-root > * 上。

源码深潜:一次主题切换背后发生了什么

装饰器侧:类名的原子化切换

withThemeByClassName 的实现 分为两层。装饰器工厂函数执行时先调用 initializeThemeState(Object.keys(themes), defaultTheme)——这会通过 addons channel 发出 REGISTER_THEMES 事件,把主题清单和默认主题通知给 Manager(见 helpers.ts),Manager 侧的切换器正是靠这条事件获知"有哪些主题可切"。

每次故事渲染时,内部逻辑(第 30-52 行)按如下规则工作:

  1. 确定当前主题:优先级为 themeOverride(来自 parameters.themes.themeOverride)> 全局 theme(工具栏选择值)> defaultTheme
  2. 摘除旧类名:遍历所有非选中主题对应的类名,从父元素上 classList.remove
  3. 挂上新类名:把选中主题映射的类名 classList.add 上去,classStringToArray 支持空格分隔的多类名。

整个过程由 useEffect 驱动、依赖 [themeOverride, selected],因此切换是响应式的,且不会在每次渲染时重复增删。

Manager 侧:两个主题用按钮,多个主题用下拉框

工具栏中的切换器实现在 theme-switcher.tsx,它的形态由主题数量决定:

  • 恰好 2 个主题(PostCSS 明暗方案正是这种场景):渲染一个 Button 切换按钮,点击后直接 updateGlobals({ theme: alternateTheme }) 切换到另一个主题(第 73-90 行);
  • 3 个及以上主题:渲染一个 Select 下拉框,选项来自主题名列表(第 92-109 行);
  • 只有 1 个主题:不渲染任何 UI。

切换的本质是把 Manager 侧的全局变量 theme 更新掉,该全局变量在 preview.ts 中被初始化为空字符串。相关的键名常量集中在 constants.ts:参数键 themes、全局键 theme、Addon ID storybook/themes、事件名 storybook/themes/REGISTER_THEMES

锁定与禁用机制

源码中有两个"锁定"入口,对应 types.ts 中声明的参数结构:

  • 故事级覆盖:当故事通过 globals.themeparameters.themes.themeOverride 指定了主题时,isLocked 为真(第 43 行),工具栏按钮变为禁用态并显示 Story override 提示——因为该故事的主题被参数接管了。README 给出的标准写法(meta 级与 story 级覆盖):

    export default {
      title: 'Example/Button',
      component: Button,
      // meta 级覆盖
      globals: { theme: 'dark' },
    };
    
    export const PrimaryDark = {
      args: { primary: true, label: 'Button' },
      // story 级覆盖
      globals: { theme: 'dark' },
    };
    
  • 整体禁用:设置 parameters.themes.disable: true 时,切换器直接返回 null,工具面板消失且 addon 行为停止(第 69-71 行)。

常见场景适配

小结

PostCSS 方案的价值在于零侵入地复用现有暗色样式:你不需要为 Storybook 重写任何 CSS,只需让 postcss-dark-theme-classprefers-color-scheme 规则"镜像"到 .is-dark,再用 withThemeByClassNameis-light / is-dark 类名按工具栏选择挂到父元素上。理解装饰器的"摘除旧类、挂载新类"逻辑、themeOverride > globals.theme > defaultTheme 的优先级,以及 Manager 与 Preview 之间通过 REGISTER_THEMES 事件的握手机制,就能把这套方案灵活适配到任意以 CSS 类控制主题的组件库中。

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