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

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

  • 公開日
  • 更新日
  • カテゴリ:Vue.js
  • タグ:JavaScript,Vue,State
Vue.jsアプリケーションの状態管理をシンプルなstoreパターンで実装する

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

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

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

Contents

  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>
<div class="img_wrap"><img :src="privateState.imageData" alt=""></div></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

サンプルページ

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

まとめ

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

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

Author

rito

  • Backend Engineer
  • Tokyo, Japan
  • PHP 5 技術者認定上級試験 認定者
  • 統計検定 3 級