まえがき
最近iCAREさんの所で Vue.js
を一緒にやらせていただいているのですが、フロントの技術スタックがかなりモダンであり、開発体験が良く、ノウハウをどんどん公開して良いと言っていただけたので、その内容を共有するシリーズです.
今回の記事の内容はiCareさんのDev Meetupで話した内容になります.
最近公式のソースを追った所、 Composition API
はapiの紹介はあれども、コードの書き方やその背景、Tips等は全然見当たりませんでした.
また、すごく強力なapiである provide/inject
の紹介も全然見当たりません.
今回はiCAREさんの所で Vue.js
を書く際に皆で意識している Composition API
と活用している provide/inject
のノウハウとその詳細な理由を共有します.
※ 直接編集リクエストをくれていつもありがとうございます. いつも通り記事の内容に意見がありましたら直接編集リクエストをください.
Composition API のコードの書き方
はじめに
Composition API
のソース・コードは、ルールを守って書いた上だと可読性・再利用性・凝集性を向上させれるポテンシャルがありますが、ベストプラクティスを十分学ぶ前に自由に書いた場合のコードではそれらのメリットが失われおり、むしろ悪化する事があります.
ここでは銀の弾丸ではない Composition API
を利用する上で必要な知識を学んで行きます.
用語
React hooks
を利用して作られた Component
の中で利用する為の関数の事を Custom Hooks
と呼びます.
Composition API
を利用して作られた関数は、rfc では composition function
と呼ばれていましたので、その様に呼んでいきます. (一応Vue.js公式イベントでも composition function
と読んでいたのでこれで良さそう
ソース: https://vue-composition-api-rfc.netlify.app/api.html
Composition APIのコンセプト
Composition API
のコンセプトは、提供されているAPIを利用して composition function
を作成していき、作成した composition function
達を組み合わせて、その Component
の render関数
に必要な最低限の event handler
や、 reactive
な文言等のコンテキストを決定します.
これ自体は既出の一般的なアイディアであり、テクニックです.
setup
Vue 3.x
では ComponentOptions
に setupメソッド
を追加できます.
Composition API
を利用するには setupメソッド
を実装する必要があります.
Vue 2.x
で Composition API
を利用するためには npm
に登録されている @vue/composition-api
を install
し、Composition API
を利用するための準備をします.
import VueCompositionAPI from '@vue/composition-api'
Vue.use(VueCompositionAPI)
その後 Vue 3.x
のように Composition API
を使いたい場所で defineComponent
関数を利用すれば、ComponentOptions
に setupメソッド
を実装できます.
以下が実装例です.
import { defineComponent } from '@vue/composition-api'
export default defineComponent({
name: 'HelloWorld',
props: {
counter: {
type: Number,
required: true,
},
},
setup(props, context) {
}
})
setupメソッドの解説
setupメソッド
の第1引数には解決された props
が渡されます.
setupメソッド
の this
には undefined
が束縛されています.
this
の代わりに第2引数の context
が提供されています.
context
の中には以前 this
でアクセスしていた多くの項目が存在します.
以下は @vue/composition-api@1.0.0-beta.10
で提供されている setupメソッド
の型定義です.
declare type SetupFunction<Props, RawBindings = {}> = (this: void, props: Props, ctx: SetupContext) => RawBindings | (() => VNode | null) | void;
interface SetupContext {
readonly attrs: Record<string, string>;
readonly slots: {
[key: string]: (...args: any[]) => VNode[];
};
readonly parent: ComponentInstance | null;
readonly root: ComponentInstance;
readonly listeners: {
[key: string]: Function;
};
emit(event: string, ...args: any[]): void;
}
write composition function code for setup
今回サンプルコードではカウンターアプリを作ってみます.
まずは Composition API
を使わないで書いた例です.
import Vue from 'vue'
export default Vue.extend({
template: `
<div>
<div>count: {{ count }}</div>
<button @click="inc">inc</button>
<button @click="dec">dec</button>
<button @click="reset">reset</button>
</div>
`,
name: 'SampleCounter',
props: {
countNum: {
type: Number,
default: 0
}
},
data() {
return {
count: this.countNum
}
},
methods: {
set(count) {
this.count = count
},
inc() {
this.count++;
},
dec() {
this.count--;
}
},
watch: {
count(count) {
this.$emit('change', count)
},
countNum(count) {
this.set(count)
}
},
})
今までの書き方だとn箇所でカウントロジックを利用したい場合、 mixin
を作って再利用をするのは1つの手段です.
しかし mixin
が抱えている問題を持ち込みたくない自分みたいな人だと、n箇所に似たようなコードを書く方を選択します.
Composition API
を利用すれば、mixin
の課題を解決した composition function
を作成できます.
以下が例です.
import { reactive, computed } from '@vue/composition-api'
function useCounter(initial = 0) {
const state = reactive({ count: initial });
return {
count: computed(() => state.count),
set: count => {
state.count = count
},
inc: () => {
state.count++;
},
dec: () => {
state.count--;
}
};
}
Composition API
のおかげでカウンターに関連するコードを纏めることができました.
以下が useCounter
の使用例です.
defineComponent({
template: `
<div>
<div>count: {{ count }}</div>
<button @click="inc">inc</button>
<button @click="dec">dec</button>
</div>
`,
name: 'SampleCounter',
props: {
countNum: {
type: Number,
default: 0
}
},
setup(props, context) {
const counterContext = useCounter(props.countNum)
watch(() => props.countNum, count => counterContext.set(count))
watch(counterContext.count, count => context.emit(count))
return counterContext
},
})
n箇所でカウントロジックを利用したい場合、今回作成した composition function
をn箇所で使う事ができます.
mixin
が抱えていた衝突等の問題は一切ありません.
Composition APIの幾つかの注意事項
こちらのドキュメントを見てみます.
https://composition-api.vuejs.org/#basic-example
Lifecycle Hooks
の項目に書かれている一文を紹介します.
These lifecycle registration methods can only be used during the invocation of a setup hook. It automatically figures out the current instance calling the setup hook using internal global state.
これの意味は以下の通りです
これらのライフサイクル登録メソッドは、
setupフック
の呼び出し時にのみ使用できます。内部のグローバル状態を利用して、setupフック
を呼び出している現在のインスタンスを自動的に把握します。
つまり、onMounted
等のライフサイクルに登録する関数は setupメソッド
の実行時にしか利用できず、setTimeout
で非同期に、もしくは watch
等で非同期に登録することはできません.
これと同じ制約をもっているapiをもう1つ紹介します.
それは、 provide/inject
です.
これも内部のグローバル状態に保持されている現在のインスタンスを利用するapiです.
composition functionの注意事項
幾つかの Composition API
は setupフック
の呼び出し時にのみ使用できるapiがあります.
それらを 利用している/将来的に利用する 予定のある composition function
も同様に setupフック
の呼び出し時以外では使用できなくなります. (もし setupフック
の呼び出し時以外で composition function
を呼出した場合、その composition function
は将来に新たに追加された setupフック
呼出し時でしか使えない便利なapi又は同様の制約を抱えている関連ライブラリ等も全て利用できなくなるということです.
関数を利用する際に、作られた関数のインターフェースは理解する必要がありますが、程度によりますが、基本的には詳細な実装を全て追ってない状態で、この様な関数は利用されても大丈夫です.
以上のことから、 composition function
は、 setupフック
の呼び出し時にのみ使用する事で、不用意なバグや事故を回避し、恩恵を受け続けることができます.
恩恵を受け続ける為に、 setupフック
の中でのみ、 composition function
を使用するようにすべきです.
つまり、 setTimeout
や computed
, watch
、 event handler
の中等で composition function
を呼出してはいけません.
setupフック呼出し後に生成させるrefやreactive等
setupフック
呼出し完了後に ref
や reactive
等を生成する事は可能です.
例えば、computed
の中で ref
を作成したり、reactive
を作成することが可能です.
コードレビューしているとその様なコードでは ref
や reactive
が必要ない場合が全てでした.
setupフック
の呼び出し完了後に ref
や reactive
を作らなければ実装できない物は今の所は存在しないし、今後も存在しないはずです.
制限のまとめ
制限をまとめると、setupフック
の呼出し時に computed
や メソッド
等の参照する値は全てそれまでに束縛されており、 setupフック
呼出し完了後に束縛対象が消えたり、新たに産まれたり、 composition function
が呼び出されたりする事はないということです.
以下は準拠している例と違反している例です.
まずは、準拠している例です.
これは共有される counter
を利用している例です.
setupフック
の呼出し時に全ての依存の束縛が完了しており、その後に composition function
を呼出したりしていません.
これは正しく動作します.
CodeSandboxはこちらです.
https://codesandbox.io/s/vue-composition-api-forked-1q916
import Vue from "vue";
import VueCompositionApi from "@vue/composition-api";
import { inject, provide } from "@vue/composition-api";
import { defineComponent, reactive, computed } from "@vue/composition-api";
Vue.use(VueCompositionApi);
function createContext(contextName = "") {
const contextKey = Symbol(contextName); // Reflect.ownKeys をサポートしていない環境もサポートする場合はuuid生成のライブラリをベースにkeyの構築をする方が良いかも
return [
contextValue => provide(contextKey, contextValue),
() => inject(contextKey)
];
}
const [useSharedCounterProvider, useSharedCounter] = createContext(
"SharedCounter"
);
function useCounter(initial = 0) {
const state = reactive({ count: initial });
return {
count: computed(() => state.count),
set: count => {
state.count = count;
},
inc: () => {
state.count++;
},
dec: () => {
state.count--;
}
};
}
const providerComponentFactory = (providerHooks, providerName = "") =>
defineComponent({
template: "<div><slot/></div>", // Vue 3.xではfragmentが使える
name: providerName,
setup() {
useSharedCounterProvider(providerHooks());
}
});
// useCounterの実行結果をprovideするコンポーネントの作成
const SharedCounterProvider = providerComponentFactory(
useCounter,
"SharedCounterProvider"
);
const SampleInjectCounter = defineComponent({
template: `
<div>
<div>count: {{ count }}</div>
<button @click="inc">inc</button>
<button @click="dec">dec</button>
</div>
`,
name: "SampleInjectCounter",
setup() {
return useSharedCounter();
}
});
new Vue({
components: {
SharedCounterProvider,
SampleInjectCounter
},
template: `
<shared-counter-provider>
<sample-inject-counter/>
<sample-inject-counter/>
<shared-counter-provider>
<sample-inject-counter/>
</shared-counter-provider>
</shared-counter-provider>
`,
name: "SampleApp"
}).$mount("#app");
次は event handler
で composition function
を呼出している例です. (違反例
これは event handler
が Error
を吐きます.
CodeSandboxはこちら
https://codesandbox.io/s/vue-composition-api-forked-onjbj
invalidInc
をクリックすると以下のように Error
がでます.
const InvalidSampleInjectCounter = defineComponent({
template: `
<div>
<div>invalidComputed: {{ invalidComputed }}</div>
<button @click="invalidInc">invalidInc</button>
<button @click="invalidDec">invalidDec</button>
</div>
`,
name: "InvalidSampleInjectCounter",
setup() {
const { count } = useSharedCounter();
return {
invalidComputed: computed(() => count.value ** 2),
invalidInc: () => useSharedCounter().inc(),
invalidDec: () => useSharedCounter().dec()
};
}
});
Errorメッセージ
はこの通りで、この Error
は想定通りです. [Vue warn]: inject() can only be used inside setup() or functional components.
Composition APIのまとめ
基本的に Composition API
を利用すると今までの Option API
にあった制限やスコープの影響で正常動作しないリスクのあったいくつかの手法が解禁されます.
それらの手法を使わない場合は Option API
の方が上手く書ける場合は多々あります.
今までの Option API
で良くも悪くも慣れている状況下で Composition API
をチームでいきなり使いこなすには超えるハードルが多く、その為チーム一丸となって段階的に浸透させる戦略等をチームに合う形で考える事が必要かもしれないです.
Vuex vs provide/inject
共有ステートを作成する時に、Vue.js公式
からは幾つかの手段が用意されています.
以下がその一覧です. (他にもあるかも
- Vue.observable
- Vuex
- dataを共有する専用の
Vue.js
インスタンスの作成 - provide/inject
共有ステートを作成したい要望がある時、最近だと Vue.observable
も有力な選択肢に入ってきます.
しかし、Vue.observable
で state
を global
に作成して利用する場合は、singleton
であり、singleton
が持つ新たな課題を産みます. (例えば、同一プロセスでのテストの同時実行数への制限や、各テストで singleton
の初期化を忘れない、差し替え難易度等... etc
例:
// これはsingletonなglobal stateで新たな課題を産みます.
export const sharedCounter = Vue.observable({
count: 0
})
以前だと多くのプロジェクトで Vuex
を使うか使わないかの話になる場合が多いです.
Vuex
は分離できない単一の大きなモジュールのコードを複数人で書き、作成していく為、複数人で単一のモジュールを開発する文脈由来の課題が発生し、品質維持の難易度が高いです. Vuex
を初めて導入した後の多くのプロジェクトでは可能なら多くのコードを書き直したいと考えているチームは多いはずです.
provide/inject は小さく分離できる小さなモジュールをアプリケーション 全体/部分 で簡単に共有できるものであり、provideするものをある程度まで小さくすれば影響範囲の把握がしやすいです.
provide/inject
の乱用もよくありませんが、認証情報、環境依存情報、一部のコンポーネント専用の情報(Form等)等の共有を実現するのに役立ちます.
当然 singleton
でもないですし、テストも容易です.
provide/inject
は基本的にはVuexより手軽にできると思います.
これは先程と同じコードですが、 provide/inject
のデモとして十分であるため、再利用させていただきます.
CodeSandboxはこちらです.
https://codesandbox.io/s/vue-composition-api-forked-1q916
import Vue from "vue";
import VueCompositionApi from "@vue/composition-api";
import { inject, provide } from "@vue/composition-api";
import { defineComponent, reactive, computed } from "@vue/composition-api";
Vue.use(VueCompositionApi);
function createContext(contextName = "") {
const contextKey = Symbol(contextName); // Reflect.ownKeys をサポートしていない環境もサポートする場合はuuid生成のライブラリをベースにkeyの構築をする方が良いかも
return [
contextValue => provide(contextKey, contextValue),
() => inject(contextKey)
];
}
const [useSharedCounterProvider, useSharedCounter] = createContext(
"SharedCounter"
);
function useCounter(initial = 0) {
const state = reactive({ count: initial });
return {
count: computed(() => state.count),
set: count => {
state.count = count;
},
inc: () => {
state.count++;
},
dec: () => {
state.count--;
}
};
}
const providerComponentFactory = (providerHooks, providerName = "") =>
defineComponent({
template: "<div><slot/></div>", // Vue 3.xではfragmentが使える
name: providerName,
setup() {
useSharedCounterProvider(providerHooks());
}
});
// useCounterの実行結果をprovideするコンポーネントの作成
const SharedCounterProvider = providerComponentFactory(
useCounter,
"SharedCounterProvider"
);
const SampleInjectCounter = defineComponent({
template: `
<div>
<div>count: {{ count }}</div>
<button @click="inc">inc</button>
<button @click="dec">dec</button>
</div>
`,
name: "SampleInjectCounter",
setup() {
return useSharedCounter();
}
});
new Vue({
components: {
SharedCounterProvider,
SampleInjectCounter
},
template: `
<shared-counter-provider>
<sample-inject-counter/>
<sample-inject-counter/>
<shared-counter-provider>
<sample-inject-counter/>
</shared-counter-provider>
</shared-counter-provider>
`,
name: "SampleApp"
}).$mount("#app");
この provide/inject
は公式ドキュメントにも書かれていますが、React
の Context API
と酷似しています.
Vuex vs provide/inject
の話をもし深追いしたい場合は、Redux vs React Context
と Google検索
すれば、期待以上の沢山の有意義な議論を目にすることが可能です.
iCARE では Vuex
を使わずに、 provide/inject
を積極的に活用しています.
最後に
如何だったでしょうか?
Vue 3.x
がリリースされ、jsx
と functional component
が使いやすくなる日が待ち遠しいですね.
Fragment
や Teleport
、Suspense
等も凄く楽しみです