像Avatar,Button, 和Tooltip 这样的展示型组件只通过道具接收输入,没有内部状态。这使得为它们隔离和编写故事变得简单明了。然而,在应用树中更高的组件在使用Storybook进行隔离时就比较棘手。
像Forms,List, 和Cards 这样的连接组件跟踪应用程序的状态,然后在树上传递行为。它们通常需要一个 "束缚/包装器",以便以有用的方式呈现:
- 样式:
ThemeProvider和全局样式 - 布局:模仿布局的DOM结构
- 数据获取:GraphQL提供者或钩子来进行API调用。
- 状态管理:Redux、MobX、Recoil等的存储提供者。
这篇文章展示了如何使用装饰器来隔离连接的组件。你将学会构建装饰器,使用参数控制其行为,并使用它们来模拟组件的依赖性。
为什么要孤立地构建连接的组件?
每个组件都有无数的变化,基于应用程序的状态、主题、响应行为、设备特性、国际化等等。开发人员编写故事以涵盖所有这些用例。这使他们能够立即查看任何变体,然后验证其外观和感觉:


Supabase的Button组件和Storybook设计系统的Cardinal组件的众多变体。
虽然Storybook被广泛用于设计系统,但对于前端团队来说,为应用程序组件编写故事也很常见。这些组件与应用程序的状态、上下文和钩子 "相连",产生更复杂的变化。开发人员选择在Storybook中构建连接的组件,因为它更容易开发难以触及的用例,如加载、错误和空状态:
Codeacademy、Gitlab、IBM、DC/OS Labs和Monday.com只是其中的几个例子:




使用装饰器来隔离连接的组件
UI组件需要数据和动作处理程序来渲染。这些通常是作为道具传入的,但连接的组件也通过上下文、API请求和钩子直接访问它们。
为了隔离一个连接的组件,你必须模拟它的依赖关系。在Storybook中,你可以使用装饰器来提供模拟的上下文,并为不同的组件变化编写故事。

Storybook分为两部分:管理器,它渲染Storybook的用户界面(搜索、导航、工具栏和附加组件)和预览,你的故事在这里被渲染。
装饰器是在预览iframe内运行的包装代码。它们使你能够控制故事的布局,它的渲染方式,并提供模拟环境。让我们通过几个例子来探索所有这些可能性。
控制你的故事的布局
装饰器最基本的用例是为一个组件提供布局限制。假设你正在建立一个Sidebar 组件。默认情况下,它的扩展是为了填充它的父容器。然而,它应该被用于一个页面布局中,它只占视口宽度的一小部分。我们可以使用一个装饰器来模仿这种页面结构,就像这样:
// Sidebar.stories.js
import { Sidebar } from './Sidebar';
const withLayout = (Story) => (
<div style={{ display: 'flex' }}>
<div style={{ flex: '0 0 240px', marginRight: 16 }}>{Story()}</div>
<div style={{ display: 'flex', flex: '1 1 auto' }}>children</div>
</div>
);
export default {
title: 'Sidebar',
component: Sidebar,
decorators: [withLayout],
};
export const Base = () => { /* ... */ };
export const NonLatestVersion = () => { /* ... */ };

加载全局提供者
许多库依靠全局提供者进行配置。例如,Styled Components和Chakra UI使用一个提供者来定制主题。而React Intl使用一个提供者来传递特定于本地的翻译。

我们可以在.storybook/preview.js ,添加一个全局装饰器来加载这些提供者。下面是一个如何用Storybook设置React Intl的例子:
// .storybook/preview.js
import React from 'react';
import { IntlProvider } from 'react-intl';
import messages from './compiled-lang/fr.json';
const withIntl = (StoryFn) => (
<IntlProvider locale="en" timeZone="Asia/Tokyo" messages={messages}>
{Story()}
</IntlProvider>
);
export const decorators = [withIntl];
通过参数控制装饰器
与故事函数一起,装饰器也接收故事上下文对象,它包含故事的args、参数、globals等。这意味着你可以使用参数来配置附加组件。
例如,这个withTheme 装饰器为你所有的组件提供一个主题,并加载全局样式。此外,你可以通过故事参数控制哪个主题是活动的。关于这项技术的完整概述,请查看。如何在Storybook中添加一个主题切换器:
// .storybook/preview.tsx
import { ThemeProvider } from 'styled-components'
import { GlobalStyle } from '../src/styles/GlobalStyle'
import { darkTheme, lightTheme } from '../src/styles/theme'
const withTheme = (Story, 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 />
<Story />
</ThemeProvider>)
}
export const decorators = [withTheme]
状态管理库的模拟上下文
提供者模式也被Redux、MobX和Recoil等状态管理库广泛使用,让组件访问状态存储。在这种情况下,我们可以使用故事装饰器提供一个模拟存储,以呈现不同的组件变体。

考虑这个TaskList 组件与Redux商店的连接。该存储中的应用状态决定了TaskList 的变体被呈现出来:

为了控制应用状态,我们将使用@reduxjs/toolkit 中的实用程序创建一个模拟存储。 然后使用故事装饰器将不同的状态对象应用到模拟存储中。这使我们能够将难以触及的组件状态复制成故事:
// TaskList.stories.js
import React from 'react';
import { Provider } from 'react-redux';
import { configureStore, createSlice } from '@reduxjs/toolkit';
import TaskList from './TaskList';
export default {
component: TaskList,
title: 'TaskList',
};
// Mock state that'll be passed to the mock redux store
const MockState = {
tasks: [/*...code omitted for brevity */],
status: 'idle',
error: null,
};
// Mocked redux store
const Mockstore = ({ taskboxState, children }) => (
<Provider
store={configureStore({
reducer: {
taskbox: createSlice({
name: 'taskbox',
initialState: taskboxState,
}).reducer,
},
})}
>
{children}
</Provider>
);
const Template = () => <TaskList />;
export const Default = Template.bind({});
Default.decorators = [
(story) => <Mockstore taskboxState={MockedState}>{story()}</Mockstore>,
];
export const Loading = Template.bind({});
Loading.decorators = [
(story) => (
<Mockstore
taskboxState={{
...MockedState,
status: 'loading',
}}
>
{story()}
</Mockstore>
),
];
export const Empty = Template.bind({});
Empty.decorators = [
(story) => (
<Mockstore
taskboxState={{
...MockedState,
tasks: [],
}}
>
{story()}
</Mockstore>
),
];
在React生态系统中,使用钩子和上下文的组合而不是状态管理库的情况越来越普遍。在这种情况下,你可以使用React Context插件来为你的组件提供和操作上下文。
模拟REST和GraphQL API请求
当你继续往上看组件树时,你开始将UI与后端API和服务进行连接。我们可以在Storybook中模拟这些请求。
JavaScript生态系统提供了许多优秀的工具来模拟API请求。更重要的是,这些工具中的大多数都可以作为Storybook的附加组件使用。因此,与其建立一个自定义的装饰器,不如使用一个附加组件来快速入门。
Mock Service Worker (MSW)是一个多功能插件,使用服务工作者在网络层面拦截请求并返回模拟数据。它可以与REST和GraphQL后端一起工作:


在引擎盖下,MSW插件是由装饰器驱动的。它自动将你的故事包裹在一个MSW装饰器中,并允许你通过参数在故事级别提供请求处理程序:
// CategoryDetailPage.stories.js
import { rest } from 'msw';
import { CategoryDetailPage } from './CategoryDetailPage';
import { restaurants } from '../../mocks/restaurants';
export default {
title: 'CategoryDetailPage',
component: CategoryDetailPage,
};
const Template = () => <CategoryDetailPage />;
export const Default = Template.bind({});
Default.parameters = {
msw: {
handlers: [
rest.get('/restaurants', (req, res, ctx) => res(ctx.json([restaurants[0]]))),
],
},
};
export const Loading = Template.bind({});
Loading.parameters = {
msw: {
handlers: [
rest.get('/restaurants', (req, res, ctx) => res(ctx.delay('infinite'))),
],
},
};
export const Missing = Template.bind({});
Missing.parameters = {
deeplink: { route: '/categories/wrong', path: '/categories/:id' },
msw: {
handlers: [rest.get('/restaurants', (req, res, ctx) => res(ctx.json([])))],
},
};
虽然MSW是一个非常实用的选择,但你也可以找到Apollo、URQL、GraphQL Kit和Axios的特定库附加组件:

使用装饰器来扩展Storybook的功能
除了模拟一个组件的依赖性,装饰器还可以使你为你的Storybook添加额外的功能位。例如,Measure、Outline和Backgrounds插件使用装饰器将代码注入预览iframe中,使CSS调试更容易。关于这一技术的更多信息,请查看创建附加组件教程:



结论
UIs考虑到了语言、设备、用户偏好和应用程序状态的无尽变化。通过Storybook,你可以将这些变化捕捉为故事,并在开发和测试过程中重新审视它们。
你可以通过道具提供模拟数据来重现一个展示性组件的不同状态。但要隔离连接的组件就更有挑战性了,因为它们与应用程序的状态、交互和API请求是相连的。