
如何在 Storybook 中构建连接的组件
学习如何使用装饰器模拟 context、app 状态和 API 请求

像Avatar、Button和Tooltip这样的展示型组件仅通过props接收输入,并且没有内部状态。这使得隔离并为它们编写故事变得简单。然而,应用程序树中更上层的组件在Storybook中进行隔离构建则更具挑战性。
像Forms、List和Cards这样的连接型组件会跟踪应用程序状态,然后将行为传递到树的下层。它们通常需要一个“脚手架/包装器”才能以有用的方式渲染。
- 样式:
ThemeProvider和全局样式 - 布局:模仿布局的DOM结构
- 数据获取:GraphQL提供商或钩子以发出API请求。
- 状态管理:Redux、MobX、Recoil等的store提供商。
本文展示了如何使用装饰器来隔离连接型组件。您将学习如何构建装饰器,通过参数控制其行为,并使用它们来模拟组件依赖项。
为什么要隔离构建连接型组件?
每个组件都有无数种变体,具体取决于应用程序状态、主题、响应式行为、设备功能、国际化等。开发者编写故事来涵盖所有这些用例。这使他们能够立即查看任何变体,然后验证其外观和感觉。


虽然Storybook广泛用于设计系统,但前端团队也常常为应用程序组件编写故事。这些组件“连接”到应用程序状态、上下文和钩子,从而产生更复杂的变体。开发人员选择在Storybook中构建连接型组件,因为这样可以更容易地开发难以触及的用例,例如加载、错误和空状态。
Codeacademy,Gitlab,IBM,DC/OS Labs和Monday.com只是其中的一些例子。




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

Storybook分为两部分:manager,它渲染Storybook UI(搜索、导航、工具栏和插件),以及preview,您的故事在此渲染。
一个装饰器是运行在preview 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添加一个全局装饰器来加载这些提供者。下面是如何使用React Intl与Storybook进行设置的示例。
// .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、参数、全局变量等。这意味着您可以使用参数来配置插件。
例如,这个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,以使组件能够访问状态存储。在这种情况下,我们可以使用故事装饰器提供一个模拟存储来渲染不同的组件变体。

考虑这个与Redux store连接的TaskList组件。该store中的应用程序状态决定了渲染TaskList的哪个变体。

为了控制该应用程序状态,我们将使用@reduxjs/toolkit中的实用程序创建一个模拟store。然后使用故事装饰器将不同的状态对象应用于模拟store。这使我们能够将难以触及的组件状态模拟为故事。
// 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 addon来为您的组件提供和操纵上下文。
模拟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插件使用装饰器将代码注入preview iframe,从而简化CSS调试。有关更多关于这种技术的信息,请参阅创建插件教程。



结论
UI包含无数种语言、设备、用户偏好和应用程序状态的排列组合。使用Storybook,您可以将这些变体捕获为故事,并在开发和测试期间重新访问它们。
您可以通过props提供模拟数据来重现展示型组件的不同状态。但隔离连接型组件更具挑战性,因为它们连接到应用程序状态、交互和API请求。
Storybook装饰器使您能够模拟这些依赖项。您可以构建自定义装饰器来用提供者包装您的组件,或者使用现成的插件来模拟API请求。
Storybook装饰器帮助您隔离构建和测试连接型组件。
— Storybook (@storybookjs) 2022年7月7日
🏗️ 控制故事布局
💽 加载全局提供者
🎛️ 模拟状态管理库的上下文
📡 模拟REST和GraphQL API请求
开始我们的新教程:https://#/PgSyCiDw6V pic.twitter.com/eTnMkpUMsh