使用 Storybook 建立頁面
Storybook 協助您建立任何元件,從小的「原子」元件到組合頁面。但是,當您向上移動元件層級朝向頁面層級時,您會處理更複雜的情況。
在 Storybook 中建立頁面有很多種方法。以下是一些常見的模式和解決方案。
- 純展示型頁面。
- 已連接元件(例如,網路請求、context、瀏覽器環境)。
純展示型頁面
BBC、The Guardian 的團隊以及 Storybook 維護者自己都建立純展示型頁面。如果您採用這種方法,則無需執行任何特殊操作即可在 Storybook 中呈現您的頁面。
將元件撰寫為完全展示型,直到螢幕層級,是很簡單的。這使其易於在 Storybook 中展示。這個想法是,您在 Storybook 之外的應用程式中的單個包裝元件中完成所有混亂的「已連接」邏輯。您可以在 Intro to 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 中模擬這些請求。
模擬提供者
元件可以從 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
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,我們可以使用裝飾器。
將容器提供給您的應用程式
現在,在您的應用程式的 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
中設定一個裝飾器,以便為所有 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;