StoryBook…?なんかフロントエンドの技術なのかな?
というStoryBookが全く何かわからない方向け(僕もです、、、)、簡単にStoryBookをフロントエンド開発に導入するメリットや実際に導入するところまで行って見たいと思います。
また、本記事は、Raise Techのフロントエンドエンジニアコースの受講記でもあります。
こちらの記事は、第15回の講義のまとめとなります。
StoryBookとは
まず、StoryBookとはというところから見ていきましょー!
・StoryBookとは、フロントエンド開発を支援するツール!
・コンポーネントのカタログのようなもので、チームで共有がしやすくなる!
▼公式ドキュメント
https://storybook.js.org/
ということで、個人で小さなアプリを作るときには特に導入するメリットはあまりなさそうですが、チームで開発する時にコミュニケーションしやすくするための便利なツールという理解でいいかなぁと思います。
また、コンポーネントが持てる状態を可視化することができるようです。
ユーザーが画面を触った時に、動きがある箇所(ボタンやフォーム周りでのエラー)をStoryBookでは表現することができるようです…便利!!
チュートリアルをやってみよう!
StoryBookが公式で日本語のチュートリアルを準備してくれているので、実際に触っていきます。
▼StoryBookチュートリアル(日本語)
https://storybook.js.org/tutorials/intro-to-storybook/react/ja/get-started/
はじめに
とりあえず、チュートリアルに沿って順番に進めていきます。
① アプリケーションを作成(作成したいフォルダまで移動して下記をターミナルに入力)
npx creact-react-app hogehoge
②StoryBookを追加する
npx -p @storybook/cli sb init
ここまで行いVScodeで作成されたファイルを見てみます。
なにか色々と作られておりますね。
ここでは、設定ファイルやコマンド・定義ファイルのサンプルが生成された状態となっています。
チュートリアルに沿って行うと、作成したアプリケーションが問題なく動作するかの確認も行えますので漏れなく行っておくといいかなと思います。
この段階では、僕もよくわからなかったのですが、とりあえずCSSをいじってみたらStoryBookのカタログが変更しました!!
よくわからんけど、、、感動!!
次に、index.css
を上書きするようにチュートリアルで指示があるのですが、そのままやるとエラーがでてしまうので一部コメントアウトして対応しました。(参考記事:https://github.com/chromaui/learnstorybook.com/issues/403)
※412行目〜422行目をコメントアウト。
コマンド入力することで、対象のアセットがダウンロードされるようなので、先程コメントアウトした箇所を戻してもエラーがなくなります。
単純なコンポーネントを作る
なるほど。
確かにコンポーネントから作っていくのは効率良さそう。
では、チュートリアルに沿って進めていきます。
下記のコンポーネントを作成していきます。
まずは、Task.js
と Task.stories.js
という2つのファイルを作成します。
Task.js
の中身↓
import React from 'react';
import PropTypes from 'prop-types';
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>
);
}
Task.stories.js
の中身↓
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',
},
};
こちらのファイルで Task に対する3つのテスト用の状態を書いています。
yarn storybook
で確認してみます。
3つのストーリーが確認できました。
また、 propsTypes
を使うことでコンポーネントが示すデータ構造を示すことができます。
TypesScriptのようなものだと解釈しております。
import React from 'react';
import PropTypes from 'prop-types';
export default function Task({ task: { id, title, state }, onArchiveTask, onPinTask }) {
// ...
}
Task.propTypes = {
/** Composition of the task */
task: PropTypes.shape({
/** Id of the task */
id: PropTypes.string.isRequired,
/** Title of the task */
title: PropTypes.string.isRequired,
/** Current state of the task */
state: PropTypes.string.isRequired,
}),
/** Event to change the task to archived */
onArchiveTask: PropTypes.func,
/** Event to change the task to pinned */
onPinTask: PropTypes.func,
};
複合的なコンポーネント
単純なコンポーネントを合わせた複合的なコンポーネントを作成していきます。
基本的な考え方は変わらないとのこと。
先程作ったTask.jsx
を合わせた タスクリスト を作成していきます。
まずは、src/components/TaskList.jsx
と src/components/TaskList.stories.jsx
を作成します。
TaskList.js
の中身↓
import React from 'react';
import Task from './Task';
export default 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>
);
}
TaskList.stories.js
の中身↓
import React from 'react';
import TaskList from './TaskList';
import * as TaskStories from './Task.stories';
export default {
component: TaskList,
title: 'TaskList',
decorators: [story => <div style={{ padding: '3rem' }}>{story()}</div>],
};
const Template = args => <TaskList {...args} />;
export const Default = Template.bind({});
Default.args = {
// Shaping the stories through args composition.
// The data was inherited from the Default story in task.stories.js.
tasks: [
{ ...TaskStories.Default.args.task, id: '1', title: 'Task 1' },
{ ...TaskStories.Default.args.task, id: '2', title: 'Task 2' },
{ ...TaskStories.Default.args.task, id: '3', title: 'Task 3' },
{ ...TaskStories.Default.args.task, id: '4', title: 'Task 4' },
{ ...TaskStories.Default.args.task, id: '5', title: 'Task 5' },
{ ...TaskStories.Default.args.task, id: '6', title: 'Task 6' },
],
};
export const WithPinnedTasks = Template.bind({});
WithPinnedTasks.args = {
// Shaping the stories through args composition.
// Inherited data coming from the Default story.
tasks: [
...Default.args.tasks.slice(0, 5),
{ id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
],
};
export const Loading = Template.bind({});
Loading.args = {
tasks: [],
loading: true,
};
export const Empty = Template.bind({});
Empty.args = {
// Shaping the stories through args composition.
// Inherited data coming from the Loading story.
...Loading.args,
loading: false,
};
ここまでで最低限のコンポーネントは作成できて、ストーリーを作ることができました。
更にここから状態を作り込んでいきます。
import React from 'react';
import Task from './Task';
export default 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>
);
}
ここまで記述すると、下記のような状態が確認できます。
データ
今まででコンポーネントを作ってきましたので、作ったコンポーネントとデータをつないでいきます。
タスクの状態を変更するアクションを処理する単純な Redux のストアを作ります。
// A simple redux store/actions/reducer implementation.
// A true app would be more complex and separated into different files.
import { createStore } from "redux";
// The actions are the "names" of the changes that can happen to the store
export const actions = {
ARCHIVE_TASK: "ARCHIVE_TASK",
PIN_TASK: "PIN_TASK",
};
// The action creators are how you bundle actions with the data required to execute them
export const archiveTask = (id) => ({ type: actions.ARCHIVE_TASK, id });
export const pinTask = (id) => ({ type: actions.PIN_TASK, id });
// All our reducers simply change the state of a single task.
function taskStateReducer(taskState) {
return (state, action) => {
return {
...state,
tasks: state.tasks.map((task) =>
task.id === action.id ? { ...task, state: taskState } : task
),
};
};
}
// The reducer describes how the contents of the store change for each action
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;
}
};
// The initial state of our store when the app loads.
// Usually you would fetch this from a server
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" },
];
// We export the constructed redux store
export default createStore(reducer, { tasks: defaultTasks });
TaskList.jsx
を更新します。
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 }) {
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>
);
}
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);
connect()
でreduxストアと接続ができるようです。
https://react-redux.js.org/api/connect
TaskList.stories.js
も更新します。
import React from 'react';
import { PureTaskList } from './TaskList';
import * as TaskStories from './Task.stories';
export default {
component: PureTaskList,
title: 'TaskList',
decorators: [story => <div style={{ padding: '3rem' }}>{story()}</div>],
};
const Template = args => <PureTaskList {...args} />;
export const Default = Template.bind({});
Default.args = {
// Shaping the stories through args composition.
// The data was inherited the Default story in task.stories.js.
tasks: [
{ ...TaskStories.Default.args.task, id: '1', title: 'Task 1' },
{ ...TaskStories.Default.args.task, id: '2', title: 'Task 2' },
{ ...TaskStories.Default.args.task, id: '3', title: 'Task 3' },
{ ...TaskStories.Default.args.task, id: '4', title: 'Task 4' },
{ ...TaskStories.Default.args.task, id: '5', title: 'Task 5' },
{ ...TaskStories.Default.args.task, id: '6', title: 'Task 6' },
],
};
export const WithPinnedTasks = Template.bind({});
WithPinnedTasks.args = {
// Shaping the stories through args composition.
// Inherited data coming from the Default story.
tasks: [
...Default.args.tasks.slice(0, 5),
{ id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
],
};
export const Loading = Template.bind({});
Loading.args = {
tasks: [],
loading: true,
};
export const Empty = Template.bind({});
Empty.args = {
// Shaping the stories through args composition.
// Inherited data coming from the Loading story.
...Loading.args,
loading: false,
};
画面
ここから、Storybookを使用してコンポーネントを組み合わせて画面を作っていきます。
画面表示用のコンポーネントを作成します。
// src/components/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 = {
/** The error message */
error: PropTypes.string,
};
PureInboxScreen.defaultProps = {
error: null,
};
export default connect(({ error }) => ({ error }))(PureInboxScreen);
App
コンポーネントを変更して InboxScreen.jsx
を表示できるようにします。
import React from 'react';
import { Provider } from 'react-redux';
import store from './lib/redux';
import InboxScreen from './components/InboxScreen';
import './index.css';
function App() {
return (
<Provider store={store}>
<InboxScreen />
</Provider>
);
}
export default App;
story用のファイルも作成します。
// src/components/InboxScreen.stories.js
import React from 'react';
import { Provider } from 'react-redux';
import { action } from '@storybook/addon-actions';
import { PureInboxScreen } from './InboxScreen';
import * as TaskListStories from './TaskList.stories';
// A super-simple mock of a redux store
const store = {
getState: () => {
return {
tasks: TaskListStories.Default.args.tasks,
};
},
subscribe: () => 0,
dispatch: action('dispatch'),
};
export default {
component: PureInboxScreen,
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',
};
ここまでで行うことで、ローカル環境でタスクリストの表示が確認できるようになりましたね!
コードコピーしているだけなのでいまいち理解できていない気もするが、、、とりあえず先に進めましょう!
デプロイ
現状まだ、自分のローカルでしかStoryBookを見ることができないので、公開URLを作って共有できるようにしていきます。
StoryBookのホスティングchromatic(https://www.chromatic.com/)を使います。
今回実際に作成したURLになります。↓
https://www.chromatic.com/builds?appId=6153b898d4b84c003a78391c
まとめ
初めてStoryBookを使ってみましたが、正直まだよくわかっていないです笑
実際に自分で開発していくなかで取り込んでみて何度かやっていくなかでわかっていくかと思いますので、またレベルが上がったらこちらの記事も更新していきますー!