Docs
Storybook Docs

使用 Storybook 构建页面

Storybook 可帮助你构建任何组件,从小型“原子”组件到组合页面。但是,随着你将组件层次结构向上移动到页面级别,你需要处理的复杂性会更大。

¥Storybook helps you build any component, from small “atomic” components to composed pages. But as you move up the component hierarchy toward the page level, you deal with more complexity.

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

¥There are many ways to build pages in Storybook. Here are common patterns and solutions.

  • 纯演示页面。

    ¥Pure presentational pages.

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

    ¥Connected components (e.g., network requests, context, browser environment).

纯演示页面

¥Pure presentational pages

BBC、卫报和 Storybook 维护人员的团队自己构建了纯演示页面。如果你采用这种方法,你不需要做任何特殊的事情来在 Storybook 中渲染你的页面。

¥Teams at the BBC, The Guardian, and the Storybook maintainers themselves build pure presentational pages. If you take this approach, you don't need to do anything special to render your pages in Storybook.

编写组件以完全渲染​​到屏幕级别非常简单。这使其易于在 Storybook 中显示。我们的想法是,你在 Storybook 之外的应用中的一个封装器组件中完成所有混乱的“连接”逻辑。你可以在 Storybook 简介教程的 数据 章节中看到这种方法的示例。

¥It's straightforward to write components to be fully presentational up to the screen level. That makes it easy to show in Storybook. The idea is that you do all the messy “connected” logic in a single wrapper component in your app outside of Storybook. You can see an example of this approach in the Data chapter of the Intro to Storybook tutorial.

优点:

¥The benefits:

  • 一旦组件处于这种形式,就很容易编写故事。

    ¥Easy to write stories once components are in this form.

  • 故事的所有数据都编码在故事的参数中,可以与 Storybook 工具的其他部分(例如 controls)很好地配合使用。

    ¥All the data for the story is encoded in the args of the story, which works well with other parts of Storybook's tooling (e.g. controls).

缺点:

¥The downsides:

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

    ¥Your existing app may not be structured in this way, and it may be difficult to change it.

  • 在一个地方获取数据意味着你需要将其深入到使用它的组件。这在组成一个大型 GraphQL 查询(例如)的页面中很自然,但其他数据获取方法可能会使其不太合适。

    ¥Fetching data in one place means that you need to drill it down to the components that use it. This can be natural in a page that composes one big GraphQL query (for instance), but other data fetching approaches may make this less appropriate.

  • 如果你想在屏幕上的不同位置逐步加载数据,它的灵活性会降低。

    ¥It's less flexible if you want to load data incrementally in different places on the screen.

用于演示屏幕的参数组成

¥Args composition for presentational screens

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

¥When you are building screens in this way, it is typical that the inputs of a composite component are a combination of the inputs of the various sub-components it renders. For instance, if your screen renders a page layout (containing details of the current user), a header (describing the document you are looking at), and a list (of the subdocuments), the inputs of the screen may consist of the user, document and subdocuments.

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

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

¥In such cases, it is natural to use args composition to build the stories for the page based on the stories of the sub-components:

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) 的理念,你的故事维护负担最小。

¥This approach is beneficial when the various subcomponents export a complex list of different stories. You can pick and choose to build realistic scenarios for your screen-level stories without repeating yourself. Your story maintenance burden is minimal by reusing the data and taking a Don't-Repeat-Yourself(DRY) philosophy.

模拟连接组件

¥Mocking connected components

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

¥Connected components are components that depend on external data or services. For example, a full page component is often a connected component. When you render a connected component in Storybook, you need to mock the data or modules that the component depends on. There are various layers in which you can do that.

模拟导入

¥Mocking imports

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

¥Components can depend on modules that are imported into the component file. These can be from external packages or internal to your project. When rendering those components in Storybook or testing them, you may want to mock those modules to control their behavior.

模拟 API 服务

¥Mocking API Services

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

¥For components that make network requests (e.g., fetching data from a REST or GraphQL API), you can mock those requests in your stories.

模拟提供商

¥Mocking providers

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

¥Components can receive data or configuration from context providers. For example, a styled component might access its theme from a ThemeProvider or Redux uses React context to provide components access to app data. You can mock a provider and the value it's providing and wrap your component with it in your stories.

避免模拟依赖

¥Avoiding mocking dependencies

可以通过 props 或 React 上下文传递连接的 "container" 组件的依赖,从而完全避免模拟连接的 "container" 组件的依赖。但是,它需要严格区分容器和展示组件逻辑。例如,如果你有一个负责数据获取逻辑和渲染 DOM 的组件,则需要按照前面所述进行模拟。

¥It's possible to avoid mocking the dependencies of connected "container" components entirely by passing them around via props or React context. However, it requires a strict split of the container and presentational component logic. For example, if you have a component responsible for data fetching logic and rendering DOM, it will need to be mocked as previously described.

在演示组件中导入和嵌入容器组件是很常见的。但是,正如我们之前发现的,我们可能必须模拟它们的依赖或导入以在 Storybook 中渲染它们。

¥It’s common to import and embed container components amongst presentational components. However, as we discovered earlier, we’ll likely have to mock their dependencies or the imports to render them within Storybook.

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

¥Not only can this quickly grow to become a tedious task, but it’s also challenging to mock container components that use local states. So, instead of importing containers directly, a solution to this problem is to create a React context that provides the container components. It allows you to freely embed container components as usual, at any level in the component hierarchy without worrying about subsequently mocking their dependencies; since we can swap out the containers themselves with their mocked presentational counterpart.

我们建议将上下文容器划分到应用中的特定页面或视图上。例如,如果你有一个 ProfilePage 组件,你可能会设置如下文件结构:

¥We recommend dividing context containers up over specific pages or views in your app. For example, if you had a ProfilePage component, you might set up a file structure as follows:

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

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

¥It’s also often helpful to set up a “global” container context (perhaps named GlobalContainerContext) for container components that may be rendered on every page of your app and add them to the top level of your application. While it’s possible to place every container within this global context, it should only provide globally required containers.

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

¥Let’s look at an example implementation of this approach.

首先,创建一个 React 上下文,并将其命名为 ProfilePageContext。它只做导出 React 上下文:

¥First, create a React context, and name it ProfilePageContext. It does nothing more than export a React context:

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

ProfilePage 是我们的展示组件。它将使用 useContext 钩子从 ProfilePageContext 中检索容器组件:

¥ProfilePage is our presentational component. It will use the useContext hook to retrieve the container components from 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 中的容器

¥Mocking containers in Storybook

在 Storybook 上下文中,我们不是通过上下文提供容器组件,而是提供它们的模拟对应项。在大多数情况下,这些组件的模拟版本通常可以直接从其关联故事中借用。

¥In the context of Storybook, instead of providing container components through context, we’ll instead provide their mocked counterparts. In most cases, the mocked versions of these components can often be borrowed directly from their associated stories.

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

¥If the same context applies to all ProfilePage stories, we can use a decorator.

为你的应用提供容器

¥Providing containers to your application

现在,在你的应用上下文中,你需要通过使用 ProfilePageContext.Provider 封装它来为 ProfilePage 提供它所需的所有容器组件:

¥Now, in the context of your application, you’ll need to provide ProfilePage with all of the container components it requires by wrapping it with ProfilePageContext.Provider:

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

¥For example, in Next.js, this would be your pages/profile.js component.

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

¥Mocking global containers in Storybook

如果你已经设置了 GlobalContainerContext,则需要在 Storybook 的 preview.js 中设置一个装饰器,以便为所有故事提供上下文。例如:

¥If you’ve set up GlobalContainerContext, you’ll need to set up a decorator within Storybook’s preview.js to provide context to all stories. For example:

.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;