文档
Storybook 文档

使用 Storybook 构建页面

Storybook 帮助您构建任何组件,从小的“原子”组件到组合页面。但是,当您沿着组件层次结构向上移动到页面级别时,您将面临更多复杂性。

在 Storybook 中构建页面有很多方法。以下是常见模式和解决方案。

  • 纯表现型页面。
  • 连接组件(例如,网络请求、上下文、浏览器环境)。

纯表现型页面

BBC、卫报和 Storybook 维护人员团队本身构建纯表现型页面。如果您采用这种方法,则不需要做任何特殊操作即可在 Storybook 中呈现您的页面。

将组件完全写成表现型直到屏幕级别,这很简单。这使得在 Storybook 中展示变得容易。想法是,您在 Storybook 之外的应用程序中,在一个单独的包装器组件中执行所有混乱的“连接”逻辑。您可以在 Storybook 入门教程的 数据 章节中看到此方法的示例。

优点

  • 一旦组件处于这种形式,故事就很容易编写。
  • 故事的所有数据都编码在故事的参数中,这与 Storybook 工具的其他部分配合良好(例如 控件)。

缺点

  • 您的现有应用程序可能没有按这种方式构建,改变它可能很困难。

  • 在一个地方获取数据意味着您需要将其向下传递到使用它的组件。这在一个组合一个大型 GraphQL 查询(例如)的页面中可能是自然的,但其他数据获取方法可能使其不再合适。

  • 如果您想在屏幕上的不同位置增量加载数据,它灵活性较差。

表现型屏幕的参数组合

当您以这种方式构建屏幕时,复合组件的输入通常是其渲染的各种子组件的输入的组合。例如,如果您的屏幕渲染一个页面布局(包含当前用户的详细信息)、一个标题(描述您正在查看的文档)和一个列表(子文档),则屏幕的输入可能包括用户、文档和子文档。

YourPage.ts|tsx
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>
  );
}

在这种情况下,使用 参数组合 来根据子组件的故事构建页面的故事是很自然的

YourPage.stories.ts|tsx
// 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,
  },
};

当各种子组件导出一个复杂的不同故事列表时,这种方法是有益的。您可以选择构建屏幕级故事的真实场景,而无需重复自己。您的故事维护负担很小,因为您重用数据并采用了不重复自己 (DRY) 的理念。

模拟连接组件

连接组件是依赖于外部数据或服务的组件。例如,完整的页面组件通常是连接组件。当您在 Storybook 中渲染连接组件时,您需要模拟组件依赖的数据或模块。您可以通过多种方式做到这一点。

模拟导入

组件可以依赖导入到组件文件中的模块。这些模块可以来自外部包或项目内部。在 Storybook 中渲染这些组件或测试它们时,您可能希望模拟这些模块来控制它们的行为。

模拟 API 服务

对于进行网络请求的组件(例如,从 REST 或 GraphQL API 获取数据),您可以在故事中模拟这些请求。

模拟提供者

组件可以从上下文提供者接收数据或配置。例如,一个样式化组件可能会从 ThemeProvider 中访问其主题,或者 Redux 使用 React 上下文来提供组件访问应用程序数据。您可以在故事中模拟提供者及其提供的价值,并用它来包装您的组件。

避免模拟依赖项

可以通过 props 或 React 上下文传递连接的“容器”组件的依赖项来完全避免模拟它们。但是,这需要严格的容器和表示组件逻辑分离。例如,如果有一个组件负责数据获取逻辑和渲染 DOM,则需要像前面描述的那样模拟它。

在表示组件中导入和嵌入容器组件很常见。但是,正如我们之前发现的那样,我们可能需要模拟它们的依赖项或导入才能在 Storybook 中渲染它们。

这不仅会很快演变成一项繁琐的任务,而且模拟使用本地状态的容器组件也很有挑战性。因此,与其直接导入容器,不如创建一个提供容器组件的 React 上下文。它允许您像往常一样自由地嵌入容器组件,在组件层次结构的任何级别,而不用担心随后模拟它们的依赖项;因为我们可以用模拟的表示组件替换容器本身。

建议将上下文容器划分到应用程序中特定页面或视图之上。例如,如果您有一个名为 ProfilePage 的组件,则可能设置如下文件结构:

ProfilePage.js
ProfilePage.stories.js
ProfilePageContainer.js
ProfilePageContext.js

设置一个“全局”容器上下文(可能命名为 GlobalContainerContext)对于可能渲染在应用程序每个页面的容器组件,并将它们添加到应用程序的顶层也很有用。虽然可以将每个容器都放到这个全局上下文中,但它只应该提供全局所需的容器。

让我们看一个这种方法的示例实现。

首先,创建一个 React 上下文,并将其命名为 ProfilePageContext。它除了导出一个 React 上下文之外什么也不做。

ProfilePageContext.js|jsx
import { createContext } from 'react';
 
const ProfilePageContext = createContext();
 
export default ProfilePageContext;

ProfilePage 是我们的表示组件。它将使用 useContext hook 从 ProfilePageContext 中检索容器组件。

ProfilePage.js|jsx
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 的上下文中,我们将提供模拟的对应项,而不是通过上下文提供容器组件。在大多数情况下,这些组件的模拟版本通常可以直接从其关联的故事中借用。

ProfilePage.stories.js|jsx
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 故事,我们可以使用 装饰器

将容器提供给您的应用程序

现在,在应用程序的上下文中,您需要通过用 ProfilePageContext.Provider 包装它来为 ProfilePage 提供它需要的所有容器组件。

例如,在 Next.js 中,这将是您的 pages/profile.js 组件。

pages/profile.js|jsx
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 中设置一个装饰器,以便为所有故事提供上下文。例如

.storybook/preview.ts
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;