使用 Storybook 构建页面
Storybook 帮助你构建任何组件,从小的“原子”组件到组合页面。但是,当你在组件层级结构中向上移动到页面级别时,你会遇到更多的复杂性。
在 Storybook 中构建页面有很多方法。这里有一些常见的模式和解决方案。
- 纯展示型页面。
- 连接组件(例如,网络请求、上下文、浏览器环境)。
纯展示型页面
BBC、The Guardian 和 Storybook 维护团队本身都在构建纯展示型页面。 如果你采用这种方法,则无需执行任何特殊操作即可在 Storybook 中渲染你的页面。
编写组件以使其完全展示到屏幕级别是很简单的。这使得它很容易在 Storybook 中展示。 我们的想法是,你在 Storybook 之外的应用程序的单个包装器组件中完成所有混乱的“连接”逻辑。 你可以在 Storybook 教程简介的 Data 章节中看到这种方法的示例。
优点
- 一旦组件采用这种形式,就很容易编写 stories。
- story 的所有数据都编码在 story 的 args 中,这与 Storybook 工具的其他部分(例如 controls)配合良好。
缺点
-
你现有的应用程序可能并非以这种方式构建,并且可能难以更改。
-
在一个地方获取数据意味着你需要将其向下传递到使用它的组件。 这在组合一个大型 GraphQL 查询的页面中可能是很自然的(例如),但其他数据获取方法可能使其不太合适。
-
如果你想在屏幕上不同的位置逐步加载数据,它的灵活性会降低。
用于展示型屏幕的 Args 组合
当你以这种方式构建屏幕时,通常复合组件的输入是它渲染的各种子组件的输入的组合。 例如,如果你的屏幕渲染一个页面布局(包含当前用户的详细信息)、一个标题(描述你正在查看的文档)和一个列表(子文档列表),则屏幕的输入可能由用户、文档和子文档组成。
import PageLayout from './PageLayout';
import Document from './Document';
import SubDocuments from './SubDocuments';
import DocumentHeader from './DocumentHeader';
import DocumentList from './DocumentList';
export interface DocumentScreenProps {
user?: {};
document?: Document;
subdocuments?: SubDocuments[];
}
export function DocumentScreen({ user, document, subdocuments }: DocumentScreenProps) {
return (
<PageLayout user={user}>
<DocumentHeader document={document} />
<DocumentList documents={subdocuments} />
</PageLayout>
);
}
在这种情况下,很自然地使用 args 组合来基于子组件的 stories 构建页面的 stories
// Replace your-framework with the name of your framework
import type { Meta, StoryObj } from '@storybook/your-framework';
import { DocumentScreen } from './YourPage';
// 👇 Imports the required stories
import * as PageLayout from './PageLayout.stories';
import * as DocumentHeader from './DocumentHeader.stories';
import * as DocumentList from './DocumentList.stories';
const meta: Meta<typeof DocumentScreen> = {
component: DocumentScreen,
};
export default meta;
type Story = StoryObj<typeof DocumentScreen>;
export const Simple: Story = {
args: {
user: PageLayout.Simple.args.user,
document: DocumentHeader.Simple.args.document,
subdocuments: DocumentList.Simple.args.documents,
},
};
当各种子组件导出不同的 stories 的复杂列表时,这种方法是有益的。 你可以选择构建屏幕级 stories 的真实场景,而无需重复自己。 通过重用数据并采用“不要重复自己”(DRY) 的理念,你的 story 维护负担是最小的。
模拟连接组件
连接组件是依赖于外部数据或服务的组件。 例如,完整的页面组件通常是连接组件。 当你在 Storybook 中渲染连接组件时,你需要模拟组件依赖的数据或模块。 你可以在不同的层级中执行此操作。
模拟导入
组件可能依赖于导入到组件文件中的模块。 这些模块可能来自外部包或项目内部。 在 Storybook 中渲染这些组件或测试它们时,你可能需要模拟这些模块以控制其行为。
模拟 API 服务
对于发出网络请求的组件(例如,从 REST 或 GraphQL API 获取数据),你可以在你的 stories 中模拟这些请求。
模拟 providers
组件可以从上下文 providers 接收数据或配置。 例如,样式化组件可能会从 ThemeProvider 访问其主题,或者 Redux 使用 React 上下文来为组件提供对应用程序数据的访问权限。 你可以模拟 provider 及其提供的值,并在你的 stories 中用它包装你的组件。
避免模拟依赖项
通过 props 或 React 上下文传递,可以完全避免模拟连接的“容器”组件的依赖项。 但是,这需要严格分离容器组件和展示组件的逻辑。 例如,如果你的组件负责数据获取逻辑和渲染 DOM,则仍然需要像之前描述的那样进行模拟。
在展示组件中导入和嵌入容器组件是很常见的。 但是,正如我们之前发现的那样,我们可能需要模拟它们的依赖项或导入,才能在 Storybook 中渲染它们。
这不仅很快会变得繁琐,而且模拟使用本地状态的容器组件也具有挑战性。 因此,解决此问题的方法不是直接导入容器,而是创建一个提供容器组件的 React 上下文。 这使您可以像往常一样在组件层次结构的任何级别自由嵌入容器组件,而无需担心随后模拟它们的依赖项; 因为我们可以将容器本身替换为它们模拟的展示组件副本。
我们建议在应用程序中的特定页面或视图中划分上下文容器。 例如,如果您有一个 ProfilePage
组件,您可以按如下方式设置文件结构
ProfilePage.js
ProfilePage.stories.js
ProfilePageContainer.js
ProfilePageContext.js
设置一个“全局”容器上下文(可能命名为 GlobalContainerContext
)通常也很有帮助,用于可能在应用程序的每个页面上渲染的容器组件,并将它们添加到应用程序的顶层。 虽然可以将每个容器都放在这个全局上下文中,但它应该只提供全局必需的容器。
让我们看一个这种方法的示例实现。
首先,创建一个 React 上下文,并将其命名为 ProfilePageContext
。 它所做的只是导出一个 React 上下文
import { createContext } from 'react';
const ProfilePageContext = createContext();
export default ProfilePageContext;
ProfilePage
是我们的展示组件。 它将使用 useContext
Hook 从 ProfilePageContext
中检索容器组件
import { useContext } from 'react';
import ProfilePageContext from './ProfilePageContext';
export const ProfilePage = ({ name, userId }) => {
const { UserPostsContainer, UserFriendsContainer } = useContext(ProfilePageContext);
return (
<div>
<h1>{name}</h1>
<UserPostsContainer userId={userId} />
<UserFriendsContainer userId={userId} />
</div>
);
};
在 Storybook 中模拟容器
在 Storybook 的上下文中,我们不会通过上下文提供容器组件,而是提供它们模拟的副本。 在大多数情况下,这些组件的模拟版本通常可以直接从它们关联的 story 中借用。
import React from 'react';
import { ProfilePage } from './ProfilePage';
import { UserPosts } from './UserPosts';
//👇 Imports a specific story from a story file
import { Normal as UserFriendsNormal } from './UserFriends.stories';
export default {
component: ProfilePage,
};
const ProfilePageProps = {
name: 'Jimi Hendrix',
userId: '1',
};
const context = {
//👇 We can access the `userId` prop here if required:
UserPostsContainer({ userId }) {
return <UserPosts {...UserPostsProps} />;
},
// Most of the time we can simply pass in a story.
// In this case we're passing in the `normal` story export
// from the `UserFriends` component stories.
UserFriendsContainer: UserFriendsNormal,
};
export const Normal = {
render: () => (
<ProfilePageContext.Provider value={context}>
<ProfilePage {...ProfilePageProps} />
</ProfilePageContext.Provider>
),
};
如果相同的上下文适用于所有 ProfilePage
story,我们可以使用 装饰器。
向你的应用程序提供容器
现在,在你的应用程序上下文中,你需要通过使用 ProfilePageContext.Provider
包裹 ProfilePage
来为其提供所有需要的容器组件
例如,在 Next.js 中,这将是你的 pages/profile.js
组件。
import React from 'react';
import ProfilePageContext from './ProfilePageContext';
import { ProfilePageContainer } from './ProfilePageContainer';
import { UserPostsContainer } from './UserPostsContainer';
import { UserFriendsContainer } from './UserFriendsContainer';
//👇 Ensure that your context value remains referentially equal between each render.
const context = {
UserPostsContainer,
UserFriendsContainer,
};
export const AppProfilePage = () => {
return (
<ProfilePageContext.Provider value={context}>
<ProfilePageContainer />
</ProfilePageContext.Provider>
);
};
在 Storybook 中模拟全局容器
如果你已经设置了 GlobalContainerContext
,你需要在 Storybook 的 preview.js
中设置一个装饰器,以便为所有 story 提供上下文。 例如
import React from 'react';
// Replace your-framework with the framework you are using (e.g., react, vue3)
import { Preview } from '@storybook/your-framework';
import { normal as NavigationNormal } from '../components/Navigation.stories';
import GlobalContainerContext from '../components/lib/GlobalContainerContext';
const context = {
NavigationContainer: NavigationNormal,
};
const AppDecorator = (storyFn) => {
return (
<GlobalContainerContext.Provider value={context}>{storyFn()}</GlobalContainerContext.Provider>
);
};
const preview: Preview = {
decorators: [AppDecorator],
};
export default preview;