최근 회사에서 스토리북 도입의 필요성에 대한 이야기가 나와 공부할 겸 찾아봤는데..!
튜토리얼만 따라하는데 엥 에러가!?
알고보니 한국어 버전은...ㅠㅠ
짜잔~! 최신 버전이 아니었답니다~!
ㅎㅎ..
따라서!
스토리북 공식 사이트 튜토리얼 영문ver 보면서 정리한 것 몇 가지와 실제로 적용하면서 해결한 문제나 배운 것 등등을… 미래의 나를 위해 간단히 정리해본다.
(image: 스토리북 공식 메인 페이지)
사이트의 소개에 따르면 귀찮지 않게 UI작업을 할 수 있다고 하고, 또 페이지랑 UI 컴포넌트를 별개로 작업하는 데에 도움을 준다고 한다.
백문이 불여일견
사용 예시 사진을 바로 보자
컴포넌트 하나가 덜렁 올라와있고
왼쪽 메뉴에 보면 Default, Pinned, Archived로 Task가 나뉘어있는 것을 볼 수 있다.
이걸 리스트로 만든 형태를 또 스토리북에 올리면 아래와 같이 만들 수 있다.
이 역시 With Pinned Tasks, Loading, Empty 등의 스토리가 추가로 작성되어있다.
이렇게 스토리북은 UI를 분리해
스토리를 기준으로 UI를 구성하고 보여지는 모습을 확인할 수 있게 해준다.
따라서 개별 컴포넌트에 대한 테스트가 용이해진다.
후에 한 번 더 언급하겠지만 이런식으로 개별 컴포넌트를 구성하는 것을 의식함으로서 컴포넌트간의 의존성도 줄일 수 있게 되고
컴포넌트 기반의 개발을 진행하는 데에도 큰 이점이 있다.
이미 진행 중인 프로젝트가 있다면 해당 프로젝트에서, 없다면 프로젝트를 생성하고
npx storybook@latest init
를 작성하면 되고
만약 템플릿을 받아 튜토리얼을 진행해보고 싶다면 아래의 커맨드 라인을 작성하자.
# Clone the template
npx degit chromaui/intro-storybook-react-template taskbox
cd taskbox
# Install dependencies
yarn
만약 에러가 났다면?
나같은 경우는 typescript-eslint 에러가 한 번, 절대 경로 때문에 에러가 한 번 났다.
이 두가지는 패키지 설치로 해결할 수 있었어서,
에러 메세지와 해결 방법만 간단히 적어두겠다.
발생 에러 메세지 (1)
[eslint] Failed to load plugin '@typescript-eslint' declared in '.eslintrc': Package subpath './lib/definition' is not defined by "exports" in /…
해결 방법
프로젝트 루트 디렉토리에서 터미널을 열고 다음 명령어로 필요한 패키지들을 설치
yarn add --dev @typescript-eslint/eslint-plugin @typescript-eslint/parser
발생 에러 메세지(2)
Module not found: `Can't resolve '~/media' in '/~/media' in '/…`
아마 절대 경로를 @
를 넣어 많이 사용하니
Module not found: Can't resolve '@
어쩌구 하는 에러가 발생할 확률이 높겠다.
해결 방법
(1) 패키지 설치
yarn add -D tsconfig-paths-webpack-plugin
(2) .storybook/main.ts
수정
// .storybook/main.ts
import type { StorybookConfig } from "@storybook/react-webpack5"
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin")
const config: StorybookConfig = {
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|mjs|ts|tsx)"],
addons: [
"@storybook/addon-links",
"@storybook/addon-essentials",
"@storybook/preset-create-react-app",
"@storybook/addon-onboarding",
"@storybook/addon-interactions",
],
framework: {
name: "@storybook/react-webpack5",
options: {
builder: {
useSWC: true,
},
},
},
docs: {
autodocs: "tag",
},
staticDirs: ["../public"],
webpackFinal: async (config) => {
if (config.resolve) {
config.resolve.plugins = [
...(config.resolve.plugins || []),
new TsconfigPathsPlugin({
extensions: config.resolve.extensions,
}),
]
}
return config
},
}
export default config
기타 에러들은 사용하면서 추가되면 (에러 발생 안 하길..)
몇가지 다시 모아서 정리해보든 하겠다!
설치가 잘 되었다면
yarn storybook
으로 스토리북을 열어보자. localhost:6006에서 실행되고, 백문이 불여일견이라며 올렸던 사진과 같은 화면이 보일 것이다.
해당 화면은
//Task.jsx
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
htmlFor="checked"
aria-label={`archiveTask-${id}`}
className="checkbox"
>
<input
type="checkbox"
disabled={true}
name="checked"
id={`archiveTask-${id}`}
checked={state === 'TASK_ARCHIVED'}
/>
<span className="checkbox-custom" onClick={() => onArchiveTask(id)} />
</label>
<label htmlFor="title" aria-label={title} className="title">
<input
type="text"
value={title}
readOnly={true}
name="title"
placeholder="Input title"
/>
</label>
{state !== 'TASK_ARCHIVED' && (
<button
className="pin-button"
onClick={() => onPinTask(id)}
id={`pinTask-${id}`}
aria-label={`pinTask-${id}`}
key={`pinTask-${id}`}
>
<span className={`icon-star`} />
</button>
)}
</div>
);
}
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.stories.js
import Task from './Task';
export default {
component: Task,
title: 'Task',
tags: ['autodocs'],
};
export const Default = {
args: {
task: {
id: '1',
title: 'Test Task',
state: 'TASK_INBOX',
},
},
};
export const Pinned = {
args: {
task: {
...Default.args.task,
state: 'TASK_PINNED',
},
},
};
export const Archived = {
args: {
task: {
...Default.args.task,
state: 'TASK_ARCHIVED',
},
},
};
args
객체에 인자를 넘겨주는 식으로 여러 스토리를 구성하고 있고 해당 인자에 맡게 렌더링 된 화면이 스토리북에 올라가는 것이다!
조금 더 복잡한 코드도 봐보자.
할 일을 체크하는 컴포넌트
import React from 'react';
import Task from './Task';
import { useDispatch, useSelector } from 'react-redux';
import { updateTaskState } from '../lib/store';
export default function TaskList() {
// We're retrieving our state from the store
const tasks = useSelector((state) => {
const tasksInOrder = [
...state.taskbox.tasks.filter((t) => t.state === 'TASK_PINNED'),
...state.taskbox.tasks.filter((t) => t.state !== 'TASK_PINNED'),
];
const filteredTasks = tasksInOrder.filter(
(t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'
);
return filteredTasks;
});
const { status } = useSelector((state) => state.taskbox);
const dispatch = useDispatch();
const pinTask = (value) => {
// We're dispatching the Pinned event back to our store
dispatch(updateTaskState({ id: value, newTaskState: 'TASK_PINNED' }));
};
const archiveTask = (value) => {
// We're dispatching the Archive event back to our store
dispatch(updateTaskState({ id: value, newTaskState: 'TASK_ARCHIVED' }));
};
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 (status === 'loading') {
return (
<div className="list-items" data-testid="loading" key={'loading'}>
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
</div>
);
}
if (tasks.length === 0) {
return (
<div className="list-items" key={'empty'} data-testid="empty">
<div className="wrapper-message">
<span className="icon-check" />
<p className="title-message">You have no tasks</p>
<p className="subtitle-message">Sit back and relax</p>
</div>
</div>
);
}
return (
<div className="list-items" data-testid="success" key={'success'}>
{tasks.map((task) => (
<Task
key={task.id}
task={task}
onPinTask={(task) => pinTask(task)}
onArchiveTask={(task) => archiveTask(task)}
/>
))}
</div>
);
}
와 이걸 기반으로 작성된 스토리다.
import TaskList from './TaskList';
import * as TaskStories from './Task.stories';
import { Provider } from 'react-redux';
import { configureStore, createSlice } from '@reduxjs/toolkit';
// A super-simple mock of the state of the store
export const MockedState = {
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' },
],
status: 'idle',
error: null,
};
// A super-simple mock of a redux store
const Mockstore = ({ taskboxState, children }) => (
<Provider
store={configureStore({
reducer: {
taskbox: createSlice({
name: 'taskbox',
initialState: taskboxState,
reducers: {
updateTaskState: (state, action) => {
const { id, newTaskState } = action.payload;
const task = state.tasks.findIndex((task) => task.id === id);
if (task >= 0) {
state.tasks[task].state = newTaskState;
}
},
},
}).reducer,
},
})}
>
{children}
</Provider>
);
export default {
component: TaskList,
title: 'TaskList',
decorators: [(story) => <div style={{ padding: '3rem' }}>{story()}</div>],
tags: ['autodocs'],
excludeStories: /.*MockedState$/,
};
export const Default = {
decorators: [
(story) => <Mockstore taskboxState={MockedState}>{story()}</Mockstore>,
],
};
export const WithPinnedTasks = {
decorators: [
(story) => {
const pinnedtasks = [
...MockedState.tasks.slice(0, 5),
{ id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
];
return (
<Mockstore
taskboxState={{
...MockedState,
tasks: pinnedtasks,
}}
>
{story()}
</Mockstore>
);
},
],
};
export const Loading = {
decorators: [
(story) => (
<Mockstore
taskboxState={{
...MockedState,
status: 'loading',
}}
>
{story()}
</Mockstore>
),
],
};
export const Empty = {
decorators: [
(story) => (
<Mockstore
taskboxState={{
...MockedState,
tasks: [],
}}
>
{story()}
</Mockstore>
),
],
};
그러면 짠!
이런 식으로 스토리북에서도 동작하게끔 작성할 수 있다.
해당 코드들은 튜토리얼의 코드이며, 공식 사이트에 보다 많은 예시가 있으니 궁금하다면 따라가면서 하나씩 작성 해보는 걸 추천한다!
cf) 튜토리얼만 따라해도 배포가 뚝딱이랍니다!?
일단 널리 알려진 통상적 장단점부터 적자면 이렇다.
장점:
단점:
그렇다면 갓배워서 갓 적용 중인 내 입장에서는?
아직 판단하기는 이르지만, 소통적인 측면에서 큰 이점이 있을 것 같다.
특히 디자인 시스템이 자리잡고 있지 않았다면, 이미 만들어진 컴포넌트와 거의 동일하고 유사한 컴포넌트를 새로 만들어보았을 확률이 높다.
나 역시 스토리북을 고민하다 배워보고, 적용하기로 마음 먹은 게 이 같은 이유에서니 말이다.
하지만 단점이라기보다 어려운 점을 얘기하자면
Atomic하게 컴포넌트가 개발되어있지 않다면 스토리 하나 만드는 것도 좀 어려울 수 있다.
위에서 예상되는 장점으로 적은 것과 연결되는 부분인데,
후에 한 번 더 언급하겠지만 이런식으로 개별 컴포넌트를 구성하는 것을 의식함으로서
컴포넌트간의 의존성도 줄일 수 있게 되고
_컴포넌트 기반의 개발_을 진행하는 데에도 큰 이점이 있다.
(↑ 바로 이 부분)
기존에 작성된 작은 컴포넌트들이 이미 유기적이고, 결합도가 높게 짜여있다면 이걸 적용하면서 코드를 다시 뜯어 고쳐야할 수도 있다.
고치고 난다면 물론 좋은 일이겠지만 그 중간 과정이 어려운 것은 어쩔 수 없을 것이다.
또, 아는 게 많을 수록 활용하기 편한 tool이라는 생각이 들어서, 결국 얼마나 공부해서 잘 써먹느냐에 따라 스토리북의 이점을 가져가는 정도가 다를 것 같다.
개발 생산성이 높아진 후에 다시 한 번 의견을 적어보는 것도 좋겠다!
그럼 이만!