Nuxt.jsで学ぶ、Vue.jsコンポーネント設計の基本

# はじめに
Nuxt.js(以下Nuxt)は、Vue.js(以下Vue)をサーバーサイドで動かす目的以外にも、[ディレクトリ構造](https://ja.nuxtjs.org/guide/directory-structure)やVueエコシステムのライブラリがセットになっているため、設計の工数を削減する目的で採用するケースもあると思います！

お中元に迷ったときのヨックモックみたいですね😊

本記事では、Nuxtのディレクトリ構造を土台とし、Vueのコンポーネント設計について、各レイヤー（ディレクトリ）のコンポーネントが担うべき責務をまとめました。

Vue3.0がやってくると、[Composition API](https://github.com/vuejs/rfcs/pull/42) 導入に伴い設計のベストプラクティスが変化すると思いますが、Vue2系を触ってきた個人的な総括の気持ちで書いています。

# Vueコンポーネントの基本とNuxtでの例
Vueコンポーネントは下記図のように、ツリー状にネストし構築されていきます。
![components.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/198724/17e43dbd-9a9f-4adf-a9a3-26f1ce69b4b7.png)
[※Vue.js公式サイト コンポーネントによる構成より](https://jp.vuejs.org/v2/guide/#%E3%82%B3%E3%83%B3%E3%83%9D%E3%83%BC%E3%83%8D%E3%83%B3%E3%83%88%E3%81%AB%E3%82%88%E3%82%8B%E6%A7%8B%E6%88%90)

DOMもツリーなので、Webエンジニアには馴染みやすい概念ですよね。

Nuxtでは下記図のように、Layout > Page > Optional Component というツリー構造でVueコンポーネントがネストされていきます。
[※参考: Nuxt公式サイトのビュー概要図](https://ja.nuxtjs.org/nuxt-views-schema.svg)

## Vueコンポーネントの責務について
Nuxtでは[asyncDataメソッド](https://ja.nuxtjs.org/guide/async-data#asyncdata-%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89)をVueコンポーネントに実装可能ですが、このメソッドは**Pageコンポーネント以外で利用することはできません**。

Nuxtを使った人は、一度はasyncDataメソッドをPage以外の場所で使おうとしたのではないかと思いますが、なぜこのような作りになっているのでしょう。

asyncDataのようにコンポーネント毎（ツリーの階層ごと）で可能な処理が異なる = **責務が明確化されている** ことで、外部のAPIやStore(VueのデファクトだとVuex)に依存している箇所が明確になる、テスタビリティが向上する、メンテしやすいCSS設計へ貢献するといった効果を見込めます。

そんなNuxtですが、Componentsディレクトリ以下ではコンポーネント設計の指針を設定していません。

すべてのロジックをPageとStoreに詰め込むと、Storeとのやり取りを行うPageコンポーネントが肥大化しやすいため、次章ではStoreのやり取りをComponents以下に持たせる設計の定番を紹介します。

# Presentational and Container パターン
[Reactで提唱されたこちらの名作記事](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)とともに、モダンフロントエンドのコンポーネント分割における最も有名なパターンだと思います。

Redux(Fluxを基にした状態管理ライブラリ)公式サイトでは[次のように言及されています。](https://redux.js.org/basics/usage-with-react#presentational-and-container-components)

|Presentational|Container|
|---|---|
|見た目に関すること|動作に関すること|
|Storeとの疎通禁止|Storeと疎通する|

VueではStoreとしてVuexを採用するケースが多い(Nuxtでは標準)と思いますが、コンポーネント分割はReactなどと同様のルールが採用可能です。

## コンポーネントをネストする規則
[原典](https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0)ではコンポーネントをネストする際のルールはかなり自由です。
Containerの中にContainerもPresentationalも入れてOKですし、反対にPresentationalの中にContainerもPresentationalも入れてOKとされています。
しかし実際の運用では、PresentationalはContainerを読み込めない、といったルールを設けることをオススメします。

[アメブロでの実装例](https://www.atmarkit.co.jp/ait/articles/1702/28/news017_2.html)では、Atomic DesignのOrganismsをContainerとし、MoleculesとAtomsをPresentationalとして設定することで役割をより明確化しています。

※ Atomic Design について
Atomic Design の運用は、通常フロントエンドエンジニアだけでは実現できません。デザイナーの協力が不可欠で、チームごとに適用すべきかどうか異なると思います。

# Nuxt設計例
以上の話をふまえ、Nuxtアプリケーションの設計の具体例を紹介します。

## ディレクトリ構成
```
├── components
│   ├── container // pageごとにcontainerを管理する。このレイヤーではComponentの使いまわしを意識しない
│   │   ├── page1
│   │   ├── page2
│   │   └── shared // Containerを複数のページでimportする場合に用いる
│   └── presentational // Atomic designでの一例。他にはbuttonなどのパーツでディレクトリ切るのも🙆‍♀️
│       ├── molecules
│       └── atoms
├── pages
│   ├── page1
│   └── page2
│       ├── edit.vue
│       └── index.vue
├── layouts
│   ├── default.vue
│   └── error.vue
└── store
    ├── store1.ts
    └── index.ts
```

## 役割早見表
||Storeとの疎通|外部APIとの通信|テストコード|Style|Props/Slot|import可能|
|---|---|---|---|---|---|---|---|
|Container|○|×|なるべく書かない|Presentational配置のみ|Pageに依存する場合のみ|Store, Presentational|
|Presentational|×|×|なるべく書かない|○|○|(molecules)Presentational, (atoms)×|
|Page|△|△|なるべく書かない|ContainerとPresentational配置のみ|×|Store, Container, Presentational|
|Layout|×|×|×|PageとContainerとPresentational配置のみ|×|Page, Container, Presentational|
|Store|-|○|○|-|-|×|
※Componentのテストは難しいので(特にメンテナンス…)、アプリケーションにとって重要なロジックは、テスタビリティを保ちやすいStoreに寄せていくのを強くオススメします。

### StateをPageに持たせるかStoreに持たせるか
NuxtはStoreのAction以外でも、asyncDataのようにComponentから外部APIと通信することを想定されたメソッドが存在しています。

Vue.jsはComponent内にローカルStateを持つことが簡単で、Vuexを用いたFluxパターンとv-modelに代表される双方向データバインディングを共存させることが出来ます。

個人的にはどちらを選ぶかというよりも、アプリケーションにとって重要なStateであるならばStoreに持たせて、そうでないならComponentのローカルStateとして取り扱うといった使い分けが良いと考えています。

※テスタビリティまで考えるとすべてのロジックをStoreに寄せていった方がメンテしやすいです。一方そのようにしてFluxに準拠するならば、Reactを採用すべきでVueを採用するメリットが薄くなります。
TypeScriptとの相性であったりフロントエンドのテスタビリティを含め、より安全なアプリケーションが求められる環境では、Reactの方が適正が高い(結果として工数が少なくなる)と最近は感じます。そのためVue3.0は期待大🙋‍♀️

## おまけ: サンプルコード
コードを読んだ方が理解しやすい方向け。
7ケタの郵便番号を入力すると、次のページへ進めるボタンが押せるページの部分的なサンプルコードを記載しておきます。

**ポイント**
・StoreにcommitしてるのはContainerのみ。Presentationalは必ずPropsで受け取る。
・PageはStoreと疎通しているがgetterで参照してるだけ。この設計はテストしやすくオススメ。
・型定義などは@typesに記述するか、Storeに寄せていきそこからimportして使うことをオススメ。
・今回解説を省略してますが、小さなatomsはSassのmixinsを用いて作るとコードが少なくなり楽です。

```javascript:~/pages/inquiry/edit.vue
<template>
  <main class="edit">
    <TitleIcon class="titleIcon" title="お問い合わせ" icon="inquiry" />
    <UserInput class="buttonLink" />
    <ButtonLink
      class="buttonLink"
      link="/inquiry/confirm"
      text="確認へ進む"
      :disabled="$store.getters['user/isCompleteInput']"
    />
  </main>
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator'

import UserInput from '~/components/container/inquiry/UserInput.vue'
import TitleIcon from '~/components/presentational/atoms/TitleIcon.vue'
import ButtonLink from '~/components/presentational/atoms/ButtonLink.vue'

@Component({
  components: {
    UserInput, TitleIcon, ButtonLink
  }
})
export default class InquiryEdit extends Vue {}
</script>

<style lang="scss" scoped>
.titleIcon {
  margin-bottom: 40px;

  @include isPc() {
    margin-bottom: 60px;
  }
}

.buttonLink {
  margin-bottom: 80px;

  @include isPc() {
    margin-bottom: 120px;
  }
}
</style>
```

```javascript:~/components/container/inquiry/UserInput.vue
<template>
  <div>
    <h2 class="heading">郵便番号</h2>
    <ValidateNumberInput
      :value="postalCode"
      :digit="7"
      placeholder-text="7ケタの郵便番号を入力してください"
      @input="inputCode"
    />
  </div>
</template>

<script lang="ts">
import { Component, Vue } from 'nuxt-property-decorator'

import ValidateNumberInput from '~/components/presentational/molecules/ValidateNumberInput.vue'

// https://github.com/championswimmer/vuex-module-decorators#accessing-modules-with-nuxtjs
import { userStore } from '~/store'

@Component({
  components: {
    ValidateNumberInput
  }
})
export default class UserInput extends Vue {
  public postalCode: string = userStore.postalCode

  public inputCode(v: string, err: string): void {
    this.postalCode = v
    if (err) {
      userStore.setPostalCode('')
      return
    }

    userStore.setPostalCode(v)
  }
}
</script>

<style lang="scss" scoped>
.heading {
  @include heading()
}
</style>
```

```javascript:~/components/presentational/molecules/ValidateNumberInput.vue
<template>
  <div>
    <NumberInput
      v-model="number"
      class="numberInput"
      :icon="icon"
      :is-error="error.length > 0"
      :placeholder-text="placeholder"
    />
    <ErrorMessage :text="error" />
  </div>
</template>

<script lang="ts">
import { Component, Vue, Prop } from 'nuxt-property-decorator'

import NumberInput from '~/components/presentational/atoms/NumberInput.vue'
import ErrorMessage from '~/components/presentational/atoms/ErrorMessage.vue'

@Component({
  components: {
    NumberInput,
    ErrorMessage
  }
})
export default class ValidateNumberInput extends Vue {
  @Prop() digit!: number
  @Prop() value!: string
  @Prop() placeholder!: string

  public error: string = ''

  private validateNumber(v: string | null): boolean {
    if (v === null) return false

    const regexp = new RegExp(`^[0-9]{${this.digit}}$`)
    return regexp.test(v)
  }

  get number(): string {
    return this.value
  }

  set number(v: string): void {
    this.error = ''
    if (!this.validateNumber(v)) {
      this.error = this.errorMessage
    }
    this.$emit('input', v, this.error)
  }

  get errorMessage(): string {
    return `${this.digit}桁の数字を入力してください。`
  }

  get icon(): string | null {
    if (!this.number) return null
    if (this.error !== '') {
      return 'errorIcon'
    }

    return 'okIcon'
  }
}
</script>

<style lang="scss" scoped>
.numberInput {
  margin-bottom: 8px;
}
</style>
```

```javascript:~/components/presentational/atoms/ErrorMessage.vue
<template>
  <transition name="fade">
    <p v-show="text" class="errorMessage">{{ text }}</p>
  </transition>
</template>

<script lang="ts">
import { Component, Vue, Prop } from 'nuxt-property-decorator'

@Component
export default class ErrorMessage extends Vue {
  @Prop() text!: string
}
</script>

<style lang="scss" scoped>
.errorMessage {
  color: $error;
}

.fade-enter-active {
  transition: opacity 0.3s;
}

.fade-enter {
  opacity: 0;
}
</style>
```

```javascript:~/store/user.ts
import { Module, VuexModule, Mutation } from 'vuex-module-decorators'

export interface User {
  postalCode: string
}

@Module({ stateFactory: true, name: 'user', namespaced: true })
export default class extends VuexModule {
  public postalCode: User['postalCode'] = ''

  @Mutation
  public setPostalCode(code: string): void {
    this.postalCode = code
  }

  public get isCompleteInput(): boolean {
    return this.postalCode !== ''
  }
}
```
※動作確認してないので、間違った部分あったらごめんなさい😭