うしろのこの本ください

なんでもかきます

vue3のprovide,injectで型安全なストアを作る

vue3が本格的に使われるようになってくるとvue2系では影が薄かったprovideinjectも流行ってくると思います。(願望) こいつらがいい感じに型で補強してあげるとより使いやすくなるので紹介します。

provide/injectって何

そもそもこいつらが何かという話ですが、簡単に言えば親のインスタンスにkey:valueを保持しておいてどのコンポーネントからでも取り出せるようにする関数です。 以下のように、provideで登録したkeyとvalueinjectで簡単に利用することができます。valueは登録時にリアクティブにしておけば、取り出した側での変更を追跡します。

main.ts

import { createApp, ref } from 'vue'
import App from './App.vue'

const key = 'count' // 一意なキーを生成する
const value = ref(0)

const app = createApp(App)
app.provide(key, value) // app.provideかvueからimportしたprovide()で登録
app.mount('#app')

components/ReactiveCounter.vue

<script lang="ts">
import { defineComponent, inject } from 'vue'

export default defineComponent({
  name: 'ReactiveCounter',
  setup() {
    const count = inject('count')
    const plusOne = () => count.value++
    return {
      count,
      plusOne
    }
  }
})
</script>

<template>
  <div>{{ count.value }}</div> // カウントアップされる
  <button  @click="plusOne">+</button>
</template>

この登録したvalueであるcountは別のコンポーネントから参照した際にも変更を保持しています。つまり、provide/injectは親のインスタンスに値を保持して管理するリアクティブなDI機構と言えます。

実装はここら辺ですが非常にシンプルで、currentInstanceにキー付きで値を保持しているだけです。

vue-next/apiInject.ts at 94562daea70fde33a340bb7b57746523c3660a8e · vuejs/vue-next · GitHub

ちなみにcurrentInstanceはgetCurrentInstance関数で取得することができるので、開発者がcurrentInstance.providesに対してProxyを通して任意のsetter/getterトラップを仕込むこともできそうです。(強制的にreadonlyにする、setter関数をreturnするようにする等)

provide/injectに型をつける

さて肝心の使い心地ですが、ちょっとプリミティブすぎて大人数で開発すると普通に事故りそうですね。せめてキーに基づいた登録できる値の型の制限とか、直参照禁止とかそういうレベルになってほしいところです。ここら辺のニーズはtsの恩恵を受けることで満たすことができます。

vue3からinjectとは別にInjectionKeyという型がexportされています。使い方は以下のような感じで、injectによって取り出す値の型をInjectionKeyジェネリクスとして渡し、それをkeyの型として扱うことでprovide時に登録するvalueの型をチェックするというものです。

composables/store.ts

import { InjectionKey, Ref } from 'vue'

type Count = Ref<number>

export const CountSymbol: InjectionKey<Count> = Symbol()

main.ts

import { createApp, ref } from 'vue'
import { CountSymbol } from './composables/store'
import App from './App.vue'

const value = ref('0')

const app = createApp(App)
app.provide(CountSymbol, value) // error: 型 '"0'" の引数を型 'Count' のパラメーターに割り当てることはできません。
app.mount('#app')

これで初期値を間違えて入れてしまってあらゆるDI先を破壊してしまう、みたいな事故を防げますし、vue-test-utilsでのprovideのモックも型情報からダミーデータを生成しやすくなって楽です。(この辺はまた別で記事にします) 今回はInjectionKeyを利用していますが、provideinjectジェネリクスに型を渡しても同じことは実現できるので好みの問題かもしれません。キーの発行やデータの取り出しをstoreなどに隠蔽したい場合はこの方法がおすすめです。

使う側はそのままinjectを使うよりもcomposableなカスタム関数を用意した方が都合が良いです。なお、injectはキーが存在しない場合undefinedを返すようになっているのでチェックが必要です。

余談ですが、compositionを利用した関数は以下のようにcomposablesディレクトリ配下からuseというプレフィックスをつけて提供するのがVue周辺ライブラリでは定着しつつあります。

composables/store.ts

import { inject, InjectionKey, Ref } from 'vue'

type Count = Ref<number>

export const CountSymbol: InjectionKey<Count> = Symbol()

export const useCount = () => {
  const count = inject(CountSymbol)
  if (!count) {
    throw new Error('useCount() is called without provider.')
  }
  return count
}

これで利用側でinject用のキーを扱わなくてもよくなります。

もう少し堅牢にしてみる

これだけでは若干使いやすくなった程度なので、もう少し工夫して信頼できる程度にしてみます。vue3からreadonlyという関数が提供されているのでそれを使います。readonlyはその名の通り、tsの組み込み型であるReadonlyで引数をラップしつつリアクティブ化して返却する関数です。これにより提供する値を直接上書きすることを禁止し、別途提供される更新用関数を用いて更新させるようにします。readonlyにはオブジェクトしか渡せませんが、refを通した値は{ value: count }という形でラップされるため扱うことができます。

composables/store.ts

import { inject, InjectionKey, Ref } from 'vue'

type Count = Ref<number>

export const CountSymbol: InjectionKey<Count> = Symbol()

export const useCount = () => {
  const c = inject(CountSymbol)
  if (!c) {
    throw new Error('useCount() is called without provider.')
  }

  const count = readonly(c)
  const setCountPlusOne = () => c.value++ // readonlyにする前の値を更新する
  return { count, setCountPlusOne  }
}

これでsetter経由でしかストアの値を更新できないようにできました。この辺の実装はVuexのmutation/stateの関係と似ていますね。(あちらは型で守るには工夫が必要ですが)

components/ReactiveCounter.vue

<script lang="ts">
import { defineComponent } from 'vue'
import { useCount } from './composables/store'

export default defineComponent({
  name: 'ReactiveCounter',
  setup() {
    return {
      ...useCount(),
    }
  }
})
</script>

<template>
  <div>{{ count.value }}</div> // setCountPlusOneを介してのみカウントアップされる
  <button  @click="setCountPlusOne">+</button>
  {{ count + 1 }} // Cannot assign to 'count' because it is a read-only property.
</template>

ちなみにreadonlyを通した値をテンプレート上で上書きしようとするとVeturがエラーにしてくれますが、ブラウザではコンソールでSet operation on key "xxx" failed: target is readonly.という警告を出します。エラーではないので要注意です。

テンプレートの型チェックの体験をなんとかする記事はこれ ushirock.hateblo.jp

propsにする場合の型は以下のようにすればOKです。

import { defineComponent, PropType, Ref } from 'vue'

type Prop = {
  count: Ref<number>
  setCountPlusOne: () => void
}

export default defineComponent({
  props: {
    // プリミティブな値(ref())の場合
    refValue: {
      type: Object as PropType<Readonly<Ref<number>>>
    }
    // オブジェクト(reactive())の場合
    objValue: {
      type: Object as PropType<Readonly<Prop>>
    }
  }
})

おわり

実際productionで運用するにはまだまだ足りていない部分も多いですが、かなりシンプルな実装で型に守られた小さなストアを構築できるのがprovideinjectの強みです。また、vue-test-utilsのglobal.provide関数によって簡単にデータをモックすることができる点も魅力的です。型を活かすことで様々な恩恵が受けられるため、ぜひともlang="ts"defineComponent()と合わせて使ってみてください。

それでは