前情提要 不論是 App 或是 Web, 與使用者第一線互動的就是 UI 了。 另一面在需求設計上, 我們總會想像一個畫面, 想像著使用者如何使用我們的產品, 也就是說 UI 是理想與真實的邊界。
Designer 完成了設計, Engineer 將之實作出來, 主流的開發方式會透過 Component 來節省時間。
為什麼我們需要 Storybook ? 但是真的能節省時間嗎 ?
開發人員彼此之間會不會重複造輪子? 他們又要怎麼溝通? 修改到底層元件會不會影響到上層元件? 會不會改 A 壞 B? 複雜的 Component, 特殊的情境如何測試 ?
Storybook 恰恰能解決這些問題,
作為開發人員的指南和文件
獨立於應用程式建立 Component
測試特殊情境
對我來說,最重要的事,我可以用類似 TDD 的方式開發, 在 Storybook 的官方文件提到這個方法為 CDD. 在 TDD 中我們把一個個 Use Case 寫成 Test Case, 我們可以挪用這個觀念, 在 Storybook 中把每一個 Component 的各種狀態(State), 當作 Use Case, 然後透過 Mock State 讓 Component 呈現該有的樣貌。
心得 大前端的時代,僅僅只看 Web 的話, 我認為這個時代前端的重心就在兩個主要的技術之上, Component 與 State Management。 而實作你可以有以下的選擇, 僅介紹我聽過的主流 Library, Component 與 State Management 沒有絕對的搭配關係。
改編 Storybook 教程 為什麼要改編 Storybook 教程(React Version) ?
這個教程會以一個簡單的 Todo List, 從創建應用程式、簡單的 Component 到複雜, 與狀態管理器介接, 測試到部署。
但是他缺了一味,TypeScript , 所以我自已用 TypeScript 進行了改寫並稍作一下記錄。
環境
開始 設定初始化的環境
設定 React Storybook 開啟命令提示視窗,執行以下命令以創建 React App
1 2 3 4 # Create our application: npx create-react-app taskbox cd taskbox
安裝 Storybook
1 2 3 4 npm i storybook # Add Storybook: npx -p @storybook/cli sb init
啟動開發環境的 Storybook,
1 2 # Start the component explorer on port 6006 : yarn storybook
測試與執行
1 2 3 4 5 # Run the test runner (Jest) in a terminal: yarn test --watchAll # Run the frontend app proper on port 3000 : yarn start
下載 CSS ,存檔至 src/index.css
安裝 degit
加入 Add assets (字型與 Icon)
1 2 npx degit chromaui/learnstorybook-code/src/assets/font src/assets/font npx degit chromaui/learnstorybook-code/src/assets/icon src/assets/icon
Git Commit
1 2 > git add . > git commit -m "first commit"
簡單的 component 在 src/components/
資料夾建立 component Task.js
1 2 3 4 5 6 7 8 9 10 11 import React from 'react' ;export default function Task ({ task: { id, title, state }, onArchiveTask, onPinTask } ) { return ( <div className ="list-item" > <input type ="text" value ={title} readOnly ={true} /> </div > ); }
建立 Task.stories.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 import React from 'react' ;import Task from './Task' ;export default { component : Task , title : 'Task' , }; const Template = args => <Task {...args } /> ;export const Default = Template .bind ({});Default .args = { task : { id : '1' , title : 'Test Task' , state : 'TASK_INBOX' , updatedAt : new Date (2018 , 0 , 1 , 9 , 0 ), }, }; export const Pinned = Template .bind ({});Pinned .args = { task : { ...Default .args .task , state : 'TASK_PINNED' , }, }; export const Archived = Template .bind ({});Archived .args = { task : { ...Default .args .task , state : 'TASK_ARCHIVED' , }, };
隨時你都可以執行 yarn storybook
試跑來看看 storybook 調整 Storybook 的 config 檔 (.storybook/main.js)
1 2 3 4 5 6 7 8 9 10 module .exports = { stories : ['../src/components/**/*.stories.js' ], addons : [ '@storybook/addon-links' , '@storybook/addon-essentials' , '@storybook/preset-create-react-app' , ], };
(.storybook/preview.js) 這設定為了 log UI 上的某些操作產生的事件, 在之後我們會看到 完成(onArchiveTask
)或置頂(onPinTask
) 兩個事件
1 2 3 4 5 6 7 8 import '../src/index.css' ;export const parameters = { actions : { argTypesRegex : '^on[A-Z].*' }, };
調整 Task.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 import React from 'react' ;export default function Task ({ task: { id, title, state }, onArchiveTask, onPinTask } ) { return ( <div className ={ `list-item ${state }`}> <label className ="checkbox" > <input type ="checkbox" defaultChecked ={state === 'TASK_ARCHIVED' } disabled ={true} name ="checked" /> <span className ="checkbox-custom" onClick ={() => onArchiveTask(id)} /> </label > <div className ="title" > <input type ="text" value ={title} readOnly ={true} placeholder ="Input title" /> </div > <div className ="actions" onClick ={event => event.stopPropagation()}> {state !== 'TASK_ARCHIVED' && ( // eslint-disable-next-line jsx-a11y/anchor-is-valid <a onClick ={() => onPinTask(id)}> <span className ={ `icon-star `} /> </a > )} </div > </div > ); }
加入測試用的外掛(add on)
1 yarn add -D @storybook/addon-storyshots react-test-renderer
執行測試
測試結果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 yarn run v1.22.0 $ react-scripts test (node:52888) DeprecationWarning: \`storyFn\` is deprecated and will be removed in Storybook 7.0. https://github.com/storybookjs/storybook/blob/next/MIGRATION.md#deprecated-storyfn PASS src/components/storybook.test.js (14.703 s) Storyshots Task √ Default (13 ms) √ Pinned (2 ms) √ Archived (1 ms) › 3 snapshots written. Snapshot Summary › 3 snapshots written from 1 test suite. Test Suites: 1 passed, 1 total Tests: 3 passed, 3 total Snapshots: 3 written, 3 total Time: 16.716 s Ran all test suites related to changed files.
簡單的 component 改用 typescript 首先,Task.js
調整副檔名為 Task.tsx
,Task.stories.js
為 Task.stories.tsx
. 測試檔案 storybook.test.js
也一併修改 storybook.test.ts
並修改 .storybook/main.js
1 2 3 4 module .exports = { stories : ['../src/components/**/*.stories.tsx' ], };
建立 tsconfig.json
檔
用 TypeScript 改寫
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 import React from 'react' ;export enum TaskState { Inbox = 'TASK_INBOX' , Pinned = 'TASK_PINNED' , Archived = 'TASK_ARCHIVED' } export interface TaskArgs { item :TaskItem , onArchiveTask : (id:string ) => void , onPinTask : (id:string ) => void } export class TaskItem { id : string = '' title : string = '' state : TaskState = TaskState .Inbox updatedAt?: Date } export default function Task (args:TaskArgs ) { return ( <div className ={ `list-item ${args.item.state }`}> <label className ="checkbox" > <input type ="checkbox" defaultChecked ={args.item.state === TaskState.Archived} disabled ={true} name ="checked" /> <span className ="checkbox-custom" onClick ={() => args.onArchiveTask(args.item.id)} /> </label > <div className ="title" > <input type ="text" value ={args.item.title} readOnly ={true} placeholder ="Input title" /> </div > <div className ="actions" onClick ={event => event.stopPropagation()}> {args.item.state !== TaskState.Archived && ( // eslint-disable-next-line jsx-a11y/anchor-is-valid <a onClick ={() => args.onPinTask(args.item.id)}> <span className ={ `icon-star `} /> </a > )} </div > </div > ); }
改寫 Task.store.tsx
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import React from 'react' ;import Task , { TaskItem , TaskArgs , TaskState } from './Task' ;import { Story } from '@storybook/react/types-6-0' ;export default { component : Task , title : 'Task' , }; const Template :Story <TaskArgs > = args => <Task {...args } /> ;var defaultItem :TaskItem = { id :'1' , title :'Test Task' , state :TaskState .Inbox , updatedAt : new Date (2018 , 0 , 1 , 9 , 0 ), }; export const Default = Template .bind ({});Default .args = { item : defaultItem, };export const Pinned = Template .bind ({});var pinnedItem = Copy (defaultItem);pinnedItem.state =TaskState .Pinned Pinned .args = { item : pinnedItem };export const Archived = Template .bind ({});var archivedItem = Copy (defaultItem);archivedItem.state =TaskState .Archived ; Archived .args = {item : archivedItem};function Copy (obj:any ) { return Object .assign ({},obj); }
組合成複雜的 component (TypeScript 版本) 與教程最主要的不同之處在於使用了 TypeScript 的語法撰寫
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 import React from 'react' ;import Task , { TaskItem , TaskState } from './Task' ;import { connect } from 'react-redux' ;export interface TaskListProps { loading?:boolean ; tasks : TaskItem []; onArchiveTask : (id:string )=> void ; onPinTask : (id:string )=> void ; } export function PureTaskList (props:TaskListProps ) { const events = { onArchiveTask :props.onArchiveTask , onPinTask :props.onPinTask , }; const LoadingRow = ( <div className ="loading-item" > <span className ="glow-checkbox" /> <span className ="glow-text" > <span > Loading</span > <span > cool</span > <span > state</span > </span > </div > ); if (props.loading ) { return ( <div className ="list-items" > {LoadingRow} {LoadingRow} {LoadingRow} {LoadingRow} {LoadingRow} {LoadingRow} </div > ); } if (props.tasks === undefined || props.tasks .length === 0 ) { return ( <div className ="list-items" > <div className ="wrapper-message" > <span className ="icon-check" /> <div className ="title-message" > You have no tasks</div > <div className ="subtitle-message" > Sit back and relax</div > </div > </div > ); } const tasksInOrder = [ ...props.tasks .filter (t => t.state === TaskState .Pinned ), ...props.tasks .filter (t => t.state !== TaskState .Pinned ), ]; return ( <div className ="list-items" > {tasksInOrder.map(item => ( <Task key ={item.id} item ={item} {...events }/> ))} </div > ); } export default connect ( (props:TaskListProps ) => ({ tasks : props.tasks .filter (t => t.state === TaskState .Inbox || t.state === TaskState .Pinned ), }) )(PureTaskList );
TaskList.stories.tsx
設置,也是使用 TypeScript 撰寫。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 import React from 'react' ;import { TaskList , TaskListArgs } from './TaskList' ;import { TaskItem , TaskState } from './Task' ;import { Story } from '@storybook/react/types-6-0' ;export default { component : TaskList , title : 'TaskList' , decorators : [(story: () => React.ReactNode ) => <div style ={{ padding: '3rem ' }}> {story()}</div > ], excludeStories : /.*Data$/ , }; const Template :Story <TaskListArgs > = args => <TaskList {...args } /> var defaultItem :TaskItem = { id :'1' , title :'Test Task' , state :TaskState .Inbox , updatedAt : new Date (2018 , 0 , 1 , 9 , 0 ) }; export const Default = Template .bind ({});Default .args = { tasks : [ { ...defaultItem, id : '1' , title : 'Task 1' }, { ...defaultItem, id : '2' , title : 'Task 2' }, { ...defaultItem, id : '3' , title : 'Task 3' }, { ...defaultItem, id : '4' , title : 'Task 4' }, { ...defaultItem, id : '5' , title : 'Task 5' }, { ...defaultItem, id : '6' , title : 'Task 6' }, ], }; export const WithPinnedTasks = Template .bind ({});WithPinnedTasks .args = { tasks : [ ...Default .args .tasks !.slice (0 ,5 ), { id : '6' , title : 'Task 6 (pinned)' , state : TaskState .Pinned }, ], }; export const Loading = Template .bind ({});Loading .args = { tasks : [], loading : true , }; export const Empty = Template .bind ({});Empty .args = { ...Loading .args , loading : false , };
介接 Store 資料 建立 Redux
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 import { createStore } from 'redux' ;import { TaskItem , TaskState } from '../components/Task' ;export const archiveTask = (id: string ) => { console .log ("archive task:" +id); return ({ type : TaskState .Archived , id }) }; export const pinTask = (id: string ) => { console .log ("pin task:" +id); return ({ type : TaskState .Pinned , id }) }; export const reducer = (state: any , action: { id:string ; type : TaskState; } ) => { switch (action.type ) { case TaskState .Archived : case TaskState .Pinned : return taskStateReducer (action.type )(state, action); default : return state; } }; const defaultTasks :Array <TaskItem > = [ { id : '1' , title : 'Something' , state : TaskState .Inbox }, { id : '2' , title : 'Something more' , state : TaskState .Inbox }, { id : '3' , title : 'Something else' , state : TaskState .Inbox }, { id : '4' , title : 'Something again' , state : TaskState .Inbox }, ]; export default createStore (reducer, { tasks : defaultTasks });function taskStateReducer (taskState: TaskState ) { return (state: { tasks: TaskItem[]; }, action: { id: string ; } ) => { return { ...state, tasks : state.tasks .map (task => task.id === action.id ? { ...task, state : taskState } : task ), }; }; }
修改 TaskList.tsx 視作一個 container 與 redux 作介接:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 import React from 'react' ;import Task , { TaskItem , TaskState } from './Task' ;import { connect } from 'react-redux' ;import { archiveTask, pinTask } from '../lib/redux' ;export default connect ( (props:TaskListArgs ) => ({ tasks : props.tasks .filter (t => t.state === TaskState .Inbox || t.state === TaskState .Pinned ), }), dispatch => ({ onArchiveTask : (id: string ) => dispatch (archiveTask (id)), onPinTask : (id: string ) => dispatch (pinTask (id)), }))(TaskList );
加上 Page InboxScreen
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 import React from 'react' ;import { connect } from 'react-redux' ;import TaskList from './TaskList' ;export class InboxScreenArgs { error :string | undefined } export function InboxScreen (args:InboxScreenArgs ) { if (args.error ) { return ( <div className ="page lists-show" > <div className ="wrapper-message" > <span className ="icon-face-sad" /> <div className ="title-message" > Oh no!</div > <div className ="subtitle-message" > Something went wrong</div > </div > </div > ); } return ( <div className ="page lists-show" > <nav > <h1 className ="title-page" > <span className ="title-wrapper" > TaskBox</span > </h1 > </nav > <TaskList /> </div > ); } export default connect ((props:InboxScreenArgs ) => (props))(PureInboxScreen );
一樣也加上 Story ,InboxScreen.stories.tsx
讓我們可以透過 Storybook 作人工 E2E 測試
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 import React from 'react' ;import { Provider } from 'react-redux' ;import { InboxScreenArgs , InboxScreen } from './InboxScreen' ;import { Story } from '@storybook/react/types-6-0' ;import store from '../lib/redux' export default { component : InboxScreen , decorators : [(story: () => React.ReactNode ) => <Provider store ={store} > {story()}</Provider > ], title : 'InboxScreen' , }; const Template :Story <InboxScreenArgs > = args => <PureInboxScreen {...args } /> ;export const Default = Template .bind ({});export const Error = Template .bind ({});Error .args = { error : 'Something' , };
完整代碼可以參考此處 。
參考
(fin)