文档
Storybook Docs

使用 Storybook 构建页面

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

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

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

纯展示型页面

BBC、The Guardian 和 Storybook 的维护者自己构建纯展示型页面。如果您采用这种方法,则无需执行任何特殊操作即可在 Storybook 中渲染您的页面。

编写完全展示到屏幕级别的组件非常简单。这使得在 Storybook 中展示它们变得容易。这个想法是,您将在 Storybook 外部的应用中的单个包装器组件中处理所有混乱的“已连接”逻辑。您可以在 Intro to Storybook 教程的“Data”章节中看到此方法的示例。

好处

  • 一旦组件采用这种形式,就可以轻松编写 Story。
  • Story 的所有数据都编码在 Story 的 args 中,这与其他 Storybook 工具(例如controls)配合良好。

缺点

  • 您现有的应用程序可能不是这样结构的,并且可能很难更改。

  • 在一个地方获取数据意味着您需要将其传递给使用它的组件。这对于一个组合了单个大型 GraphQL 查询(例如)的页面来说可能是自然的,但其他数据获取方法可能会使其不太合适。

  • 如果您想在屏幕的不同位置增量加载数据,这种方式的灵活性较低。

展示型屏幕的 Args 组合

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

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>
  );
}

在这些情况下,自然会使用args composition 来根据子组件的 Story 构建页面 Story。

YourPage.stories.ts|tsx
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, vue3-vite, etc.
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 = {
  component: DocumentScreen,
} satisfies Meta<typeof DocumentScreen>;
 
export default meta;
type Story = StoryObj<typeof meta>;
 
export const Simple: Story = {
  args: {
    user: PageLayout.Simple.args.user,
    document: DocumentHeader.Simple.args.document,
    subdocuments: DocumentList.Simple.args.documents,
  },
};

当各种子组件导出复杂多样的 Story 时,此方法非常有用。您可以选择性地构建屏幕级别 Story 的逼真场景,而无需重复自己。通过重用数据并遵循“不要重复自己”(DRY)的理念,您的 Story 维护负担将降至最低。

模拟已连接的组件

已连接的组件是依赖于外部数据或服务的组件。例如,一个完整的页面组件通常是一个已连接的组件。当您在 Storybook 中渲染已连接的组件时,您需要模拟该组件依赖的数据或模块。您可以在多个层面执行此操作。

模拟导入

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

模拟 API 服务

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

模拟提供者

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

避免模拟依赖项

通过 props 或 React context 将依赖项传递给已连接的“容器”组件,可以完全避免模拟它们的依赖项。然而,这需要严格区分容器和展示组件的逻辑。例如,如果您有一个负责数据获取逻辑和渲染 DOM 的组件,则需要按上述方式对其进行模拟。

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

这不仅会迅速变得乏味,而且很难模拟使用本地状态的容器组件。因此,与其直接导入容器,不如创建一个提供容器组件的 React context 来解决这个问题。它允许您像平常一样自由地在组件层次结构的任何级别嵌入容器组件,而无需担心后续模拟其依赖项;因为我们可以将容器本身替换为它们的模拟展示组件。

我们建议将上下文容器按应用程序的特定页面或视图进行划分。例如,如果您有一个 ProfilePage 组件,您可以设置如下文件结构:

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

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

让我们来看一个此方法的实现示例。

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

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 的上下文中,我们不会通过 context 提供容器组件,而是提供它们的模拟版本。在大多数情况下,这些组件的模拟版本通常可以直接从其关联的 Story 中借用。

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>
  ),
};

如果相同的 context 适用于所有ProfilePage Story,我们可以使用decorator

为您的应用程序提供容器

现在,在您的应用程序的上下文中,您需要通过用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中设置一个 decorator 来为所有 Story 提供 context。例如:

.storybook/preview.ts|tsx
import * as React from 'react';
 
// Replace your-framework with the framework you are using, e.g. react-vite, nextjs, nextjs-vite, etc.
import type { Meta, StoryObj } 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;