Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
78
Help us understand the problem. What is going on with this article?
@suzu-4

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

More than 1 year has passed since last update.

はじめに

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

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

78
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
suzu-4
好きなYouTuberは川口春奈です。

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
78
Help us understand the problem. What is going on with this article?