
如何在 Storybook 中构建连接组件
学习如何使用装饰器模拟上下文、应用状态和 API 请求

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


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




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

Storybook 分为两个部分:manager,它渲染 Storybook UI(搜索、导航、工具栏和插件),以及 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 = () => { /* ... */ };

加载全局 provider
许多库依赖全局 provider 进行配置。例如,Styled Components 和 Chakra UI 使用 provider 定制主题。而 React Intl 则使用 provider 传递本地化翻译。

我们可以向 .storybook/preview.js
添加一个全局装饰器来加载这些 provider。以下是如何使用 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、parameters、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]
模拟状态管理库的上下文
Provider 模式也广泛用于 Redux、MobX 和 Recoil 等状态管理库,以为组件提供状态存储的访问权限。在这种情况下,我们可以使用故事装饰器提供模拟存储来渲染不同的组件变体。

考虑这个连接到 Redux 存储的 TaskList
组件。该存储中的应用程序状态决定了 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 生态系统中,越来越多地使用 hooks 和 context 的组合代替状态管理库。在这种情况下,您可以使用 React Context 插件为您的组件提供和操作上下文。
模拟 REST 和 GraphQL API 请求
随着您继续向上遍历组件树,您开始将 UI 连接到后端 API 和服务。我们可以直接在 Storybook 中模拟这些请求。
JavaScript 生态系统提供了许多出色的工具用于模拟 API 请求。更重要的是,这些工具中的大多数都作为 Storybook 插件提供。因此,与其构建自定义装饰器,不如使用插件快速入门。
Mock Service Worker (MSW) 是一个多功能插件,它使用 service worker 在网络层面拦截请求并返回模拟数据。它适用于 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 调试更容易。有关此技术的更多信息,请参阅创建插件教程。



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