コンポーネント設計の知見
デフォルトのcreate-nuxt-appで作成されるフォルダ構成と特に違いが出る箇所
mock
components
containers
store
types
まずは、ローカルだけで動作確認できるようにmock
を作る
- こちらはローカルで動作確認を行うためのモックの置き場。詳しくはNuxt.js + Expressでローカルサーバ構築
components
(コンポーネントの設計)
- まずコンポーネント責務によって
Presentational Components
とContainer 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
...責務を明確にしてレベルに分けてデザインする。-
Lv1:
Atoms
(原子)←最小の単位 -
Lv2:
Molecules
(分子) -
Lv3:
Organisms
(生体) -
Lv4:
Templates
テンプレート -
Lv5:
Pages
ページ
-
Lv1:
振る舞いの共通化
-
Presentational Components
内でAPI呼び出しのためのstoreへのアクセスをしてしまうとContainer Component
の役割も担うことになってしまいメンテナンスがしにくい。 -
そんな場合は
対象のAPI呼び出し専用のContainer Component
を作成し、それを親コンポーネントとして配置することでイベントとプロパティの受け渡しが可能になる。 -
例)検索用ダイアログで動的に検索処理を走らせたい場面
- ダイアログとしての見た目(Presentational)と検索処理(Container)を分ける
-
こんな時は**画面の描画部分と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
- このように
action
、mutation
、getter
をそれぞれ分割して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の
getter
やmutations
でstateを取得したり書き換えたりすることができます。
一旦以上とします。(2019/10/19)
- まだ参画して3週間なのでその都度更新していきます。
- アーキテクチャ編と書いたものの、他の
編
を書くかは検討中ww