Story 撰寫慣例
每個元件至少三種 story
| 類型 | 回答什麼 | 一定要有嗎 |
|---|---|---|
| 典型用法 | 這個元件正常長什麼樣 | 必要 |
| 狀態並列 | 它的每一種狀態長什麼樣、彼此差在哪 | 必要(見下) |
| 互動 playground | 換各種參數會怎樣 | 建議 |
| 壓力測試 | 塞極端值會不會壞 | 見壓力測試 Story |
狀態要並列,不要分開
這是最重要的一條,也是最常被做反的一條。
一個元件有四種狀態時,直覺是寫四支 story。但分開之後每一種看起來都很正常—— 你沒有辦法發現「已改動」與「鎖定」在深色模式下的邊框只差一階亮度, 或是「空選項」的高度比其他三種矮了 4px,讓表單裡的一整列跳動。
export const 四態並列: Story = {
render: () => (
<div className="flex flex-col gap-3">
<Chips label="預設" … />
<Chips label="已改動未送出" … changed />
<Chips label="鎖定" … disabled lockHint="此筆已結案,需先解除鎖定" />
<Chips label="沒有選項" … options={[]} emptyHint="尚未建立任何選項" />
</div>
),
};
把同一個元件的所有狀態放在一支 story 裡垂直排列,每一列標上狀態名。
不要一個狀態一支 story。它們單獨看都對,並排才看得出不對。
驗收時的用法:把這一支切到深色模式再看一次,然後灰階列印一次。 三種模式都能分辨四種狀態,才算過。
互動 playground:用中文 arg
Storybook 的 Controls 面板可以讓非工程師直接調參數。前提是參數名看得懂。
export const 互動: StoryObj<{ 資料筆數: number; 每頁筆數: number; 斑馬紋: boolean }> = {
args: { 資料筆數: 42, 每頁筆數: 15, 斑馬紋: true },
argTypes: {
資料筆數: { control: { type: "range", min: 0, max: 200, step: 1 } },
每頁筆數: { control: "inline-radio", options: [5, 15, 30, 50] },
斑馬紋: { control: "boolean" },
},
render: (a) => (
<DataTable rows={makeRows(a.資料筆數)} pageSize={a.每頁筆數} zebra={a.斑馬紋} … />
),
};
三個部分:中文 args 型別 → argTypes 指定控制項 → render 映射回真實 props。
為什麼值得多這一層映射
| 直接用 props 當 args | 中文 arg + render 映射 | |
|---|---|---|
| 面板可讀性 | pageSize zebra crosshair | 每頁筆數、斑馬紋、十字對準 |
| 誰能用 | 讀得懂 props 的人 | 設計、驗收的人 |
| 能不能組合出真實情境 | 只能一個個調 | 可以做「資料筆數 = 0」這種跨 prop 的情境 |
| 代價 | 無 | 多一層 render 映射(約 5 行) |
實際效益是驗收:「把資料筆數拉到 0 看看空狀態」這句話,對方可以自己做, 不必回頭找工程師改程式碼。
對外展示用的元件(DataTable、EditableField)都配一支中文 playground。
不要每支 story 都做。playground 是給「參數很多、組合很多」的元件用的; 一個只有兩種變體的元件,並列展示比 playground 有用。
陷阱:playground 裡需要 hook 時
render 是一個函式,不是元件——直接在裡面呼叫 useState 會違反 Hook 規則。
(有時候它「看起來能動」,然後在切換 args 時炸掉,症狀非常難解讀。)
正解是在 render 內宣告一個內部元件:
render: (a) => {
const Demo = () => {
const [v, setV] = useState("0");
return <TabPills tabs={makeTabs(a.分頁數)} value={v} onChange={setV} />;
};
return <Demo key={a.分頁數} />; // ← key 是關鍵
},
key 為什麼必要:改 args 時 React 會沿用同一個元件實例,內部 state 不會重置。
於是「分頁數」從 5 調到 2 之後,value 還停在已經不存在的第 4 個分頁上,
畫面看起來像 bug——但那是 story 的問題,不是元件的問題。
把會影響結構的 arg 放進 key,強制 remount。
更簡單的替代做法:用 render: function Render() { … } 具名函式元件。
兩種都可以,但同一個 repo 只選一種。
命名慣例
| 場景 | 命名 | 例 |
|---|---|---|
| 一般 story | 中文語意 | 完整功能、三種空狀態 |
| 互動 playground | 互動 或 ⟨元件⟩_互動 | 互動、按鈕_互動 |
| 狀態並列 | ⟨N⟩態並列 | 三態並列、四態並列 |
| 元件名需要對照 | 中文在前、英文在後 | 資料表 DataTable |
一個 repo 一種慣例,寫在這一頁上。
不要混用 按鈕_互動 和 互動_按鈕。側邊欄是按字母排序的,
兩種寫法會讓同類 story 散落在不同位置。
story 就是規格的證據
文件頁說「鎖定態仍然可以聚焦」,那就必須有一支 story 能讓人當場用 Tab 鍵驗證。
文件頁寫下的每一條行為規範,都要有一支 story 對得上。
不要寫「元件會處理這個情況」卻沒有任何 story 展示它。 沒有 story 的規範,三個月後沒有人知道它到底還成不成立。
這是 story 與文件的分工: 文件講「為什麼」,story 證明「真的是這樣」。
截圖驗證一定要比對期望值
用無頭瀏覽器對 story 截圖來檢查外觀是好方法,但只用肉眼看截圖會得出錯誤結論。
本書實測過:對六組色相主題各截一張按鈕 story,其中幾張拍出來是別組主題的顏色,
看起來像「那組主題壞掉了」。實際上 token 完全正確——是截圖搶在 Storybook
把網址上的 globals 套進 <html> 之前就完成了。
加長等待時間解決不了。 無頭瀏覽器的 --virtual-time-budget 走的是虛擬時間:
它會快轉計時器,但不等真正的非同步工作。把 4000 拉到 12000 之後仍然有三張是錯的,
而且每次錯的不是同一張。
可靠的做法是驗到相符為止:截圖 → 掃描畫面找期望色 → 不符就重截。 實測有一張試到第 4 次才對。
截圖之後取樣像素、與 token 的期望值逐一比對。 程式判定「相符」才算通過,不是人看起來覺得對。
不要只把截圖拿來目視,也不要靠「等久一點」。搶跑拍到的畫面看起來是 合理的——它不像壞掉的圖,只像另一個顏色。 這種假失敗會讓人去修沒有壞的東西。
不要用固定的取樣座標。版面一改(多一段說明、換一個視窗尺寸), 座標就落在別的東西上,於是十二組全部報錯——一個純粹由驗證方法 造成的假警報。改成掃描整張圖找期望色,位置無關。
同一個道理適用於任何「印出數字給人看」的驗收腳本:量測對象要是實際會被畫出來的
那個值。本書的色彩驗收腳本犯過兩次——一次拿沒轉過色相的 muted 去量聚焦環對比,
一次拿理想的純白去量 brand 的前景對比,兩次都印出低於門檻的假數字。
報告算錯對象比不印還糟,它會讓人去修沒壞的東西。
play function:有行為規範就要有行為驗證
story 並列的是長相,play function 驗的是行為——焦點去哪裡、鍵盤按了會怎樣、 aria 屬性有沒有連動。判準很簡單:文件頁寫了行為規範的元件,就要有一支 play function 把那條規範變成可執行的斷言。對話框寫了「Esc 關閉、焦點回到觸發鈕」,play 就按一次 Esc 驗一次;欄位錯誤寫了「aria-describedby 指向錯誤訊息」,play 就查那條連線。
寫法慣例(現有八支都照這個模式):
import { within, expect, userEvent, waitFor } from "@storybook/test";
export const 對話框: Story = {
render: () => ( … ),
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.click(canvas.getByRole("button", { name: "作廢這筆" }));
// …斷言
},
};
portal 元件(Dialog、Select、Toast、篩選面板)的斷言要查
canvasElement.ownerDocument.body——浮層掛在 body 直下,
canvas 子樹裡找不到它。
查詢一律走 getByRole 帶可及名稱。這讓 play 順便當了半個
無障礙測試:role 或名稱斷了,查詢就失敗。
不要在 play 結尾留下開著的浮層或改掉的狀態——後面的視覺掃描拍的是 play 跑完的畫面,殘留狀態會讓兩支守衛互相干擾。
兩道 Storybook 守衛(CI 自動跑)
| 守衛 | 指令 | 管什麼 | 不管什麼 |
|---|---|---|---|
| 無障礙行為 | npm run verify:storybook | 全部 story 的 axe 掃描(role、名稱、巢狀互動…)+ play function 全數執行成功 | 顏色對比(verify:color 是唯一顏色權威);region 等頁面級規則(story 是片段) |
| 視覺回歸 | npm run verify:visual | 六主題 × 兩模式 × 哨兵 story:截圖掃全圖,驗「期望色存在+其他主題的 --brand 不存在」 | 版面位移(沒有基準圖比對——跨平台字型假紅與基準維護成本,有三次證據再議) |
兩支都吃建好的 Storybook 產物(本機 npm run build-storybook 後直接跑;
CI 在建置步驟之後自動接)。上面截圖三坑的方法論——掃全圖不取座標、驗到相符為止、
容差 ±2——就是 verify:visual 的實作,從「寫在文件裡的教訓」變成了每次都跑的閘門。
combobox 有一條特別容易踩的規則,第一次全量掃描就抓到五處:
可及名稱不能取自值文字(值會變,名稱不會)。SelectTrigger 一定要用
<Label htmlFor> 接 id,或給 aria-label——選了「黃金」的下拉,讀屏要念的是
「等級」,不是「黃金」。