まえがき
vue.js のコンポーネント設計で悩んで、 Atomic Design を勉強し直したら
なんとなく形になった気がするので、まとめてみます。
2020/06/14 エラーハンドリングなどに portal-vue を使う場合を追記しました。
コンポーネント設計と Atomic Design の関わり方
Atomic Design は元々 UI 設計として考え出されたものです。
巨大なアプリケーションでも効率よくパーツを再利用し、
さらに細かい調節もできるように考えられています。
Atomic Design では「コンポーネント」という言葉が使われており、
一見すると vue などアプリケーションにもそのまま設計を利用できそうに思えます。
しかし、UI 設計の手法・思想をそのままアプリケーションの
コンポーネント設計に持ってくるのは少し無理がありました。
アプリケーション開発では、同じレイアウトでもデータの更新のタイミングが異なる場合があります。
この**データの流れやロジックをどこで行うか?**という概念があるため、
UI 設計として作られた Atomic Design をそのままアプリケーションで使えない理由になります。
ただ、Atomic Design の 各段階の関係性 に注目すると、
「データの更新が役割上可能な階層」と「レイアウトを調整に専念する階層」に
分けられることに気づきました。
Atomic Design の段階と役割
段階 | 内容 | UI例 |
---|---|---|
Atoms | これ以上分けられない単位 | ボタン・テキスト入力欄・ラベル |
Molecules | Atoms が複数組み合わさったもの | 入力フォーム (ラベル+入力欄) |
Organisms | 1つの明らかな機能を持つ | ヘッダー |
Templates | ページの枠組みを構成する | ワイヤーフレーム |
Pages | ページにコンテンツを入れたもの | デザインカンプ |
以上が Atomic Design で提唱されている5段階です。
よく見ると Atoms, Molecules, Templates はレイアウトとしてのまとまり、
Organisms と Pages はコンテンツベースでのまとまりになっています。
「レイアウトとしてのまとまり」は、中に入る情報に関わらず、見た目・機能単位でのまとまり、
「コンテンツベースでのまとまり」はコンテンツ(データ)単位でのまとまり
という分け方になります。
「レイアウトに関心が高い」「コンテンツに関心が高い」それぞれのまとまり方を
コンポーネント設計に当てはめると、次のように言い換えられると思います。
・Atoms, Molecules, Templates はユーザーインターフェースや、
親コンポーネントから渡されたデータをどう表示するかに専念する。
・Organisms と Pages はコンテンツに関心が高い。API や store などデータの取得・変更の責務を持つ。
これは Redux の推奨する、
Presentational Component (表示のためのコンポーネント)と
Container Component (ロジックコンポーネント)に近い分け方だと思っています。
参考:[redux] Presentational / Container componentの分離 - react-redux.connect()のつかいかた
(後述しますが、Organisms はレイアウト機能もあるので、完全に Container Component だとは言えませんが。。)
そして元の UI 設計としての Atomic Design でも、階層の逆流は禁止しています。
自分より上の階層のコンポーネントを取り込むことはできません。
反対に、自分より下の階層の読み込みは可能です。
例外として、Organisms は同じ階層の読み込みが可能です。
ここのルールが崩れてしまうと、レイアウトの依存関係も壊れてしまうので、UI 設計では禁止になっていました。
Vue のコンポーネント設計でも、データとコンテンツの依存関係を考えると、
階層の逆流は禁止するべきです。
(vue の Props と考え方が似ています。)
コンポーネント粒度の見極め
粒度について、Atomic Design では特に Atoms, Molecules, Organisms の分け方で悩むことが多い気がします。
Atomic Design 各段階を、どうやって HTML コーディングするか説明すると
- Atoms は button, input など基本1つのタグで構成できるもの(例外:option を子要素にもつ select)
- Molecules は複数のタグで最小限のレイアウトを構成するもの(カルーセル・入力フォームとバリデーションのセットなど)
- Organisms はセクショニングコンテンツ
と言い換えることができます。
「Organisms はセクショニングコンテンツ」という言葉でピンとこない人は、
基本的には
- 見出しを持つ
- section, article, aside, nav でマークアップできる
- 内容について親コンポーネントに依存しない
- 他のページにコンポーネントをそのまま移植しても成り立つ
- コンポーネント内でデータの取得・更新が完結する
などの特徴があれば、Organisms として切り出し可能です。
Templates と Pages コンポーネントはページ全体に影響します。
Templates はページの大まかな配置(ワイヤーフレーム)、
Pages はコンテンツ制御(データ取得・更新などのロジックのみ)を管轄します。
Templates は Presentational、Pages は Container と言い換えることができます。
また、Pages は基本的に1画面に1つ必要ですが、
レイアウトが一緒なら Templates は複数の Pages で共有してしまって良いと思います。
(新規ユーザ情報入力とユーザ登録情報更新のように、ロジックが微妙に異なるが見た目がほとんど一緒の場合)
例
例として、ログインページを Atomic Design ベースでコンポーネント化していきます。
ページは下の図のように見出し・メールアドレス入力・パスワード入力・送信ボタンで構成されます。
まず、Pages ディレクトリにログインページのコンポーネントを作ります。
router から呼び出すコンポーネントは、この Pages コンポーネントになります。
今回の例ではページ表示前にデータ取得などを行わないのでロジックは書かず、
全体のレイアウトを定義する templates/LoginPage.vue を読み込むだけにします。
<template>
<LoginPage />
</template>
<script>
import LoginPage from '@/templates/LoginPage.vue'
export default {
components: {
LoginPage
},
}
</script>
Templates コンポーネントでは表示される UI を記述します。
<template>
<section>
<h1>ログイン</h1>
<p>アカウントをお持ちの方は<br>下記よりログインしてください。</p>
<form @submit.prevent id="login">
<label for="mail">メールアドレス</label>
<input id="mail" v-model="mailValue" type="text" placeholder="sample@mail.jp">
<label for="password">パスワード</label>
<input id="password" v-model="passwordValue" type="password" autocomplete="off">
<button type="button" @click="clickLogin">ログイン</button>
</form>
<p>アカウントをお持ちでない方は<br>会員登録が必要です。</p>
<p><a href="#">新規会員登録</a></p>
</section>
</template>
<script>
import { login } from '@/api/user.js';
export default {
methods: {
clickLogin(e) {
e.preventDefault();
login({ mail: this.mailValue, password: this.passwordValue });
},
},
}
</script>
「ログイン」ボタンを押すと、入力したメールアドレスとパスワードを API へ投げる想定です。
Templates では データの変更は行わない ルールなので、
API 通信は Pages か Organisms で実行する必要があります。
このページのログインフォームは独立しているため(親コンポーネントと連携が必要ない・別のページにそのまま移植できるレベルの完結した内容)、
なのでログイン部分は Organisms として切り出せます。
Organisms にすれば データの更新が可能 なので、 store にアクセスしたり、API でデータを送信することができます。
↓ 上記 templates/LoginPage.vue から Organisms にフォーム部分を切り出し、
「ログイン」ボタンが押されたら userAPI に通信するようにしたもの。
<template>
<form @submit.prevent id="login">
<label for="mail">メールアドレス</label>
<input id="mail" v-model="mailValue" type="text" placeholder="sample@mail.jp">
<label for="password">パスワード</label>
<input id="password" v-model="passwordValue" type="password" autocomplete="off">
<button type="button" @click="clickLogin">ログイン</button>
</form>
</template>
<script>
import { login } from '@/api/user.js';
export default {
methods: {
clickLogin(e) {
e.preventDefault();
login( { mail: this.mailValue, password: this.passwordValue });
},
},
}
</script>
切り出した organisms/LoginForm.vue を Templates から読み込むように変更します。
Templates はこれで完成です。
<template>
<section>
<h1>ログイン</h1>
<p>アカウントをお持ちの方は<br>下記よりログインしてください。</p>
<LoginForm />
<p>アカウントをお持ちでない方は<br>会員登録が必要です。</p>
<p><a href="#">新規会員登録</a></p>
</section>
</template>
<script>
import LoginForm from '@/organisms/LoginForm.vue'
export default {
components: {
LoginForm
},
}
</script>
また、ログインフォームの中のメールアドレス、パスワードの入力は
別のページでも使い回す可能性があるので、それぞれコンポーネント化したほうが良さそうです。
外部にデータ更新を行わず、2つ以上の HTML 要素からなるのでこれらは Molecules コンポーネントにします。
Molecules についでにフォームのバリデーション処理も実装します。
<template>
<div>
<label for="mail">メールアドレス</label>
<input
id="mail" :value="mailValue" type="text" placeholder="sample@mail.jp"
@change="changeMail"
>
<p v-if="error" id="errorMail">{{ error }}</p>
</div>
</template>
<script>
import { checkMail } from '@/module/validation.js';
export default {
data() {
return {
mailValue: '',
error: '',
}
},
methods: {
changeMail(e) {
const val = e.target.value;
if (checkMail(val)) {
this.mailValue = val;
this.error = '';
} else {
this.mailValue = '';
this.error = 'メールアドレスを正しい形式にしてください';
}
this.$emit('handle-change-mail', this.mailValue);
},
},
}
</script>
入力箇所を Molecules にすると organisms/LoginForm.vue は下記のように変わります。
一緒に入力バリデーションの結果によってボタンの disabled を制御できるようにしました。
<template>
<form @submit.prevent id="login">
<InputMail @handle-change-mail="changeMail">
<InputPassword @handle-change-password="changePassword">
<button type="button" :disabled="buttonDisabled" @click="clickLogin">ログイン</button>
</form>
</template>
<script>
import { login } from '@/api/user.js';
import InputMail from '@/molecules/InputMail.vue';
import InputPassword from '@/molecules/InputPassword.vue';
export default {
data() {
return {
mailValue: '',
passwordValue: '',
}
},
computed: {
buttonDisabled() {
return !!(this.mailValue && this.passwordValue);
}
},
methods: {
clickLogin(e) {
e.preventDefault();
login( { mail: this.mailValue, password: this.passwordValue });
},
changeMail(val) {
this.mailValue = val;
},
changePassword(val) {
this.passwordValue = val;
},
},
}
</script>
input 要素と button 要素は他のページでもほぼ共通の振る舞いになるので、
Atoms コンポーネントにしても良さそうです。
先ほどの図にコンポーネント切り分けを追加するとこのようになります。
実際に各要素をコンポーネント化していく際は、上から下へ、
Pages → Templates → Organisms → Molecules → Atoms の順で考えていくと
コンポーネントを細かく分割しすぎず、作業がしやすかったです。
また、コンポーネントが巨大になり、複数の処理が混在しているようだったら
どんどん Organisms や Molecules にコンポーネント分割してしまった方が良かったです。
エラーハンドリングは Organisms にエラーダイアログのコンポーネントを作り、
それを Templates で必ず読み込むようにしました。
エラーダイアログを発生させたいタイミングは、データの更新を行った場合に限定されたので
エラー発生時は Pages か Organisms からエラー用 vuex を更新し、表示状態の制御をしていました。(ローディングも似た感じで実装しました)
(2020.06.14 追記)
「vuex 肥大化避けるべし」という最近の風潮に合わせて、
エラーとローディングは portal-vue で実装するようにしました。
使い方は、HTML として出力したい <portal-target="loadingArea(ローディングの場合)">
を
Templates に記述します。
Pages もしくは Organisms で <portal>
を含んだコンポーネントを読み込み、
そこで prop で portal へ流したい情報を渡します。
確かに vuex を使わないでハンドリングできるけど、vuex で実装した方がわかりやすいですね。。
ディレクトリ構成
最後にコンポーネント設計のディレクトリ構成を置いておきます。
ディレクトリ | 内容 |
---|---|
js | |
├ api | axios での ajax 通信、web ストレージへのアクセス |
├ atoms | button, input など |
├ module | 汎用的な関数。 バリデーションや userAgent 判定など |
├ molecules | 入力フォーム(見出し+パーツ)、スライダー、 カード型 UI など |
├ organisms | ログイン、エラー、ローディング、notification など |
├ pages | ページのコンテンツ |
├ store | ログイン情報、会員情報 |
└ templates | ページの大枠 |
まとめ
Atomic Design をベースに、レイアウトの粒度とデータの更新箇所の制限・コンポーネントの階層構造のルールを作りました。
実際にこの考え方で web アプリを作ってみた感想なのですが、
メリット
・コンポーネント分割は「Atomic Design をベースにしている」とイメージ共有がしやすい
・データの流れが限定的なのでテストしやすい
・データ構造の変更に強い
・vuex の複雑さを感じなくなる
・コンポーネントの使い回しは意図通りできる
・1ファイルあたりの行数が長くなりにくい
・外部 CSS を使う場合、設計も Atomic Design の考え方をそのまま使える
デメリット
・ルールが複雑。。
・最初戸惑う人が多かった
・Organisms のファイルが増えがち
・Organisms から Pages にデータを伝えたい場合など、変更を通知するだけの中継イベントができてしまう
└ 同一ページ内のデータのやり取りであっても vuex を使ってもいいかもしれない
・Atoms の存在感が薄いかも
Presentational と Container で分ける方法も理にかなっているけど、
単一ファイルコンポーネントの場合は Atomic Design ベースの考え方は
レイアウトとロジックのバランスが良い気がしていて、Vue との相性がいい気がしました。
もっと良い設計方法ができると思うのですが、思いついたら追加していきます。
考慮不足やわかりにくい箇所があればご指摘ください