JavaScript
TypeScript
vue.js
Vuex
Vue.js #3Day 12

Vuexによる状態管理を含む最高に快適な Vue.js + TypeScript の開発環境を目指す話

※ この記事は気合を入れすぎてアドベントカレンダーの担当日を超過しそうになったため、後日追記予定とした項目がいくつかあります。
基本的には抑えるべきポイントは全て書いてありますが、サンプルコードを今後追加予定のため、 こちら のレポジトリをウォッチしておくことをオススメします。


Patreon での支援募集をはじめました。この記事が良かった!という方は、今後の情報発信のためにもぜひぜひ支援お願いします。
https://www.patreon.com/potato4d

Vue.js Advent Calendar 2018 年の管理役の potato4d です。昨年に引き続き、今年も Vue.js のアドベントカレンダーは他にもたくさんあるので、ぜひ #1 から追ってみてください。

https://qiita.com/advent-calendar/2018/vuejs

12日目の今日は、 Vue.js + TypeScript での理想的な開発環境について考えてみたいと思います。
Vue.js における TypeScript 利用はまだまだ課題が多く、型の恩恵をうけるためのテクニックも複数存在しています。

この記事では、そんな手法の特徴についてまとめてご紹介します。
実際のプロジェクトを Vue.js + TypeScript で開発する際、どの形で運用していくのがベストであるかの議論などのたたき台などにご利用ください。

この記事のレポジトリについて

アドベントカレンダーにつき体力切れしてまだソースコードがすべてできているわけではありませんが、この記事の内容のコードは順次以下のレポジトリに掲載されます。

https://github.com/potato4d/vue-typescript-adcal-examples

適宜確認いただければと思います。

はじめに

そもそもプロジェクトに TypeScript を入れたいモチベーションはなんでしょう。エディタによる型の補完の恩恵を受けたい、typoなどがあったときにすばやく検知したい、ドキュメントとしての型を遵守したい、エンバグを可能な限り防ぎたい……。

様々なモチベーションがあると思いますが、もし現場が Vue.js による短期的な生産性を求めているのであれば、TypeScript とは相性があまり良くないかもしれません。

Vue.js + TypeScript は、そのままでは型の恩恵が弱くメリットを享受しづらく、最大限の恩恵を受けようとすると、JavaScript の Vue.js アプリケーション開発と比較していくらか開発のハードルが上がります。

特に、 TypeScript を簡単に利用しているだけでは触る機会が多くない型の拡張などを行う機会も頻繁に出てくるため、チームやプロジェクトメンバーが TypeScript に不慣れな人間だけで構成されている場合は、採用しないほうが無難かもしれません。

十分な識者がいる状態では、存分にそのメリットを享受することができるため、採用する理由は十分にあるはずです。

少し考えてみた上で、やはり TypeScript が必要と感じる場合は、そのフェーズにきているということですので、これ以降のセクションが役に立つはずです。

ただ、 Vue.js + TypeScript は完全な型をつけようとすると消耗しやすい分野です。そのため、この記事では 無理せず最大限の恩恵を受けられる 環境を重視することとします。

基本的な制約と方針について抑える

いよいよ Vue.js 開発で TypeScript と向き合っていくことに心が決まりました。まず Vue.js + TypeScript で開発するために必要な特殊知識を一通りキャッチアップしてしまいます。

ビルド環境の構築

こちらは現代であれば、 Vue CLI v3 で TypeScript プラグインを導入しておけば基本的に間違いありません。 Vue CLI v3 が出るまでは、 webpack に不慣れな人にとっては構築が非常に煩雑かつ難しい環境となっていましたが、今は webpack に慣れていないかたでも、簡単に構築することができます。

今回は簡略化のためにサンプルはすべて以下の選択肢で作られたプロジェクトをベースとします。
なお、Vuex を取り扱うものだけ、 Vuex にチェックをいれる形で進行します。 

$ vue create project


Vue CLI v3.1.3
┌───────────────────────────┐
│  Update available: 3.2.1  │
└───────────────────────────┘
? Please pick a preset: Manually select features
? Check the features needed for your project:
 ◉ Babel
 ◉ TypeScript
 ◯ Progressive Web App (PWA) Support
 ◯ Router
 ◯ Vuex
 ◯ CSS Pre-processors
 ◯ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing

「Vue.extend」と「Class Component」二つの書き方

Vue.js のコンポーネントは、基本的には実体はただのオブジェクトとなります。それはつまり、 Pure JavaScript であるため取り回しやすいという特徴がある反面、それ以上手を入れられず、型の恩恵を受けられないというデメリットもあります。

Vue.js は props や data を定義すると this に生えることだけでもわかるように、大量のメタプログラミングのもとにコンポーネントを作り出すフレームワークです。そのため、JavaScript流の書き方では実用できない水準の型検査となってしまいます。

例えば、以下のようなコードがある場合

sample.vue
<template>
  <div>
    {{displayCount}}
  </div>
</template>

<script lang="ts">
export default {
  props: {
    count: Number
  },
  computed: {
    displayCount() {
      return `${this.count}`.replace(/(\d)(?=(\d{3})+$)/g , '$1,')
    }
  }
}
</script>

当然のように this.count は存在しないためエラーとなります。これは至極当然な挙動であり、TypeScript compiler に対して適切に Vue.js のメタプログラミング処理が伝えられていないことによって起こるものです。

現在、この問題を解決する方法は、大きく二つ存在します。それが、 Vue.extend と vue-class-component です。

Vue.extend

まずは Vue.extend からです。これは、 Vue.js のコアにあるコンポーネントを定義するための API となります。JavaScript を使って、 SFC だけを記述している場合はこれを使うシチュエーションはあまり多くないかもしれませんが、 TypeScript での開発では、この Vue.extend の型定義が非常に効果を発揮します。

内部的な型の定義は複雑であるためおいておくとして、結果だけを書くと、 export default の値を Vue.extend でラップするだけで、単一ファイルコンポーネントの機能の型補完が全てなされる ものとなります。

例えば、先程のコードを Vue.extend で定義した場合は以下のようになりますが

sample.vue
<template>
  <div>
    {{displayCount}}
  </div>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  props: {
    count: Number
  },
  computed: {
    displayCount() {
      return `${this.count}`.replace(/(\d)(?=(\d{3})+$)/g , '$1,')
    }
  }
})
</script>

何も問題なく this.count の補完が効いた上、 computed も適切に処理され、自動的に this.count が number 型に、 this.displayCount が string 型に推論されます。

Vue.extend は、Vue.js の最もポピュラーな記法を引き継いで開発ができるため、 これまでずっと Vue.js で開発をしてきた人には最適な選択肢 となります。

これまで通りの開発スタイルを継続したい場合は、利用を強くオススメします。私自身、 Vue + TypeScript では、必ずこの Vue.extend スタイルで開発を行っています。

vue-class-component

Vue.extend に対しての対抗馬として、オフィシャルのライブラリである vue-class-component を利用する手段もあります。Vue.js らしい記法とは打って変わって、デコレータをベースにした、クラスベースでの記法となります。Angular や一昔前の React に近い記法となるため、人によってはこちらのほうが馴染みがあるかもしれません。

NPM または yarn で追加でパッケージを導入することで、利用できるようになります。

$ yarn add vue-class-component

導入したら、実際に例を見てみます。
先程の Vue.extend の例を、 vue-class-component で解決する場合は、以下のようなコードとなります。

sample.vue
<template>
  <div>
    {{displayCount}}
  </div>
</template>

<script lang="ts">
import Vue from 'vue'
import Component from 'vue-class-component'

@Component({
  props: {
    count: Number
  }
})
export default class CountDisplayComponent extends Vue {
  get displayCount() {
    return `${this.count}`.replace(/(\d)(?=(\d{3})+$)/g , '$1,')
  }
}
</script>

@Component ディレクティブで props を定義し、 computed は class の Getter として定義されていることがわかります。なお、@Component はただのデコレータなので、例えば props ではなく data にしたい場合は、以下のように記述することもできます。

sample.vue
<template>
  <div>
    {{displayCount}}
  </div>
</template>

<script lang="ts">
import Vue from 'vue'
import Component from 'vue-class-component'

@Component
export default class CountDisplayComponent extends Vue {
  count = 0
  get displayCount() {
    return `${this.count}`.replace(/(\d)(?=(\d{3})+$)/g , '$1,')
  }
}
</script>

vue-class-component では、 class のプロパティがそのまま Vue コンポーネントのローカルステートとなるため、シンプルに定義できる特徴があります。

このように、多少 Vue.js らしい書き方から外れつつも、それによって Vue.js のトリッキーな部分を意識せず、コード上から型が読み取れるような記述ができることが vue-class-component の特徴です。

JavaScript のプロジェクトでも利用できますが、あまり利用する機会がないはずです。ですので、普段から Vue.js + TypeScript で統一して、堅牢に開発を進めたいという型にオススメな記法となっています。

今後 Vue.js 3.0 では、コアでの Class Component のサポートが予定されていますので、お好みの場合は安心して利用していくことができるはずです。

vue-property-decorator について

vue-property-decorator についても、簡単にご紹介します。

vue-class-component は、 @Component デコレーターに機能を追加していく形で実装します。これはこれで便利な半面、もう少し @Component が肥大しない形で、シンプルに定義したいモチベーションも出てくることがあります。

そういったときは、同じような使い心地で、より細分化されたプロパティを定義できる vue-propety-decorator を利用するという手もあります。

こちらはあくまでもサードパーティとなりますが、 Vue.js の TypeScript 部分のコアにも関わっている kaorun343 さんの個人開発物となるため、利用者も非常に多いライブラリとなっています。

例えば、先程の vue-class-component のコードは、 vue-property-decorator を利用すると、以下のように記述できます。

sample.vue
<template>
  <div>
    {{displayCount}}
  </div>
</template>

<script lang="ts">
import { Vue, Component, Prop } from 'vue-property-decorator'

@Component
export default class CountDisplayComponent extends Vue {
  @Prop(Number) count!: number

  get displayCount() {
    return `${this.count}`.replace(/(\d)(?=(\d{3})+$)/g , '$1,')
  }
}
</script>

コード例から見て取れるように、 @Component デコレータは最低限のコンポーネント定義のみを提供し、細かなデータの取り扱いはそれぞれのデコレーターにて実現するような記法となっています。

vue-property-decorator 自体が vue-class-component に依存しているため、あまり大きな違いはありませんが、好みに応じてこちらの利用することも選択のひとつです。

どちらを利用すべきか

Vue.extend を利用するか vue-class-component を利用するか。これらについては率直に言うと好みとなります。どちらも好む人もいれば、不満に思う人もいるはずです。

基本的には決めの問題なので、余計な自転車置き場の議論を生まないためにも、そのプロジェクトの主導となる人が好みで決めることを推奨します。

今回の記事では以後全て Vue.extend の形式で記述しますが、これはとくに Vue.extend での開発が絶対という意見ではありませんので、適切だと判断できるものを利用しましょおう。

単一ファイルコンポーネントの declare の拡張

環境と独特の記法についてキャッチアップできたら、次は型周りの整備についても抑えておきます。

前提として、Vue.js は this の中にユーザー定義のオブジェクトが注入されることが頻繁にあるフレームワークです。

this.$storethis.$notify といった、 dollar からはじまるプロパティがそれに該当しますが、これらについては、当然デフォルトでは型が用意されていません。ライブラリであれば、型定義が存在するかもしれませんが、プロジェクト内で自身で定義した場合は、別です。

Vue.js 自体が DI コンテナのような役割を担っているものの、 Type friendly ではないため、 declare の拡張によって、手動で拡張を進めていく必要があります。

普段 JavaScript のコードの型定義ファイルを書くなどの経験がない場合、馴染みがないかもしれませんが、今後 Vuex の型定義の強化などでも declare の拡張を行うことは頻繁にあります。

そのため、基本的な部分を一通り理解しておきましょう。

単一ファイルコンポーネントの declare の拡張

Vue CLI v3 で作られたプロジェクトの場合、単一ファイルコンポーネントの型定義ファイルは src/shims-vue.d.ts となり、デフォルトでは中身はこのように書かれています。

shims-vue.d.ts
declare module '*.vue' {
  import Vue from 'vue'
  export default Vue
}

Vue.js 以外のプロジェクトであれば、 .vueのような特殊な拡張子は存在しないため、基本的にフレームワーク側が提供する型定義を利用することとなりますが、 Vue.js の場合は .vue ファイルをうまく読み取るために拡張がなされています。Vue + TypeScript では、ここをうまく利用して拡張を進めることも重要となります。

例えば、 Production ビルドのバグ報告をうけるためにアプリケーションのバージョンを格納するための Vue プラグインをプロジェクト内に作っていた場合を想定します。例えば、以下のようなコードが存在するシチュエーションです。

src/plugins/version.ts
import Vue from 'vue'
export type APP_VERSION = string

Vue.prototype.$appVersion: APP_VERSION = '1.0.0'

Vue の prototype に string 型の $appVersion が追加されています。が、もちろんデフォルトではそんなものはなく、プロトタイプ汚染をうまく使っているだけなので、自身で型定義を拡張してやる必要があります。

実際に行う場合、以下のように行います。

shims-vue.d.ts
import Vue from 'vue'
import { APP_VERSION } from './plugins/version'

declare module '*.vue' {
  interface Vue {
    $version: APP_VERSION
  }
}

これで単一ファイルコンポーネントからも、 this.$version が string 型として補完が効くようになりました。実際の開発では高度な Type alias や interface を利用することになるかと思いますが、そのときも理屈は同じなのでうまく拡張していけば OK です。

この処理は定義のマージとして、 TypeScript の特徴にもなっているため、もしあまり利用したことがない場合は、簡単にドキュメントを読んでおくと円滑です。

https://www.typescriptlang.org/docs/handbook/declaration-merging.html

公式ドキュメントの例との記法の違いについて

また、公式ドキュメントでは '*.vue' ではなく、'vue/types/vue' を拡張している例があります。

公式ドキュメントの例
// 1. 拡張した型を定義する前に必ず 'vue' をインポートするようにしてください
import Vue from 'vue'

// 2. 拡張したい型が含まれるファイルを指定してください
//    Vue のコンストラクタの型は types/vue.d.ts に入っています
declare module 'vue/types/vue' {
  // 3. 拡張した Vue を定義します
  interface Vue {
    $myProperty: string
  }
}

こちらは実用上は特に大きな違いはなく、以下のように書いてもうまくいきます。お好みのほうを利用すると OK です。

公式ドキュメントの例
import Vue from 'vue'
declare module '*.vue' {
  interface Vue {
    $myProperty: string
  }
}

ここまでで Vue.js 特有の単一ファイルコンポーネントの拡張についてご紹介しました。

dollar からはじまるプロパティを生やすことは、実質的に DI 機構が備わっているような状態となって非常にテスタブルなコードが書きやすくなる反面、型定義をきちんとしないと割れ窓となりうるため、拡張しつつ積極的に使っていくと幸せになれます。

状態管理について

ここまでで、単に Vue.js を使う場合に抑えておくべき事項は一通りキャッチアップできました。しかし、まだ大きな問題として、状態管理が残っています。

Vue.js の場合、ここにも独特の課題感があるため、抑えて正しく対処していく必要があります。

Vuex + TypeScript の課題感と利用の是非

Vue.js での状態管理というと、謹製の Vuex を使っているケースがほとんどです。コアライブラリであることからの安定性はもちろん、各種Integrationや、 Vue.js devtools での管理など、利用することによるメリットはたくさんあります。

しかし、 Vuex はまだあまり TypeScript と相性が良いわけではありません。デフォルトでは mapGetters や mapActions のような map 系メソッドは全て any になってしまいますし、そもそも this.$store.state の内容も any になってしまいます。

これは Vuex が Vue.js と密接に繋がっている以上、ある程度は仕方ないことなのですが、半端に型定義があるが十分ではないという状況であるため、拡張にも一手間も二手間も必要な状態となっています。

Vuex を利用することによる恩恵を失うという大きなデメリットもありますが、何よりも型を優先する場合は、使わないことを検討することも選択肢の一つです。

とはいえ、使わない場合はそれなりに癖がありますので、代表的なパターンについて、後の項でいくつか紹介します。

Vuex を使わない状態管理

先に Vuex を使わない場合の状態管理について考えてみます。Vuex を使わない場合、大きく以下の3つのパターンに分けられます。

  • Store パターンを自作する
  • Firebase などの mBaaS の機能を中心に据える
  • Vuex 以外の状態管理ライブラリを利用する

それぞれ紹介します。

Store パターンを自作して賄うケース

まずは Store パターンを自作して賄うケースです。もし Vuex には馴染みがあるけれど、その根底にある Store パターンについては聞いたことがないという場合は、 Vue.js 公式ドキュメントの状態管理のページを一度読んでみることをオススメします。

Reactive Store の構築

実際に作ってみます。 Vue.js 向けのストアを作る時は少しコツが必要となります。リアクティブ性を担保しつつうまくデータを取り扱うために、ストアの内部に Vue インスタンスを格納するような作りにします。

具体的には、以下のようなコードとなります。 Vue インスタンスではありながら、テンプレートを持つわけでも、どこかにマウントされているわけでもありません。こうした場合は、双方向のバインディングやリアクティブなストア構造だけを利用できる、シンプルな Vue クラスのインスタンスとして利用できます。

src/store/index.ts
import Vue from 'vue'

class Store<S> {
  private instance: Vue

  constructor(initialState: State) {
    this.instance = new Vue({
      data: Object.assign(
        {},
        initialState
      )
    })
  }

  get state(): S {
    return (this.instance.$data as any) as S
  }

  get count(): number {
    return this.state.count
  }

  increment() {
    this.instance.$data.count++
  }
}

export interface State {
  count: number
}

export class AppStore extends Store<State> {}

グローバルへの Store の格納

実際に利用する場合は、以下のような形で Store インスタンスをグローバルに登録します。

src/main.ts
import Vue from 'vue'
import App from './App.vue'
import { AppStore } from './store/'

Vue.config.productionTip = false

Vue.prototype.$store = new AppStore({
  count: 0
})

new Vue({
  render: h => h(App)
}).$mount('#app')

あわせて declare 拡張も書きます。以下のように $store に AppStore のインスタンスが入っていることを明示してやると OK です。

src/shims-vue.d.ts
import Vue from 'vue'
import * as Store from './store/'

declare module 'vue/types/vue' {
  interface Vue {
    $store: Store.AppStore
  }
}

これで準備ができました。

コンポーネントから個別への Store へのアクセス

実際のストアへのアクセスは、以下のように行うことになります。ただの TypeScript の世界なので、型の補完などに問題はありませんが、もちろん Vuex ほど高機能ではないため、機能性を求めるなら拡張していく必要があります。

src/App.vue
<template>
  <div>
    <p>{{count}}</p>
    <button type="button" @click="increment">+</button>
  </div>
</template>

<script lang="ts">
import Vue from 'vue'
import { State, AppStore } from '@/store';

const store = new AppStore({
  count: 0
})

export default Vue.extend({
  methods: {
    increment() {
      this.$store.increment()
    }
  },
  computed: {
    count(): number {
      return this.$store.state.count
    }
  }
})
</script>

<style>
#app {
  font-family: 'Avenir', Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-align: center;
  color: #2c3e50;
  margin-top: 60px;
}
</style>

シンプルなプロジェクトや、全てを TypeScript の元に解決したい世界観の場合、このように Store パターンを実装して解決してやるのも一つの手と言えます。基本的には Vuex で開発をしているかたでも、選択肢の一つとして持っておくと重宝する機会があるはずです。

なお、このパターンはサンプルプロジェクトを用意しています。興味のあるかたは覗いてみてください。

https://github.com/potato4d/vue-typescript-adcal-examples/tree/master/packages/vue-typescript-with-store

Firebase など外部のクライアントがもつデータベースで賄うケース

例えば Firebase Cloud Firestore をフル活用しているプロジェクトの場合などでは、そもそも Firebase Cloud Firestore 自体がデータのやりとりをするインターフェースを持っているため、これを中心に組むという解決方法も存在します。

私自身、この方式での Vue.js + TypeScript 環境は Production にて運用しています。

Vue.js や React.js、 TypeScript に GraphQL Apollo、 Jest によるテスティティングや決済基盤の Stripe まで、様々なモダン JavaScript についての学習教材を販売している JSLounge Archives という EC サイトを Nuxt.js + Firebase で構築しており、そのスタッフ向けの管理画面を Vue.js + TypeScript にて運用しております。

JSLounge Archive: https://jslounge-archives.elevenback.jp/

Firebase SDK を導入して Vue プラグインとして登録する

実際の導入例です。Firebase 自体について詳しく説明しても仕方がないので、 Firebase についての解説はスキップするほか、取得も可能な限り動的的な取得とし、解説のためにリアルタイムの複雑性は最小限とします。

Firebase を読み込んだ上で、 $firebase$database を prototype 宣言に追加してしまいます。

src/main.ts
import Vue from 'vue'
import App from './App.vue'
import firebase from 'firebase'

firebase.initializeApp({
  // Your token
})

;(async () => {
  const database = await firebase.database()
  Vue.prototype.$firebase = firebase
  Vue.prototype.$database = database
  new Vue({
    render: h => h(App),
  }).$mount('#app')
})()

あわせて shims-vue.d.ts も拡張します。Firebase を軸に触る場合、エンティティの interface を定義することは特に多くなってくるはずですので、 shims-vue.d.ts も含めて、 src/types といった専用のディレクトリを切っておくことをオススメします。実際に、現在 src/types 内にて運用しています。

src/types/shims-vue.d.ts
import Vue from 'vue'
import firebase, { app } from 'firebase'

declare module 'vue/types/vue' {
  interface Vue {
    $firebase: typeof firebase
    $database: firebase.firestore.Firestore
  }
}

これで一応そのまま Firestore につなげることはできるので、コンポーネント内でユーザー定義の型と紐付けると、動かすこと自体はできます。例えば、以下のような形です。

src/App.vue
<template>
  <div>
    {{count}}
  </div>
</template>

<script lang="ts">
import Vue from 'vue'

interface UserData {
  count: number
}

export default Vue.extend({
  data() {
    return {
      count: 0
    }
  },
  async mounted() {
    try {
      const doc = await this.$database.collection('users').doc('user1').get()
      this.count = doc.data() as UserData
    } catch(e) {}
  }
})
</script>

…… this.$database.collection('users').doc('user1').get() までは Firebase SDK の型定義によって補完が効いていますが、このまま頑張るのは正直あんまりなものがあります。どのみち自分たちでつける必要があることに違いがありませんが、コンポーネント個別の仕事として適切とは言えません。

ですので、専用の Repository を作ってしまうと良いでしょう。

専用の Repository の作成

実際にデータの読み書きのための Repository を作るケースをご紹介します。ここでは、ソースコードが複雑になる firebase インスタンスの引き回しなどは省略し、簡略化したサンプルコードとしてご紹介します。

まずはユーザー定義の共通の型を置く場所を作っておきます。

src/types/entities.ts
export interface User {
  id: string
  count: number
}

その上で、共通処理を行うための database.ts のようなものを用意しておくと良いでしょう。

src/utils/database.ts
import firebase from 'firebase'
import * as Entity from '../types/entities'

firebase.initializeApp({
  // Your token
})

let database: firebase.firestore.Firestore = null

export async function initialize() {
  if (database) return
  const database = await firebase.database()
}

export async function getUserData(userId: string) {
  try {
    const doc = await this.$database.collection('users').doc(userId).get()
    return doc.data() as Entity.User
  } catch(e) {}
}

最後に、 main.ts は $database をこちらを向けるように変更。

src/main.ts
import Vue from 'vue'
import App from './App.vue'
import firebase from 'firebase'
import * as database from './utils/database'

;(async () => {
  Vue.prototype.$firebase = firebase
  Vue.prototype.$database = database
  new Vue({
    render: h => h(App),
  }).$mount('#app')
})()

あわせて shims-vue.d.ts も編集しておきます。

src/types/shims-vue.d.ts
import Vue from 'vue'
import firebase, { app } from 'firebase'
import * as database from './database'

declare module 'vue/types/vue' {
  interface Vue {
    $firebase: typeof firebase
    $database: database
  }
}

こんな感じで定義してやると、大体丸く収まります。実際のコンポーネントでのコードは、以下のような形となります。

src/App.vue
<template>
  <div>
    {{count}}
  </div>
</template>

<script lang="ts">
import Vue from 'vue'

interface UserData {
  count: number
}

export default Vue.extend({
  data() {
    return {
      count: 0
    }
  },
  async mounted() {
    try {
      const user = await this.$database.getUserData('user1')
      this.count = user.count
    } catch(e) {}
  }
})
</script>

この段階でデータの送受信の窓を Firebase で担保できており、かつ Firebase はリアルタイムの通信を行うこともできるため、継続的にコンポーネントへとデータを流し込むようにしておけば、 Firebase 単体である程度までの SPA を作ることができるようになっています。

もし普段から Firebase を利用しているという場合は、 Firebase とリアルタイムにつなぐことによって、 Single State を使わずにコンポーネント内で効率的な状態管理と反映ができることを視野に入れつつ利用することも一つの手となります。

Firebase のリアルタイム性を活用したより詳しいコードは、また別の機会にご紹介します。

Sinai を利用して賄うケース

最後に Vuex 以外の状態管理ライブラリを使うケースとして、簡単に Sinai を紹介します。

私が普段使っているわけではないので今回は具体的な紹介は省きますが、 Sinai は Vue.js コアチームの、特に Vuex や TypeScript 周りの貢献をしている ktsn 氏が個人開発している状態管理ライブラリとなります。

基本的なインターフェースは Vuex と非常に似通っていながら、 Type-safe な環境を提供しているという大きな特徴があります。

もしご興味のあるかたは、少しのぞいてみても良いかもしれません。

https://github.com/ktsn/sinai

Vuex を使う場合

Vuex を利用する場合についても考えてみます。前提として、それなりにユーザー定義で頑張って型を書く必要があり、推論器にすべてを預けて快適に解決できるというほどではありません。

特に現状、 Vuex 層の型を快適に書くことと、コンポーネント側の型を適切に守ることを一つのコードで解決できるわけではないのが課題としてあります。今回は、共通して意識しておくべき事項と、

共通事項

Vuex を利用する際の共通事項として、 あまり型に神経質になりすぎない と、 mapXXX の利用を極力控えるというものがあります。
Vuex を利用する時点で、型を完全につけることは不可能に近いですし、 mapXXX を利用した場合は、特にユーザー定義の領域が増えます。

Vuex 層とコンポーネントにゆるく型をつける

グローバル登録と declare 拡張によって Store と型を適切にマッピングする

実際に行ってみます。この方式を取る場合、編集すべきは src/main.ts と src/shims-vue.d.ts、そして、ストアの各ファイルとなります。
今回はルートのみのストアが存在するとして、一番簡単な Vuex 内と State のマッピングを行ってみます。

まずは Vuex ストアからです。 Vuex ストアについては、以下のように interface を定義し、ベースとして利用します。

src/store/index.ts
import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

export interface State {
  count: number
}

export interface Context {
  commit: (name: string) => void
}

export interface Mutations {
  [key: string]: (state: State, payload?: any) => void
}

export interface Actions {
  [key: string]: (context: Context, payload?: any) => void
}

export const state: State = {
  count: 0
}

export const mutations: Mutations = {
  increment(state) {
    state.count++
  }
}

export const actions: Actions = {
  increment({ commit }) {
    commit('increment')
  }
}

export default new Vuex.Store({
  state,
  mutations,
  actions
})

あまり頑張りすぎてもつらいので、ひとまずは Actions は [key: string]: (context: Context, payload?: any) => void といったように、かならず取りうる値を守るところから始めると幸せになれます。

ここからスタートして、あとから Context の commit を拡張して、 Mutations の keyof を取るようにしても良いですし、個々人の裁量に任せていくと無理せずに済みます。

src/shims-vue.d.ts
import Vue from 'vue'
import * as Vuex from 'vuex'
import * as Store from './store/'

declare module 'vue/types/vue' {
  interface Vue {
    $store: Vuex.Store<any>, // Store が any になっているのでこの部分は妥協する
    $state: Store.State // State はこうすることによって型定義が守られる
  }
}

ここまで定義できたら、 main.ts に $state へのアクセスを行うためのコードを追記します。単純にプロパティを定義してやれば OK です。

src/main.ts
import Vue from 'vue'
import App from './App.vue'
import store from './store/'

Vue.config.productionTip = false

Object.defineProperty(Vue.prototype, '$state', {
  get(this: Vue) {
    return this.$store.state;
  }
})

new Vue({
  store,
  render: h => h(App)
}).$mount('#app')

これで準備完了です。

コンポーネントから利用する

実際にこれをコンポーネントから利用する時は、基本的に read は $state を利用し、 write は $store を利用します。例えば、上記に書かれているカウンタを実装する場合は、以下のようになります。

src/App.vue
<template>
  <div id="app">
    <p>{{count}}</p>
    <button type="button" @click="increment">+</button>
  </div>
</template>

<script lang="ts">
import Vue from 'vue'

export default Vue.extend({
  name: 'app',
  methods: {
    increment() {
      this.$store.dispatch('increment')
    }
  },
  computed: {
    count(): number {
      return this.$state.count
    }
  }
})
</script>

全てのコードに適切に型が付与されており、うまく動作することが確認できます。ある程度省エネかつ堅牢な型の構築が可能なので、

mapXXX は封印する

この記法は楽で便利ですが、いくつか欠点があります。

特に大きなものとしては、冒頭に注意点として書いた通り、TypeScript では mapXXX が割れ窓の原因となるため、そもそも考慮外として実装しています。

つまり、このシステムでは this.$store 経由でのストアへのアクセスや、 this.$state 経由での状態へのアクセスにコードが限定されます。どうしてもこの点が受け付けられない場合は、他の方法をオススメします。

逆に、この形で進める場合は mapXXX を利用することは完全にやめてしまうのが自然です。

より強力に型をつける

そこまでこだわらずに、ゆるく拡張していく場合は自身で定義していくのも悪くありませんが、多少冗長となっても Vuex の機能を十分に利用したい場合や、 mapXXX を妥協したくないパターンも有るかと思います。

その場合、 vuex-type-helper というパッケージを軸に、コアの機能とうまく組み合わせることによって、強力な型が実現できます。 vuex-type-helper も、 Vue.js コアチームの TypeScript を中心に改善を行っている ktsn 氏による個人的な開発物となります。

あくまでも個人開発なので多少は注意が必要ですが、基本的には Vuex コアの構造を活かした形で型定義の拡張だけが行われているものとなるため、つけはずしし辛いということはなく、安心して利用できるものとなっています。

vuex-type-helper を導入する

実際に導入してみます。外部パッケージとなるため、 NPM / Yarn から導入します。

$ yarn add vuex-type-helper

vuex-type-helper に寄せたコードに置き換える

導入後、実際に適用してみます。vuex-type-helper の実体は型定義の拡張であるため、他の Vue.js のパッケージのように、 Vue.use をして終了というわけではありません。通常の Vuex コードから少し中身を書き換える必要があります。

少し独特な記法が必要となるので、先に例をご紹介します。以下はレポジトリのサンプルコードとして掲載されているものを、実際にモジュールとして読み込める形で改変したものです。
Vuex のモジュール空間や Ducks パターンのようにそれぞれを個別で定義した上で、ジェネリクスを最大限活用するようなコードとなっています。

https://github.com/ktsn/vuex-type-helper#example

src/store/index.ts
import Vue from 'vue'
import * as Vuex from 'vuex'
import { DefineGetters, DefineMutations, DefineActions, Dispatcher, Committer } from 'vuex-type-helper'

Vue.use(Vuex)

export interface CounterState {
  count: number
}

export interface CounterGetters {
  half: number
}

export interface CounterMutations {
  inc: {
    amount: number
  }
}

export interface CounterActions {
  incAsync: {
    amount: number
    delay: number
  }
}

const state: CounterState = {
  count: 0
}

const getters: DefineGetters<CounterGetters, CounterState> = {
  half: state => state.count / 2
}

const mutations: DefineMutations<CounterMutations, CounterState> = {
  inc (state, { amount }) {
    state.count += amount
  }
}

const actions: DefineActions<CounterActions, CounterState, CounterMutations, CounterGetters> = {
  incAsync ({ commit }, payload) {
    setTimeout(() => {
      commit('inc', payload)
    }, payload.delay)
  }
}

export const store = new Vuex.Store({
  state,
  getters,
  mutations,
  actions
})

コードの記法が多少複雑化しますが、本格的に書く場合はこの形式を利用しても良いでしょう。
なお、この場合でもやはり declare 拡張による store の強化は必須となります。

一応それを行いたくない場合は、 mapXXX をストア上で作ってしまうという方法もありますので、こちらも紹介しておきます。なお、この方法を行う場合は、名前空間付きのモジュールを作成することが前提となります。

基本的には本格的なアプリケーションでは名前空間をつけないことはないはずなので問題ないとは思いますが、ご注意ください。

src/store/index.ts
import Vue from 'vue'
import * as Vuex from 'vuex'
import { DefineGetters, DefineMutations, DefineActions, Dispatcher, Committer } from 'vuex-type-helper'

Vue.use(Vuex)

export interface CounterState {
  count: number
}

export interface CounterGetters {
  half: number
}

export interface CounterMutations {
  inc: {
    amount: number
  }
}

export interface CounterActions {
  incAsync: {
    amount: number
    delay: number
  }
}

const state: CounterState = {
  count: 0
}

const getters: DefineGetters<CounterGetters, CounterState> = {
  half: state => state.count / 2
}

const mutations: DefineMutations<CounterMutations, CounterState> = {
  inc (state, { amount }) {
    state.count += amount
  }
}

const actions: DefineActions<CounterActions, CounterState, CounterMutations, CounterGetters> = {
  incAsync ({ commit }, payload) {
    setTimeout(() => {
      commit('inc', payload)
    }, payload.delay)
  }
}

export const {
  mapState,
  mapGetters,
  mapMutations,
  mapActions,
} = createNamespacedHelpers<CounterState, CounterGetters, CounterMutations, CounterActions>('count');

export const store = new Vuex.Store({
  state,
  getters,
  mutations,
  actions
})

こうすることによって、コンポーネントからは以下のようにアクセスすることができます。この場合、 declare 拡張を全く使わずに型の拡張の恩恵を受けることができます。

読み出し先が各モジュールとなるためそれなりに煩雑となりますが、こちらを行う場合は this.$store には完全に触らない形になるので記法を統一しつつシンプルに書ける利点はあります。

課題感としては、 Store と密結合になってしまうので、テスタブルとは言いづらい点などがあります。この部分はどうしようもないので、適切にテストコードを書き進めたい場合などは推奨できません。

src/App.vue
<template>
  <div>
    {{count}}
  </div>
</template>

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

export default Vue.extend({
  computed: {
    ...mapGetters(['count'])
  }
})
</script>

その他のパッケージによる解決について

vuex-type-helper 以外にも、 Vuex + TypeScript に挑戦している人は多くいます。私自身、他の選択肢は試しに触った程度でしかないため詳しくは述べませんが、例えば https://github.com/takefumi-yoshii/vuex-aggregate などがあります。

特に Vue.js 以外の技術を触ることが多い技術者が Vue.js に他のフレームワークでの考え方を輸入したようなものは多くあるため、マッチするものがあるか探ってみるのも一つの手でしょう。

コアでの TypeScript 強化の現状について

ここまで頑張って型をつけ続ける必要があるのかと、多少なりとも不満に思う人も多いはずです。この状況に対してコアが特に何もアクションを取っていないかというと、全くそういう事はありません。

昨年から Write Stores in Typescript #564(昨年1月) や、 Feature: Manually install 'vuex/types/vue.d.ts' #994(昨年10月)など、今でも議論が続いている Issue が多くあります。

しかし、 Breaking Changes を伴う変更や、影響範囲の大きな変更が多くなるため、実現には至っていないのが現状となります。

私自身は議論に参加しているわけではないのでなんとも言えませんが、 Vue 3 によってコアが TypeScript で書き換えられた後の直近のメジャーバージョンアップですべてが解決されるのでは?と予想しています。

動向を引き続きウォッチしておくことが大切そうです :eyes:

まとめ

長くなってしまったので、抑えておくべきポイントを箇条書きでまとめます。

はじめに

  • そもそも Vue.js + TypeScript をやる必要があるのか?
    • Vue.js 特有のライトさは一定以上失われるが、それでもやるか?
    • ある程度 Vue.js と TypeScript 両方に精通した人がチームにいるのであればやることは選択肢の一つ
    • それなりに面倒であることを理解して選定をすると良い

Vue.js 特有の事情について

  • ビルド環境
    • 現在は Vue CLI v3 で決まり
  • コンポーネントはデフォルトでは Type friendly ではない
    • Vue.extend か vue-class-component で開発を進める
    • どちらを利用するかは好みであり、 Vue.js らしく書きたいのか、 TypeScript らしく書きたいのかで決めて OK
  • declare 拡張の基本を抑えておく
    • d.ts を書くときによく使うもの
    • 型を拡張できる
    • SFC の $store などを拡張するときに酷使する

状態管理について

状態管理周りのまとめです。

Vuex の是非

  • Vuex はデフォルトでは型に弱い
  • Vuex を利用するメリット(公式である・devtoolsなど)を捨ててでも TS によせるか Vuex を頑張るか

Vuex を使わない

  • 自分で Store パターンを定義する
    • 簡単にできてある程度までなら書けるかもしれないが、だんだん辛くなるはず
    • 型の面では快適
  • Firebase などに移譲する
    • declare で拡張して this から FireStore につなげるような形が楽
    • とはいえどのみち専用の Repository を作ることになる
    • 全然ありではある
  • Sinai を使う
    • 別に Sinai でなくとも良いが、 TypeScript 識者のツールセットを使うと幸せt

Vuex を使う

  • 雑に型をつける
    • Ducks 的な分割形式でコードを書いてそれぞれに型をつけていく
    • まぁある程度つくしある程度快適には書ける
    • とはいえ Action あたりを快適に書くのは割とつらい
  • vuex-type-helper を使う
    • 割とゴリゴリ手動で型を書く必要がある
    • 定義してしまえば使う側としては十分強く書けるのは書ける
  • 今後の Vuex 事情について
    • コアも対応へのモチベーションはある
    • とはいえまだ先に見える……。

おわりに

ライトにまとめる予定がずいぶんと大作になってしまいましたが、いかがでしたでしょうか。Vue.js + TypeScript での開発は、まだまだ課題感も多くあり、簡単に幸福な開発環境が手に入るわけではないのが正直なところだと思います。

しかし、 Vue.js の開発でできる幅や引き出しを広げるという意味でも、来る Vue.js v3 のリリースに向けて、今から TypeScript での開発に慣れ親しんでおくというのも一つの手ではないでしょうか。

この記事が、 Vue.js + TypeScript に挑戦するすべての人に、少しでも役に立つことを願っています。

余談

Patreon での支援募集をはじめました。この記事が良かった!という方は、今後の情報発信のためにもぜひぜひ支援お願いします。
https://www.patreon.com/potato4d