はじめに
少し前に作って、更新が滞っていたアプリケーションに機能を追加したかった。
ちょうどよかったので Vue + Class Decorator + TypeScript の構成だったが、 Vue + CompositionAPI + TypeScript に作り直した。
この記事では、その過程で思ったことや考えたことを共有したい。
作成しているアプリケーションは以下。
- ソース: https://github.com/sterashima78/vue-webpage-builder
- アプリケーション: https://sterashima78.github.io/vue-webpage-builder
- 使い方はソースについている README を見てください
作り直す前
- コンポーネント定義はクラススタイル
- vue-class-componentでやってた
- Vuex もクラススタイル
- vuex-module-decoratorsでやってた
とりあえず型がついていたのでよかった。
作り直すにあたって考えたこと
Vuexいる?
なんで Vuex を使いたいのかを考えた。
- 共有したい状態を中央管理したい
- composition-api でできる
- タイムトラベルデバッグがしたい
- 個人的にはそんなに使わない
- コンポーネントに注入できる
- vuex-module-decorators 使ってたら結局 store は注入しない
- コンポーネントの注入された共有された状態を無秩序に触れると困ることが多いのでむしろ明示的にインポートするほうがいい。
Vuex いらない。
composition-api を使って状態を共有する方法はいくつかの記事があるけどかんたんに紹介する。
export const useState = ()=> {
return {
state: reactive({
count: 0
})
}
}
上記の場合は useState がコールされるたびに新しく状態が生成されるので、コンポーネント間で独立したものになる。
const state = reactive({
count: 0
})
export const useState = ()=> ({ state })
上記の場合は useState がコールされると state が返却されるが、この state はモジュール内で定義されたものなので、コンポーネント間で共有される。
状態を変更するためのインターフェースを提供したければ普通に状態を変更する関数を公開すればいい。
const _state = reactive({
count: 0
})
// 公開する状態はreadonly
const state = computed(()=> _state)
// 注入された状態を更新する
export const increment = (state) => () => state.count++
export const decrement = (state) => () => state.count--
export const useState = ()=> ({
state,
increment: increment(_state),
decrement: decrement(_state)
})
場合にもよるが、状態を変更する関数は上記のように変更対象の状態を注入できるようにしておくと試験がしやすい。
import { increment } from "./composition"
describe("useState", ()=> {
describe("increment", ()=> {
test("count を 1 増やす", ()=> {
const mockState = { count: 100 }
increment(mockState)()
expect(mockState.count).toBe(101)
})
})
})
これだとあんまり効果が実感できないが、例えば 1000 以上は増えちゃいけないみたいな要件があるときには試験しやすい。
Class Component と Vue の相性悪くない?
基本的にコンポーネントはコンポーネントが持っている状態を、ユーザからのアクションに応じて変更する。そして、この状態に応じてUIが更新される。
ここから考えると、自分の状態とそれを変更するロジックをひとまとめにする class をコンポーネント定義に使うのはとても自然に感じてた。
実際 Class Component は this の型付けもうまくできていたしはじめは使いやすかった。
ただ、これを複数のクラスやコンポーネントとの連携を考える急に難しくなると感じた。
OOPではオブジェクト間でのメッセージングでソフトウェアを組み立てていくけど、実際はコンポーネント定義の糖衣構文として使っているだけなので、そういうメッセージングを行う頭で考えてしまうと設計を間違える。
まぁ、これ自体はそういう頭で考えなければいいのだが、コンポーネントを定義するクラスに他のクラスに依存させたいときはまた難しい。
OOPでこれを行うときはコンストラクタインジェクションなどで依存を注入するなどがポピュラーだと思うが、 Vue のコンポーネント定義ではコンストラクタ定義ができない。
そのため、直接クラス内でインスタンスを生成する必要がある。
class HogeComponent extends Vue {
constructor(private domain: DomainClass) {
}
hoge() {
this.state = this.domain.foo("bar")
}
}
そのために provide, inject の仕組みを Vue は用意してくれているが、コンポーネントからコンポーネントへの提供を想定しているので試験などやりにくい。
class コンポーネント使いにくい。
composition-api を使ってあげるとこの辺がうまく解決できる。
重要なのは、 setup メソッドは 所謂 main に相当するものであると考えることだと思う。
つまり、 setup にはロジックを記述せずに依存インポートと、composition 関数への依存注入に責任をもたせる。
// 依存はダイレクトにモジュールをインポートしないで引数で注入する
export useHoge = (domainLogic)=> {
const state = ref(null)
const hoge ()=> {
state.value = domainLogic("bar")
}
return {
state, hoge
}
}
<script>
import { domainLogic } from "@/domain"
import { useHoge } from "./composiotons/"
export default defineComponent({
setup() {
// 依存の解決だけ行う
return { ...useHoge(domainLogic) }
}
})
</script>
こうすることで composition 単体での試験がとてもやりやすくなる。
describe("useHoge", ()=> {
describe("hoge", ()=> {
const mockFn = jest.fn()
mockFn.mockImplementation(()=> "foo")
const { hoge, state } = useHoge(mockFn)
hoge()
expect(state).toBe("foo")
expect(mockFn.mock.calls[0][0]).toBe("bar")
})
})
おわりに
Class Componet のスタイルから、 Composition API のスタイルに書き換えたときに考えたことを記載した。
基本的に試験の容易性を高めることが、結果的に拡張性・保守性を高めることにつながると考えているので、やや偏っていたかもしれないが参考になれば嬉しい。
これ以外に composition-api を使ったプロジェクトでのテストについて以下の記事を作成しているので、よければそちらも参考にしてほしい。
また、関心を分離しながらプログラムを書くために、プレーンなJSから composition-api を使うまでのステップアップしていくさまを以下で紹介している。
https://qiita.com/sterashima78/items/e5518dabbfccdf6a205d
composition-api は適切に利用すれば Vue の抱えていた多くの問題をすると思うので、ぜひいろんな人に使い込まれてベストプラクティスが構築されていってほしいと思う。