110
99

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

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

Last updated at Posted at 2019-10-21

はじめに

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

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

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

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

Vueコンポーネントの基本とNuxtでの例

Vueコンポーネントは下記図のように、ツリー状にネストし構築されていきます。
components.png
※Vue.js公式サイト コンポーネントによる構成より

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

Nuxtでは下記図のように、Layout > Page > Optional Component というツリー構造でVueコンポーネントがネストされていきます。
※参考: Nuxt公式サイトのビュー概要図

Vueコンポーネントの責務について

NuxtではasyncDataメソッドをVueコンポーネントに実装可能ですが、このメソッドはPageコンポーネント以外で利用することはできません

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

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

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

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

Presentational and Container パターン

Reactで提唱されたこちらの名作記事とともに、モダンフロントエンドのコンポーネント分割における最も有名なパターンだと思います。

Redux(Fluxを基にした状態管理ライブラリ)公式サイトでは次のように言及されています。

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

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

コンポーネントをネストする規則

原典ではコンポーネントをネストする際のルールはかなり自由です。
Containerの中にContainerもPresentationalも入れてOKですし、反対にPresentationalの中にContainerもPresentationalも入れてOKとされています。
しかし実際の運用では、PresentationalはContainerを読み込めない、といったルールを設けることをオススメします。

アメブロでの実装例では、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を用いて作るとコードが少なくなり楽です。

~/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>
~/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>
~/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>
~/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>
~/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 !== ''
  }
}

※動作確認してないので、間違った部分あったらごめんなさい😭

110
99
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
110
99

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?