参加直播:美国东部时间周四上午 11 点,Storybook 9 发布及问答
文档
Storybook 文档

使用 Storybook 构建页面

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

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

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

纯展示性页面

BBC、卫报和 Storybook 维护者团队自身构建纯展示性页面。如果采用这种方法,您无需做任何特殊处理即可在 Storybook 中渲染您的页面。

将组件编写成直至屏幕级别的纯展示性组件非常直接。这使得在 Storybook 中展示变得容易。其理念是,您在应用程序中 Storybook 外部的一个单一包装组件中处理所有混乱的“连接”逻辑。您可以在 Storybook 教程的《数据》章节中看到这种方法的示例。

优点

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

缺点

  • 您现有的应用程序可能不是按这种方式构建的,并且可能难以更改它。

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

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

用于展示性屏幕的参数组合

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

您的页面.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>
  );
}

在这种情况下,很自然会使用参数组合来基于子组件的故事构建页面的故事。

您的页面.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,
  },
};

当各种子组件导出复杂的不同故事列表时,这种方法很有益。您可以选择性地构建屏幕级别故事的真实场景,而无需重复自己。通过重用数据和遵循“不要重复自己”(DRY) 的原则,您的故事维护负担将降到最低。

模拟连接组件

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

模拟导入

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

模拟 API 服务

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

模拟提供者

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

避免模拟依赖项

通过 props 或 React context 传递依赖项,可以完全避免模拟连接的“容器”组件的依赖项。但是,这需要严格区分容器组件和展示性组件的逻辑。例如,如果您有一个组件负责数据获取逻辑和渲染 DOM,那么它将需要像之前描述的那样进行模拟。

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

这不仅会迅速变成一项繁琐的任务,而且模拟使用本地状态的容器组件也很具挑战性。因此,解决这个问题的一种方法是创建提供容器组件的 React context,而不是直接导入容器。这使您可以在组件层级结构的任何级别自由地像往常一样嵌入容器组件,而无需担心后续模拟它们的依赖项;因为我们可以用它们的模拟展示性对应物替换容器本身。

我们建议根据应用程序中的特定页面或视图划分 context containers。例如,如果您有一个 ProfilePage 组件,您可以按照以下文件结构进行设置

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

为可能在应用程序的每个页面上渲染的容器组件设置一个“全局”容器 context(可以命名为 GlobalContainerContext)并将其添加到应用程序的顶层也通常很有帮助。虽然可以将所有容器放在此全局 context 中,但它只应提供全局必需的容器。

我们来看一下这种方法的示例实现。

首先,创建一个 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 提供容器组件,而是提供它们的模拟对应物。在大多数情况下,这些组件的模拟版本通常可以直接借用它们关联的故事。

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

为您的应用程序提供容器

现在,在您的应用程序中,您需要通过用 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 中设置一个装饰器来为所有故事提供 context。例如

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