HAKUTAI Tech Notes

IT関連、数学のことなどを主として思いつきで色々書き溜めていきます

Vuexを使ったサンプル色々【前編】

最近Vuexを使う必要が出てきたので公式ドキュメントなどを読んでいたのだが、習うより慣れろということでVuexを利用した簡単なアプリケーションをいくつか作ってみた。
今回は、二次関数の実数解を計算する機能solveEquation、カウントダウンタイマーtimer、天気予報を取得する機能weatherForecastの3つのサンプルを作成したので、Vuexの具体的な適用方法などを含めてまとめておく。 なお、このサンプルではComposition APIを利用している。
前編でsolveEquationtimer、後編で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に直接statemutationsなどを記述してアプリケーション全体の状態管理を行うことになる。 ただ、今回は3機能の状態管理をそれぞれモジュール化して個別のファイルで行うことにする。 そこで、storeディレクトリにmodulesというディレクトリを作成し、その中にsolveEquation.jstimer.jsweather.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にすることで、このモジュールが登録されているパスに基づく名前空間にgettermutationsactionsが入れられる。stateについてはnamespacedオプションによらず、モジュールを定義した時点でこの名前空間に入っている。

【②】stateの定義
アプリケーション内で管理すべき「状態:state」(変数と見なしても差し支えないと思う)を定義する。 ここでstateを一元管理しておけば、アプリケーションの階層構造などに関わらず全てのコンポーネントで簡単に参照・更新することができる。
ここでは、実数解を格納しておく配列solutionsと実数解を持つかどうかを判定するフラグisSolvableを定義している。

【③】mutationsの定義
stateの更新は必ずmutationsを介して行う必要がある。 ここで定義しているsetSolutionscheckSolvableという各ミューテーションはメソッドのようなものとして考えることができ、その第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というアクションは、solutionsisSolvableを更新するミューテーションであるsetSolutionscheckSolvableを実行している。
なお、コンテキストオブジェクトはcommit以外にもstatedispatch(アクションを実行する時に必要)などを含んでいるため、アクション内でstateを参照したり、別のアクションを実行したりすることもできる。

コンポーネントの実装

Input.vue

二次方程式の係数を入力して計算を実行するコンポーネント。 係数の入力にちょっとしたバリデーションを入れ、解の公式を用いて実数解を求める。

<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という指定の仕方になる。

Result.vue

方程式の実数解を表示するコンポーネント。実数解が存在しなければその旨のメッセージを表示する。

<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.vuesolutionsを参照して画面に表示する。 実数解が存在しない場合(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))

複数のコンポーネントで使い回したい場合、専用のゲッターをストアに用意しておけば各コンポーネントではそのゲッターを参照するだけで済む。

コンポーネントの実装

Buttons.vue

時間、分、秒の増減ボタン(+、-)やカウントダウン開始・停止ボタン、タイマーリセットボタンを用意する。

<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について追加で特筆すべきことは行なっていない。 ストアのisInitisRunningフラグを参照し、適宜ボタンの活性・非活性の制御を行っている。

Display.vue

残り時間を「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と同様に算出プロパティを利用する。mutationsactionsと異なりブラケット表記になることに注意。

getters['ゲッター名']

ゲッター名には必要なゲッターを指定すればよいが、ここではtimerモジュール内のgetDisplayTimeを参照するのでtimer/getDisplayTimeという指定の仕方になる。

【⑩】mutationsのsubscribe
ストアの変更を監視する方法はいくつかあるが、ここではuseStore関数のsubscribeを利用している。 全てのミューテーションの実行がここで監視されており、特定のミューテーションを実行した時だけ処理を行いたいという場合はmutation.typeで判別すればいい。 引数のstateはミューテーション実行後の状態が入っている。

【⑪】subscribeの停止
subscribe関数を実行すると返されるunsbscribe関数を保持しておき、subscribeを停止するタイミングで実行する。 ページを破棄するタイミングでsubscribeを停止しておけばいいのでonBeforeUnmount内部でunsbscribeを実行している。