2020-07-13

Options APIを使用してNuxt.js + TypeScriptでVuexに型指定する方法(nuxt-typed-vuex)

@gc_tech70

はじめに

こんにちは。エンジニアリング事業本部の@gc_tech70です。

今回自社内で新規のWebサービスの開発プロジェクトがあり、その際の開発技術としてNuxt.js + TypeScriptを採用しました。 本記事ではその開発時のナレッジとして、Nuxt.js + TypeScript環境におけるVuexの型指定の方法についてご紹介させていただきたいと思います。 ※TypeScriptを使用する理由は多くの記事で語られていると思いますので、この記事ではあえて言及はしません。

Nuxt.js + TypeScriptでの技術選定

まず最初にNuxt.js + TypeScriptと言っても現状(2020年7月12日時点)では技術選定として、複数の選択肢(Class API、Options API、Composition API)があると思います。 (公式でもそれぞれのAPIの記法について紹介しています。)

今回私の技術選定のポイントとしては、以下のような観点で技術選定を行いました。

  • 近日Vue3のリリースが予定されていることもあるので、Vue2系からの移行がしやすいもの
  • スピードが求められる状況だったので、学習コストはできるだけ抑えたい
  • SEOを意識しているためSSRしたい

Class API

こちらは現状のNuxt.js(2系) + TypeScript環境で最もメジャーな組み合わせとなっており、ドキュメントが豊富に揃っていることがメリットです。 また、vue-property-decoratorを使用した記法が必要となるので、Vueの標準の記法とは異なり、多少慣れが必要です。 Vue3ではこちらは廃止される ことが決定されているので、今回は不採用としました。

コード例

import { Vue, Component, Prop } from 'vue-property-decorator'

interface User {
  firstName: string
  lastName: number
}

@Component
export default class YourComponent extends Vue {
  @Prop({ type: Object, required: true }) readonly user!: User

  message: string = 'This is a message'

  get fullName (): string {
    return `${this.user.firstName} ${this.user.lastName}`
  }
}

Options API

Vue.extendを使ったパターン。 従来のVueの記法で書けるため、ある程度Vue2系に慣れいている人であれば、追加の学習コストがほとんどないと思います。 また、Vue3で廃止されることもないため、移行も比較的簡単に行えるのがメリットです。

今回はこちらのパターンを採用しました。

コード例

import Vue, { PropOptions } from 'vue'

interface User {
  firstName: string
  lastName: number
}

export default Vue.extend({
  name: 'YourComponent',

  props: {
    user: {
      type: Object,
      required: true
    } as PropOptions<User>
  },

  data () {
    return {
      message: 'This is a message'
    }
  },

  computed: {
    fullName (): string {
      return `${this.user.firstName} ${this.user.lastName}`
    }
  }
})

Composition API

こちらはVue3で採用されることが決定している新しいAPI。 ライブラリをインストールすることでNuxt2系の環境にも導入することができ、 Vue3への移行コストは一番低いと思われます。

各コンポーネントがVueインスタンス(this)に依存しないためテストが書きやすく、TypeScriptとの相性が良いなどの特徴があり、できればこちらを採用したかったのですが、以下の観点から不採用としました。

  • まだVue3がリリースされていないこともあり、公式のドキュメントが少なかったり、ベストプラクティスが確率されていないため、ハマる可能性が高い

    • 特にストアの管理についてはVuexを使用しない手法なども存在し、多様なアプローチが可能なため、最適な開発の進め方について判断が難しい

  • Nuxt.jsでSSRする際のライフサイクルフックであるasyncData、fetchに対応していない

コード例

import { defineComponent, computed, ref } from '@vue/composition-api'

interface User {
  firstName: string
  lastName: number
}

export default defineComponent({
  props: {
    user: {
      type: Object as () => User,
      required: true
    }
  },

  setup ({ user }) {
    const fullName = computed(() => `${user.firstName} ${user.lastName}`)
    const message = ref('This is a message')

    return {
      fullName,
      message
    }
  }
})

Vuexでの型指定(nuxt-typed-vuex)

上記の通り、Options APIでTypeScriptを使用することを決めたのですが、Vuexの型指定の方法について調べるとClass APIの記事がよく引っかかるので、地味に苦労しました。

今回はOptions APIで使えるVuexの型指定の方法として、公式でも紹介されているnuxt-typed-vuexというライブラリを紹介したいと思います。

導入方法

1. ライブラリのインストール

# yarn
yarn add nuxt-typed-vuex

# npm
npm i nuxt-typed-vuex

2. モジュールの追加

nuxt.configに以下の設定を追加します。

buildModules: [
  'nuxt-typed-vuex',
],

※Nuxt 2.10よりバージョンが低い場合は、「buildModules」ではなく「modules」に追加すればよいそうです。

3. モジュールのトランスパイル

nuxt.configに以下の設定を追加します。

build: {
  transpile: [
    /typed-vuex/,
  ],
},

4. ストアの型を定義

~/store/index.tsを以下の内容で作成

import { getAccessorType } from 'typed-vuex'

// これらは型推論に必要のため、空でも定義しておく
export const state = () => ({})
export const getters = {}
export const mutations = {}
export const actions = {}

export const accessorType = getAccessorType({
  state,
  getters,
  mutations,
  actions,
})

型定義ファイルの作成

VueインスタンスとNuxtのcontext(app)に4で作成した型を追加します。 これによって各Vueコンポーネントのthisとcontextからストアにアクセスする際に、型推論が効くようになります。

types/index.d.ts

import { accessorType } from '~/store'

declare module 'vue/types/vue' {
  interface Vue {
    $accessor: typeof accessorType
  }
}

declare module '@nuxt/types' {
  interface NuxtAppOptions {
    $accessor: typeof accessorType
  }
}

型定義ファイルの配置先は自由ですが、私の場合は~/typesディレクトリ配下に作成しました。 この場合、tsconfig.jsonには以下のような設定が必要です。

   "typeRoots": [
      "types",
      "node_modules/@types"
    ],

使用方法

1. モジュール作成

今回は例として、~/store/hoge.tsというストア作成してみます。

import { getterTree, mutationTree, actionTree } from 'typed-vuex'

export const state = () => ({
  title: 'hoge' as string,
})
export type RootState = ReturnType<typeof state>

export const getters = getterTree(state, {
  title: (state) => state.title,
})

export const mutations = mutationTree(state, {
  setTitle(state, title: string): void {
    state.title = title
  },
})

export const actions = actionTree(
  { state, getters, mutations },
  {
    updateTitle({ getters, commit }): void {
      const currentTitle = getters.title
      commit('setTitle', `${currentTitle}fuga`)
    },
  }
)
  • actions内からmutationsへの入力補完も効きます。 commit入力補完

  • 型チェックもしっかり行われています。 型チェック

2. モジュールの型定義追加

上記で作成したモジュールを以下のように~/store/index.tsに定義します。 こうすることでコンポーネントからも型推論が効いた状態アクセスできるようになります。

import { getAccessorType } from 'typed-vuex'
import * as hoge from '@/store/hoge'

// これらは型推論に必要のため、空でも定義しておく
export const state = () => ({})
export const getters = {}
export const mutations = {}
export const actions = {}

export const accessorType = getAccessorType({
  state,
  getters,
  mutations,
  actions,
  // 作成したモジュールはimportして、ここに追加していく
  modules: {
    hoge,
  },
})

3. コンポーネントからアクセス

試しに~/pages/hoge.vueを作成し、2で作成したストアにアクセスしてみます。


<template>
  <div>{{ title }}</div>
</template>

<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
  name: 'Hoge',
  computed: {
    title(): string {
      return this.$accessor.hoge.title
    },
  },
})
</script>

型推論が効いていることがわかります。 type-check

  • SSR時やmiddlewareなどcontextからアクセスする場合は、以下のように書くことができます。
  name: 'Hoge',
  fetch({ app: { $accessor } }) {
    // mutations
    $accessor.hoge.setTitle('fuga')

    // actions
    $accessor.hoge.updateTitle()
  },

これでVuexで型推論が効いた開発が行えるようになりました。

nuxt-typed-vuex使用上の注意点

dispatchの型推論が効かない

nuxt-typed-vuexでは残念ながらdispatchの型推論が効きません。 なので、その場合は以下のように$accessor経由で呼び出す必要があります。 この手法は公式でも紹介されています。

export const actions = actionTree(
  { state, getters, mutations },
  {
    updateTitle({ getters, commit }): void {
      const currentTitle = getters.title
      commit('setTitle', `${currentTitle}fuga`)
    },
    sampleAction(): void {
      // dispatchの代わりにactionsを呼び出す場合は、$accessor経由で呼び出す
      this.app.$accessor.hoge.updateTitle()
    },
  }
)

上記で私が地味にハマったポイントとして、$accessorを使用している関数で戻り値の指定を忘れるとTypeScriptでエラーが出るので注意が必要です。 これについては公式のドキュメントに追記されていました(2020/09/04時点) https://nuxt-typed-vuex.roe.dev/actions#referencing-other-modules ts-error

関数名の重複

  • state、getters間で変数名、関数名が重複した場合、gettersが優先的に呼び出される
  • mutations、actions間で関数名が重複した場合、actionsが優先的に呼び出される

ディレクトリ指定が必要なモジュール追加

storeディレクトリ直下のモジュールに関しては上記の通り設定すれば問題なく動作するのですが、例えば、~/store/fuga/hoge.tsのようにstore配下にディレクトリを新たに作成し、モジュールを配置した場合、少し書き方が異なります。

~/store/index.tsに~/store/fuga/hoge.tsを上記のように設定しても、この時点では問題は起きませんが、

import { getAccessorType } from 'typed-vuex'
import * as hoge from '~/store/fuga/hoge'

export const state = () => ({})
export const getters = {}
export const mutations = {}
export const actions = {}

export const accessorType = getAccessorType({
  state,
  getters,
  mutations,
  actions,
  modules: {
    hoge,
  },
})

上記の通りstoreにアクセスしてstateの値を取得しようとすると、追加したモジュールが読み込めておらず、undefinedとなります。 また、fuga.hoge.titleの形で値を取得しようとしても型エラーが出ます。

<template>
  <div>{{ title }}</div>
</template>

<script lang="ts">
import Vue from 'vue'
export default Vue.extend({
  name: 'Hoge',
  computed: {
    title(): string {
      return this.$accessor.hoge.title // undefinedになる
    },
    fugaTitle(): string {
      return this.$accessor.fuga.hoge.title // 型エラーになる
    },
  },
})
</script>

いろいろ試した結果、この場合は~/store/index.tsを以下のように記述することで解決できました。

import { getAccessorType } from 'typed-vuex'
import * as hoge from '~/store/fuga/hoge'

export const state = () => ({})
export const getters = {}
export const mutations = {}
export const actions = {}

export const accessorType = getAccessorType({
  state,
  getters,
  mutations,
  actions,
  modules: {
    // ディレクトリ名
    fuga: {
      modules: {
        // 追加したいモジュール
        hoge,
      },
    },
  },
})

ある程度の規模のプロジェクトになると、storeにディレクトリを切るのはほぼ必然だと思うので、こちらの手法は知っておいて損はないかと思います。

所感

  • TypeScript導入前のVuexに比べると、型推論が効くようになったことで格段に開発体験がよくなったと思います。

  • nuxt-typed-vuexについては、あまり有名なライブラリではなさそうだったので何かしらデメリットがあるのかと若干心配もありましたが、業務で使用しても特に問題なさそうなレベルだったので、是非また使ってみたいと思いました。

最近記事