Posted at

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


はじめに

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

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

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

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

※SSR(BFF)とライフサイクルをテーマに、もう一つ記事を書くつもりでいます。


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


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