具体的に Vuex と MVVM を比較する

MVVM を習得している人に Flux な Vuex をどう使ったら学習効率が良さそうなのか?

まずは Blazor で

Redmine の projects テーブルの一覧を表示する画面を作成します。

MVVM パターンに分離せず、ひとつの Projects.razor ファイルに作成しています。

@page "/project"
@inject HttpClient Http

<h1>プロジェクト一覧</h1>
<table class="table">
    <thead>
        <tr>
            <th>ID</th>
            <th>名前</th>
            <th>説明</th>
        </tr>
    </thead>
    <tbody>
        @foreach (var item in projects)
        {
            <tr>
                <td>@item.Id</td>
                <td>@item.Name</td>
                <td>@item.Description</td>
            </tr>
        }
    </tbody>
</table>

@code {
    private Project[] projects = new Project[] { };

    protected override async Task OnInitializedAsync()
    {
        var url = "http://localhost:8000/api/project";
        this.projects = await Http.GetFromJsonAsync<Project[]>(url);
    }

    public class Project {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Description { get; set; }
    }
}
  1. ルーティングは @page で指定します。これは Vue Router よりちょっと便利。
  2. データは、this.projects で参照できるように。Vue で言えば data に保持している感じ
  3. Web API の呼び出しは HttpClient をインジェクションして利用します。HttpClient は、連続して何度も呼び出すとタイムアウトが重なっていまってメモリを喰うという欠点があるのです。このため、Web 絡みではひとつの HttpClient を使いまわします。

.NET5 の HttpClient には、GetFromJsonAsync という拡張メソッドが入って、Newtonsoft の Json ライブラリがいらなくなりました。取り込んだのか、別途実装したのかは不明ですが、単純に JSON をクラスに取り込むだけならばこれでいけます。

Web API で取得できる JSON の中味はさておき、取り出したいキーだけをプロパティとして設定しておきます。実は、日付とかブール値とかは自動で変換してくれないので、別途コンバータか自前変換が必要なのが、いまいちなところです。まあ、単純なデシリアライズならばこれで十分かも。

Newtonsoft.Json から System.Text.Json に移行する – .NET | Microsoft Docs

データストア的には、プロジェクトの一覧を保持している projects だけなので、Vuex を使うと冗長すぎて比較としてはフェアではないのですが、これを Vue/Vuex を使って書き直します。
(実際は、Vue.js で書いた後に Blazor で書いていますが)

次に Vuex で

実のところ、Vue 側は、

  • Vue
  • Vuex
  • Vue Router
  • Vuetify

を使っています。

なので、Blazor 版よりも構造化されているのですが、ひとまず、標準的(と思われる)Vuex の使い方です。

Web API は axios を使っています。直接 store.js のほうに突っ込んでもいいのですが、Web API なライブライ的に使い廻せるところは外部に題しておきたいところです。

JavaScript の場合、ライブラリをどう作るのか不明(昔の prototype しか知らないのであった)なので、こんな感じに作って置きます。Web API の呼び出しの場合、

  • Web API 自身のエラーをどうするのか?
  • 認証系のエラーをどうするのか?
  • パラメータ不正の場合はどうするのか?

というややこしい話がありますが、大抵の場合は正常に返ってくるものと想定して作ります。そして、不正になった場合は、そのまま例外が飛ぶので、UI 側でエラーになります。

本来ならば、フェールセーフ的に axios の例外が発生したときは、しれっと projects の空配列を返したいところです。

webapi/redmine.js

import axios from 'axios'

export default {
    project: {
        async all() {
            var res = await axios.get('http://localhost:8000/api/project');
            var items = res.data ;
            return items ;
        },
    },
}

データストアは、こんな感じで書きます。
state, mutations, getters のところは普段通りの定番。getters は使わなくてもよいのですが、統一的に map できるように作っておきます。
そして、actions の中から定義済みの Web API を呼び出します。

store.js

import Vue from 'vue'
import Vuex from 'vuex'
import redmine from '@/webapi/Redmine'

Vue.use(Vuex)

export default new Vuex.Store({
    state: {
        projects: [],
    },
    mutations: {
        projects(state, payload) {
            state.projects = payload.projects
        },
    },
    getters: {
        projects(state) {
            return state.projects
        },
    },
    actions: {
        async projects({commit}) {
            var items = await redmine.project.all();
            commit('projects', { projects: items })
        },
    },
})

それぞれの名前を "projects" に統一させてしまっているのは、MVVM パターンに似せるためです。state に対する get/set が異なる名前になるとプロパティの設定/取得としては違和感があります。逆に言えば、get/set を統一的に使いたくない場合(非対称な場合)は、この projects の部分は名前を変えたほうがいいでしょう。

おそらく、検索結果をフィルターする場合には、getters の名前を変えることになるかな、と。実際、Vuex のサンプルではそうなっています。

この store.js を活用するのが Project.vue です。

Project.vue

<template>
  <v-container>
      <h2>プロジェクト一覧</h2>

      <v-data-table
        :headers="headers"
        :items="items"
        :items-per-page="5"
        class="elevation-1"
      ></v-data-table>

  </v-container>
</template>

<script>
import { mapGetters, mapActions } from 'vuex'

export default {
  name: 'Project',
  data: function() {
    return {
      headers: [
        { text: 'id', value: 'id' },
        { text: 'プロジェクト名', value: 'name' },
        { text: '詳細', value: 'description' },
      ],
    }
  },
  computed: {
    ...mapGetters({
      items: 'projects',
    })
  },
  mounted() {
      this.getProjects()
  },
  methods: {
    ...mapActions({
      getProjects: 'projects', 
    })
  }
}
</script>

テーブル表示に Vuetify の v-data-table を使っています。
getters と actions の dispatch は直接呼び出したくないので、mapGetters と mapActions でマッピングさせます。

store.js 内では projects として統一した名前にしていましたが、マッピングするときに利用しやすように名前を変えます。Web API を呼び出すだけであれば、Project.vue のコード内には this.$store は出てきません。

この画面の状態を data に持たせるのか、store.js のほうに書くのかは悩むところではありますが、MVVM パターンのように記述するのであれば、UI の状態も store.js の方に記述するのがベターでしょう。MVVM の View(Viewのバックエンドのコード)に状態を持たせてしまうと ViewModel との整合性があわなくなる、かつ再帰テストがやりづらくなるので、この手の状態値も ViewModel の方に押し込んでしまいます。

その形にならうならば、Vuex のほうも .vue のほう記述するよりも store.js に押し込んでしまったほうがよいでしょうね。
ただし、いくつか書いてみる判るのですが、View である .vue から store.js は比較的遠い位置にあります。上記の headers 配列やダイアログの表示フラグのような View のみに関するところは、.vue の data で十分なような気がします。いや、むしろ View のほうにいれておかないと、部品的なコンポーネントを作るたびに store.js を改修せねばならず、かなり面倒です。「コンポーネント」として分離されていません。

Vue の画面単位で Vuex のモジュール分けをしたほうがよいのか?

Microsoft 的な MVVM パターンの使い方で言えば、Vue のひとつのコンポーネントに対して Vuex のデータストアがあることになります。Vuex で言えば、モジュール分けになります。

Vuex でモジュール分けをすると名前空間で分けられるのはいいけど、相互に値をやり取りするたびに rootState が必要になって、これまたちょっと変な構造になります。
この部分は、もうちょっと考察が必要。

カテゴリー: 開発 パーマリンク