文件Stories
Storybook 文件

如何撰寫 stories

觀看影片教學

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

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

stories 應放置的位置

元件的 stories 定義在 story 檔案中,該檔案與元件檔案位於同一位置。story 檔案僅供開發使用,不會包含在您的生產套件中。在您的檔案系統中,它看起來像這樣

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

元件 Story 格式

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

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

預設匯出

預設匯出元數據控制 Storybook 如何列出您的 stories,並提供附加元件使用的資訊。例如,以下是 story 檔案 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 開始,story 標題會在建置過程中靜態分析。預設匯出必須包含可靜態讀取的 title 屬性,或是可以從中計算自動標題的 component 屬性。使用 id 屬性自訂您的 story URL 也必須是可靜態讀取的。

定義 stories

使用 CSF 檔案的具名匯出,來定義元件的 stories。我們建議您為 story 匯出使用 UpperCamelCase 命名方式。以下是如何在 “primary” 狀態下渲染 Button,並匯出名為 Primary 的 story。

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 是方便的輔助方法,可以使用更簡化的方法建立元件。如果您需要,可以在建立元件的 stories 時使用它們,但您應該將其視為進階使用案例。我們在撰寫自己的 stories 時,強烈建議盡可能使用args。例如,以下是一個使用 React Hooks 變更按鈕狀態的 story

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

重新命名 stories

您可以根據需要重新命名任何特定的 story。例如,為了給它一個更精確的名稱。以下是如何變更 Primary story 的名稱

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

您的 story 現在將以給定的文字顯示在側邊欄中。

如何撰寫 stories

一個 story 是一個物件,描述如何渲染元件。每個元件可以有多個 stories,而這些 stories 可以彼此建立關聯。例如,我們可以根據上面 Primary story 新增 Secondary 和 Tertiary stories。

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 以在為其他元件撰寫 stories 時重複使用,當您建構複合元件時,這會很有幫助。例如,如果我們建立一個 ButtonGroup story,我們可能會混用其子元件 Button 的兩個 stories。

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 的 stories 以反映新的架構,ButtonGroup 的 stories 將會自動更新。這種模式允許您在元件階層中重複使用資料定義,使您的 stories 更易於維護。

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

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

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

使用 Play 函式

Storybook 的 play 函式和 @storybook/addon-interactions 是方便的輔助方法,可用於測試原本需要使用者介入的元件情境。它們是小段程式碼,會在您的 story 渲染後執行一次。例如,假設您想要驗證表單元件,您可以使用 play 函式編寫以下 story,以檢查在填寫輸入資訊時元件的反應

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 的幫助,您必須編寫自己的 stories 並手動與元件互動,以測試每種可能的用例情境。

使用 Parameters(參數)

Parameters(參數)是 Storybook 定義 stories 靜態元數據的方法。story 的參數可用於在 story 或 story 群組層級為各種附加元件提供設定。

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

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

Parameters background color

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

使用 Decorators(裝飾器)

Decorators(裝飾器)是一種機制,可以在渲染 story 時將元件包裝在任意標記中。元件的建立通常基於對「渲染位置」的假設。您的樣式可能需要主題或版面配置包裝器,或者您的 UI 可能需要特定的上下文或資料提供者。

一個簡單的範例是在元件的 stories 中新增 padding。使用裝飾器來完成此操作,該裝飾器將 stories 包裝在具有 padding 的 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;

Decorators(裝飾器)可能更複雜,並且通常由附加元件提供。您也可以在story元件全域層級設定裝飾器。

兩個或多個元件的 Stories

有時您可能會有兩個或多個元件一起建立來協同運作。例如,如果您有一個父元件 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 = {};

在這種情況下,為每個 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 元件中重複使用來自子元件 ListItemstory 資料。這樣更容易維護,因為您不必在多個位置更新它。

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

請注意,以這種方式編寫 stories 存在缺點,因為您無法充分利用 args 機制,也無法在建構更複雜的複合元件時組合 args。如需更多討論,請參閱多元件 stories 工作流程文件。