
如何向 Storybook 添加主题切换器
了解如何通过连接装饰器和工具栏项目来控制故事的渲染方式

主题控制用户界面的视觉特性——调色板、排版、留白、边框样式、阴影、半径等。主题越来越受欢迎,因为应用需要支持多种颜色模式和品牌要求。
但是主题开发可能很繁琐。您必须跟踪应用中无数的状态,然后将其乘以您支持的主题数量。同时还要不断地在主题之间来回切换,以检查用户界面是否看起来正确。
使用 Storybook,您可以控制哪个主题应用于您的组件,并通过工具栏单击以在不同主题之间切换。本文将向您展示如何操作。
- 🎁 使用装饰器将主题对象传递给您的组件
- 🎛 从工具栏或使用故事参数动态切换主题
- 🖍 自动更新故事背景以匹配主题
- 🍱 在一个故事中并排渲染多个主题
我们要构建什么?
与作为输入传递到组件的数据不同,主题通过上下文提供或全局配置为 CSS 变量。
我们将构建一个主题切换工具,使您能够在 Storybook 中为所有组件提供主题对象。您将能够通过参数或工具栏中的按钮来控制哪个主题处于活动状态。

我们将使用这个 Badge 组件(来自 Mealdrop 应用)进行演示,该组件使用 React 和 styled-components 构建。

它使用主题对象中的变量来设置边框半径、背景和颜色值。主题对象使用上下文 API 传递到组件中。
// src/components/Badge/Badge.tsx
import styled, { css } from 'styled-components'
import { Body } from '../typography'
const Container = styled.div(
({ theme }) => css`
padding: 3px 8px;
background: ${theme.color.badgeBackground};
border-radius: ${theme.borderRadius.xs};
display: inline-block;
text-transform: capitalize;
span {
color: ${theme.color.badgeText};
}
`
)
type BadgeProps = {
text: string
className?: string
}
export const Badge = ({ text, className }: BadgeProps) => (
<Container className={className}>
<Body type="span" size="S">
{text}
</Body>
</Container>
)
克隆仓库
让我们开始吧!克隆仓库,安装依赖项,并跟随操作。
# Clone the template
npx degit yannbf/mealdrop#theme-switcher-base mealdrop
cd mealdrop
# Install dependencies
yarn
使用装饰器为您的组件提供主题
第一步是为我们的组件提供主题。我们将使用装饰器来实现,它将使用 ThemeProvider
包裹每个故事并传入 lightTheme
对象。
装饰器是 Storybook 的一种机制,允许您使用额外的渲染功能来增强故事。例如,您可以提供组件所依赖的上下文或其他全局配置。
让我们将 withTheme
装饰器添加到 .storybook/preview.tsx
文件中。
// .storybook/preview.tsx
import { ThemeProvider } from 'styled-components'
import { DecoratorFn } from '@storybook/react'
import { GlobalStyle } from '../src/styles/GlobalStyle'
import { lightTheme } from '../src/styles/theme'
const withTheme: DecoratorFn = (StoryFn) => {
return (
<ThemeProvider theme={lightTheme}>
<GlobalStyle />
<StoryFn />
</ThemeProvider>
)
}
// export all decorators that should be globally applied in an array
export const decorators = [withTheme]
在 .storybook/preview.js|tsx
文件中定义的装饰器是全局的。也就是说,它们将应用于您的所有故事。因此,这也是加载这些组件使用的 GlobalStyle
的理想位置。
运行 yarn storybook
以启动 Storybook,您应该看到 Badge 组件在应用浅色主题的情况下正确渲染。

通过参数设置活动主题
目前,我们的 withTheme
装饰器仅为组件提供浅色主题。为了测试浅色和深色模式,我们需要在它们之间动态切换。我们可以使用 参数 来指定要启用的主题。
参数是可以附加到故事或组件的元数据。然后,withTheme
装饰器可以从 故事上下文 对象访问它们并应用适当的主题。
更新您的装饰器以读取主题参数
// .storybook/preview.tsx
import { ThemeProvider } from 'styled-components'
import { DecoratorFn } from '@storybook/react'
import { GlobalStyle } from '../src/styles/GlobalStyle'
import { darkTheme, lightTheme } from '../src/styles/theme'
const withTheme: DecoratorFn = (StoryFn, context) => {
// Get the active theme value from the story parameter
const { theme } = context.parameters
const storyTheme = theme === 'dark' ? darkTheme : lightTheme
return (
<ThemeProvider theme={storyTheme}>
<GlobalStyle />
<StoryFn />
</ThemeProvider>
)
}
export const decorators = [withTheme]
在为组件编写故事时,您可以选择使用参数应用哪个主题。像这样
// src/components/Badge/Badge.stories.tsx
import { ComponentStory, ComponentMeta } from '@storybook/react'
import { Badge } from './Badge'
export default {
title: 'Components/Badge',
component: Badge,
} as ComponentMeta<typeof Badge>
const Template: ComponentStory<typeof Badge> = (args) => <Badge {...args} />
export const Default = Template.bind({})
Default.args = {
text: 'Comfort food',
}
export const LightTheme = Template.bind({})
LightTheme.args = Default.args
LightTheme.parameters = {
theme: 'light',
}
export const DarkTheme = Template.bind({})
DarkTheme.args = Default.args
DarkTheme.parameters = {
theme: 'dark',
}
切换回您的 Storybook,您会注意到当您在两个故事之间导航时,主题会更新。

太棒了!这使我们可以灵活地为每个故事设置主题。
切换背景颜色以匹配主题
这是一个好的开始。我们可以灵活地控制每个故事的主题。但是,背景保持不变。让我们更新我们的装饰器,以便故事的背景颜色与活动主题相匹配。
我们现在使用 ThemeBlock
组件包裹每个故事,该组件根据活动主题控制背景颜色。
// .storybook/preview.tsx
import React from 'react'
import styled, { css, ThemeProvider } from 'styled-components'
import { DecoratorFn } from '@storybook/react'
import { GlobalStyle } from '../src/styles/GlobalStyle'
import { darkTheme, lightTheme } from '../src/styles/theme'
import { breakpoints } from '../src/styles/breakpoints'
const ThemeBlock = styled.div<{ left?: boolean; fill?: boolean }>(
({ left, fill, theme }) =>
css`
position: absolute;
top: 0;
left: ${left || fill ? 0 : '50vw'};
border-right: ${left ? '1px solid #202020' : 'none'};
right: ${left ? '50vw' : 0};
width: ${fill ? '100vw' : '50vw'};
height: 100vh;
bottom: 0;
overflow: auto;
padding: 1rem;
background: ${theme.color.screenBackground};
${breakpoints.S} {
left: ${left ? 0 : '50vw'};
right: ${left ? '50vw' : 0};
padding: 0 !important;
}
`
)
export const withTheme: DecoratorFn = (StoryFn, context) => {
// Get values from story parameter first
const { theme } = context.parameters
const storyTheme = theme === 'dark' ? darkTheme : lightTheme
return (
<ThemeProvider theme={storyTheme}>
<GlobalStyle />
<ThemeBlock fill>
<StoryFn />
</ThemeBlock>
</ThemeProvider>
)
}
export const decorators = [withTheme]
现在,当您在这些故事之间切换时,主题和背景颜色都会更新。

从工具栏切换主题
通过参数硬编码主题只是一种选择。我们还可以自定义 Storybook UI 以添加一个下拉菜单,使我们能够切换哪个主题处于活动状态。
Storybook 附带 toolbars 插件,使您能够定义一个全局值并将其连接到工具栏中的菜单。
要创建一个 工具栏项目 来控制活动主题,我们需要在我们的 .storybook/preview.tsx
文件中添加一个 globalTypes
对象。
// .storybook/preview.tsx
// ...code ommited for brevity...
export const withTheme: DecoratorFn = (StoryFn, context) => {
// Get values from story parameter first, else fallback to globals
const theme = context.parameters.theme || context.globals.theme
const storyTheme = theme === 'dark' ? darkTheme : lightTheme
return (
<ThemeProvider theme={storyTheme}>
<GlobalStyle />
<ThemeBlock fill>
<StoryFn />
</ThemeBlock>
</ThemeProvider>
)
}
export const globalTypes = {
theme: {
name: 'Theme',
description: 'Global theme for components',
defaultValue: 'light',
toolbar: {
// The icon for the toolbar item
icon: 'circlehollow',
// Array of options
items: [
{ value: 'light', icon: 'circlehollow', title: 'light' },
{ value: 'dark', icon: 'circle', title: 'dark' },
],
// Property that specifies if the name of the item will be displayed
showName: true,
},
},
}
export const decorators = [withTheme]
我们还更新了 withTheme
装饰器,以首先从参数中获取主题值,如果未定义,则回退到全局值。
您现在应该看到一个用于切换主题的工具栏项目。

对于未指定主题参数的 Default
故事,您可以使用工具栏切换主题。但是,LightTheme
和 DarkTheme
故事将始终强制执行通过主题参数设置的值。
并排渲染主题
有时,如果您一次看到组件的所有主题变体,则更容易处理组件。猜猜怎么着?您可以在装饰器中多次渲染一个故事,并为每个实例提供不同的主题对象。
更新 withTheme
装饰器和 globalTypes
以添加“并排”模式
// .storybook/preview.tsx
// ...code ommited for brevity...
export const withTheme: DecoratorFn = (StoryFn, context) => {
// Get values from story parameter first, else fallback to globals
const theme = context.parameters.theme || context.globals.theme
const storyTheme = theme === 'light' ? lightTheme : darkTheme
switch (theme) {
case 'side-by-side': {
return (
<>
<ThemeProvider theme={lightTheme}>
<GlobalStyle />
<ThemeBlock left>
<StoryFn />
</ThemeBlock>
</ThemeProvider>
<ThemeProvider theme={darkTheme}>
<GlobalStyle />
<ThemeBlock>
<StoryFn />
</ThemeBlock>
</ThemeProvider>
</>
)
}
default: {
return (
<ThemeProvider theme={storyTheme}>
<GlobalStyle />
<ThemeBlock fill>
<StoryFn />
</ThemeBlock>
</ThemeProvider>
)
}
}
}
export const globalTypes = {
theme: {
name: 'Theme',
description: 'Theme for the components',
defaultValue: 'light',
toolbar: {
// The icon for the toolbar item
icon: 'circlehollow',
// Array of options
items: [
{ value: 'light', icon: 'circlehollow', title: 'light' },
{ value: 'dark', icon: 'circle', title: 'dark' },
{ value: 'side-by-side', icon: 'sidebar', title: 'side by side' },
],
// Property that specifies if the name of the item will be displayed
showName: true,
},
},
}
export const decorators = [withTheme]
这是最终结果

并排模式对于使用 Chromatic 等工具进行视觉回归测试也非常方便。您可以通过参数启用它,并在一次测试中测试组件的所有基于主题的变体。
结论
在构建用户界面时,您必须考虑应用状态、区域设置、视口大小、主题等无数的排列组合。Storybook 使测试 UI 变体变得容易。您可以使用数百个 插件 之一,或自定义 Storybook 以满足您的需求。

装饰器 使您可以完全控制故事渲染,并使您能够设置提供程序并通过使用参数或将它们连接到工具栏项目来控制其行为。切换主题只是这项技术的一种应用。您可以使用它来添加语言切换器或菜单来管理多租户配置。
您可以在 Mealdrop Storybook 中看到主题切换器的实际效果,并在 Github 上浏览其源代码。

本教程最初是作为我的课程 Storybook for React Apps 的一个章节编写的。它涵盖了从核心 Storybook 概念到更高级的工作流程的所有内容,例如将页面添加到 Storybook、模拟 API 请求、将故事连接到 Figma、测试可访问性等等。
使用 Storybook 开发主题?了解如何添加方便强大的主题切换器。
— Storybook (@storybookjs) 2022年6月22日
⚡️ 一键热切换主题
🎁 通过参数控制活动主题
🎛 使用工具栏动态切换主题
🍱 并排渲染多个主题https://#/erZMx0Na4P pic.twitter.com/oiN2WjBLz8