文件故事
Storybook 文件

如何撰寫故事

觀看影片教學

故事會捕捉 UI 元件的渲染狀態。它是一個帶有註釋的物件,描述元件在給定一組引數的情況下的行為和外觀。

在談論 React 的 props、Vue 的 props、Angular 的 @Input 和其他類似概念時,Storybook 使用通用術語引數 (args)。

故事的放置位置

元件的故事定義在與元件檔案並存的故事檔案中。故事檔案僅用於開發,不會包含在您的生產套件中。在您的檔案系統中,它看起來像這樣

components/
├─ Button/
│  ├─ Button.js | ts | jsx | tsx | vue | svelte
│  ├─ Button.stories.js | ts | jsx | tsx

元件故事格式

我們根據元件故事格式 (CSF) 來定義故事,這是一個基於 ES6 模組的標準,易於撰寫且可在工具之間移植。

關鍵要素是描述元件的預設匯出,以及描述故事的具名匯出

預設匯出

預設匯出中繼資料控制 Storybook 如何列出您的故事,並提供外掛程式使用的資訊。例如,以下是故事檔案 Button.stories.js|ts 的預設匯出

Button.stories.ts|tsx
import type { Meta } from '@storybook/react';
 
import { Button } from './Button';
 
const meta: Meta<typeof Button> = {
  component: Button,
};
 
export default meta;

從 Storybook 版本 7.0 開始,故事標題會在建構過程中以靜態方式分析。預設匯出必須包含可以靜態讀取的 title 屬性,或可以從中計算自動標題的 component 屬性。使用 id 屬性來自訂您的故事 URL 也必須是靜態可讀取的。

定義故事

使用 CSF 檔案的具名匯出來定義元件的故事。我們建議您對故事匯出使用 UpperCamelCase。以下說明如何在「主要」狀態下渲染 Button,並匯出一個名為 Primary 的故事。

Button.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { Button } from './Button';
 
const meta: Meta<typeof Button> = {
  component: Button,
};
 
export default meta;
type Story = StoryObj<typeof Button>;
 
export const Primary: Story = {
  args: {
    primary: true,
    label: 'Button',
  },
};

使用 React Hooks

React Hooks 是方便的輔助方法,可以使用更精簡的方式建立元件。您可以在建立元件的故事時使用它們,如果需要的話,儘管您應該將它們視為進階用法。我們建議在撰寫自己的故事時,盡可能使用 args。舉例來說,這是一個使用 React Hooks 來變更按鈕狀態的故事:

Button.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { Button } from './Button';
 
const meta: Meta<typeof Button> = {
  component: Button,
};
 
export default meta;
type Story = StoryObj<typeof Button>;
 
/*
 *👇 Render functions are a framework specific feature to allow you control on how the component renders.
 * See https://storybook.dev.org.tw/docs/api/csf
 * to learn how to use render functions.
 */
export const Primary: Story = {
  render: () => <Button primary label="Button" />,
};

重新命名故事

您可以重新命名任何特定的故事,例如,給它一個更精確的名稱。以下是如何更改 Primary 故事的名稱:

Button.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { Button } from './Button';
 
const meta: Meta<typeof Button> = {
  component: Button,
};
 
export default meta;
type Story = StoryObj<typeof Button>;
 
export const Primary: Story = {
  // 👇 Rename this story
  name: 'I am the primary',
  args: {
    label: 'Button',
    primary: true,
  },
};

您的故事現在將在側邊欄中顯示指定的文字。

如何撰寫故事

故事是一個描述如何呈現元件的物件。每個元件可以有多個故事,而這些故事可以彼此建構。例如,我們可以根據上面的 Primary 故事新增 Secondary 和 Tertiary 故事。

Button.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { Button } from './Button';
 
const meta: Meta<typeof Button> = {
  component: Button,
};
 
export default meta;
type Story = StoryObj<typeof Button>;
 
export const Primary: Story = {
  args: {
    backgroundColor: '#ff0',
    label: 'Button',
  },
};
 
export const Secondary: Story = {
  args: {
    ...Primary.args,
    label: '😄👍😍💯',
  },
};
 
export const Tertiary: Story = {
  args: {
    ...Primary.args,
    label: '📚📕📈🤓',
  },
};

此外,您可以在為其他元件撰寫故事時匯入 args 來重複使用,這在您建立複合元件時很有幫助。例如,如果我們建立一個 ButtonGroup 故事,我們可能會將其子元件 Button 的兩個故事混合。

ButtonGroup.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { ButtonGroup } from '../ButtonGroup';
 
//👇 Imports the Button stories
import * as ButtonStories from './Button.stories';
 
const meta: Meta<typeof ButtonGroup> = {
  component: ButtonGroup,
};
 
export default meta;
type Story = StoryObj<typeof ButtonGroup>;
 
export const Pair: Story = {
  args: {
    buttons: [{ ...ButtonStories.Primary.args }, { ...ButtonStories.Secondary.args }],
    orientation: 'horizontal',
  },
};

當 Button 的簽名變更時,您只需要變更 Button 的故事以反映新的結構描述,而 ButtonGroup 的故事將會自動更新。這種模式可讓您在元件階層中重複使用您的資料定義,使您的故事更易於維護。

不僅如此!故事函數中的每個 args 都可以使用 Storybook 的 Controls 面板進行即時編輯。這表示您的團隊可以在 Storybook 中動態變更元件,以進行壓力測試並找出邊緣案例。

您也可以在使用 Controls 面板調整其控制值之後,編輯或儲存新的故事。

附加元件可以增強 args。例如,Actions 會自動偵測哪些 args 是回呼函數,並將記錄函數附加到它們。這樣,互動(例如點擊)就會記錄在 Actions 面板中。

使用 play 函數

Storybook 的 play 函數和 @storybook/addon-interactions 是方便的輔助方法,可測試元件情境,否則需要使用者介入。它們是小型程式碼片段,會在您的故事呈現後執行一次。例如,假設您想要驗證表單元件,您可以使用 play 函數撰寫以下故事,以檢查當使用資訊填寫輸入時元件的反應:

LoginForm.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { userEvent, within, expect } from '@storybook/test';
 
import { LoginForm } from './LoginForm';
 
const meta: Meta<typeof LoginForm> = {
  component: LoginForm,
};
 
export default meta;
type Story = StoryObj<typeof LoginForm>;
 
export const EmptyForm: Story = {};
 
/*
 * See https://storybook.dev.org.tw/docs/writing-stories/play-function#working-with-the-canvas
 * to learn more about using the canvasElement to query the DOM
 */
export const FilledForm: Story = {
  play: async ({ canvasElement }) => {
    const canvas = within(canvasElement);
 
    // 👇 Simulate interactions with the component
    await userEvent.type(canvas.getByTestId('email'), 'email@provider.com');
 
    await userEvent.type(canvas.getByTestId('password'), 'a-random-password');
 
    // See https://storybook.dev.org.tw/docs/essentials/actions#automatically-matching-args to learn how to setup logging in the Actions panel
    await userEvent.click(canvas.getByRole('button'));
 
    // 👇 Assert DOM structure
    await expect(
      canvas.getByText(
        'Everything is perfect. Your account is ready and we should probably get you started!',
      ),
    ).toBeInTheDocument();
  },
};

如果沒有 play 函數和 @storybook/addon-interactions 的協助,您必須撰寫自己的故事並手動與元件互動,才能測試每個可能的使用案例情境。

使用參數

參數是 Storybook 用於定義故事靜態中繼資料的方法。故事的參數可用於為各種附加元件提供組態,無論是在故事或故事群組的層級。

例如,假設您想要針對與應用程式中其他元件不同的背景集測試您的 Button 元件。您可能會新增元件層級的 backgrounds 參數:

Button.stories.ts|tsx
// Replace your-framework with the framework you are using (e.g., react-webpack5, vue3-vite)
import type { Meta, StoryObj } from '@storybook/your-framework';
 
import { Button } from './Button';
 
const meta: Meta<typeof Button> = {
  component: Button,
  // 👇 Meta-level parameters
  parameters: {
    backgrounds: {
      default: 'dark',
    },
  },
};
export default meta;
 
type Story = StoryObj<typeof Button>;
 
export const Basic: Story = {};

Parameters background color

每當選取 Button 故事時,此參數會指示 backgrounds 附加元件重新設定自身。大多數附加元件都是透過基於參數的 API 來設定,並且可以在全域元件故事層級進行影響。

使用裝飾器

裝飾器是一種機制,可以在呈現故事時將元件包裝在任意標記中。元件通常是在對「呈現位置」的假設下建立的。您的樣式可能需要主題或版面配置包裝函式,或者您的 UI 可能需要特定的內容或資料提供者。

一個簡單的範例是在元件的故事中新增內邊距。使用裝飾器來完成此操作,該裝飾器會將故事包裝在具有內邊距的 div 中,如下所示:

Button.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { Button } from './Button';
 
const meta: Meta<typeof Button> = {
  component: Button,
  decorators: [
    (Story) => (
      <div style={{ margin: '3em' }}>
        {/* 👇 Decorators in Storybook also accept a function. Replace <Story/> with Story() to enable it  */}
        <Story />
      </div>
    ),
  ],
};
 
export default meta;

裝飾器可能更複雜,而且通常由附加元件提供。您也可以在故事元件全域層級設定裝飾器。

兩個或多個元件的故事

有時您可能會有兩個或多個協同工作的元件。例如,如果您有一個父 List 元件,它可能需要子 ListItem 元件。

List.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { List } from './List';
 
const meta: Meta<typeof List> = {
  component: List,
};
 
export default meta;
type Story = StoryObj<typeof List>;
 
//👇 Always an empty list, not super interesting
export const Empty: Story = {};

在這種情況下,為每個故事呈現不同的函式是有意義的。

List.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { List } from './List';
import { ListItem } from './ListItem';
 
const meta: Meta<typeof List> = {
  component: List,
};
 
export default meta;
type Story = StoryObj<typeof List>;
 
export const Empty: Story = {};
 
/*
 *👇 Render functions are a framework specific feature to allow you control on how the component renders.
 * See https://storybook.dev.org.tw/docs/api/csf
 * to learn how to use render functions.
 */
export const OneItem: Story = {
  render: (args) => (
    <List {...args}>
      <ListItem />
    </List>
  ),
};
 
export const ManyItems: Story = {
  render: (args) => (
    <List {...args}>
      <ListItem />
      <ListItem />
      <ListItem />
    </List>
  ),
};

您也可以在 List 元件中重複使用子 ListItem故事資料。這樣更容易維護,因為您不必在多個位置更新它。

List.stories.ts|tsx
import type { Meta, StoryObj } from '@storybook/react';
 
import { List } from './List';
import { ListItem } from './ListItem';
 
//👇 We're importing the necessary stories from ListItem
import { Selected, Unselected } from './ListItem.stories';
 
const meta: Meta<typeof List> = {
  component: List,
};
 
export default meta;
type Story = StoryObj<typeof List>;
 
export const ManyItems: Story = {
  render: (args) => (
    <List {...args}>
      <ListItem {...Selected.args} />
      <ListItem {...Unselected.args} />
      <ListItem {...Unselected.args} />
    </List>
  ),
};

請注意,像這樣撰寫故事存在缺點,因為您無法充分利用 args 機制,以及在您建立更複雜的複合元件時組合 args。如需更多討論,請參閱多元件故事工作流程文件。