ContractS開発者ブログ

契約マネジメントシステム「ContractS CLM」の開発者ブログです。株式会社HolmesはContractS株式会社に社名変更しました。

Vuex + TypeScriptで非同期通信のサンプルを作りました

Holmesでエンジニアをしているid:w-miuchiです。

Holmesのアプリケーションは、フロントエンドを主にThymeleaf + JQueryで構築しています。
今後はSPA(Single Page Application)に切り替えます。それにともないNuxtJS + TypeScriptを採用することになりました。
現在、NuxtJSおよびVue.jsを鋭意勉強中です。

まずはNuxtJSの公式ドキュメントを読むところから始めました。
読む→作って試す、と繰り返していたのですが、いまいちうまく試せなかったのがVuexでした。 公式通りであれば問題なかったのですが、実現したいのは以下の条件でした。

  • Actions, Mutations, Stateを定義
  • TypeScript(Interface)
  • 非同期通信(GET/POST)

この条件で公開されているコードを調べました。
何かの条件が欠けていることが多かったのですが、見つけたのがGoogle-Books-APIです。

こちらのコードを元にしてサンプルを作ってみました。
Vuexの大まかな流れを理解できるかと思います。

Vuexとは

詳しくは下記のVuexの公式ドキュメントを見て頂ければと思います。
Vuexはコンポーネントが共通で持てるStoreであり、コントローラーになります。
ただし「共通の状態を共有する複数のコンポーネントを持ったときに、すぐに破綻します」 とあるように使い方は難しいです。
今回はこの点は置いておいて全体像を掴むことにしました。

Vuexの構成

f:id:w-miuchi:20200422090202p:plain
"単方向データフロー"のコンセプト
Stateを扱うのはMutations、そのMutationsを操作するのはActionsとなります。
作成したサンプルにおいては以下の役割としました。

Actions

非同期処理(GETやPOST)を記述する。
またComponentから呼ぶ場合、受け渡すパラメータはインターフェースを用意する。

Mutations

非同期処理(Actions)の処理結果をStateに保存する。
Componentからは呼ばない。

State

Componentから呼ばれる。
Stateにもインターフェースを用意する。

サンプル

作ったサンプルは、以下の画面2つです。
今回はアカウント一覧画面を紹介します。

  1. アカウント一覧画面

    • 非同期でアカウント一覧情報を取得すること
    • 取得時はローディングすること
    • 検索機能があること
  2. アカウント編集画面

    • URIにidを含むこと
    • idを元に非同期でアカウント情報を取得すること
    • 名前を編集すること
    • 非同期でアカウント情報を更新すること

Store

ファイル構成

store
├── account
│   ├── edit.ts
│   ├── search.ts
│   └── types.ts
├── dummy.ts
├── store.ts
└── types.ts

インターフェースを設定

store/account/types.ts

// Account
export interface Account {
  id: string
  name: string
}

// Account検索結果
export interface SearchAccountState {
  keywords: string
  accounts: Account[]
  total: number
  page: number
  isLoading: boolean
  isThere: boolean
  isError: boolean
}

// Account検索用パラメータ 
export interface SearchAccountsPayloadObj {
  keyword: string
  page: number
}

インタフェースの定義はわかりやすくできました。
HolmesのようにサーバーサイドにJavaの使っている場合、インターフェースは理解しやすいと思います。またサーバーサイドと定義を合わせられるのもメリットです。 またローディングやページングのような共通化できるものはインターフェースにした方がいいでしょう。

RootStateにModuleとして設定

store/types.ts

import { SearchAccountState, EditAccountState } from './account/types';

export interface RootState {
  // アカウント一覧
  SearchAccountModule: SearchAccountState,
  // アカウント編集
  EditAccountModule: EditAccountState
}

検索結果と考えるとRootStateに設定することは考えどころです。
同じComponentを複数設置すると、検索結果まで同期してしまいます。

Vuex.Storeを設定

store/store.ts

import Vue from 'vue';
import Vuex from 'vuex';
import { RootState } from './types';

Vue.use(Vuex);

export default new Vuex.Store<RootState>({});

Mutaions, Actionsの処理

store/account/search.ts

import { Module, VuexModule, Mutation, Action, getModule, } from 'vuex-module-decorators'
import { Account, SetSearchAccountsObj, SearchAccountsPayloadObj } from './types';
import store from '../store';
import dummy from '../dummy';

@Module({dynamic: true, store, name: 'SearchAccountModule', namespaced: true})
class SearchAccountModule extends VuexModule {
  // Store
  public keyword: string = '';
  public accounts: Account[] = [];
  public total: number = 0;
  public page: number = 0;
  public isLoading: boolean = false;
  public isThere: boolean = true;
  public isError: boolean = false;

  // 検索結果の保存
  @Mutation
  public setSearchAccounts(payload: SetSearchAccountsObj): void {
    this.accounts = payload.accounts;
    this.page += 1;
    this.total = payload.total;
  }

  // 検索キーワードの保存
  @Mutation
  public setSearchKeywords(payload: string): void {
    this.keyword = payload
  }

  // 検索結果の初期化
  @Mutation
  public resetSearchAccounts(): void {
    this.keyword = '';
    this.accounts = [];
    this.total = 0;
    this.page = 0;
  }

  // 検索結果取得中のローディング
  @Mutation
  public setLoading(payload: boolean): void {
    this.isLoading = payload
  }

  // 検索結果の有無
  @Mutation
  public setThere(payload: boolean): void {
    this.isThere = payload
  }

  // 検索結果取得のエラー有無
  @Mutation
  public setError(payload: boolean): void {
    this.isError = payload
  }

  // 検索結果取得
  @Action({})
  public async getAccounts(payload: SearchAccountsPayloadObj): Promise<void> {
    const { data, error }: any = await
      new Promise(res => 
        // 非同期処理
        setTimeout( () => {
          const items: Account[] = dummy.accounts.filter((item: Account) => {
            return item.name.match(new RegExp(payload.keyword, 'ig'));
          });
          res({
              data: {
                total: items.length,
                items: items
              },
              error: null
            }
          )
        }, 1000));
    this.setLoading(false);

    // 検索結果のStore保存
    if (data) {
      const info: SetSearchAccountsObj = {
        total: 0,
        accounts: [],
      };
      if (data.total !== 0) {
        info.total = data.total;
        info.accounts = data.items;
        this.setSearchAccounts(info);
        this.setThere(true)
      } else {
        this.setSearchAccounts(info);
        this.setThere(false)
      }
      this.setError(false)
    } else {
      this.resetSearchAccounts();
      this.setError(true)
    }
  }
}

export default getModule(SearchAccountModule)

こちらの@Mutationや@Actionというアノテーションの書き方はvuex-module-decoratorsを利用しています。
@MutationはStateの設定処理になります。
@Actionはダミーの情報をsetTimeoutで取得していますが、axiosを利用した通信に変わることを想定しています。
ダミーの情報は以下になります。

store/dummy.ts

export default {
  accounts: [
    {id: '1', name: 'Giorno Giovanna'},
    {id: '2', name: 'Bruno Bucciarati'},
    {id: '3', name: 'Guido Mista'},
    {id: '4', name: 'Leone Abbacchio'},
    {id: '5', name: 'Narancia Ghirga'},
    {id: '6', name: 'Pannacotta Fugo'},
  ]
};

Component

<template>
  <div class="AccountBlock">
    <div class="AccountBlock--header">
      <div class="AccountBlock--title">Account List</div>
      <a class="AccountBlock--refresh" @click="fetchAccounts">Refresh</a>
      <form @submit="search"><input class="AccountBlock--inputKeyword" type="text" placeholder="Keyword" v-model="keyword"></form>
    </div>
    <div class="AccountBlock--item" v-for="(account, index) in accountItems">
      <div class="AccountBlock--itemName">{{ account.name }}</div>
      <div class="AccountBlock--itemEdit"><nuxt-link v-bind:to="{name:'account-edit-id',params:{id:account.id}}">Edit</nuxt-link></div>
      <div class="AccountBlock--itemRemove"><a href="/remove">Remove</a></div>
    </div>
    <div class="AccountBlock--Loading" v-show="isLoading">Loading...</div>
    <div class="AccountBlock--noData" v-show="!isThere">No data.</div>
  </div>
</template>

<script lang="ts">
  import { Vue, Component } from 'vue-property-decorator';
  import { Account, SearchAccountsPayloadObj } from '../../store/account/types';
  import SearchAccountModule from '../../store/account/search';

  @Component
  export default class SearchAccounts extends Vue {
    protected keyword: string = '';
    // アカウント一覧の取得
    protected get accountItems(): Account[] {
      return SearchAccountModule.accounts;
    }
    // ローディングの取得
    protected get isLoading(): boolean {
      return SearchAccountModule.isLoading;
    }
    // アカウント一覧有無の取得
    protected get isThere(): boolean {
      return SearchAccountModule.isThere;
    }
    // アカウント取得エラーの有無
    protected get isError(): boolean {
      return SearchAccountModule.isError;
    }
    // アカウント取得
    protected async fetchAccounts(): Promise<void> {
      await SearchAccountModule.resetSearchAccounts();
      SearchAccountModule.setLoading(true);
      SearchAccountModule.setThere(true);
      const data: SearchAccountsPayloadObj = {
        keyword: this.keyword,
        page: 0,
      };
      await SearchAccountModule.getAccounts(data);
      SearchAccountModule.setSearchKeywords(data.keyword)
    }
    created() {
      this.fetchAccounts();
    }
    search(e: any) {
      e.preventDefault();
      this.fetchAccounts();
    }
  }
</script>

レンダリングはStateを利用し、
非同期処理のアカウント情報取得はActionsを使用できるようになりました。

所感

Vuexの処理の流れや全体像を掴むことができました。 ただし、Storeにどの情報を持たせるべきかを考えて設計する必要があることがわかりました。

またサンプル作成のために他にもコードを調べましたが、
Vuexは作りたいものによって構造や書き方が様々でした。
非同期通信の有無やJavaScript OR TypeScriptでも大きく異なります。

ここからは、アプリケーションの構造に合わせた設計で考えて行きたいと思います。

最後に、
Holmesはエンジニア・デザイナーを募集しています!
興味がある方はこちらからご連絡ください!

lab.holmescloud.com

lab.holmescloud.com