ハードルを下げ続けるブログ@task

常に低い意識で投稿していきたいエンジニアのブログ。当ブログの内容は、個人の見解であり所属する組織の公式見解ではありませんよ。

Nuxt + Composition-API のコンポーネント設計について考えたことなど

急にブログを書く気になったのでいっぱい更新してます。

前回の記事で Nuxt に Composition-API を導入する方法を書きました。

task-kawahara.hatenablog.com

今回は、半年間 Composition-API をつかってきて、どのように コンポーネント設計を行ってきたのかについての記事になります。

Motivation

  • Vuex store での Global state 管理をやめたい
  • UIとビジネスロジックを切り離して柔軟に設計したい
  • Clean Architecture を読んだから実践したい

まずは、Vue/Nuxt を使った比較的大規模な開発で辛いところをおさらいします。

Vuex 辛い問題

Vuex がよく辛いと言われますが、僕も辛いと思います。

巨大なグローバル変数になってしまうことが避けられず、どこからでもアクセスできてしまうのが割と悪いと感じてしまいます。

設計の問題が大きいと思いますが、何でもVuexしまっちゃいがちで、一つの名前空間のStoreに複数機能が入り乱れることがよく起こっていました。

よく考えると、ページをまたがって管理したい state はそんなに多くないんですよね。

型情報がうまく取れなくて辛いという問題もあるのですが、そこは Vuex4.0 でサポートされるみたいです。

ただし、現状SSR でページを跨ぐ状態管理は Vuex を使うのが無難。

Atomic Design よくわからん問題

Atomic Designコンポーネントを設計する上で素晴らしい概念ですが、よくよく考えないと見た目だけでコンポーネントを分割しがちです。

また、atoms, molecules, organisms の解釈が人によってブレがある気がします。

ルール化することが大事。

次は UI と ビジネスロジックを分離する方法を考えます。

Clean Architecture のエッセンス

  • 境界づけられたコンテキスト
  • Repository, UseCase, UI で分割
  • 依存関係逆転の原則

リソースにアクセスする Repositoryビジネスロジックを内包し、状態管理する StoreUI添えるだけ 見た目の表現に集中できるように分割したい。

f:id:task_kawahara:20201228160913p:plain
Repository, Store, UI

多分こんな感じ。

コンポーネント設計の方針

以上のことをまとめて、

  • Vuex はグローバルで扱いたいもの以外には使わず、Composition-API で Store パターンを実装する
  • Repository を定義する
  • Atomic Design のルールを定義する
    • Atoms これ以上分割できない最小単位
    • Molecules
      • オブジェクトとオブジェクトの持つ動作
      • データのやり取りは Props を介する
    • Organisms
      • 意味のある塊であり、使い回さないことも多い
      • provide/inject を使った変数や関数のやり取りを行う
    • Pages
      • Page をまたがないモジュールはここで provide する

ディレクトリ構成

root
┣ assets
┣ components
    ┣ (atoms)
    ┣ molecules
    ┗ organisms
┣ composables*
    ┣ repositories
    ┣ stores
    ┗ utils
        ┣ filters
        ...
┣ layouts
┣ middlewares
┣ plugins
┣ pages
...

ベーシックな Nuxt のファイル構成に、Compositon-API の 関数をまとめる /composablesディレクトリを追加しています。

composables の直下に、/storesrepositories を用意し、それぞれ Store と Repository を置きます。 その他、コンポーネントで使うツールみたいなもの(誤解を恐れずに言えば mixin の代わりになるもの)は /utils の下に置くことにしました。

Composables

以下は composables の例

~/composables/stores/ContentStore.ts

export interface ContentDataInterface {
  id: number
  detail: string
  title: string
}

export interface ContentStoreActionInterface {
  fetch: (id)=> Promise<ContentDataInterface>,
  create: (payload)=> Promise<ContentDataInterface>,
}
export const ContentRepositoryKey: InjectKey<ContentStoreActionInterface> = Symbol('ContentRepository')

export const useContentStore = () => {
  const repository: ContentStoreActionInterface | undefined = inject(ContentRepositoryKey)
  if (!repository) {
    throw new Error(`${ContentRepositoryKey} is not provided`)
  }
  ...
}

~/conposables/repositories/ContentRepositroy.ts

import { HttpKey } from './common

export const useContentRepository = (): ContentStoreActionInterface => ({
  const $http = inject(HttpKey)
  const fetch = (id) => $http.get('/contents/${id}')
  const create = (payload) => $http.post('/contents')
  return { fetch, create }
})

~/pages/index.vue

<script lang="ts">
import { defineCompoenent, provide } from '@nuxtjs/composition-api' 
import { useContentStore, ContentRepositoryKey } from '~/composables/stores/ContentStore'
import { useContentRepository } from '~/composables/repositories/ContentRepository'

export default defineCompoenent({
  setup () {
    provide(ContentRepositoryKey, useContentRepository())
    const store = useContentStore()
    ...
  }
})
</script>

Components

┣ components
    ┣ (atoms)
    ┣ molecules
        ┣ buttons
        ┣ forms
        ...
    ┗ organisms
        ┣ ContentEdit.vue
        ...

molecules はパーツごとに分類しています。organisms は一箇所でしか使わないパターンも多いので、必要になるまでは平置きでもいいかなという感じです。

個人的に、atoms と modules はどちらに分類するのか迷いがちなので、atoms は用意しませんでした。

molecules ↔ organisms は props でデータのやり取りをします。

organisms は InjectKey を定義して、pages から必要な変数や関数を provide() します。

このようにUIのレイヤーを明確にすることで、不毛なPropsリレーを避けるようにしています。

以下はコンポーネントの例です

~/components/molecules/forms/ValidationInput.vue

<template>
  <span class="input">
    <input v-model="data" />
    <small v-if="isInvalid">エラー</small>
  </span>
</template>

<script lang="ts">
import { defineCompoenent, PropType, computed } from '@nuxtjs/composition-api' 


export default defineCompoenent({
  props: {
    value: {
      type: String,
      required: true
    },
    validator: {
      type: Function as PropType<(str: string)=> Boolean>
      default: (str: string) => !!str
    }
  },
  setup (props, { emit }) {
    const data = computed({
      get: () => props.value,
      set: (v) => emit('input', v)
    })
    const isInvalid = computed(() => props.validator(props.value))
    return {
      data,
      isInvalid
    }
  }
})
</script>

~/component/organisms/ContentEditForm.vue

<template>
  <form>
    <ValidationInput v-model="content.title" />
    ...
  </form>
</template>

<script lang="ts">
import { defineCompoenent, InjectKey, inject } from '@nuxtjs/composition-api' 
import ValidationInput from '~/components/molecules/forms/ValidationInput.vue'

interface ContentInterface {
  title: string
  detail: string
} 

interface ContentEditInterface {
  content: ContentInterface
  fetch: ()=> Promise<void>
  create: ()=> Promise<void>
}

export const ContentEditKey: InjectKey<ContentEditInterface> = inject('ContentEdit')

export default defineCompoenent({
  components: {
    ValidationInput
  },
  setup() {
    const injected = inject(ContentRepositoryKey)
    if (!injected) {
      throw new Error(`${ContentRepositoryKey} is not provided`)
    }
    const { content, fetch, create } = injected

    ...

    return {
      content,
      ...
    }
  }
})
</script>

~/pages/index.vue

<script lang="ts">
import { defineCompoenent, provide } from '@nuxtjs/composition-api' 
import { useContentStore, ContentRepositoryKey } from '~/composables/stores/ContentStore'
import { useContentRepository } from '~/composables/repositories/ContentRepository'
import ContentEditForm, { ContentEditKey } from '~/components/organisms/ValidationInput.vue'

export default defineCompoenent({
  components: {
    ContentEditForm
  },
  setup () {
    provide(ContentRepositoryKey, useContentRepository())
    const store = useContentStore()
    provide(ContentEditKey, store)
    ...
  }
})
</script>

あとがき

Vue 3.0 (Composition-API) のリリースによって、Vue/Nuxt を使ったフロントエンド開発の幅が広がりました。

それ故に、コンポーネント間のデータの受け渡し一つ取っても複数の書き方が存在し、迷うことがより多くなった印象です。設計する側に求められるレベルが一段上がった感じがしています。

この記事がベストというわけではありませんが、Composition-API を使っていきたいけどハードルが高いと思っている方の助けに少しでもなれば幸いです。

書いてて思ったのですが、Clean Architecture に関してはすごいなんちゃって感ありますね。。。

気持ち真似したくらいに思って貰えると幸いです。。。