最近Vuexを使う必要が出てきたので公式ドキュメントなどを読んでいたのだが、習うより慣れろということでVuexを利用した簡単なアプリケーションをいくつか作ってみた。
今回は、二次関数の実数解を計算する機能solveEquation
、カウントダウンタイマーtimer
、天気予報を取得する機能weatherForecast
の3つのサンプルを作成したので、Vuexの具体的な適用方法などを含めてまとめておく。
なお、このサンプルではComposition APIを利用している。
前編でsolveEquation
とtimer
、後編でweatherForecast
を紹介する。
以降の説明は基本的にVuexに関わる部分だけを抽出して書いている。
ソースコード全体
デモページ
Vuex公式ガイド
ディレクトリ構成
└── src ├── App.vue ├── assets │ ├── json │ │ └── city_id_data.json │ └── pic │ └── loading.gif ├── components │ ├── solveEquation │ │ ├── Input.vue │ │ ├── Result.vue │ │ └── SolveEquationContainer.vue │ ├── timer │ │ ├── Buttons.vue │ │ ├── Display.vue │ │ └── TimerContainer.vue │ └── weatherForecast │ ├── AreaSelect.vue │ ├── WeatherForecastContainer.vue │ └── WeatherTable.vue ├── main.js └── store ├── index.js └── modules ├── solveEquation.js ├── timer.js └── weather.js
Vueでアプリケーションの概形を作る
vue-cliでプロジェクトを新規作成する。
vue create
コマンドを実行してManually select features
を選択し、
Check the features needed for your project:
でVuex
を選択すれば自動で導入してくれる。
Vuexの導入時に作成されるstore
ディレクトリ内に入っているindex.js
の初期状態は下記のようになっている。
import { createStore } from 'vuex' export default createStore({ state: { }, mutations: { }, actions: { }, modules: { } })
通常はこのindex.js
に直接state
やmutations
などを記述してアプリケーション全体の状態管理を行うことになる。
ただ、今回は3機能の状態管理をそれぞれモジュール化して個別のファイルで行うことにする。
そこで、store
ディレクトリにmodules
というディレクトリを作成し、その中にsolveEquation.js
、timer.js
、weather.js
という状態管理用のファイルを作成する。
これらのjsファイルをindex.js
でインポートしてモジュールとして利用する。
import { createStore } from 'vuex' import { solveEquation } from './modules/solveEquation' import { timer } from './modules/timer' import { weather } from './modules/weather' export default createStore({ modules: { solveEquation, timer, weather } })
1. 二次方程式の実数解計算機能の実装
二次方程式の任意の係数を入力しその方程式の実数解を表示する。
単純なstatus
の参照と更新について触れる。
solveEquationモジュールの定義
export const solveEquation = { namespaced: true, // 【①】 // 【②】 state: { solutions: [], // 解の計算結果 isSolvable: true, // 解を持つかどうかのフラグ }, // 【③】 mutations: { setSolutions (state, solutions) { state.solutions = solutions }, checkSolvable (state) { // 解の配列が空でなければ実数解を持つと判定 state.isSolvable = state.solutions.length > 0 } }, // 【④】 actions: { setResult (context, solutions) { context.commit('setSolutions', solutions) context.commit('checkSolvable') } } };
【①】名前空間の指定
namespaced
をtrueにすることで、このモジュールが登録されているパスに基づく名前空間にgetter
、mutations
、actions
が入れられる。state
についてはnamespaced
オプションによらず、モジュールを定義した時点でこの名前空間に入っている。
【②】stateの定義
アプリケーション内で管理すべき「状態:state」(変数と見なしても差し支えないと思う)を定義する。
ここでstate
を一元管理しておけば、アプリケーションの階層構造などに関わらず全てのコンポーネントで簡単に参照・更新することができる。
ここでは、実数解を格納しておく配列solutions
と実数解を持つかどうかを判定するフラグisSolvable
を定義している。
【③】mutationsの定義
state
の更新は必ずmutations
を介して行う必要がある。
ここで定義しているsetSolutions
、checkSolvable
という各ミューテーションはメソッドのようなものとして考えることができ、その第1引数にはstate
(②で定義したstate
そのもの)を取る。
第2引数には任意の値を取ることができ、その値をpayloadと呼ぶ。
setSolutions
ではpayloadとして1つの値しか渡していないが、複数の値を使いたい場合はオブジェクトとして渡せば良い。
mutations
内ではあまり複雑な処理を行うのではなく、単純にstate
を更新するだけに徹したほうが望ましいとされている。
また、mutations
内での非同期処理は非推奨であり、非同期処理が必要な場合はactions
を利用する必要がある。
【④】actionsの定義 / mutationsの実行
actions
にはミューテーションの実行などを行う各アクションを定義する。
Vueコンポーネントから直接ミューテーションを実行することも可能だが、実際にはコンポーネントではアクションを呼び出すだけにしてアクションを介してミューテーションを実行することが推奨されている。
ミューテーションと同様に、アクションの第2引数でもpayloadを取ることができる。
ミューテーションを実行する時はcommit
を利用する。
各アクションが第1引数として取るコンテキストオブジェクトcontext
を利用すれば、下記のようにしてミューテーションを実行することができる。
context.commit('ミューテーション名') context.commit('ミューテーション名', value1) // payloadを一つ渡す context.commit('ミューテーション名', { value1: 'xxxxx', value2: 12345 }) // 複数の値を1つのオブジェクトにまとめて渡す
setResult
というアクションは、solutions
とisSolvable
を更新するミューテーションであるsetSolutions
とcheckSolvable
を実行している。
なお、コンテキストオブジェクトはcommit
以外にもstate
やdispatch
(アクションを実行する時に必要)などを含んでいるため、アクション内でstate
を参照したり、別のアクションを実行したりすることもできる。
コンポーネントの実装
二次方程式の係数を入力して計算を実行するコンポーネント。 係数の入力にちょっとしたバリデーションを入れ、解の公式を用いて実数解を求める。
<template> <div> <p>二次方程式の係数を入力してください。</p> <div> <input v-model="a">X<sup>2</sup> + <input v-model="b">X + <input v-model="c"> = 0 <button @click="calc">計算</button> <span v-if="error" class="error">{{ error }}</span> </div> </div> </template>
<script> import { useStore } from 'vuex' // 【⑤】 import { ref } from 'vue' export default { setup() { const $store = useStore() // 【⑤】 const a = ref() const b = ref() const c = ref() const error = ref('') const calc = () => { error.value = '' if (!a.value || !b.value || !c.value) { error.value = '未入力の係数があります!' return } if (a.value == 0) { error.value = '2次の係数に0は指定できません!' return } const solutions = [] // 方程式の解の配列 const dis = b.value ** 2 - 4 * a.value * c.value // 判別式 // 解の公式を用いて実数解を求める if (dis > 0) { solutions.push((-1 * b.value - Math.sqrt(dis)) / (2 * a.value)) solutions.push((-1 * b.value + Math.sqrt(dis)) / (2 * a.value)) } else if (dis === 0) { solutions.push((-1 * b.value ) / (2 * a.value)) } // payloadとして解の配列を渡し、ストアのsolutionsを更新する $store.dispatch('solveEquation/setResult', solutions) // 【⑥】 } return { a, b, c, error, calc } } } </script>
【⑤】useStore関数
Options APIの場合はthis.$store.state
などのようにストアを参照することができるが、Composition APIのsetup
関数内ではthis
の参照ができないため同じようにやるとTypeErrorになってしまう。
TypeError: Cannot read properties of undefined (reading '$store')
これを回避してストアにアクセスするためにはuseStore
関数をインポートして利用する必要がある。
【⑥】アクションの実行
アクションの実行はuseStore
関数のdispatch
を利用する。
dispatch('アクション名') dispatch('アクション名', value1) // payloadを一つ渡す dispatch('アクション名', { value1: 'xxxxx', value2: 12345 }) // 複数の値を1つのオブジェクトにまとめて渡す
アクション名には実行したいアクションを指定すればよいが、ここではsolveEquation
モジュール内のsetResult
を実行するので、名前空間を含めてsolveEquation/setResult
という指定の仕方になる。
方程式の実数解を表示するコンポーネント。実数解が存在しなければその旨のメッセージを表示する。
<template> <div class="solutions"> <div v-if="isSolvable"> <ul> <li v-for="solution in solutions" :key="solution">X = {{ solution }}</li> </ul> </div> <div v-else> <span>実数解は存在しません。</span> </div> </div> </template>
<script> import { useStore } from 'vuex' import { computed } from 'vue' export default { setup() { const $store = useStore() // 【⑦】 const solutions = computed(() => $store.state.solveEquation.solutions) const isSolvable = computed(() => $store.state.solveEquation.isSolvable) return { solutions, isSolvable } } } </script>
【⑦】状態の参照
ストアのstate
を参照する時は算出プロパティを利用する。
通常は$store.state.solutions
のように参照するが、今回はsolveEquation
モジュール内のstate
を参照するので$store.state.solveEquation.solutions
となる。
Input.vue
で計算した実数解でストアのsolutions
を更新し、Result.vue
でsolutions
を参照して画面に表示する。
実数解が存在しない場合(solutions
の配列が空)はisSolvable
がfalseになっているので「実数解は存在しません。」と表示される。
2. カウントダウンタイマーの実装
計測したい時間の設定、計測の停止と再開、時間のリセット機能をもつカウントダウンタイマーである。
getters
の使い方とストアの変更の監視について触れる。
timerモジュールの定義
export const timer = { namespaced: true, state: { time: 0, // 残り時間(ms) isInit: true, // 初期状態かどうかのフラグ isRunning: false, // タイマーが起動中かどうかのフラグ timerId: null // インターバルID }, // 【⑧】 getters: { // 残り時間を00:00:00.00にフォーマット getDisplayTime (state) { return new Date(state.time).toISOString().slice(11, 22) } }, mutations: { increment (state, delta) { state.time += delta * 1000 // msに変換 }, decrement (state, delta) { state.time -= delta * 1000 // msに変換 }, updateIsInit (state) { state.isInit = false }, setTimerId (state, timerId) { state.timerId = timerId }, startAndStop (state) { state.isRunning = !state.isRunning }, reset (state) { state.time = 0, state.isInit = true, state.isRunning = false } }, actions: { incrementAct (context, delta) { context.commit('increment', delta) }, decrementAct (context, delta) { // 現在のtimeがdelta以上の時だけ減算を行いtimeが負になるのを防ぐ if (context.state.time / 1000 >= delta) { context.commit('decrement', delta) } }, setTimerIdAct (context, timerId) { context.commit('setTimerId', timerId) }, startAndStopAct (context) { if (context.state.isInit) { context.commit('updateIsInit') } context.commit('startAndStop') }, resetAct (context) { context.commit('reset') } } };
【⑧】gettersの定義
ストアのstate
に対して何らかの加工(表示フォーマットを調整したり配列であればフィルタリングしたり)を施して参照したい場合にgetters
を使うことができる。
各ゲッターも第1引数にstate
を取るので、任意のstate
に対して処理を行った結果をreturnする。
なお、コンポーネント側でstate
の加工処理を行っても同じなのだが当然そのコンポーネント内でしか使えないため、他のコンポーネントでも同じように参照したい場合は何度も同じ処理を書かなければならない。
// gettersを利用せずコンポーネント側でstateを加工する場合 const time = computed(() => new Date($store.state.timer.time).toISOString().slice(11, 22))
複数のコンポーネントで使い回したい場合、専用のゲッターをストアに用意しておけば各コンポーネントではそのゲッターを参照するだけで済む。
コンポーネントの実装
時間、分、秒の増減ボタン(+、-)やカウントダウン開始・停止ボタン、タイマーリセットボタンを用意する。
<template> <div class="buttons"> <div class="buttons-row"> <div class="set-buttons"> <span>時:</span> <button :disabled="!isInit" @click="increment(3600)">+</button> <button :disabled="!isInit" @click="decrement(3600)">-</button> </div> <div class="set-buttons"> <span>分:</span> <button :disabled="!isInit" @click="increment(60)">+</button> <button :disabled="!isInit" @click="decrement(60)">-</button> </div> <div class="set-buttons"> <span>秒:</span> <button :disabled="!isInit" @click="increment(1)">+</button> <button :disabled="!isInit" @click="decrement(1)">-</button> </div> </div> <div class="buttons-row"> <button v-if="isInit" @mousedown="startAndStop">スタート</button> <button v-if="!isInit" @mousedown="startAndStop">ストップ/再開</button> <button :disabled="isRunning" @mousedown="reset">リセット</button> </div> </div> </template>
<script> import { useStore } from 'vuex' import { computed } from 'vue' export default { setup() { const $store = useStore() const isInit = computed(() => $store.state.timer.isInit) const isRunning = computed(() => $store.state.timer.isRunning) const timerId = computed(() => $store.state.timer.timerId) // タイマーの設定時間を増やす const increment = (delta) => $store.dispatch('timer/incrementAct', delta) // タイマーの設定時間を増やす const decrement = (delta) => $store.dispatch('timer/decrementAct', delta) // 計測開始(再開)・停止 const startAndStop = () => { if (isRunning.value) { clearInterval(timerId.value) } else { // 正確な計測方法ではないが、1/100秒(10ms)毎に呼び出すことで多少精度を上げている const id = setInterval(() => decrement(0.01), 10) $store.dispatch('timer/setTimerIdAct', id) } $store.dispatch('timer/startAndStopAct') } // タイマーを初期状に戻す const reset = () => { $store.dispatch('timer/resetAct') clearInterval(timerId.value) } return { isInit, isRunning, increment, decrement, startAndStop, reset } } } </script>
このコンポーネントではVuexについて追加で特筆すべきことは行なっていない。
ストアのisInit
、isRunning
フラグを参照し、適宜ボタンの活性・非活性の制御を行っている。
残り時間を「00:00:00.00」の形式で表示する。残り時間が0になったらalertでメッセージを表示して知らせる。
<template> <div> <div class="timer-display"> {{ time }} </div> </div> </template>
<script> import { useStore } from 'vuex' import { computed, onBeforeUnmount } from 'vue' export default { setup() { const $store = useStore() const time = computed(() => $store.getters['timer/getDisplayTime']) // 【⑨】 const isInit = computed(() => $store.state.timer.isInit) const timerId = computed(() => $store.state.timer.timerId) // ストアのtimeを監視し0秒になったらタイマーを停止、メッセージを表示する // 【⑩】 const unsubscribe = $store.subscribe((mutation, state) => { if (mutation.type === 'timer/decrement') { if (!isInit.value && state.timer.time === 0) { // timeが0になった瞬間に停止すると表示時間との間に若干差(1/100秒ぐらい表示が残ったまま)がでてしまうので // timeが0になってからタイマー停止まで少し遅らせている setTimeout(() => { clearInterval(timerId.value) $store.dispatch('timer/resetAct') alert('時間です') }, 100) } } }) // コンポーネントインスタンスのunmount直前でストアの監視を停止 // 【⑪】 onBeforeUnmount(() => { unsubscribe() }) return { time } } } </script>
【⑨】gettersの参照
getters
を参照する場合もstate
と同様に算出プロパティを利用する。mutations
やactions
と異なりブラケット表記になることに注意。
getters['ゲッター名']
ゲッター名には必要なゲッターを指定すればよいが、ここではtimer
モジュール内のgetDisplayTime
を参照するのでtimer/getDisplayTime
という指定の仕方になる。
【⑩】mutationsのsubscribe
ストアの変更を監視する方法はいくつかあるが、ここではuseStore
関数のsubscribe
を利用している。
全てのミューテーションの実行がここで監視されており、特定のミューテーションを実行した時だけ処理を行いたいという場合はmutation.type
で判別すればいい。
引数のstate
はミューテーション実行後の状態が入っている。
【⑪】subscribeの停止
subscribe
関数を実行すると返されるunsbscribe
関数を保持しておき、subscribeを停止するタイミングで実行する。
ページを破棄するタイミングでsubscribeを停止しておけばいいのでonBeforeUnmount
内部でunsbscribe
を実行している。