[npm] Storybook
storybook/examples @ github
這篇內容混雜了 CSF3(meta) 和 CSF2(ComponentMeta)的寫法,請讀者特別留意。
TL;DR
# storybook 會檢視專案中相依的套件,自動提供最合適的設定檔
$ npx sb init
$ npx sb upgrade
$ npm run storybook
$ npm run storybook -- --no-manager-cache # clear cache in the Storybook
# 如果想要檢視專案所吃到的 webpack 設定
$ npx start-storybook --debug-webpack # develop
$ npx build-storybook --debug-webpack # production
- 如果有使用到 react-docgen 這個 addon,建議元件使用 named export 以避免未預期的錯誤,例如,沒辦法自動解析元件的 props、沒辦法自動產生 action、被 styled-component 影響而無法正確解析 props 等等。可以參考 Nothing shows up, this is broken!。
args可以把 props 帶入 component 中parameters可以改變 addon 的設定decorators可以在 component 外再包一層 wrapper
撰寫 Story
- How to write stories @ Writing Stories
- Component Story Format @ API > Stories
基本
使用 Component Story Format (CSF) 的方式來撰寫 storybook 中的 story,所謂 CSF 的方式 是指透過 ES Modules 的方式來定義 stories 和 component metadata,每一個元件的 .stories 檔中都包含 default export 和多個 named exports:
- default export 會用來控制 Storybook 如何列出 stories,並且提供 addons 所需的資訊
- named exports 則用來定義個別的 story,預設的情況下,export 的名稱就會是該 Story 的名稱。
使用 default export 來描述 component;使用 named exports 來描述 stories。一個 Component 下可以有多個 Stories。
// https://storybook.js.org/docs/react/writing-stories/introduction
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Example/Button', // optional
component: Button,
};
// 一個 component 有一個 default export
export default meta;
type Story = StoryObj<typeof Button>;
// 一個 named export 對應一個 story
export const Primary: Story = {
// 雖然這裡示範使用 render,但比較常見的方式是使用後面說明的 args
render: () => <Button primary label="Button" />,
// 改變 story 的名稱
name: 'change a new name',
};

Storybook 中的元件顯示的名稱與排序
- Naming components and hierarchy @ Write stories
- Sidebar & URLS @ Configure > User interface
顯示名稱
default exports 中的 title 會影響到 Storybook 中側邊欄的名稱。如果需要用「資料夾」的概念來將多個 .stories 做分類,可以在 title 中使用 /,例如下面的 title:
// component level
const meta: Meta<typeof Button> = {
title: 'Example/Components/Foobar/Button',
component: Button,
};
export default meta;
// story level
export const Primary = Template.bind({});
export const Secondary = Template.bind({});
export const Large = Template.bind({});
export const Small = Template.bind({});
Small.storyName = 'Small Button';
會長出 storybook 對應的側邊欄,可以看到 EXAMPLE、Components、Foobar、Button 這之間的階層關係:

但有一個特殊的情況是 Single story hoisting,如果
- 這個 component(
.stories檔)中只有一個 Story - 這個 Story 的名稱(即,named export 的名稱)和 component 的
title相同
則之後顯示 component level,就不會再巢狀下去。
排序
Sorting Stories @ Writing Stories > Naming components and hierarchy
預設的情況下,stories 會以它們被 import 的順序來排序,如果需要修改的話,可以透過 preview.js 中的 storySort 來改變,例如:
// .storybook/preview.js
export const parameters = {
options: {
// 可以帶入函式
// storySort: (a, b) => {/* implement the sort logic */}
// 也可使用物件的方式設定
storySort: {
method: '',
order: [],
locales: '',
},
},
};
args:改變 component 的 props
Args @ Writing Stories > Args
args 會是帶入該元件的 props,可以設定在 component(stories) 或 story 層級。 當 args 改變時,component 就會重新 render。
透過 args 的使用,可以讓:
- 讓該元件的 arguments / props 可以在 Control tab 中動態的被修改
- 讓該元件的行為(例如,click)在 Actions tab 中被紀錄
args 可以設定在 component level,也可以設定在 story level。
Component Level args
設定在 component (stories) 層級,如此它會套用在所有的 stories 上,除非有被覆蓋:
// https://storybook.js.org/docs/react/writing-stories/args
import type { Meta } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
component: Button,
//👇 Creates specific argTypes
argTypes: {
backgroundColor: { control: 'color' },
},
args: {
//👇 Now all Button stories will be primary.
primary: true,
},
};
export default meta;
type Story = StoryObj<typeof Button>;
Story Level args
接著,針對不同的 stories 則使用 args 來修改,也可也搭配 object destructuring 來重複使用這些 args:
// ...
export default meta;
type Story = StoryObj<typeof Button>;
export const Primary: Story = {
args: {
primary: true,
label: 'Button',
},
};
export const Secondary: Story = {
args: {
label: 'Button',
},
};
export const Large: Story = {
args: {
...Primary.args,
size: 'large',
label: 'Button',
},
};
export const Small: Story = {
args: {
...Primary.args,
size: 'small',
label: 'Button',
},
};
當 args 的欄位太過複雜,沒辦法透過 controls addon 來控制或與 URL 同步時,可以使用 argTypes 搭配 mapping 來使用,通常絕大部分是用在 select 這類型的元件:
export default {
component: MyComponent,
argTypes: {
label: {
options: ['Normal', 'Bold', 'Italic'],
mapping: {
Bold: <b>Bold</b>,
Italic: <i>Italic</i>,
},
},
},
};
parameters:改變 addon 的設定
layout:調整 story 的呈現的位置,包括centered、fullscreen和padded。參考 Story Layout。
大部分的 addons 都可以透過 parameter 來進行設定,且這個設定可以被套用到 global 上(設定在 .storybook/preview.js)、套用到多個 stories 上(使用 default export)、或是套用到單一個 story 上(使用 named export)。
不同層級間的 parameter 會以 merged 的方式被處理,因此父層的 parameter 會被子層中相同的屬性覆蓋,但對於父層中那些沒被覆蓋到的屬性是不會被移除的。
例如,可以使用 parameters.backgrounds 來改變每個 Button 的 stories 中 canvas 可被選擇的背景顏色:
// component level parameter
const meta: Meta<typeof Button> = {
title: 'Example/Button',
component: Button,
parameters: {
// https://storybook.js.org/docs/react/configure/story-layout
layout: 'centered', // centered, fullscreen, padded
// 改變 background 可以被選擇的顏色(預設是 light 和 dark)
backgrounds: {
values: [
{ name: 'red', value: '#f00' },
{ name: 'green', value: '#0f0' },
{ name: 'blue', value: '#00f' },
],
},
},
};
export default meta;
原本預設是 light 和 dark,修改後則是 red、green 和 blue 可以選 擇:

也可以定義 global level 的 parameters:
// .storybook/preview.js
// global level parameters
export const parameters = {
actions: { argTypesRegex: '^on[A-Z].*' },
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/,
},
},
};
decorators:component wrapper
Nested container components @ storybook > tutorials
decorators 可以作為這個 story 外層的 wrapper,像是 theme wrapper、layout wrapper、state provider 等等,它一樣可以設定要套用到 global 上(設定在 .storybook/preview.js)、套用到多個 stories 上(使用 default export)、或是套用到單一個 story 上(使用 named export)。
decorators 的第二個參數可以取得 story context,在 story context 中可以取得:args、argTypes、globals、hooks、parameters、viewMode。
Styles(extra markup)
舉例來說,可以透過 decorators 在每個 Button 的 stories 外添加樣式:
// Button.stories.tsx
// component level
const meta: Meta<typeof Button> = {
title: 'Example/Button',
component: Button,
decorators: [
(Story) => (
<div style={{ margin: '3rem' }}>
<Story />
</div>
),
],
};
export default meta;
// story level
const Template: ComponentStory<typeof Button> = (args) => <Button {...args} />;
export const Primary = Template.bind({});
Primary.decorators = [
(Story) => (
<div style={{ margin: '3rem' }}>
<Story />
</div>
),
];
ThemeProvider(Context)
寫在 .storybook/preview.js 中的設定則會套用在整個 storybook 中:
// .storybook/preview.js
import { ThemeProvider } from 'styled-components';
// global level decorator
export const decorators = [
(Story) => (
<ThemeProvider theme="default">
<Story />
</ThemeProvider>
),
];
Redux
除了樣式之外,如果專案中有用到 Redux 也同樣適用。舉例來說,InboxScreen 這個元件中會用需要用到來自 Redux 的資料,因此在 Storybook 中使用它時,會需要把它包在 Provider 中:
- 建立一個 mock 的 redux store
- 透過
@storybook/addon-actions可以模擬 redux 的 dispatch 函式 - 在
decorators中把元件用<Provider />包起來
import { action } from '@storybook/addon-actions';
import { Provider } from 'react-redux';
import * as TaskListStories from '../TaskList/TaskList.stories';
import { PureInboxScreen } from './InboxScreen';
// mock 一個非常簡單的 redux store
const store = {
getState: () => ({ tasks: TaskListStories.Default.args.tasks }),
subscribe: () => 0,
dispatch: action('dispatch'), // 使用 action addon
};
export default {
component: PureInboxScreen,
// 在 decorators 中把元件用 Provider 包起來,並把 mock 的 store 帶進去
decorators: [(story) => <Provider store={store}>{story()}</Provider>],
title: 'InboxScreen',
};
const Template = (args) => <PureInboxScreen {...args} />;
export const Default = Template.bind({});
export const Error = Template.bind({});
Error.args = {
error: 'Something went wrong',
};
Play function
- Play function @ Storybook > Write Stories
- Interaction tests @ Storybook > Testing
play function 是會在 story render 前執行的程式碼片段,讓你可以模擬使用者 操作一般來和元件互動和執行測試,例如,表單驗證。建議使用 play function 前先安裝 Storybook 的 @storybook/addon-interactions,使用前可先參考設定方式。
對於一般的元件,可以直接使用 testing-library 提供的 query 方法(參考:React Testing Library 的一些實用的小技巧)來找到元素,例如:
// https://storybook.js.org/docs/react/writing-stories/play-function#writing-stories-with-the-play-function
import { userEvent, screen } from '@storybook/testing-library';
export const FilledForm = Template.bind({});
FilledForm.play = async () => {
// fill in the email field
const emailInput = screen.getByLabelText('email', {
selector: 'input',
});
await userEvent.type(emailInput, 'example-email@email.com', {
delay: 100,
});
// fill in the password field
const passwordInput = screen.getByLabelText('password', {
selector: 'input',
});
await userEvent.type(passwordInput, 'ExamplePassword', {
delay: 100,
});
// select some item
const selectElements = screen.getByRole('listbox');
await userEvent.selectOptions(selectElements, ['Taiwan']);
// click the submit button
const submitButton = screen.getByRole('button');
await userEvent.click(submitButton);
// should see the DOM with testId "error"
await waitFor(async () => {
userEvent.hover(screen.getByTestId('error'));
});
};
在 testing-library 中可以使用 fireEvent 和 userEvent 來促發事件,相較之下,userEvent 更能夠模擬使用者的行為。
預設的情況下,play function 會從 canvas 的 top-level element 開始執 行,但是當渲染的是比較複雜的元件時(例如,form 或 page),可以透過 within(canvasElement) 讓它從 component 的 root 開始執行,以獲得更好的效能:
// https://storybook.js.org/docs/react/writing-stories/play-function#working-with-the-canvas
// ...
import { userEvent, within } from '@storybook/testing-library';
export const ExampleStory = Template.bind({});
ExampleStory.play = async ({ canvasElement }) => {
// Assigns canvas to the component root element
const canvas = within(canvasElement);
// Starts querying from the component's root element
await userEvent.type(canvas.getByTestId('example-element'), 'something');
await userEvent.click(canvas.getByRole('another-element'));
};
使用 MDX 撰寫 Story
MDX @ Storybook > Writing Docs
在 Storybook 中,可以使用 xxx.stories.mdx 來:
- 同時撰寫 Story 和 DocsPage
- 單純建立一份文件
在 Storybook 中,可以使用 MDX 來達到原本使用 CSF 來撰寫 Story 的所有功能(即,原本使用 JSX/TSX 搭配 default export 和 named export),而且這些功能都有一對一的對應。
使用 MDX 來撰寫文件雖然很方便,但多數 IDE 對於 MDX 的支援度不高,所以如果你在 MDX 中試圖使用某些未被 import 的變數,或是有語法上的錯誤,IDE 都沒有辦法直接提示你,且有可能要重啟 Storybook 的時候才能在 log 中看到錯誤。
單純建立一份文件
Documentation-only MDX @ Storybook > Write Docs > MDX
如果你只是單純想要在 Storybook 中建立一份說明文件,這應該是最簡易的方式。
如果你只是想要在側邊欄多一個文件的目錄,但這份文件中並不會有任何 Story 的話,你可以使用 .stories.mdx 來達到,Storybook 預設就會去爬 xxx.stories.mdx 的檔案,記得要透過 <Meta title="..." /> 來定義該文件側邊欄顯示的名稱,如此 Storybook 就會產生出這份對應的文件:
// HowToWriteAStory.stories.mdx
import { Meta } from '@storybook/addon-docs';
<Meta title="Docs/HowToWriteAStory" />
# How to write a story in storybook
Let start with a `.stories.js` file.
記得要在 xxx.stories.mdx 中使用 <Meta /> 來達到類似原本使用 JSX/TSX 來寫 stories 時 default export 的效果,如此才會在側邊欄看到該份文件。
如此就會將這份 MDX 產生出對應的內容:
- 可以看到這份文件放在
DOCS區塊內,名稱為HowTwoWriteAStory - 右邊預設就會用 Docs 來顯示,且因為 MDX 中並沒有使用
<Story />,所以即使切換到 Canvas 頁籤時,依然只會看到文件

同時撰寫 Story 和 DocsPage
如果需要改變 DocsPage 中文件的內容,除了可以使用 parameters.docs.page 參數,指定特定的內容來顯示在 DocsPage 中,Storybook 也支援使用 MDX 的方式直接定義出 .stories 檔和 DocsPage 的內容。
如果單純只是想在 Storybook 中建立一份文件,而沒有實際 component 的 story,一樣可以使用.stories.mdx 的檔案來建立文件(參考上一個段落 — 單純建立一份文件)。
具體來說,開發者可以建立 xxx.stories.mdx 的檔案,Storybook 會去抓取在這個 MDX 中:
- 將文件中使用到
<Story />的部分,顯示在該 Story 的 Canvas 頁籤中 - 把整份 MDX 的內容顯示在該 Story 的 DocsPage 頁籤中
舉例來說,當我們寫出下面的 MDX 時:
<Meta />中的資訊可以想成是原本用 JSX/TSX 寫 stories 時,default export 中告知 storybook 的資訊<Story />是要顯示出來的 Story 元件,也就是原本用 JSX/TSX 寫 stories 時 named export 的元件- 如果需要在同一個區塊內顯示多個 Story,則需要使用
<Canvas />把多個<Story />包起來,要留意 Story 之間不要有額外的空行或換行
// Button.stories.mdx
import { Canvas, Meta, Story } from '@storybook/addon-docs';
import { Button } from './Button';
<Meta
title="Example/Button"
component={Button}
parameters={
{
/* ... */
}
}
decorators={
[
/* ... */
]
}
/>
export const Template = (args) => <Button {...args} />;
# Button
With `MDX`, we can define a story for `Button` right in the middle of our
Markdown documentation.
We can define it in a `Canvas` to get a code snippet:
<Canvas>
<Story
name="Primary"
args={{
primary: true,
label: 'Button',
}}
>
{Template.bind({})}
</Story>
<Story
name="Secondary"
args={{
label: 'Button',
}}
>
{Template.bind({})}
</Story>
<Story
name="Small"
args={{
label: 'Button',
size: 'small',
}}
>
{Template.bind({})}
</Story>
<Story
name="Large"
args={{
label: 'Button',
size: 'large',
}}
>
{Template.bind({})}
</Story>
</Canvas>
根據在 MDX 中使用到的 <Story />,在 Canvas 區塊中一樣會出現如同使用 Button.stories.js 中 named export 定義的 story:

但在 DocsPage 的地方則會直接使用 MDX 文件來作為顯示:

也就是說,當我們使用 .stories.mdx 時,這份文件可以同時產生 Story 中的 Canvas 和 Docs 區塊的內容。
addon-docs
可以在 @storybook/addon-docs 中找到所有在 CSF 中自動對應產生的所有元件:
- 使用
<Meta />定義 CSF 中 default export 的內容 - 使用
<Canvas />可以把多個 Story 包在一起呈現,並且顯示「Show code」 - 使用
<Story />定義 CSF 中 named export 的內容,可以單獨被使用,不一定要放在<Canvas />中 - 使用
<ArgsTable />帶入元件即可自動文件中原本就會列出所有元件參數的 Table
// Button.stories.mdx
import { ArgsTable, Canvas, Meta, Story } from '@storybook/addon-docs';
import { Button } from './Button';
<Meta title="Example/Button" component={Button} />
export const Template = (args) => <Button {...args} />;
# Button
With `MDX`, we can define a story for `Button` right in the middle of our
Markdown documentation.
We can define it in a `Canvas` to get a code snippet:
<Canvas>
<Story
name="Primary"
args={{
primary: true,
label: 'Button',
}}
>
{Template.bind({})}
</Story>
{/* ... */}
</Canvas>
We could also get the argument table of Button:
<ArgsTable of={Button} />
上述的 MDX 將可以產生如下圖的文件:

嵌入 Story 或建立連結
另外,透過 URL 可以檢視各個 Canvas 或 Doc 的 URL(ID):

如果有需要的話,也可以在 MDX 的文件中,透過 ID 載入該 Story:
<Story id="example-button--small" />
或連結到特定 Story:
Go to the story of the [Button](?path=/story/example-button--small).
舉例來說,下面的 MDX:
// How.stories.mdx
import { Meta, Story } from '@storybook/addon-docs';
import { Button } from './Button';
<Meta title="Docs/HowToWriteAStory" />
# How to write a story in storybook
Let start with a `.stories.js` file.
<Story id="example-button--small" />
Go to the story of the [Button](?path=/story/example-button--small).
將可以產生對應的文件:

撰寫文件:DocsPage 區塊
DocsPage @ Storybook > DocsPage
如果你只是單純想要建立一份文件放在 Storybook 中可以被檢視,請參考上一個段落的「單純建立一份文件」。
在 Storybook 中,DocsPage 指的是 Docs 區塊的內容,預設就會使用 @storybook/addon-docs 中提供的元件來產生內容:

除了可以使用 xxx.stories.mdx 的方式來同時產生 Canvas 和 DocsPage 中的內容之外,如果你只是想要修改(替換掉)DocsPage 中的內容,而不想動到 Canvas 區塊時,可以使用 parameters.docs.page 的屬性來做到這件事。