はじめに
Nuxt.js(以下Nuxt)は、Vue.js(以下Vue)をサーバーサイドで動かす目的以外にも、ディレクトリ構造やVueエコシステムのライブラリがセットになっているため、設計の工数を削減する目的で採用するケースもあると思います!
お中元に迷ったときのヨックモックみたいですね😊
本記事では、Nuxtのディレクトリ構造を土台とし、Vueのコンポーネント設計について、各レイヤー(ディレクトリ)のコンポーネントが担うべき責務をまとめました。
Vue3.0がやってくると、Composition API 導入に伴い設計のベストプラクティスが変化すると思いますが、Vue2系を触ってきた個人的な総括の気持ちで書いています。
Vueコンポーネントの基本とNuxtでの例
Vueコンポーネントは下記図のように、ツリー状にネストし構築されていきます。
※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を用いて作るとコードが少なくなり楽です。
<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>
<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>
<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>
<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>
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 !== ''
}
}
※動作確認してないので、間違った部分あったらごめんなさい😭