Atomic Design ベースの Vue コンポーネント設計

## まえがき

vue.js のコンポーネント設計で悩んで、 [Atomic Design](http://atomicdesign.bradfrost.com) を勉強し直したら
なんとなく形になった気がするので、まとめてみます。

2020/06/14 エラーハンドリングなどに portal-vue を使う場合を追記しました。

## コンポーネント設計と Atomic Design の関わり方

Atomic Design は元々 UI 設計として考え出されたものです。
巨大なアプリケーションでも効率よくパーツを再利用し、
さらに細かい調節もできるように考えられています。

Atomic Design では「コンポーネント」という言葉が使われており、
一見すると vue などアプリケーションにもそのまま設計を利用できそうに思えます。
しかし、UI 設計の手法・思想をそのままアプリケーションの
コンポーネント設計に持ってくるのは少し無理がありました。
アプリケーション開発では、同じレイアウトでもデータの更新のタイミングが異なる場合があります。
この**データの流れやロジックをどこで行うか？**という概念があるため、
UI 設計として作られた Atomic Design をそのままアプリケーションで使えない理由になります。

ただ、Atomic Design の **各段階の関係性** に注目すると、
「**データの更新が役割上可能な階層**」と「**レイアウトを調整に専念する階層**」に
分けられることに気づきました。

### Atomic Design の段階と役割
![atomic-design-process.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131281/9ac8eeaf-f458-8db7-32aa-bda723696af7.png)

| 段階 | 内容 | UI例 |
|:-:|:-:|:-:|
| Atoms | これ以上分けられない単位  | ボタン・テキスト入力欄・ラベル  |
| Molecules | Atoms が複数組み合わさったもの | 入力フォーム<br>（ラベル＋入力欄） |
| Organisms | １つの明らかな機能を持つ | ヘッダー |
| 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()のつかいかた](https://qiita.com/tuttieee/items/a3ca7d9d415049d02d60)
（後述しますが、Organisms はレイアウト機能もあるので、完全に Container Component だとは言えませんが。。）

そして元の UI 設計としての Atomic Design でも、**階層の逆流は禁止**しています。
自分より上の階層のコンポーネントを取り込むことはできません。
反対に、自分より下の階層の読み込みは可能です。
例外として、Organisms は同じ階層の読み込みが可能です。

ここのルールが崩れてしまうと、レイアウトの依存関係も壊れてしまうので、UI 設計では禁止になっていました。
Vue のコンポーネント設計でも、データとコンテンツの依存関係を考えると、
階層の逆流は禁止するべきです。
（vue の Props と考え方が似ています。）


## コンポーネント粒度の見極め

粒度について、Atomic Design では特に Atoms, Molecules, Organisms の分け方で悩むことが多い気がします。

Atomic Design 各段階を、どうやって HTML コーディングするか説明すると

- Atoms は button, input など基本１つのタグで構成できるもの（例外：option を子要素にもつ select）
- Molecules は複数のタグで最小限のレイアウトを構成するもの（カルーセル・入力フォームとバリデーションのセットなど）
- Organisms はセクショニングコンテンツ

と言い換えることができます。

「**Organisms はセクショニングコンテンツ**」という言葉でピンとこない人は、
基本的には

1. 見出しを持つ
2. section, article, aside, nav でマークアップできる
- 内容について親コンポーネントに依存しない
- 他のページにコンポーネントをそのまま移植しても成り立つ
- コンポーネント内でデータの取得・更新が完結する

などの特徴があれば、Organisms として切り出し可能です。

Templates と Pages コンポーネントはページ全体に影響します。
Templates はページの大まかな配置（ワイヤーフレーム）、
Pages はコンテンツ制御（データ取得・更新などのロジックのみ）を管轄します。
Templates は Presentational、Pages は Container と言い換えることができます。

また、Pages は基本的に１画面に１つ必要ですが、
レイアウトが一緒なら Templates は複数の Pages で共有してしまって良いと思います。
（新規ユーザ情報入力とユーザ登録情報更新のように、ロジックが微妙に異なるが見た目がほとんど一緒の場合）

## 例
例として、ログインページを Atomic Design ベースでコンポーネント化していきます。
ページは下の図のように見出し・メールアドレス入力・パスワード入力・送信ボタンで構成されます。

![1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131281/ade098f9-70ee-b791-889b-2f81acd76fa9.png)

まず、Pages ディレクトリにログインページのコンポーネントを作ります。
router から呼び出すコンポーネントは、この Pages コンポーネントになります。

今回の例ではページ表示前にデータ取得などを行わないのでロジックは書かず、
全体のレイアウトを定義する **templates/LoginPage.vue** を読み込むだけにします。

```pages/LoginPage.vue
<template>
  <LoginPage />
</template>
<script>
import LoginPage from '@/templates/LoginPage.vue'

export default {
  components: {
    LoginPage
  },
}
</script>
```

Templates コンポーネントでは表示される UI を記述します。

```templates/LoginPage.vue
<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 に通信するようにしたもの。

```organisms/LoginForm.vue
<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 はこれで完成です。

```templates/LoginPage.vue
<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>
```

また、ログインフォームの中のメールアドレス、パスワードの入力は
別のページでも使い回す可能性があるので、それぞれコンポーネント化したほうが良さそうです。
外部にデータ更新を行わず、２つ以上の HTML 要素からなるのでこれらは Molecules コンポーネントにします。
Molecules についでにフォームのバリデーション処理も実装します。

```molecules/inputMail.vue
<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 を制御できるようにしました。

```organisms/LoginForm.vue
<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 コンポーネントにしても良さそうです。

先ほどの図にコンポーネント切り分けを追加するとこのようになります。
![atomivDesign.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/131281/86bc2ba3-4e99-eb57-ca04-0d151df13517.png)


実際に各要素をコンポーネント化していく際は、上から下へ、
Pages → Templates → Organisms → Molecules → Atoms の順で考えていくと
コンポーネントを細かく分割しすぎず、作業がしやすかったです。

また、コンポーネントが巨大になり、複数の処理が混在しているようだったら
どんどん Organisms や Molecules にコンポーネント分割してしまった方が良かったです。

エラーハンドリングは Organisms にエラーダイアログのコンポーネントを作り、
それを Templates で必ず読み込むようにしました。
エラーダイアログを発生させたいタイミングは、データの更新を行った場合に限定されたので
エラー発生時は Pages か Organisms からエラー用 vuex を更新し、表示状態の制御をしていました。（ローディングも似た感じで実装しました）

（2020.06.14 追記）
「vuex 肥大化避けるべし」という最近の風潮に合わせて、
エラーとローディングは [portal-vue](https://portal-vue.linusb.org/) で実装するようにしました。
使い方は、HTML として出力したい ```<portal-target="loadingArea（ローディングの場合）">``` を
Templates に記述します。
Pages もしくは Organisms で ```<portal>``` を含んだコンポーネントを読み込み、
そこで prop で portal へ流したい情報を渡します。
確かに vuex を使わないでハンドリングできるけど、vuex で実装した方がわかりやすいですね。。

## ディレクトリ構成

最後にコンポーネント設計のディレクトリ構成を置いておきます。

| ディレクトリ | 内容 |
|:--|:--|
| js | |
| ├ api | axios での ajax 通信、web ストレージへのアクセス |
| ├ atoms | button, input など |  
| ├ module | 汎用的な関数。<br>バリデーションや userAgent 判定など |  
| ├ molecules | 入力フォーム（見出し+パーツ）、スライダー、<br>カード型 UI など |  
| ├ organisms | ログイン、エラー、ローディング、notification など |
| ├ pages | ページのコンテンツ |
| ├ store | ログイン情報、会員情報 |
| └ templates | ページの大枠 |


## まとめ

Atomic Design をベースに、レイアウトの粒度とデータの更新箇所の制限・コンポーネントの階層構造のルールを作りました。

実際にこの考え方で web アプリを作ってみた感想なのですが、

:relaxed: メリット
・コンポーネント分割は「Atomic Design をベースにしている」とイメージ共有がしやすい
・データの流れが限定的なのでテストしやすい
・データ構造の変更に強い
・vuex の複雑さを感じなくなる
・コンポーネントの使い回しは意図通りできる
・1ファイルあたりの行数が長くなりにくい
・外部 CSS を使う場合、設計も Atomic Design の考え方をそのまま使える

:frowning2: デメリット
・ルールが複雑。。
・最初戸惑う人が多かった
・Organisms のファイルが増えがち
・Organisms から Pages にデータを伝えたい場合など、変更を通知するだけの中継イベントができてしまう
　└ ~~同一ページ内のデータのやり取りであっても vuex を使ってもいいかもしれない~~
・Atoms の存在感が薄いかも

Presentational と Container で分ける方法も理にかなっているけど、
単一ファイルコンポーネントの場合は Atomic Design ベースの考え方は
レイアウトとロジックのバランスが良い気がしていて、Vue との相性がいい気がしました。

もっと良い設計方法ができると思うのですが、思いついたら追加していきます。
考慮不足やわかりにくい箇所があればご指摘ください:bow_tone1: