26
17

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 + typescriptの新規開発案件で得た知見〜アーキテクチャ(Atomic Designとか)~

Last updated at Posted at 2019-10-20

コンポーネント設計の知見

デフォルトのcreate-nuxt-appで作成されるフォルダ構成と特に違いが出る箇所

  • mock
  • components
  • containers
  • store
  • types

まずは、ローカルだけで動作確認できるようにmockを作る

components(コンポーネントの設計)

  • まずコンポーネント責務によってPresentational ComponentsContainer Componentsへ分ける

Presentational Components

  • 見た目に対する部分

Container Components

  • 処理に対する部分

表で表現すると

Presentational Components Container Components
目的 見た目(マークアップ, スタイル) 動作(データの取得、状態の更新)
Store へのアクセス できない できる
データの読込 Props Vuex の State の Getter を呼ぶ
データの変更 Props から取得した Callback を呼ぶ Vuex の Action を Dispatch する

コンポーネントの粒度

Category       Directory State       Storeへのアクセス 責務   
Atoms src/components/atoms 持たない NG Presentational  Component
Molecules src/components/molecules 持つ NG Presentational  Component
Organisms src/components/organisms 持つ NG Presentational  Component
Templates src/layouts 持つ OK Container
Component
Pages src/pages 持つ OK Container
Component
  • ここで使用している設計技術としてAtomic Designというものがある。

  • Atomic Design...責務を明確にしてレベルに分けてデザインする。

    • Lv1Atoms(原子)←最小の単位
    • Lv2Molecules(分子)
    • Lv3Organisms(生体)
    • Lv4Templatesテンプレート
    • Lv5Pagesページ
  • 参考:Atomic Design を分かったつもりになる

振る舞いの共通化

  • Presentational Components内でAPI呼び出しのためのstoreへのアクセスをしてしまうとContainer Componentの役割も担うことになってしまいメンテナンスがしにくい。

  • そんな場合は対象のAPI呼び出し専用のContainer Componentを作成し、それを親コンポーネントとして配置することでイベントとプロパティの受け渡しが可能になる。

  • 例)検索用ダイアログで動的に検索処理を走らせたい場面

    • ダイアログとしての見た目(Presentational)と検索処理(Container)を分ける
  • こんなことがしたい時があると思います
    検索ダイアログ.gif

  • こんな時は**画面の描画部分とAPIコール(stateへのset)部分を分ける。**

APIコール部分のContainer コンポーネントを用意

src/containers/search-container.vue

<template>
  <div>
    <slot :search="search" :result="result"></slot>  //①
  </div>
</template>

<script lang="ts">
import { Component, Getter, Vue } from 'nuxt-property-decorator'
import { ClientFilter } from '~/store/domain/clients/actions'
import { Client, PagedResources, PageRequest } from '~/types'

@Component({})
export default class Search extends Vue {
  @Getter('domain/clients/search') result: PagedResources<Client[]> //②

  async search(filter: ClientFilter, pageRequest: PageRequest) {
    await this.$store.dispatch('domain/clients/search', { //③
      filter,
      pageRequest
    })
  }
}
</script>

  • ①. 要素を使用して子コンポーネントにイベントとプロパティを受け渡しができるようにする
  • ②.検索結果を取得するgetter
  • ③.検索 API を呼び出すためのアクションをディスパッチする

ダイアログコンポーネント(Presentational)

  • こちらは見た目の部分なので検索イベントを Emit するだけ、結果は Prop から受け取るようにする。
src/components/organisms/dialog.vue
<template>
  <el-dialog
    title="得意先選択"
    :visible.sync="visible"
    width="50%"
    :before-close="handleClose"
  >
    <client-search-form
      :query="query"
      @search="search"
    ></client-search-form>
    <client-list
      v-if="resources"
      :clients="resources._embedded.results"
      :page-metadata="resources.page"
      :selectable="true"
      @select="handleSelect"
      @current-change="handlePageChange"
      @size-change="handleSizeChange"
    />
    <span slot="footer" class="dialog-footer">
      <el-button @click="hide">閉じる</el-button>
    </span>
  </el-dialog>
</template>

<script lang="ts">
import { Component, Prop, Vue, Watch } from 'nuxt-property-decorator'
import { ComponentOptions } from 'vue'
import ClientSearchForm from '~/components/organisms/client-search-form/client-search-form.vue'
import ClientList from '~/components/organisms/client-list/client-list.vue'
import { ClientFilter } from '~/store/domain/clients/actions'
import { Client, PagedResources, PageRequest } from '~/types'

@Component({
  components: {
    ClientSearchForm,
    ClientList
  }
} as ComponentOptions<Vue>)
export default class ClientSelectDialog extends Vue {
  /**
   * 表示フラグ
   */
  @Prop()
  visible

  /**
   * 検索結果
   */
  @Prop()
  result: PagedResources<Client[]> //①

  resources = this.result

  query = {
    keyword: ''
  } as ClientFilter

  page = 0
  size = 10

  search(form: ClientFilter) {
    /**
     * 検索イベント
     * @type {object}
     */
    // eslint-disable-next-line
    this.$emit('search', form, { //②
      page: 0
    } as PageRequest)
  }

  hide() {
    this.query = {
      keyword: ''
    } as ClientFilter
    /**
     * 表示切り替えイベント
     * @type {object}
     */
    this.$emit('update:visible', false)
  }

  handleSelect(client: Client) {
    this.hide()
    /**
     * 選択イベント
     * @type {object}
     */
    this.$emit('select', client)
  }

  handlePageChange(page: number) {
    this.page = page - 1
    this.$emit('search', this.query, {
      page: this.page,
      size: this.size
    } as PageRequest)
  }

  handleSizeChange(size: number) {
    this.size = size
    this.$emit('search', this.query, {
      page: this.page,
      size: this.size
    } as PageRequest)
  }

  @Watch('visible')
  handleVisible() {
    this.resources = null
  }

  @Watch('result')
  handleResultChange() {
    this.resources = this.result
  }

  handleClose() {
    this.hide()
  }
}
</script>
  • ①.検索結果がここに入ってくる
  • ②.Containerへ検索APIのコールをしている(正しくはお父さん、検索APIをコールしてねというメソッド)

使いたい場所へ配置する

  • 検索ダイアログを配置するとしたら何かのフォーム部品とかになるかと思うのでそんなイメージ
  • コンテナでラップする
  • v-slot で定義した名前を利用して親コンポーネントからスロットプパティを受け取る
src/components/organisms/なんとかform.vue
<search-container v-slot="scope">
  
  <dialog
    :visible.sync="clientSelectDialogVisible"
    :result="scope.result"
    @search="scope.search"
    @select="handleClientSelect"
  ></dialog>
</search-container>

storeの構成

  • 今回はバックエンドが静的型付け言語だったのでtypescriptを使用している
  • storeにフォーカスしてフォルダ構成を載せてみる
├─store
│  │  ├─各store//それぞれの責務でファイルを分ける
│  │  │    └─action.ts
│  │  │    └─mutation.ts
│  │  │    └─index.ts
│  │  │    └─getter.ts
│  │  ├─modules
│  │  └─index.ts //まとめてexport

  • このようにactionmutationgetterをそれぞれ分割してindex.tsでまとめてexportしている
  • 中身はそれぞれ以下のようにしている(ソースコード自体はVueやNuxtの経験者の方ならご理解頂けると思うので解説はしていません)
  • 登場するRootState,PageRequestはtypes部分で紹介
各モジュール/actions.ts
import { ActionTree } from 'vuex'
import { hogeTransfer } from '~/types/hoge'
import { hogeState } from '~/types/hoge/state'
import { RootState } from '~/types/state'
import '@nuxtjs/axios'
import { PageRequest } from '~/types/util'

//初期表示情報
const actions: ActionTree<hogeState, RootState> = {
  async describe({ commit }) {
    const response = await this.$axios.$post<hogeTransfer>(
      `/hoge/describe-myorg`
    )
    commit('myorg', { myorg: response })
  }
}

export default actions

各モジュール/mutations.ts
import { MutationTree } from 'vuex'
import { hogeState } from '~/types/hoge/state'

const mutations: MutationTree<hogeState> = {
  myorg(state, { myorg }) {
    state.myorg = myorg
  }
}

export default mutations

各モジュール/getters.ts
import { GetterTree } from 'vuex'
import { hogeState } from '~/types/hoge/state'
import { RootState } from '~/types/state'

const getters: GetterTree<hogeState, RootState> = {
  myorg: state => state.myorg,
}

export default getters

各モジュール/index.ts
import { Module } from 'vuex'
import getters from './getters'
import actions from './actions'
import mutations from './mutations'
import { hogeState } from '~/types/hoge/state'
import { RootState } from '~/types/state'

const state: hogeState = {
  myorg: null,
}

const hoge: Module<hogeState, RootState> = {
  namespaced: true,
  state,
  getters,
  actions,
  mutations
}

export default hoge

  • あとは大元のindex.tsでmodulesに設定
index.ts
import Vuex, { StoreOptions } from 'vuex'
import { RootState } from '~/types/state'
import hoge from '~/store/hoge'

const storeOptions: StoreOptions<RootState> = {
  modules: {
    hoge
  }
}

const store = () => new Vuex.Store<RootState>(storeOptions)
export default store

  • こうすることでhoge.$storeでstoreにアクセス可能になる

types

  • storeの時と同じようにフォルダ構成をおさらい
│  └─types
│      ├─各types
│      └─util
  • util内は以下のようになってます
.
├── AggregateRoot.ts
├── PageMetadata.ts
├── PageRequest.ts
├── PagedResources.ts
└── index.ts

  • こちらはバージョンはページ情報など主にメタデータを管理しています。

  • こちらのソースコードは先頭にも掲載していますがgithub:nuxt-typscriptで記載

  • あとはtypes直下へ機能(コンポーネント)ごとにフォルダを切って扱うオブジェクト単位で型を定義。

  • 例としてマイページで使用する自分の所属する組織情報の型をMyOrgTypeとして定義してみると。。。

types/mypage/MyOrgType.ts
import { AggregateRoot } from '~/types/util'

export interface MyOrgType extends AggregateRoot {
  header: string
  name: string
  id: string
  position: string
  items: string[]
}

  • index.tsを作成します。
types/mypage/index.ts
import { MyOrgType } from './MyOrgType'

export {
  MyOrgTransfer,
  //扱うオブジェクトが増えるたびに書き足していく
}

  • state.tsを作成します
types/mypage/state.ts
import {
  MyOrgType,
} from '~/types/mypage'

export interface DomainData {
  mypage: MypageState
}

export interface MypageState {
  myorg: MyOrgTransfer
  //何かの履歴を表示するコンポーネント用の型とかが入ってくる想定
}

  • こうすることでstoreのgettermutationsでstateを取得したり書き換えたりすることができます。

一旦以上とします。(2019/10/19)

  • まだ参画して3週間なのでその都度更新していきます。
  • アーキテクチャ編と書いたものの、他のを書くかは検討中ww
26
17
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
26
17

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?