この記事は「株式会社オープンストリーム Advent Calendar 2019」の5日目の記事です。
「Vue.jsとAtomic Designを実践した」という事例が増えてきましたね✨
そこで今回はNuxt.jsとStorybookを用意しながらAtomic Designを実践したお話をできればなと思います。
(自明的に)この記事の対象としては次の3つになります。
- Nuxt.jsを使いたい(これ一つで完結したWebアプリを作りたい, SPAを簡単に導入したいなどなど)
- Storybookを使いたい
- Atomic Designを取り入れたい
開発環境
macOSと
$ sw_vers
ProductName: Mac OS X
ProductVersion: 10.15.1
BuildVersion: 19B88
$ node -v
v13.2.0
$ npm -v
6.13.1
$ yarn -v
1.21.0
Windowsで動作確認をしています。
C:\Windows>ver
Microsoft Windows [Version 10.0.18363.476]
...
$ node -v
v13.2.0
$ npm -v
6.13.1
$ yarn -v
1.21.0
Atomic Design
はじめにUIを一番小さい要素である原子(Atoms)に分割して、それを下の図のように順番に組み合わせて意味のあるUIパーツやテンプレート(ページ)を作るデザイン手法です。
( http://atomicdesign.bradfrost.com/chapter-2/ より引用 )
(かなりざっくり言ってしまうと)例えばWebページでいうボタンやフォームのラベルなどがAtomsに該当します。
小さい要素であるAtomsから順番に作っておくと、例えば新しい機能を作る中で「同じようなボタンを新しく作りたい」と感じたときは
- すでにあるAtomsからMoleculesを作る
- すでにあるOrganismsに別のデータ(文言など)を投入してTemplatesに追加して配置する
など すでに書いたものを流用することができます。
Atomic Designの参考となる記事
Atomic Designについては、こちらの記事などがわかりやすいです。
https://design.dena.com/design/atomic-design-%E3%82%92%E5%88%86%E3%81%8B%E3%81%A3%E3%81%9F%E3%81%A4%E3%82%82%E3%82%8A%E3%81%AB%E3%81%AA%E3%82%8B/
Atomic Designの元となる書籍はこちらから見ることができます。
http://atomicdesign.bradfrost.com/
「一番小さい単位であるAtomsではどのくらいまで分割すればいいのか」など、各コンポーネントの大きさについてはこちらがわかりやすいです。
https://speakerdeck.com/nrslib/vue-dot-js-and-atomic-design-guideline-for-components-division
Nuxt.jsでAtomic Designを実現する際のファイル構成
NuxtでAtomic Designを実現するとき、Atoms/Molecules/Organisms/Templatesは components
以下に作るためコンポーネントとして扱います。
Atomsを組み合わせてMoleculesを作る…というようにコンポーネント間の親子関係が発生します。
作るサンプル
スライダーを動かすと「レビューらしきもの」の表示件数を変えることができて、「星を表示」の操作で各「レビューらしきもの」についている星を隠したりするサンプルを考えてみましょう。
バックエンドのところは作りません。そこはNuxt.jsとか他のものでAPIを作って…
SPAのページを遷移してもデータを保持できるようにVuexを使って…も今はナシです🙅後にしましょう
サンプルコードはこちらに置いておきます。
https://github.com/ysd-marrrr/nuxt-atomic-design-20191205
プロジェクトの作成
Nuxtのプロジェクトを作るときと同様です。 create-nuxt-app
を使っています😄
$ npx create-nuxt-app nuxt-atomic-design-20191205
create-nuxt-app v2.12.0
✨ Generating Nuxt.js project in nuxt-atomic-design-20191205
? Project name nuxt-atomic-design-20191205
? Project description My learning Nuxt.js + Atomic Design Project
? Author name ysd-marrrr
? Choose the package manager Yarn
? Choose UI framework Bulma
? Choose custom server framework None (Recommended)
? Choose Nuxt.js modules
? Choose linting tools ESLint, Prettier
? Choose test framework None
? Choose rendering mode Single Page App
? Choose development tools jsconfig.json (Recommended for VS Code)
componentsにAtoms/Molecules...を作成するため、ディレクトリを予め用意します、
$ mkdir -p ./components/{atoms,molecules,organisms,templates}
Atomic DesignでいうPagesはそのまま ./pages
に置きます。
Storybookの設定
こちらもNuxtにStorybookを導入するときと同じようにします。
http://tacamy.hatenablog.com/entry/2019/05/27/113131
$ npx -p @storybook/cli sb init --type vue
Nuxtのコンポーネントをそのまま使いたいので、 ../components
を使うように ./.storybook/config.js
を修正します。
import { configure } from '@storybook/vue';
configure(require.context('../components', true, /\.stories\.js$/), module);
Storybook導入時のサンプルは要らないので削除します。
$ rm -rf ./stories
StorybookにBulmaのCSSを適用する
Nuxt用のWebpackとStorybook用のWebpackは別物なので、Storybook用の設定を追加します。
StorybookでもBulmaを使いたいので、ファイルを読み込めるようsass-loader
などを追加します。
$ yarn add -D node-sass sass-loader mini-css-extract-plugin
BulmaでWebpackを使うときと同様に**Storybookのwebpack.config.js
**を編集します。
https://bulma.io/documentation/customize/with-webpack/#3-5-create-a-webpack-config-webpack-4
const path = require('path');
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
module.exports = ({ config }) => {
config.resolve.alias['@'] = path.resolve(__dirname, '../')
config.module.rules.push({
test: /\.scss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader'
},
{
loader: 'sass-loader',
options: {
sourceMap: true
}
}
]
});
config.plugins.push(new MiniCssExtractPlugin({
filename: 'css/mystyles.css'
}));
return config
}
この設定を追加しないとStorybookを起動したときにコンポーネントが見つからない旨のエラーが出ます
ERROR in ./components/atoms/sliders/Slider.stories.js
Module not found: Error: Can't resolve '@/components/atoms/sliders/Slider.vue' in '~/Projects/nuxt-atomic-design-20191205/components/atoms/sliders'
@ ./components/atoms/sliders/Slider.stories.js 2:0-59 6:14-20
@ ./components sync .stories.js$
@ ./.storybook/config.js
@ multi ./node_modules/@storybook/core/dist/server/common/polyfills.js ./node_modules/@storybook/core/dist/server/preview/globals.js ./.storybook/config.js (webpack)-hot-middleware/client.js?reload=true&quiet=true
また、MiniCssExtractPluginが設定されていないとStorybookにBulmaが適用できません。
私の環境ではこの後にcore-jsをダウングレードしないと core-jsの不具合でNuxtのビルドができなくなってしまったので、必要に応じでダウングレードします。
https://qiita.com/auau3700/items/27bd33ee8df6d3e505f4
$ yarn add core-js@2.6.9
次にStorybookにおける各コンポーネントでBulmaのCSSを読み込ませます。 .storybook
内に CommonCss.vue
を作ります。
<template>
<div class="decorator">
<slot name="story"></slot>
</div>
</template>
<script>
export default {
name: 'CommonCss'
}
</script>
<style lang="scss">
@charset "utf-8";
@import "~bulma/bulma";
</style>
CommonCss.vue
を最初に読み込むように config.js
を修正します。 この読み込みでトラブルがあると「コンポーネント単体でSCSSが効かない」などおかしな挙動に襲われます(実体験)
import { configure, addDecorator } from '@storybook/vue';
import CommonCss from './CommonCss.vue';
addDecorator(story => ({
components: { CommonCss },
render(h) {
return (
<common-css>
<story slot="story"></story>
</common-css>
)
}
}))
// automatically import all files ending in *.stories.js
configure(require.context('../components', true, /\.stories\.js$/), module);
Atoms
コンポーネントを作る際は、コンポーネントの本体となる .vue
ファイルと、Storybookの設定である .stories.js
を用意します。
また、Atoms/Molecules...の中にも大量にコンポーネントを作るため、意味のある単位でその中にディレクトリを作っておくと良いでしょう。
スライダー
$ mkdir ./components/atoms/sliders
コンポーネントのファイルもスライダーだけのシンプルなものにしましょう。
<template>
<input type="range" value="10" min="1" max="20" />
</template>
Storybookの storiesOf()
の第一引数はStorybookの左側に表示される階層なので、./components
と同じにしておきましょう。
import { storiesOf } from '@storybook/vue';
import Slider from '@/components/atoms/sliders/Slider.vue';
storiesOf('atoms/sliders/Slider', module).add('default', () => ({
components: { Slider },
template: '<slider />'
}));
切り替えボタン
$ mkdir ./components/atoms/tab
切り替えボタンも同様に作りましょう。BulmaのTabsを流用します。
https://bulma.io/documentation/components/tabs/#styles
ボタンの文字は仮のものを入れておきましょう。後で本当の内容に直せます。
レビュー表示部
$ mkdir ./components/atoms/labels
$ mkdir ./components/atoms/boxes
レビュー表示部も同様です。こちらは
- タイトル
- レーティング(星5つです!)
- 本文
- それらを囲む枠(Box)
に分けます。
ボタンのときと同じく 内容を入れたい衝動 に駆られますが、ここは抑えて仮の文字を入れましょう。 本当の文字はもっと上のレイヤーの Pages で入れるべきです。
枠はBulmaのBoxを流用しちゃいましょう。
https://bulma.io/documentation/elements/box/
Boxの中にパーツを配置したいため、BoxのコンポーネントではSlotを用います。
<template>
<div class="box"><slot /></div>
</template>
ちなみに、このAtomsの段階ではBoxのwidthは100%と横幅いっぱいにします。実際に配置するときはCSSフレームワークのグリッドシステムなどで横幅を決めます。
わかりやすいように仮に文字を入れたサンプルですが、影がついているBoxの横幅がいっぱいに広がっています。
Molecules
Atomsを組み合わせてMoleculesを作ります。現時点では コンポーネントのファイルでAtomsコンポーネントを読み込む以外は同じ作りにします。
Storybookの設定はMoleculesになっても変わりはなく、Atomsと同様にコンポーネントを指定します。
import { storiesOf } from '@storybook/vue'
import ReviewsBox from '~/components/molecules/reviews/ReviewsBox.vue'
storiesOf('molecules/reviews/ReviewsBox', module).add('default', () => ({
components: { ReviewsBox },
template: '<reviews-box />'
}))
レビューの星
$ mkdir ./components/molecules/ratings
星を繰り返し表示できるようにします。
この場合は v-for
で「リストの要素に応じて繰り返す」のではなく「x回繰り返す」実装にするため、次に紹介されている parseInt()
, index in x
のような方法にしました。
v-for
において必須である v-bind:key
が重複してしまう問題は解決できていません。
https://stackoverflow.com/questions/44617484/vue-js-loop-via-v-for-x-times-in-a-range
星を表示する数は仮のものを用意します。
<template>
<div class="ratings">
<span v-for="activeStar in stars" :key="activeStar">
<rating-star />
</span>
</div>
</template>
<script>
import RatingStar from '@/components/atoms/ratings/RatingStar.vue'
export default {
components: { RatingStar },
data() {
return {
stars: 3
}
}
}
</script>
スライダー
$ mkdir ./components/molecules/sliders
このMoleculesでは次のAtomsを読み込んで組み合わせていきます。
- どんなことをするスライダーなのか、その名前(実際の値は後述する
props
を使う方法で入れます) - スライダー本体
- スライダーの値を表示する部分
<template>
<div class="display-stars-panel columns">
<div class="column is-one-fifth"><default-label /></div>
<div class="column is-two-fifths"><slider /></div>
<div class="column is-one-fifth"><default-label /></div>
</div>
</template>
<script>
import DefaultLabel from '@/components/atoms/labels/DefaultLabel.vue'
import Slider from '@/components/atoms/sliders/Slider.vue'
export default {
components: {
DefaultLabel,
Slider
}
}
</script>
Organisms
Moleculesを組み合わせてOrganismsを作ります。
こちらもStorybookの設定はMolecules, Atomsと同様にコンポーネントを読み込ませます。
コントロールパネル(スライダー+ボタン)
$ mkdir ./components/organisms/controls
Moleculesと同様にこのコンポーネントで
- スライダー部
- 切り替えボタン
を組み合わせます。
レビュー表示部(子コンポーネントにSlotを使った場合)
このようにSlotを設定した子コンポーネントの タグの中に パーツを配置すると、子コンポーネントで定義した <slot/>
の位置に入ります。
<template>
<box>
<rating-star />
<review-title />
<default-label />
</box>
</template>
<style lang="scss" scoped></style>
<script>
import RatingStar from '@/components/atoms/ratings/RatingStar.vue'
import DefaultLabel from '@/components/atoms/labels/DefaultLabel.vue'
import ReviewTitle from '@/components/atoms/labels/ReviewTitle.vue'
import Box from '@/components/atoms/boxes/Box.vue'
export default {
components: { RatingStar, DefaultLabel, ReviewTitle, Box }
}
</script>
Templates
Organismsを組み合わせてTemplatesを作ります。今回は一つだけ作ります。
こちらもStorybookの設定はOrganisms, Molecules, Atomsと同様にコンポーネントを読み込ませます。
あとのPagesでデータを入れるので、この段階でまだ実際のデータを入れないようにしましょう。
また、Nuxtのlayouts
と混同しそうになりますが、layouts
は各ページで共通して表示するヘッダーを記述するだけにとどめます。
Templatesはページごとに異なる内容で、Atomsから組み上げたときに作ります。
(ヘッダーを作り込むときはAtomsから必要になるかもしれません)
Pages
Templatesを読み込んで(後ほど実データを投入して)Pagesを作ります。 こちらは components
ディレクトリではなく pages
ディレクトリを使います。
実際のデータを入れず、コンポーネントを組み合わせてここまで実装するとこんな感じになると思います。
(ヘッダー部分は layouts
ディレクトリのファイルにて設定しています)
親子コンポーネント間のやり取り(props, events)
ここからは作成した各コンポーネントに実際のデータを入れていく作業になります。
図のほうがわかりやすいので、例えばスライダーを操作したときのデータのやり取りを図にすると次の通りになります。
スライダーをユーザーが操作した際に子コンポーネントに反映させる場合もそうですが、 親のpagesで設定した初期値を子コンポーネントに渡すときも props
を使います。
ラベルに文字を表示する: props
props
を設定して、渡された値をそのまま表示できるようにします。
後述する「props
を直接変更しないように実装する必要がある」という課題を解決するためにprops
の名前は末尾にProp
とつけています。
<template>
<span>{{ displayTextProp }}</span>
</template>
<script>
export default {
props: {
displayTextProp: {
type: String
}
}
}
</script>
使う側からはv-bind
でprops
にデータを投入します。
この実装では、
-
DefaultLabel
としてラベルを使いまわしていて - 「表示する数を選択」の文言は 表示する件数を制御するスライダーの Moleculesの段階で表示できればいいので
そのまま入れてしまいます。
<template>
<div class="display-stars-panel columns">
<!-- Moleculesの段階で固定の文言として表示したいので入れる -->
<div class="column is-one-fifth"><default-label :display-text-prop="'表示する数を選択'"/></div>
<!-- ここから下は親の設定値を動的に反映させるので後で -->
<div class="column is-two-fifths"><slider /></div>
<div class="column is-one-fifth"><default-label /></div>
</div>
</template>
Storybookで「Atoms単体」として出したいときのためにprops
のデフォルト値を設定する
props
にdefault
を設定すると、AtomsだけをStorybookで開いたときに「ラベルだと真っ白…」ということがなくなります。
props: {
displayTextProp: {
type: String,
default: 'Type text here'
}
}
また、ESLintの設定によってはprops
のdefault
を設定しないとwarningが出ることがあります。
21:5 warning Prop 'displayAmountProp' requires default value to be set vue/require-default-prop
ただし、ボタン(タブ)のデータとなるprops
にもdefault
を設定して、これを子コンポーネント全部に入れるとなると……悲惨なことになります。
props
にdefault
が必要なのはわかりますが…これはまだ解決できていません🙇♂️
...
props: {
availableOptionsProp: {
type: Array,
default: function() {
return [
{ value: 'false', text: 'A' },
{ value: 'true', text: 'B' }
]
}
}
},
data: function() {
return {
availableOptions: this.availableOptionsProp
}
}
子コンポーネントから動的な値を親に送る: events
/ this.$emit()
例えば「スライダーの値を変更したとき、その値を使って何か フロントエンド(クライアント)側で 処理をしたい」という場面があります。
その時にはevent
にメソッドを紐づけて、メソッド内のthis.$emit()
で変更された値を親に送ります。
子で処理しようとすると「どこで処理しているのかわからない」「コンポーネントを使いまわしたいけどイベントで勝手に動く」など混乱の原因になります。
<template>
<div>
<input
type="range"
min="1"
max="20"
v-model="sliderValue"
@change="onSliderChange"
/>
</div>
</template>
<script>
export default {
props: {
sliderValueProp: {
type: Number,
default: 0
}
},
data: function() {
return {
sliderValue: this.sliderValueProp
}
},
methods: {
onSliderChange: function() {
this.$emit('sliderUpdate', this.sliderValue)
}
}
}
</script>
さらにイベントを親に渡したいときは 受け取ったイベントにmethodsを紐づけて、もう一度イベントを送ります。
子コンポーネントから this.$emit()
の第2引数で送った値はmethods
で定義したメソッドの引数で受け取ることができます。
...
<div class="column is-two-fifths"><slider @sliderUpdate="onSliderUpdate"/></div>
</template>
<script>
import DefaultLabel from '@/components/atoms/labels/DefaultLabel.vue'
import Slider from '@/components/atoms/sliders/Slider.vue'
export default {
components: {
DefaultLabel,
Slider
},
props: {
displayAmountProp: {
type: Number,
default: 0
}
},
data: function() {
return {
displayAmount: this.displayAmountProp
}
},
methods: {
onSliderUpdate: function(newValue){
this.$emit('onSliderUpdate', newValue)
}
}
...
親で処理が終わった後、そのデータを子に反映させたいときはprops
を用います。
<template>
<div class="root-contents">
<main-template
:display-amount-prop="displayAmount"
:is-display-stars-prop="isDisplayStars"
:display-stars-options-prop="displayStarsOptions"
:review-data-prop="actualDisplayData"
@onDisplaySettingsChanged="onDisplaySettingsChanged"
/>
</div>
</template>
さらにpropsを子コンポーネントに渡したいときは、一度propsの値を受けてから再度propsを設定します。
<template>
<div class="container">
<controll-panel
:display-amount-prop="displayAmount"
:is-display-stars-prop="isDisplayStars"
:display-stars-options-prop="displayStarsOptionsProp"
@onDisplaySettingsChanged="onDisplaySettingsChanged"
/>
...
</template>
<script>
...
export default {
components: {
ControllPanel,
ReviewsBox
},
props: {
displayAmountProp: {
type: Number,
default: 0
},
...
v-bind
などでprops
を直接変更させないために
スライダーの値を取得したいときに次の実装をすると問題が出ます。
- スライダーの
value
にprops
を紐づける: 反応しない - スライダーの
v-bind
にprops
を紐づける: 反応するが warningが出る
vue.esm.js:628 [Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders. Instead, use a data or computed property based on the prop's value. Prop being mutated: "sliderValueProp"
そのため「v-model
に紐づけて変更する用」にdata
を用意しないといけません。
コンポーネントのdata
なのかprops
なのかを区別する必要があるため、props
としている変数は末尾にProps
とつけています。
このようにdata
も設定すると より子コンポーネントで状態を保持している感が強まるので 混乱しないためにも
- 親コンポーネントに変更後の値を渡して
- 親で通信などの処理をしてから
その結果を子に反映させる実装をします。
親からprops
を受け取りたい場合はdata
ではなくcomputed
にしないと反映されない
…と言いたいのですが、props
の値をdata
で受ける実装にしてしまうと、親からデータが渡ってきたときに それが反映されません。
<script>
export default {
...
props: {
displayAmountProp: {
type: Number,
default: 0
}
},
data() {
return {
displayAmount: this.displayAmountProp
}
},
...
この場合 data
の代わりに computed
を使います。
<script>
export default {
...
props: {
displayAmountProp: {
type: Number,
default: 0
}
},
computed: {
displayAmount() {
return this.displayAmountProp
}
},
...
props
/events
でコンポーネント間のデータのやり取りをしていて一番ハマったポイントです。
スライダーなどユーザーの操作で変更される可能性があるprops
はcomputed
と併用しましょう。
動作確認
組みあがったものの動作を確認したいときは次のコマンドを
$ yarn dev
StorybookでUIパーツを確認したいときは次のコマンドを使います(今頃かよ、という気がしますが)
$ yarn storybook
*Windowsの場合は C:\Windows\system32
を環境変数に追加しないと、ブラウザを開けずにStorybookが終了します。
https://qiita.com/maedadada/items/97c54b68d5825b60c393
おわりに
Atomic Designを実践し、WebアプリのUIをコンポーネントにすることで「この機能増やしてー」と頼まれたときにAtoms/Molecules/Organismsを流用して変更を素早く実現できています。
今のところは💦
ただし、その一方で
- Atoms/Molecules/Organismsの3つにUIパーツが全て収まらない
- Atomsを用いたOrganismsが出てきてしまう、など悩ましい状況が出てくる
- コンポーネントを大量に作成したり、大量のpropsやeventsを設定する、となると時間がかかる
そしてAdvent Calendarに間に合わなくなる🙇♂️
といった問題があります。
適切にAtomsなどに分割して仕様変更に対応しやすい構造を目指しましょう。
(後ほど使いまわしたい共用部分が発生して)手戻りを許容するのであれば、Atomsを作成してprops
/ events
を設定するコストを減らすためにMoleculesから作成するのもアリだと考えています。
Advent Calendar遅れてホント申し訳ない
最後に、このサンプルのソースコードはこちらです。
https://github.com/ysd-marrrr/nuxt-atomic-design-20191205
\次は @miyatay さんです/
参考
Atomic Design by Brad Frost
http://atomicdesign.bradfrost.com/
Atomic Design を分かったつもりになる | DeNA DESIGN BLOG
https://design.dena.com/design/atomic-design-%E3%82%92%E5%88%86%E3%81%8B%E3%81%A3%E3%81%9F%E3%81%A4%E3%82%82%E3%82%8A%E3%81%AB%E3%81%AA%E3%82%8B/
Vue.js × Atomic Design - コンポーネント分割の指針 / Vue.js and Atomic Design - Guideline for components division
https://speakerdeck.com/nrslib/vue-dot-js-and-atomic-design-guideline-for-components-division
Nuxt.jsでStorybookを使用してみたメモ - WebTecNote
https://tenderfeel.xsrv.jp/javascript/4121/
Nuxt.jsへのStorybookの導入と、Sassの変数や共通CSSを読めるようにする設定 - tacamy--blog
http://tacamy.hatenablog.com/entry/2019/05/27/113131
Nuxt.js + Vuetifyのセットアップでcore-js関係の依存関係が見つからないと怒られた
https://qiita.com/auau3700/items/27bd33ee8df6d3e505f4
Vue Js - Loop via v-for X times (in a range)
https://stackoverflow.com/questions/44617484/vue-js-loop-via-v-for-x-times-in-a-range
ng s -oコマンド実行時の「spawn cmd ENOENT」エラー対処法
https://qiita.com/maedadada/items/97c54b68d5825b60c393