vue3が本格的に使われるようになってくるとvue2系では影が薄かったprovide
とinject
も流行ってくると思います。(願望)
こいつらがいい感じに型で補強してあげるとより使いやすくなるので紹介します。
provide/injectって何
そもそもこいつらが何かという話ですが、簡単に言えば親のインスタンスにkey:valueを保持しておいてどのコンポーネントからでも取り出せるようにする関数です。
以下のように、provide
で登録したkeyとvalueをinject
で簡単に利用することができます。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
を利用していますが、provide
やinject
のジェネリクスに型を渡しても同じことは実現できるので好みの問題かもしれません。キーの発行やデータの取り出しを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で運用するにはまだまだ足りていない部分も多いですが、かなりシンプルな実装で型に守られた小さなストアを構築できるのがprovide
とinject
の強みです。また、vue-test-utilsのglobal.provide
関数によって簡単にデータをモックすることができる点も魅力的です。型を活かすことで様々な恩恵が受けられるため、ぜひともlang="ts"
とdefineComponent()
と合わせて使ってみてください。
それでは