94
86

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Nuxt.js + TypeScript + Vuexをvuex-module-decoratorsでがっちりインテリセンスを効かせる

Last updated at Posted at 2020-06-25

はじめに

以前は、Vue.jsアプリケーションにおいてTypeScriptを導入する最大の障壁となるのがVuexでした。

TypeScriptとVuexの相性は良くなく、コンポーネントからstoreを呼び出したときに型安全が守られない、インテリセンスが効かないといった問題がありました。

Vuexの型課題を解決するために様々な方法が考案されており、Treeと称される型定義を使用したりそもそもVuexを利用しないで独自の状態管理を行うなど様々です。

今回はその中でも、Nuxt.js公式で推奨されているvuex-module-decoratorsを使用します。

セットアップ

Nuxt.jsでVuexを通常利用する場合には、storeディレクトリにモジュールと対応するファイルを設置します。
例えば、myModule.jsというファイルをstoreディレクトリに設置すれば、myModuleといモジュールで自動的に作成され、コンポーネントからアクセスすることができます。

ただし、今回のようにvuex-module-decoratorsを使用する場合には、下準備が必要です。

vuex-module-decoratorsをインストール

ます初めに、vuex-module-decoratorsを使用するためにインストールをします。

yarn add -D vuex-module-decorators
# OR
npm install -D vuex-module-decorators

store/index.ts

ここからはNuxt.jsで使用するために必要な手順です。公式のREAD MEの手順に従って実装していきます。

まずは、~/store/index.tsファイルを作成し、以下のコードを記述します。

~/store/index.ts
import { Store } from 'vuex'
import { initialiseStores } from '~/utils/store-accessor'
const initializer = (store: Store<any>) => initialiseStores(store)
export const plugins = [initializer]
export * from '~/utils/store-accessor'

このファイルは一度作成したら、基本編集しません。
コンポーネントからimport { todoStore } from '~/storeのようにできるようにするためここで初期化します。

utils/store-accsessor

次に、store/index.tsの中で利用されている~/utils/store-accsessor.tsです。

~/utils/store-accsessor.ts

/* eslint-disable import/no-mutable-exports */
import { Store } from 'vuex'
import { getModule } from 'vuex-module-decorators'
import Todo from '~/store/todo'

let TodoStore: Todo
function initialiseStores(store: Store<any>): void {
  TodoStore = getModule(Todo, store)
}

export { initialiseStores, TodoModule }

ここでは、作成したモジュールをインポートしてstoreに登録します。
新たにモジュールを作成するたびに、作成したモジュールをこのファイルに追加していきます。

これでVuexのセットアップは完了です。実際にモジュールを作成して使用してみましょう。

モジュールの作成

今回はみんな大好きTODOリストを作成して、vuex-module-decoratorsを体感します。

まずは、storeの作成です。todoというモジュールで作成するので、~/store/todo.tsという構造でファイルを作成します。

~/store/todo.ts
import { Module, VuexModule, Mutation, Action } from 'vuex-module-decorators'
import { $axios } from '~/utils/api'

type Todo = {
  id?: Number
  title: String
  description: String
  done: Boolean
}

@Module({
  name: 'todo',
  stateFactory: true,
  namespaced: true
})
export default class Todos extends VuexModule {
  private todos: Todo[] = []

  public get getTodos() {
    return this.todos
  }

  public get getTodo() {
    return (id: Number) => this.todos.find((todo) => todo.id === id)
  }

  public get getTodoCount() {
    return this.todos.length
  }

  @Mutation
  private add(todo: Todo) {
    this.todos.push(todo)
  }

  @Mutation
  private remove(id: Number) {
    this.todos = this.todos.filter((todo) => todo.id !== id)
  }

  @Mutation set(todos: Todo[]) {
    this.todos = todos
  }

  @Action({ rawError: true })
  public async fetchTodos() {
    const { data } = await $axios.get<Todo[]>('/api/todos')
    this.set(data)
  }

  @Action({ rawError: true })
  public async createTodo(payload: Todo) {
    const { data } = await $axios.post<Todo>('/api/todo', payload)
    this.add(data)
  }

  @Action({ rawError: true })
  async deleteTodo(id: Number) {
    await $axios.delete(`/api/todo/${id}`)
    this.remove(id)
  }
}

こんな感じで作成してみました。
Vuexをクラスベースで作成するのが特徴です。
さらに、デコレータを使用してモジュールであることや、MutationActionメソッドであることを伝えます。
クラス内でなら、他のプロパティの要素にはthisでアクセスすることができます。

モジュールについて、一つづつ詳しく見てみましょう。

デコレータ、Nuxt アプリケーションインスタンスインポート

まずはファイルの先頭で必要なものをインストールします。

import { Module, VuexModule, Mutation, Action } from 'vuex-module-decorators'
import { $axios } from '~/utils/api'

クラスの作成に必要なものをvuex-module-decoratorsからインポートします。
また、VuexのモジュールからはNuxtアプリケーションインスタンスにアクセスできないので、axiosなどを使用したいときには一手間必要です。

Vuexで$axiosを使用する方法

まずは、プラグインを作成します。plugins/axios-accessor.tsファイルを作成します。

import { Plugin } from '@nuxt/types'
import { initializeAxios } from '~/utils/api'

const accessor: Plugin = ({ $axios }) => {
  initializeAxios($axios)
}

export default accessor

nuxt.config.jspluginsに忘れずに追加します。

plugins: [
    '~/plugins/axios-accessor',
  ]

utils/api.tsファイルを作成して、そこからインポートする必要があります。

/* eslint-disable import/no-mutable-exports */
import { NuxtAxiosInstance } from '@nuxtjs/axios'

let $axios: NuxtAxiosInstance

export function initializeAxios(axiosInstance: NuxtAxiosInstance) {
  $axios = axiosInstance
}

export { $axios }

型宣言

モジュールで使用する独自の型を宣言しています。
typesフォルダを作成して、そこから型定義をインポートするのでもよいでしょう。


type Todo = {
  id: number
  title: string
  description: string
  done: boolean
}

クラス作成

モジュールクラスを作成します。
クラス宣言の前に@moduleデコレータを付与する必要があります。
また、stateFactory: trueを渡すことで、Nuxt.jsのモジュールであることを宣言します。
クラスはVuexModuleを継承して作成されます。

@Module({
  name: 'todo',
  stateFactory: true,
  namespaced: true
})
export default class Todos extends VuexModule {

state

stateは、クラスのプロパティとして作成します。

private todos: Todo[] = []

アクセス修飾子は必須ではありませんが、Vuexの流儀の従うのなら、外部からstateにアクセスさせたくないのでprivateで宣言しておくのがよいでしょう。
stateクラス内部でのみ扱うようにします。

getters

gettersはそのままクラスのget構文として作成します。
get構文には引数を渡すことができないので、関数をreturnすることで渡してあげることができます。

public get getTodos() {
    return this.todos
  }

  public get getTodo() {
    return (id: number) => this.todos.find((todo) => todo.id === id)
  }

  public get getTodoCount() {
    return this.todos.length
  }

mutasions

mutationsには、@Mutationsデコレータを付与します。

@Mutation
  private add(todo: Todo) {
    this.todos.push(todo)
  }

  @Mutation
  private remove(id: number) {
    this.todos = this.todos.filter((todo) => todo.id !== id)
  }

  @Mutation
  private set(todos: Todo[]) {
    this.todos = todos
  }

mutationsには本来外部から直接アクセスしても構わないですが、非同期の有無にかかわらず、actions経由での更新に統一するというルールにしたがってアクセス修飾子はprivateとしています。

actions

最後に、actionsです。@Actionデコレータを付与して作成します。

@Action({ rawError: true })
public async fetchTodos() {
  const { data } = await $axios.get<Todo[]>('/api/todos')
  this.set(data)
}

@Action({ rawError: true })
public async createTodo(payload: Todo) {
  const { data } = await $axios.post<Todo>('/api/todo', payload)
  this.add(data)
}

@Action({ rawError: true })
public async deleteTodo(id: number) {
  await $axios.delete(`/api/todo/${id}`)
  this.remove(id)
}

mutationsのアクセスにthisが使えるので、インテリセンスが使えるのでいい感じです。

コンポーネントから呼び出す

それでは、実際に作成したモジュールをコンポーネントから呼び出してみます。
従来のようなインテリセンスの効かないmapActionsmapGettersは使用せずに、methodscomputedに定義して使用します。

~/pages/todo.vue
<template>
  <div>
    <h1>TODOリスト</h1>
    <table>
      <tr>
        <th>ID</th>
        <th>TITLE</th>
        <th>DONE</th>
      </tr>
      <tr v-for="todo in todos" :key="todo.id">
        <td>{{ todo.id }}</td>
        <td>{{ todo.title }}</td>
        <td v-if="todo.done"></td>
        <td v-else></td>
      </tr>
    </table>
  </div>
</template>

<script lang="ts">
import Vue from 'vue'
import { TodoStore } from '~/store'

export default Vue.extend({
  async asyncData({ error }) {
    try {
      await TodoStore.fetchTodos()
    } catch (e) {
      console.log(e)
      error({
        statusCode: e.response.status,
        message: e.response.message
      })
    }
  },
  computed: {
    todos() {
      return TodoStore.getTodos
    }
  }
})
</script>

import { TodoStore } from '~/store'でモジュールをインポートして使用します。
下記の通り、インテリセンスがよく効いています。

スクリーンショット 20200624 22.49.42.png

computedプロパティにも型が効いています。

スクリーンショット 20200624 22.51.20.png

実際に正しく動作させるよう、/api/todosエンドポイントを作成する必要があります。
試しに適当にリストを返すものを作成しました。

router.use('/todos', (_req, res) => {
  res.json([
    {
      id: 1,
      title: 'リスト1',
      description: 'lorem ipsum',
      done: true
    },
    {
      id: 2,
      title: 'リスト2',
      description: 'lorem ipsum',
      done: false
    },
    {
      id: 3,
      title: 'リスト3',
      description: 'lorem ipsum',
      done: true
    }
  ])
})

ページを表示すると、全てが正しく動作していることがわかります。

スクリーンショット 20200624 22.54.42.png

注意する点

@Actionの{ rawError: true }を忘れると正しいエラーが得られない

Actionsメソッド内では、エラーを非同期処理などエラーを捕捉したい場面が多いかと思います。

例えば、次のようなこコードはAxiosのエラーを捕捉することを期待しています。

@Action
  public async createTodo(payload: Todo) {
    const { data } = await $axios.post<Todo>('/api/todo', payload)
    this.add(data)
  }
async asyncData({ error }) {
    try {
      await TodoStore.fetchTodos()
    } catch (e) {
      console.log(e)
      error({
        statusCode: e.response.status,
        message: e.response.message
      })
    }
  },

しかし、このままだと実際に補足するエラーは次のようになってしまいます。

ERR_ACTION_ACCESS_UNDEFINED: Are you trying to access this.someMutation() or this.someGetter inside an @action?
That works only in dynamic modules.

ERR_ACTION_ACCESS_UNDEFINEDと全く身に覚えがないエラーが補足されていますが、これは一体何のエラーなのでしょうか?

実は、@Actionの{ rawError: true }を指定しないと、デフォルトですべてのエラーはライブラリ内部で定義されている固定文言がthrowされます。

デフォルトでエラーを握りつぶしてしまう動作は予期しづらく、かつエラーメッセージも分かりづらいものになっているのでハマりどころだと思います。

他のモジュールがVuexを使用すると競合が発生する

ERR_STORE_NOT_PROVIDEDというエラーに悩まされていたのですが、原因はAuth-Moduleというモジュールを追加したことでした。

このモジュールに限らず、Vuexを使用しているモジュールを使用すると同様のエラーが発生すると思われます。

解決策は、nuxt.config.jsのモジュールの設定でvuex:falseを追加します。

  auth: {
    redirect: {
      login: '/login',
      logout: '/',
      callback: '/login',
      home: '/'
    },
    strategies: { 
       // 省略
    },
    vuex: false // これを追加
  },

このエラーのたちの悪いところは@Actionの{ rawError: true }を指定しないとさらにわけがわからなくなるところですね。

おわりに

はじめは、今までのVuexの記法と大きく違うクラス記法で慣れない部分もありましたが、いざ使ってみるとインテリセンス効きまくりで完全に虜になりました。普段からtypoしまくってる私にとってもうTypeScriptは手放せない存在になりつつあります。

Vuex + TypeScriptはまだ発展途上で、情報もあまり多くなくもしかしたら1年もしないうちにベストプラクティスが変わってしまう可能性はありますが、それを差し置いても導入するメリットはあると感じられました。

94
86
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
94
86

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?