文件
Storybook 文件

使用 Storybook 建立頁面

Storybook 協助您建立任何元件,從小的「原子」元件到組合頁面。但是,當您向上移動元件層級朝向頁面層級時,您會處理更複雜的情況。

在 Storybook 中建立頁面有很多種方法。以下是一些常見的模式和解決方案。

  • 純展示型頁面。
  • 已連接元件(例如,網路請求、context、瀏覽器環境)。

純展示型頁面

BBC、The Guardian 的團隊以及 Storybook 維護者自己都建立純展示型頁面。如果您採用這種方法,則無需執行任何特殊操作即可在 Storybook 中呈現您的頁面。

將元件撰寫為完全展示型,直到螢幕層級,是很簡單的。這使其易於在 Storybook 中展示。這個想法是,您在 Storybook 之外的應用程式中的單個包裝元件中完成所有混亂的「已連接」邏輯。您可以在 Intro to Storybook 教學的資料章節中看到這種方法的範例。

優點

  • 一旦元件採用這種形式,就很容易撰寫 stories。
  • story 的所有資料都編碼在 story 的 args 中,這與 Storybook 工具的其他部分(例如控制項)配合良好。

缺點

  • 您現有的應用程式可能不是以這種方式建構的,並且可能難以更改它。

  • 在一個地方提取資料意味著您需要將其向下鑽取到使用它的元件。這在組合一個大型 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 組合來根據子元件的 stories 建立頁面的 stories

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

當各種子元件匯出複雜的不同 stories 清單時,此方法非常有用。您可以挑選並選擇來為您的螢幕層級 stories 建立真實的場景,而無需重複自己。透過重複使用資料並採用「不要重複自己」(DRY) 的哲學,您的 story 維護負擔是最小的。

模擬已連接元件

已連接元件是依賴外部資料或服務的元件。例如,完整頁面元件通常是已連接元件。當您在 Storybook 中呈現已連接元件時,您需要模擬元件依賴的資料或模組。您可以在各種層級執行此操作。

模擬導入

元件可能依賴導入到元件檔案中的模組。這些模組可能來自外部套件或專案內部。在 Storybook 中呈現這些元件或測試它們時,您可能希望模擬這些模組以控制它們的行為。

模擬 API 服務

對於發出網路請求的元件(例如,從 REST 或 GraphQL API 提取資料),您可以在您的 stories 中模擬這些請求。

模擬提供者

元件可以從 context 提供者接收資料或設定。例如,樣式化的元件可能會從 ThemeProvider 訪問其主題,或者 Redux 使用 React context 來提供元件對應用程式資料的訪問權限。您可以模擬提供者及其提供的值,並在您的 stories 中使用它來包裝您的元件。

避免模擬依賴項

可以完全避免模擬已連接「容器」元件的依賴項,方法是透過 props 或 React context 傳遞它們。但是,它需要嚴格劃分容器和展示型元件邏輯。例如,如果您有一個元件負責資料提取邏輯和呈現 DOM,則需要像先前描述的那樣對其進行模擬。

在展示型元件中導入和嵌入容器元件是很常見的。但是,正如我們先前發現的那樣,我們可能必須模擬它們的依賴項或導入,以便在 Storybook 中呈現它們。

這不僅會很快變得乏味,而且模擬使用本地狀態的容器元件也很具挑戰性。因此,解決此問題的方法不是直接導入容器,而是建立一個 React context 來提供容器元件。它允許您像往常一樣在元件層級的任何層級自由嵌入容器元件,而無需擔心隨後模擬它們的依賴項;因為我們可以將容器本身換成它們模擬的展示型對應物。

我們建議在應用程式中的特定頁面或視圖上劃分 context 容器。例如,如果您有一個 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 中,我們將提供它們模擬的對應物,而不是透過 context 提供容器元件。在大多數情況下,這些元件的模擬版本通常可以直接從它們關聯的 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>
  ),
};

如果相同的 context 適用於所有 ProfilePage stories,我們可以使用裝飾器

將容器提供給您的應用程式

現在,在您的應用程式的 context 中,您需要透過使用 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 中設定一個裝飾器,以便為所有 stories 提供 context。例如

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