使用 Storybook 建構頁面
Storybook 可協助您建構任何元件,從小的「原子」元件到組合的頁面。但是,當您在元件階層中向上移動到頁面層級時,您會處理更複雜的問題。
在 Storybook 中建構頁面有很多種方法。以下是一些常見的模式和解決方案。
- 純展示頁面。
- 已連線的元件(例如,網路請求、內容、瀏覽器環境)。
純展示頁面
BBC、The Guardian 和 Storybook 維護人員的團隊都建構純展示頁面。如果您採用這種方法,您無需執行任何特殊操作即可在 Storybook 中渲染您的頁面。
將元件撰寫為完全展示到螢幕層級是很簡單的。這使得它很容易在 Storybook 中顯示。這個概念是,您在應用程式中 Storybook 之外的單一包裝元件中執行所有混亂的「已連線」邏輯。您可以在 Storybook 教學的「資料」章節中看到這種方法的範例。
好處
- 一旦元件採用這種形式,就很容易撰寫 stories。
- story 的所有資料都編碼在 story 的 args 中,這與 Storybook 工具的其他部分(例如,控制項)搭配使用效果很好。
缺點
-
您現有的應用程式可能不是以這種方式結構化的,而且可能難以更改它。
-
在一個位置擷取資料意味著您需要將其向下傳遞到使用它的元件。這在一個組合一個大型 GraphQL 查詢的頁面(例如)中可能很自然,但其他資料擷取方法可能使其不太適合。
-
如果您想在螢幕上的不同位置以遞增方式載入資料,則靈活性較差。
展示螢幕的 Args 組合
當您以這種方式建構螢幕時,複合元件的輸入通常是它渲染的各種子元件的輸入組合。例如,如果您的螢幕渲染頁面佈局(包含目前使用者的詳細資料)、標頭(描述您正在檢視的文件)和清單(子文件),則螢幕的輸入可能包含使用者、文件和子文件。
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
// 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 中模擬這些請求。
模擬 providers
元件可以從 context providers 接收數據或配置。例如,一個 styled component 可能會從 ThemeProvider 訪問其主題,或者 Redux 使用 React context 來提供元件訪問應用程式數據的權限。您可以模擬一個 provider 及其提供的值,並在您的 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
import { createContext } from 'react';
const ProfilePageContext = createContext();
export default ProfilePageContext;
ProfilePage
是我們的表示元件。它將使用 useContext
hook 從 ProfilePageContext
檢索容器元件。
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 中借用。
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,我們可以使用 decorator。
向您的應用程式提供容器
現在,在您的應用程式的 context 中,您需要通過使用 ProfilePageContext.Provider
包裹 ProfilePage
,來向它提供它需要的所有容器元件。
例如,在 Next.js 中,這會是您的 pages/profile.js
元件。
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,以便向所有 stories 提供 context。例如
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;