Storybook 是一個用來透過獨立元件快速開發 UI 介面的工具,以往要開發元件時,我們可能需要建立一個全新的頁面才能進行開發,但這樣的開發方式可能會有一個狀況:沒有辦法事先開發或是預覽流程中不存在的元件。
透過 Storybook 我們在開發元件時,不需要重新建立複雜的頁面結構,而是可以擁有獨立的工作區進行元件的獨立開發,同時能在元件塞入靜態、動態資料,建立屬於自己的元件庫,或是達到元件文件化的目的:
這樣開發出來的文件複用性更高,除了上方提到的特色外, Sotrybook 自動綁定 UI 測試,還可以透過擴充功能(addons)讓 storybook 可以動態切換狀態,解決開發者找不到共用元件而重複開發元件、狀態等痛點。
首先我們可以透過以下指令安裝 Storybook CLI,要注意不要使用空的專案,建議要使用既有開發環境來初始化 Storybook,我這邊是使用 Vite React TypeScript 框架來輔助:
$ npx storybook@latest init
成功初始化 Storybook 後,會發現在 src 資料夾下自動生成了 stories 資料夾,這個資料夾是 storybook 初始化所提供的範例,啟動後會自動獲得一份 Storybook 指引手冊:
vite-project
├──src/
│ ├──stories/
│ │ ├──assets/
│ │ │ ├─... -> 指引手冊中會用到的一些 icon
│ │ │ ├─Button/
│ │ ├──button.css
│ │ ├──Button.stories.ts
│ │ ├──Button.tsx
│ │ ├──header.css
│ │ ├──Header.stories.ts
│ │ ├──Header.tsx
│ │ ├──page.css
│ │ ├──Page.stories.ts
│ │ ├──Page.tsx
│ │ ├──introduction.mdx -> 指引手冊頁面
├──...
接著我們就能執行以下指令啟動 storybook:
#npm
$ npm run storybook
# yarn
$ yarn storybook
就可以看到 storybook 依照 src/stories 資料夾底下的檔案自動生成了元件庫,也可以看到官方所提供的指引手冊:
在 storybook 中,我們會以 story 來稱呼某元件在特定狀態底下所呈現出來的示例。
我們會使用 <元件>.stories.ts
的檔案來捕捉元件被渲染的狀態,這個 story 可以有不同的屬性、狀態,我們透過 Stroybook 內建、擴充工具(addons)來動態切換這些元件的狀態、尺寸、呈現方式,藉此 story 了解元件使用的方式與情境。
舉例來說,初始化範本中的按鈕元件可以動態切換樣式、文字內容、背景顏色、尺寸,下方為初始狀態:
我們來試著透過 storybook 提供的介面改變按鈕的樣式:
如果這樣還沒辦法充分展現 Storybook 的厲害之處,我補充一些它能派上用場的時機點:
在以上情境使用 Storybook 作為開發工具,能有效避免專案進行重工,接著就讓我們使用 Storybook 所提供的範例來了解怎麼撰寫自己的 UI 元件庫吧!
在撰寫 Story 前我們先來聊聊關於元件路徑問題,官方文件建議在撰寫 Story 時,指定的元件 Story 建議放在元件檔案的「旁邊」,也就是說在初始化時的 src/stories
資料夾目錄中的配置單純可以參考用(目錄層級可以參考上方安裝的章節)。
我們完全可以依照自己的開發習慣來決定 story 檔案放置的路徑,不會因為不是寫在 src/stories
目錄底下而無法啟動 storybook ,根據安裝工具的不同可能會在不同的資料層層級產生 .storybook 檔案,以及對應的 tsconfig 設置,從這些檔案中可以去調整 storybook 執行時,哪些資料夾層級下的 .stories.ts(tsx)會生效,跟據環境不同,配置檔案的方式與目錄階層也會不同。
我們可以把 Button 元件的資料層級整理成:
vite-project
├──src/
│ ├──components/
│ │ ├──atoms/
│ │ │ ├─Button/
│ │ │ │ ├─index.ts
│ │ │ │ ├─index.css
│ │ │ │ ├─Button.stories.ts
要注意的是經過整理的資料夾層級因為目錄層級不同,引入的方式也會不同,如果好奇我的元件拆分層級是參考什麼模式,可以參考這篇文章。
接著我們就由 Storybook 官方所提供的 Button 元件檔案看看要怎麼定義 story 的基礎資料:
// Button.stories.ts|tsx
import type { Meta } from '@storybook/react';
import { Button } from './Button';
const meta: Meta<typeof Button> = {
title: 'Example/Button',
component: Button,
};
export const Interactive = {};
export default meta;
如果我們要在 React Typescript 中撰寫 story,首先我們要引入一個 Meta
型別,我們可以在這個型別中帶入一些屬性建立 story 的基本資料,例如:title
屬性可以用來建立該 story 在 storybook 中的層級,上方範例中所看到的層級會是:
我們可以透過 title
屬性,以路徑的方式來整理我們想要的元件分類。
其次,除了 title
屬性外,component 屬性用來讓 Storybook 知道要渲染哪一個元件,設定好這這兩個屬性後我們再透過不具名的匯出,這非常重要,一定要匯出這一層 storybook 才可以偵測到 storybook 的 Meta 資訊。
接著透過具名匯出的方式,匯出至少一個 story 範本,這個 story 可以是空物件,某一個版本之後 storybook 必須得匯出至少一個 story,才能正確顯示,如果只有匯出單獨 Meta 資訊,storybook 就會報錯,跟你說它找不到這個 story,接著就可以在 Storybook 中看到 Button 元件了。
args
物件載入靜態元件資訊在 stroybook 中使用具名匯出,可以直接建立一個獨立的 stroy,假設有個想要進行 UI 測試的元件 Button :
// ./atoms/Button/index.ts
import React from 'react';
import classnames from 'classnames';
import styles from './index.module.css';
const Button = ({ className, type, content }) => (
<button className={classnames(styles.button, styles[type], className)}>{content}</button>
);
export default Button;
Storybook 的 args 物件可以協助我們進行靜態 Props、文字的帶入:
// ./atoms/Button/Button.stories.ts
import Button from 'components/atoms/Button';
export default {
title: 'atoms/Button',
component: Button,
};
// 展開元件的屬性
const Template = args => <Button {...args} />;
// 將 story 綁定空物件
export const Interactive = Template.bind({});
// 新增屬性靜態值
Interactive.args = {
type: 'basic',
content: 'Button',
};
即可在 storybook 的 controls 頁籤中,找到我們預設的靜態屬性,並透過面板進行屬性動態調整:
argTypes
優化 controls 介面透過 args
所建立的 storybook controls 介面,輸入的內容會是自定義的字串,這會有個問題:協作者會不曉得元件還有哪些既有的屬性可以使用。
因此,我們可以透過使用 argTypes
來優化 controls 介面, argTypes
物件讓開發者可以用更直覺且互動性的方式動態調整元件狀態:
// ./atoms/Button/__test__/Button.stories.ts
import Button from 'components/atoms/Button';
export default {
title: 'atoms/Button',
component: Button,
};
const Template = args => <Button {...args} />;
export const Interactive = Template.bind({});
Interactive.argTypes = {
type: { // Button 元件的 type 屬性
options: ['primary', 'secondary'],
control: { type: 'select' },
},
content: { control: 'text' }, // Button 元件的 content 屬性
};
argTypes
物件第一層級的屬性為「元件屬性」,在這些元件屬性中,我們可以用其他的控制項來優化 storybook controls 介面,例如: control
屬性可以指定這些控制項要以哪種 UI 型態呈現,以及要用什麼樣的內容給定預設值,以上方的 Button.stories.ts
來說,會這樣呈現:
(希望未來方格子的 Markdown 可以支援表格,我這邊暫時先用截圖代替)
storybook 提供的 argTypes interface:
{
[key: string]: {
control?: ControlType | { type: ControlType; /* See below for more */ } | false;
description?: string;
if?: Conditional;
mapping?: { [key: string]: { [option: string]: any } };
name?: string;
options?: string[];
table?: {
category?: string;
defaultValue?: { summary: string; detail?: string };
disable?: boolean;
subcategory?: string;
type?: { summary?: string; detail?: string };
},
type?: SBType | SBScalarType['name'];
}
}
除了透過 control addon 協助切換元件狀態,storybook 提供 action addon,檢驗 UI 是否有正確觸發事件,我們可以透過 argTypes 屬性來告訴 storybook 哪個元件屬性需要被觸發事件:
// Button.stories.js|jsx|ts|tsx
import { Button } from './Button';
export default {
title: 'Button',
component: Button,
argTypes: { onClick: { action: 'clicked' } }, // 事件名稱,可以自定義
};
點擊按鈕時即可在 Actions 頁籤中看到事件物件:
或是可以使用 @storybook/addon-actions
所提供的 action
方法,也能模擬點擊事件:
import { action } from '@storybook/addon-actions';
import Button from 'components/atoms/Button';
export default {
title: 'atoms/Button',
component: Button,
};
// action 方法的第一個參數為自定義 onClick 事件名稱
const Template = args => <Button {...args} onClick={action('on click')} />;
如果想要模擬更多事件,則可以使用 @storybook/addon-actions
所提供的 actions
方法,展開後帶入事件名稱,來檢核各種事件是否有被正確觸發:
import { actions } from '@storybook/addon-actions';
import Button from 'components/atoms/Button';
export default {
title: 'atoms/Button',
component: Button,
};
const Template = args => <Button {...actions('onClick', 'onMouseOver')} {...args} />;
針對 storybook 的開發模式,以及要怎麼樣寫才能寫出自己順手,其他人也能無痛使用的功用元件,是一門滿深的功夫,也會跟每個團隊的開發模式有所不同,有時候我們規劃好了一套模式,但有可能因為各式各樣的因素而打亂了。
目前自己遇到最好也最有效率的方式,是在專案中建立元件的模板自動化工具(例如:搭配 node fs 模組之類的),讓開發者建立元件時更加快速、便利,把 storybook 的實作模式強制綁定到這個模板中,就不怕有人在開發的過程中把 storybook 忘記了。
希望這篇文章可以讓大家更了解 storybook 的方便之處,我是 Vivian,我們下次見!
關於我:
一名從英文系畢業的前端工程師,喜歡閱讀、寫東西及自我成長。
|Instagram: Vivian Yeh|vivian_enlife
|聯絡我:vivian.enlife@gmail.com