こんにちは。エンジニアリング事業本部の@gc_tech70です。
今回自社内で新規のWebサービスの開発プロジェクトがあり、その際の開発技術としてNuxt.js + TypeScriptを採用しました。 本記事ではその開発時のナレッジとして、Nuxt.js + TypeScript環境におけるVuexの型指定の方法についてご紹介させていただきたいと思います。 ※TypeScriptを使用する理由は多くの記事で語られていると思いますので、この記事ではあえて言及はしません。
まず最初にNuxt.js + TypeScriptと言っても現状(2020年7月12日時点)では技術選定として、複数の選択肢(Class API、Options API、Composition API)があると思います。 (公式でもそれぞれの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}`
}
}
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}`
}
}
})
こちらはVue3で採用されることが決定している新しいAPI。 ライブラリをインストールすることでNuxt2系の環境にも導入することができ、 Vue3への移行コストは一番低いと思われます。
各コンポーネントがVueインスタンス(this)に依存しないためテストが書きやすく、TypeScriptとの相性が良いなどの特徴があり、できればこちらを採用したかったのですが、以下の観点から不採用としました。
まだVue3がリリースされていないこともあり、公式のドキュメントが少なかったり、ベストプラクティスが確率されていないため、ハマる可能性が高い
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
}
}
})
上記の通り、Options APIでTypeScriptを使用することを決めたのですが、Vuexの型指定の方法について調べるとClass APIの記事がよく引っかかるので、地味に苦労しました。
今回はOptions APIで使えるVuexの型指定の方法として、公式でも紹介されているnuxt-typed-vuexというライブラリを紹介したいと思います。
# yarn
yarn add nuxt-typed-vuex
# npm
npm i nuxt-typed-vuex
nuxt.configに以下の設定を追加します。
buildModules: [
'nuxt-typed-vuex',
],
※Nuxt 2.10よりバージョンが低い場合は、「buildModules」ではなく「modules」に追加すればよいそうです。
nuxt.configに以下の設定を追加します。
build: {
transpile: [
/typed-vuex/,
],
},
~/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"
],
今回は例として、~/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への入力補完も効きます。
型チェックもしっかり行われています。
上記で作成したモジュールを以下のように~/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,
},
})
試しに~/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>
型推論が効いていることがわかります。
name: 'Hoge',
fetch({ app: { $accessor } }) {
// mutations
$accessor.hoge.setTitle('fuga')
// actions
$accessor.hoge.updateTitle()
},
これでVuexで型推論が効いた開発が行えるようになりました。
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
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については、あまり有名なライブラリではなさそうだったので何かしらデメリットがあるのかと若干心配もありましたが、業務で使用しても特に問題なさそうなレベルだったので、是非また使ってみたいと思いました。