はじめに
k.s.ロジャースの藤本です。
今回は公式のチュートリアルをやってみました。
その前に「そもそもstorybookってなんだろう」という状態からだったので
いろんな方の記事を参考にしてみました。
僕なりにまとめると、
その中でコンポーネントの動作確認ができ
かつスタイルガイドとしても使える便利なツール```
と解釈しました。
(間違えてたらすいません)
色んな記事を見てるとデザイナーさんとの認識合わせができることについて触れられている気がしました。
## インストール
```bash
npx create-react-app taskbox
cd taskbox
npx -p @storybook/cli sb init
実行
npm run storybook
初期画面
Simple component
以下のコンパイル済みCSSをsrc/index.css
に格納
https://github.com/chromaui/learnstorybook-code/blob/master/src/index.css
public/
にfont
とicon
を格納
https://github.com/chromaui/learnstorybook-code/tree/master/public
stories.js
を読み込めるように修正
import { configure } from '@storybook/react';
import '../src/index.css';
const req = require.context('../src', true, /\.stories.js$/);
function loadStories() {
req.keys().forEach(filename => req(filename));
}
configure(loadStories, module);
Task.js
とTask.stories.js
を作成
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>
);
}
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import Task from './Task';
export const task = {
id: '1',
title: 'Test Task',
state: 'TASK_INBOX',
updatedAt: new Date(2018, 0, 1, 9, 0),
};
export const actions = {
onPinTask: action('onPinTask'),
onArchiveTask: action('onArchiveTask'),
};
storiesOf('Task', module)
.add('default', () => <Task task={task} {...actions} />)
.add('pinned', () => <Task task={{ ...task, state: 'TASK_PINNED' }} {...actions} />)
.add('archived', () => <Task task={{ ...task, state: 'TASK_ARCHIVED' }} {...actions} />);
状態、イベントを設定
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' && (
<a onClick={() => onPinTask(id)}>
<span className={`icon-star`} />
</a>
)}
</div>
</div>
);
}
確認してみます。
その後、propTypes
を設定することで問題を早期発見できるとのことでした。
import React from 'react';
import PropTypes from 'prop-types';
export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) {
// ...
}
Task.propTypes = {
task: PropTypes.shape({
id: PropTypes.string.isRequired,
title: PropTypes.string.isRequired,
state: PropTypes.string.isRequired,
}),
onArchiveTask: PropTypes.func,
onPinTask: PropTypes.func,
};
自動テスト
アドオンとbabel-macrosを追加
yarn add --dev @storybook/addon-storyshots react-test-renderer require-context.macro
yarn add --dev babel-plugin-macros
import initStoryshots from '@storybook/addon-storyshots';
initStoryshots();
{
"plugins": ["macros"]
}
各ファイルの追加とconfig.jsの一部変更
require.context
からrequireContext
に変更されています。
import { configure } from '@storybook/react';
import requireContext from 'require-context.macro';
import '../src/index.css';
const req = requireContext('../src/components', true, /\.stories\.js$/);
function loadStories() {
req.keys().forEach(filename => req(filename));
}
configure(loadStories, module);
$ yarn test
yarn run v1.17.3
$ react-scripts test
PASS src/storybook.test.js
Storyshots
Task
√ default (15ms)
√ pinned (2ms)
√ archived (1ms)
› 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: 9.264s
Ran all test suites related to changed files.
Composite component
コンポーネントを組み合わせて実際の画面を確認してみます。
TaskList.js
とTaskList.stories.js
を作成
addDecorator()
で各タスクにスタイルなどを追加できるようです。
import React from 'react';
import Task from './Task';
function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
const events = {
onPinTask,
onArchiveTask,
};
if (loading) {
return <div className="list-items">loading</div>;
}
if (tasks.length === 0) {
return <div className="list-items">empty</div>;
}
return (
<div className="list-items">
{tasks.map(task => <Task key={task.id} task={task} {...events} />)}
</div>
);
}
export default TaskList;
import React from 'react';
import { storiesOf } from '@storybook/react';
import TaskList from './TaskList';
import { task, actions } from './Task.stories';
export const defaultTasks = [
{ ...task, id: '1', title: 'Task 1' },
{ ...task, id: '2', title: 'Task 2' },
{ ...task, id: '3', title: 'Task 3' },
{ ...task, id: '4', title: 'Task 4' },
{ ...task, id: '5', title: 'Task 5' },
{ ...task, id: '6', title: 'Task 6' },
];
export const withPinnedTasks = [
...defaultTasks.slice(0, 5),
{ id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
];
storiesOf('TaskList', module)
.addDecorator(story => <div style={{ padding: '3rem' }}>{story()}</div>)
.add('default', () => <TaskList tasks={defaultTasks} {...actions} />)
.add('withPinnedTasks', () => <TaskList tasks={withPinnedTasks} {...actions} />)
.add('loading', () => <TaskList loading tasks={[]} {...actions} />)
.add('empty', () => <TaskList tasks={[]} {...actions} />);
各状態のUIを設定
import React from 'react';
import Task from './Task';
function TaskList({ loading, tasks, onPinTask, onArchiveTask }) {
const events = {
onPinTask,
onArchiveTask,
};
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 (loading) {
return (
<div className="list-items">
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
</div>
);
}
if (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 = [
...tasks.filter(t => t.state === 'TASK_PINNED'),
...tasks.filter(t => t.state !== 'TASK_PINNED'),
];
return (
<div className="list-items">
{tasksInOrder.map(task => <Task key={task.id} task={task} {...events} />)}
</div>
);
}
export default TaskList;
propTypesを設定
import React from 'react';
import PropTypes from 'prop-types';
import Task from './Task';
function TaskList() {
...
}
TaskList.propTypes = {
loading: PropTypes.bool,
tasks: PropTypes.arrayOf(Task.propTypes.task).isRequired,
onPinTask: PropTypes.func.isRequired,
onArchiveTask: PropTypes.func.isRequired,
};
TaskList.defaultProps = {
loading: false,
};
export default TaskList;
Data
アプリケーション側にデータの配線を行います。
react-redux
redux
をインストール
yarn add react-redux redux
単純なStoreを作成
import { createStore } from 'redux';
export const actions = {
ARCHIVE_TASK: 'ARCHIVE_TASK',
PIN_TASK: 'PIN_TASK',
};
export const archiveTask = id => ({ type: actions.ARCHIVE_TASK, id });
export const pinTask = id => ({ type: actions.PIN_TASK, id });
function taskStateReducer(taskState) {
return (state, action) => {
return {
...state,
tasks: state.tasks.map(
task => (task.id === action.id ? { ...task, state: taskState } : task)
),
};
};
};
export const reducer = (state, action) => {
switch (action.type) {
case actions.ARCHIVE_TASK:
return taskStateReducer('TASK_ARCHIVED')(state, action);
case actions.PIN_TASK:
return taskStateReducer('TASK_PINNED')(state, action);
default:
return state;
}
};
const defaultTasks = [
{ id: '1', title: 'Something', state: 'TASK_INBOX' },
{ id: '2', title: 'Something more', state: 'TASK_INBOX' },
{ id: '3', title: 'Something else', state: 'TASK_INBOX' },
{ id: '4', title: 'Something again', state: 'TASK_INBOX' },
];
export default createStore(reducer, { tasks: defaultTasks });
storeに接続し、該当タスクをレンダリングするようTaskList.js
を修正
import React from 'react';
import PropTypes from 'prop-types';
import Task from './Task';
import { connect } from 'react-redux';
import { archiveTask, pinTask } from '../lib/redux';
export function PureTaskList({ loading, tasks, onPinTask, onArchiveTask }) {
/* 以前のTaskList内を張り付ける */
}
PureTaskList.propTypes = {
loading: PropTypes.bool,
tasks: PropTypes.arrayOf(Task.propTypes.task).isRequired,
onPinTask: PropTypes.func.isRequired,
onArchiveTask: PropTypes.func.isRequired,
};
PureTaskList.defaultProps = {
loading: false,
};
export default connect(
({ tasks }) => ({
tasks: tasks.filter(t => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'),
}),
dispatch => ({
onArchiveTask: id => dispatch(archiveTask(id)),
onPinTask: id => dispatch(pinTask(id)),
})
)(PureTaskList);
TaskList
がPureTaskList
になっているので合わせて修正
import React from 'react';
import { storiesOf } from '@storybook/react';
import { PureTaskList } from './TaskList';
import { task, actions } from './Task.stories';
export const defaultTasks = [
{ ...task, id: '1', title: 'Task 1' },
{ ...task, id: '2', title: 'Task 2' },
{ ...task, id: '3', title: 'Task 3' },
{ ...task, id: '4', title: 'Task 4' },
{ ...task, id: '5', title: 'Task 5' },
{ ...task, id: '6', title: 'Task 6' },
];
export const withPinnedTasks = [
...defaultTasks.slice(0, 5),
{ id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
];
storiesOf('TaskList', module)
.addDecorator(story => <div style={{ padding: '3rem' }}>{story()}</div>)
.add('default', () => <PureTaskList tasks={defaultTasks} {...actions} />)
.add('withPinnedTasks', () => <PureTaskList tasks={withPinnedTasks} {...actions} />)
.add('loading', () => <PureTaskList loading tasks={[]} {...actions} />)
.add('empty', () => <PureTaskList tasks={[]} {...actions} />);
Screens
コンポーネントを組み合わせて
実際の画面を使い開発をしてみます。
InboxScreen.js
を作成
import React from "react";
import PropTypes from "prop-types";
import { connect } from "react-redux";
import TaskList from "./TaskList";
export function PureInboxScreen({ error }) {
if (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>
);
}
PureInboxScreen.propTypes = {
error: PropTypes.string
};
PureInboxScreen.defaultProps = {
error: null
};
export default connect(({ error }) => ({ error }))(PureInboxScreen);
InboxScreen
のコンポーネントを使用するようにApp.js
を修正
import React, { Component } from "react";
import { Provider } from "react-redux";
import store from "./lib/redux";
import InboxScreen from "./components/InboxScreen";
class App extends Component {
render() {
return (
<Provider store={store}>
<InboxScreen />
</Provider>
);
}
}
export default App;
チュートリアルにはありませんが、実際の画面を見てみます。
npm start
StorybookでもInboxScreen
をレンダリングしてみます。
InboxScreen.stories.js
を作成
import React from "react";
import { storiesOf } from "@storybook/react";
import { PureInboxScreen } from "./InboxScreen";
storiesOf("InboxScreen", module)
.add("default", () => <PureInboxScreen />)
.add("error", () => <PureInboxScreen error="Something" />);
チュートリアル通りですが、エラーが出ます。
Presentational Component(見た目)をレンダリングするコンポーネントなので
Container Component(ロジック)を単独でレンダリングすることはできないようです。
今回の例では、PureInboxScreen
単体はPresentational Componentですが
TaskList
がそうではないことが原因のようです。
.addDecorator
を使用し、取り回せるよう
src/components/InboxScreen.stories.js
を修正
import React from "react";
import { storiesOf } from "@storybook/react";
import { action } from "@storybook/addon-actions";
import { Provider } from "react-redux";
import { PureInboxScreen } from "./InboxScreen";
import { defaultTasks } from "./TaskList.stories";
const store = {
getState: () => {
return {
tasks: defaultTasks
};
},
subscribe: () => 0,
dispatch: action("dispatch")
};
storiesOf("InboxScreen", module)
.addDecorator(story => <Provider store={store}>{story()}</Provider>)
.add("default", () => <PureInboxScreen />)
.add("error", () => <PureInboxScreen error="Something" />);
Addons
アドオンを設定することができるようです
Knobsをセットアップ
yarn add @storybook/addon-knobs
.storybook/addons.js
にaddon-knobs
を追記
import "@storybook/addon-actions/register";
import "@storybook/addon-knobs/register";
import "@storybook/addon-links/register";
addon-knobs
のインポートの追加
.addDecorator
にパラメーターを渡します
default
のストーリーにobject
を結合
import React from 'react';
import { storiesOf } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import { withKnobs, object } from "@storybook/addon-knobs/react";
import Task from './Task';
export const task = {
id: '1',
title: 'Test Task',
state: 'TASK_INBOX',
updatedAt: new Date(2018, 0, 1, 9, 0),
};
export const actions = {
onPinTask: action('onPinTask'),
onArchiveTask: action('onArchiveTask'),
};
storiesOf('Task', module)
.addDecorator(withKnobs)
.add("default", () => {
return <Task task={object("task", { ...task })} {...actions} />;
})
.add("pinned", () => (
<Task task={{ ...task, state: "TASK_PINNED" }} {...actions} />
))
.add("archived", () => (
<Task task={{ ...task, state: "TASK_ARCHIVED" }} {...actions} />
));
Knobs
のタブが追加されていて、defaultにパラメータ―が表示されていることを確認
その他(チュートリアル外)
テーマを設定
storybookのテーマが設定できるみたいなので設定してみました
import { addParameters } from '@storybook/react';
import { themes } from '@storybook/theming';
// Option defaults.
addParameters({
options: {
theme: themes.dark,
},
});
終わりに
「Creating addons」や「Deploy」などもありましたが
Storybookを実務で利用する方法とは若干逸れていると思い一部割愛させていただきました。
今回、チュートリアルを触ってみた個人的な感想を簡単にまとめると
良いところ
・コンポーネントカタログから直感的に必要なコンポーネントを探せる
・UIコンポーネントの再利用性が高い
・アプリを実行する必要が無く、Storybook単体でデザインの確認や実装ができる
・HMRが標準で設定されているため素早く動作確認ができる
気になるところ
・小規模プロジェクトだとわざわざ導入する必要は無いかも
個人的にはこんなところでした。
少し使い方は複雑かなとも思いましたが
慣れてくるとスムーズに開発できそうです。
弊社のプロジェクトでも導入されているため
実務部分についてはこれから理解を深めていきます。
応用に関しては知見が深まったらまたブログを書きます。
間違いやご指摘がありましたが是非ご教授頂ければと思います。