RitoLabo

Vue.jsアプリケーションの状態管理をシンプルなstoreパターンで実装する

  • 公開:
  • 更新:
  • カテゴリ: JavaScript Vue.js
  • タグ: JavaScript,Vue,State

Vue.jsは、構築されたアプリケーションの規模や複雑度などによって段階的にライブラリを導入して行くことのできるプログレッシブフレームワークです。

Vuex/VueRouterを始め、全ての機能を必ず使わなくてはいけないわけではなく、本当に必要になった時に導入する事でその恩恵を十分に受けることが出来ます。

今回は、シンプルなVue.jsアプリケーションの状態管理を簡単なstoreパターンで行います。

アジェンダ
  1. 開発環境
  2. 状態管理とstoreパターン
  3. Store
  4. State(状態)の集約
  5. Action
  6. 子コンポーネント側の実装

開発環境

今回の開発環境は以下の通りです。

  • Vue.js 2.x
  • Vue CLI 3.x
  • Node.js 12.x
  • npm 6.5

SFC(単一ファイルコンポーネント)を単体で動かすので、 インスタントプロトタイピングの為のグローバルアドオン「cli-service-global」 がインストール済みである必要があります。

Vue CLIを使った開発環境構築の詳細については以下からも確認できます
Vue CLIでVue.jsの開発環境をサクッと構築する

尚、今回は、状態管理がメインテーマなので、一定のコンポーネントは作成済みの状態からスタートします。
前回の記事Vue.jsで画像やファイルの入力フォームを実装するで作成したものに実装を行っていきますので、 ソースコード、または使用するコンポーネントの中身について知りたい場合はそちらを確認してください。

状態管理とstoreパターン

Vue.jsでの状態管理といえばVuexですが、今回はそれを使わずに状態管理を実装しましょうというお話です。

シンプルなVue.jsアプリケーションの場合、Vuexだと実装コストや負債が高い(無駄に良いものを使ったが為に無駄に構造やコードが複雑になってメンテナンスや拡張が億劫になる)場合があります。

そこで登場するのが”単純な”storeパターンです。

これは公式リファレンスでも紹介されている手法で、Vuexに行く前の、もう少しシンプルな状態管理を示してくれているものですが、簡単なアプリケーションに適用するには最適です。

状態管理
https://jp.vuejs.org/v2/guide/state-management.html

今回は、このstoreパターンを用いて状態管理を実装していきますが、前回の記事で作成した、「ファイル入力フォームパーツ」と「画像ファイルの入力フォームパーツ」の2つを持つフォーム画面について状態管理を行っていきます。 要は「問い合わせ画面だけ」とか、「ファイルアップロードだけ」みたいなシンプルなVue.jsアプリケーションの状態管理を行う。みたいなノリです。 それだけだったらVuexに頼るまでもないので、よりシンプルなstoreパターンで状態管理していきます。

Store

まずは状態管理を行うstoreを作成します。

/store.js
export default {
state: {
file: {
data: false,
required: false
},
fileImage: {
data: false,
required: false
}
}
}

状態を保持するstateプロパティを作成し、その中に今回の2つのフォームから受け取ったデータを保持していく流れになります。 requiredプロパティは、2つのフォームがそれぞれ入力必須かそうでないかを保持する為のプロパティです。

この状態をミニマムとして、これから機能を付与していきます。

State(状態)の集約

まずは、2つのコンポーネントの共通部分をMixinとして切り出してある FileInputable.jsのdataオプションを見てみてください。

/FormParts/Mixins/FileInputable.js
data () {
return {
file: {},
error: '',
inArea: false
}
}

フォームパーツ単位で分けた2つのコンポーネントですが、状態管理の事には触れずに作成したので、 ここで入力されたファイルのデータを保持する状態になっています。

実はこの状態はけっこう死亡フラグで、そのまま実装を進めると状態管理が複雑になってしまいます。

というのも、お問い合わせ画面然り、登録画面然りですが、フォームって、フォームパーツが集まって1つのフォーム(というか機能)が成立しているものです。

つまりは、フォームパーツ単位で1つのコンポーネントとして分けた場合は、必ず親コンポーネントが存在してそれらを まとめている流れになります。(それぞれのinputタグをformタグがまとめているイメージ)

そうなるとどういう事が起こるかと言うと、子コンポーネントがそれぞれフォームから入力された値やデータを 保持している状態になり、肝心の親コンポーネントが入力データを持っていないという状況が生まれます。 (フォームを送信する時って、inputたちがそれぞれサーバーへ送信するのではなくてformさんが入力値を1つにまとめて投げるよね。というイメージ)

こうなるとスーパー面倒で、じゃあ送信ボタン押下したらどうやって各子コンポーネントに点在している入力データを かき集めてきますか?みたいな事になります。

子に入力値がセットされたら親にもそれを渡して...と、データフローが一方向ではなくなるし、処理も増え複雑になるしと、管理する前から状態管理の崩壊です。

また、「全ての入力が出来ていたら送信ボタンをアクティブにする」みたいな事をやりたい場合にも、一筋縄ではいかなくなります。 (つまりはアンチパターンなわけです)

こういった事にならないように「状態管理」という言葉が存在しているわけですね、と身を持って体験できました。

Vuexも今回扱う簡単なstoreパターンも、データフローを一方向にして状態管理をシンプルにする事が目的です。

という事でまずは、集約したいデータ(入力値)とコンポーネントだけで保持していて良いもの(そのコンポーネントでしか使わないようなもの)を分けます。 先程作成したstoreを利用して、dataオプションを以下に変更します。

/FormParts/Mixins/FileInputable.js
import { FileEvaluable } from './FileEvaluable'

// 追加
import store from '../../store'

export const FileInputable = {
mixins: [ FileEvaluable ],

data () {

// return {
// file: {},
// error: '',
// inArea: false
// }

// ↓ 変更

return {
privateState: {
name: '',
error: '',
inArea: false,
},
sharedState: store.state
}
},

読んで字の如くですが、sharedStateに集約するstore-stateをセットし、 privateStateにそのコンポーネントでしか使わないプロパティをセットするようにします。 (ここは公式リファレンスでの解説通りです)

見てもらうとわかる通り、fileプロパティは子コンポーネントから消え去り、これからはstoreで管理していきます。

Action

次に、ファイルが選択された時に入力をstoreへセットしますが、 データフローを一方向にするために必ず守らなければいけない約束があります。

「storeの変更(action)は必ずstore内で行う」

つまり子コンポーネントでは直接storeを変更しない。という事です。

という事で、ファイルセットなどの処理はstore内に定義し、子コンポーネントはそこへ入力値を渡す。 という流れで実装していきます。

/store.js
export default {
debug: process.env.NODE_ENV !== 'production',
state: {
file: {
data: false,
required: false
},
fileImage: {
data: false,
required: false
}
},
setFileAction (file) {
if (this.debug) console.log('setFileAction triggered.')
this.state.file.data = file
}

}

ファイルの入力データをセットするメソッドを定義しました。 子コンポーネントでは、こうしてstoreで定義されたアクションを用いてデータを操作していく流れになります。

中でconsole.log()が記述されていますが、状態変化の経過を把握する目的で公式リファレンスに紹介されているので そのまま採用しています。(流れを確認しやすいので玄人でなければおすすめです)

もしESLintに怒られてあれなら、行端で以下コメントを記述すると泣き止みます。

console.log('setFileRequiredAction triggered with', bool) // eslint-disable-line no-console

子コンポーネント側の実装

この流れで子コンポーネント側も実装していきますが、あとはstoreで定義したメソッドに置き換えていくだけです。例えば

/FormParts/Mixins/FileInputable.js
changeFile (e) {
const files = e.target.files || e.dataTransfer.files

// if (this.validation(files[0])) {
// this.file = files[0]
// } else {
// this.file = {}
// }

// ↓ 変更

if (this.validation(files[0])) {
store.setParamAction(files[0], this.privateState.name)
} else {
store.removeParamAction(this.privateState.name)
}
},

これまでは自身に格納していた入力値を、storeで用意したactionを用いて入力値をstoreへ送っています。

このような流れで実装を進めていくと、2つの子コンポーネントの状態管理がとてもシンプルになります。

以下、シンプルなstoreパターンで実装を行い前回から変更したファイルを以下に示します。

/store.js
export default {
debug: process.env.NODE_ENV !== 'production',
state: {
file: {
data: false,
required: false
},
fileImage: {
data: false,
required: false
}
},
setRequiredAction (bool, type) {
switch (type) {
case 'file': { this._setFileRequiredAction(bool); break }
case 'image': { this._setFileImageRequiredAction(bool); break }
default: { break }
}
},
setParamAction (file, type) {
switch (type) {
case 'file': { this._setFileAction(file); break }
case 'image': { this._setFileImageAction(file); break }
default: { break }
}
},
removeParamAction (type) {
switch (type) {
case 'file': { this._removeFileAction(); break }
case 'image': { this._removeFileImageAction(); break }
default: { break }
}
},
_setFileRequiredAction (bool) {
if (this.debug) console.log('setFileRequiredAction triggered with', bool)
this.state.file.required = bool
},
_setFileImageRequiredAction (bool) {
if (this.debug) console.log('setFileImageRequiredAction triggered with', bool)
this.state.fileImage.required = bool
},
_setFileAction (file) {
if (this.debug) console.log('setFileAction triggered.')
this.state.file.data = file
},
_setFileImageAction (file) {
if (this.debug) console.log('setFileImageAction triggered.')
this.state.fileImage.data = file
},
_removeFileAction () {
if (this.debug) console.log('removeFileAction triggered.')
this.state.file.data = false
},
_removeFileImageAction () {
if (this.debug) console.log('removeFileImageAction triggered.')
this.state.fileImage.data = false
},
destroyParamAction () {
if (this.debug) console.log('destroyParamAction triggered.')
this._removeFileAction()
this._removeFileImageAction()
}
}
/FormParts/Mixins/FileInputable.js
import { FileEvaluable } from './FileEvaluable'
import store from '../../store'

export const FileInputable = {
mixins: [ FileEvaluable ],

data () {
return {
privateState: {
name: '',
error: '',
inArea: false,
},
sharedState: store.state
}
},

mounted () {
this.privateState.name = this.params.name
const isRequired = typeof this.params.required === 'boolean' ? this.params.required : false
store.setRequiredAction(isRequired, this.privateState.name)
},

computed: {
isError () {
return !!this.privateState.error !== ''
},
classesDragArea () {
return {
'drag_on': this.privateState.inArea
}
}
},

methods: {
dropFile(e) {
this.changeFile(e)
this.offArea()
},
changeFile (e) {
const files = e.target.files || e.dataTransfer.files

if (this.validation(files[0])) {
store.setParamAction(files[0], this.privateState.name)
} else {
store.removeParamAction(this.privateState.name)
}
this.$emit('check-ready')
},
resetFile () {
const input = this.$refs.file;
input.type = 'text'
input.type = 'file'
store.removeParamAction(this.privateState.name)
this.privateState.error = ''
this.$emit('check-ready')
},
validation (file) {
if (!this.isAllowFileType(file.type)) {
this.privateState.error = this.getErrorMessageType()
return false
}

if (!this.isAllowFileSize(file.size)) {
this.privateState.error = this.getErrorMessageSize()
return false
}

this.privateState.error = ''
return true
},
onArea () { this.privateState.inArea = true },
offArea () { this.privateState.inArea = false },
}
}
/FormParts/FileInput.vue
<template>
<div>
<div class="drop_area" :class="classesDragArea" @dragover.prevent="onArea" @drop.prevent="dropFile" @dragleave.prevent="offArea" @dragend.prevent="offArea">
<label>ファイルを選択
<input @change="changeFile" ref="file" type="file">
</label>
</div>
<p><span id="file_name" v-show="sharedState.file.data.name">{{ sharedState.file.data.name }} <span class="reset_file_ico" @click="resetFile">×</span></span></p>
<p id="error" v-show="privateState.error">{{ privateState.error }}</p>
</div>
</template>

<script>
import { FileInputable } from './Mixins/FileInputable'

export default {
name: "FileInput",
mixins: [ FileInputable ],
}
</script>
/FormParts/FileInputImage.vue
<template>
<div>
<div class="drop_area" :class="classesDragArea" @dragover.prevent="onArea" @drop.prevent="dropFile" @dragleave.prevent="offArea" @dragend.prevent="offArea">
<label>ファイルを選択
<input @change="changeFile" ref="file" type="file">
</label>
</div>
<p><span id="file_name" v-show="sharedState.fileImage.data.name">{{ sharedState.fileImage.data.name }} <span class="reset_file_ico" @click="resetFile">×</span></span></p>
<p id="error" v-show="privateState.error">{{ privateState.error }}</p>
<p v-if="isPreview"><img :src="privateState.imageData" alt=""></p>
</div>
</template>

<script>
import { FileInputable } from './Mixins/FileInputable'
import { ImageCreatable } from './Mixins/ImageCreatable'

export default {
name: "FileInputImage",
props: ['params'],
mixins:[ FileInputable, ImageCreatable ],

data () {
return {
privateState: {
preview: true
},
}
},

mounted () {
const preview = this.params.preview;
this.privateState.preview = (typeof preview === 'boolean') ? preview : true
},

updated () {
const file = this.sharedState.fileImage.data

if (typeof file.size === 'undefined') {
this.privateState.imageData = ''
return
}

if (this.isImage(file.type)) {
this.createImage(file)
}
},

computed: {
isPreview () {
return this.privateState.imageData !== '' && this.privateState.preview
}
},
}
</script>
/FormSample.vue
<template>
<form @submit.prevent="send">
<file-input @check-ready="isReady" :params="{ name: 'file', limit: 100, unit: 'kb', allow: 'csv', required: true }"></file-input>
<file-input-image @check-ready="isReady" :params="{ name: 'image', limit: 100, unit: 'kb', allow: 'png,jpg,gif', preview: true, required: true }"></file-input-image>
<button :disabled="privateState.disabled" type="submit">send</button>
</form>
</template>

<script>
import store from './store'

import FileInput from './FormParts/FileInput.vue'
import FileInputImage from './FormParts/FileInputImage.vue'

export default {
name: "FormSample",
components: {
FileInput,
FileInputImage
},

data () {
return {
privateState: {
disabled: true,
},
sharedState: store.state
}
},

methods: {
isReady () {
let status = []
const keys = Object.keys(this.sharedState)
for (let i = 0; i < keys.length; i++) {
let param = this.sharedState[keys[i]]
status.push(param.required === true && param.data === false)
}
this.privateState.disabled = !status.every(function(bool){
return bool === false
})
},
send () {
if (!this.privateState.disabled) {
console.log({
file: this.sharedState.file.data,
image: this.sharedState.fileImage.data
})
}
}
}
}
</script>

<style>
label {
font-size: 12px;
padding: 2px 3px;
}
label:hover {
cursor: pointer;
}
label input {
display: none;
}
#file_name {
font-size: 14px;
margin-left: 20px;
}
.reset_file_ico {
padding: 0 4px;
font-size: 12px;
border: 1px solid #c6c6c6;
border-radius: 10px;
}
.reset_file_ico:hover {
cursor: pointer;
border-color: #5f6674;
}
#error {
color: #d70035;
}
.drop_area {
width: 200px;
padding: 10px;
text-align: center;
border: 1px dashed #c6c6c6;
background-color: #f9f9f9;
}
.drag_on {
border: 2px dashed #bcbcbc;
background-color: #fafdff;
}
</style>

一部、入力必須の設定や送信ボタンとその周辺の処理は追加していますが、 主にやっている事は1つで、入力値の操作をstoreに集約させているだけです。

では実際に動かして動作確認を行います。以下のvueコマンドを叩いてブラウザから確認します。

vue serve FormSample.vue --open

storeパターンによる状態管理・動作確認

サンプルページ

一方向のデータフローでシンプルに送信処理の手前までを実装できました。

まとめ

以上で作業は完了です。
状態管理、データフローを意識して構築していく事はVue.jsアプリケーションには必要不可欠な要素です。 ただしVuexだけが状態管理ではなくて、アプリケーションの規模によってはこういった単純なstoreパターンのようにシンプルに実装してよいと思います。

そしてこの事を最もシンプルに説明しているのはやはり公式リファレンスなので、状態管理のセクションを是非読んでみてください。
サンプルコード