はじめに
この記事はVueとTypeScriptの開発で状態管理について悩んでいる方へ,Vuexを使わないという選択をした場合どのように状態管理をしていくかを綴った記事です。
Vuexを否定することが目的ではありません,Vuexを使いたい場合は@tsrnkさんが書かれているこちらの記事のようにvuex-module-decoratorsを使う方法をおすすめします。
Vue 3ではComposition APIに ref
, reactive
が追加されたのでこちらを使いましょう。
何に悩んでいたのか
TypeScriptとVuexは相性が悪く型安全性について度々問題にぶつかります。
厳密にはTypeScriptで書くVueとVuexの相性が悪いです。
そもVue 2.xはJavaScript製であり,もともとTypeScriptのような型安全を考慮した作りにはなっていません。
ですのでTypeScriptの型定義ファイルによって型をあと付けして可能な限り型安全が保たれるようにしています。
Vuexで作ったストアをコンポーネントから呼び出すためにはコンポーネント内でthis.$store
を使います。
例えばストアのcount
というステートをコンポーネントから呼び出したい場合には以下の様に書きます。
export default new Vuex.Store({
state: {
count: 0
},
});
@Component
export default class extends Vue {
created() {
console.log(this.$store.state.count);
}
}
このときthis.$store
の持つオブジェクトはStore
型であり,ストアの内容であるStoreOptions
型のオブジェクトとは異なるものです。
これもVueがJavaScript製でありメタプログラミングによってVueとVuexのやり取りをしていることに起因しています。
そのため,コンポーネントからストアを呼び出す際にはvuex-classのようにVuexとコンポーネントを対応させるというアプローチでこの問題に対処してきました。
例えばvuex-classではコンポーネントを以下のように書きます(公式より抜粋)。
<script lang="ts">
import Vue from 'vue';
import Component from 'vue-class-component';
import { State, Getter, Action, Mutation, namespace } from 'vuex-class';
@Component
export class MyComp extends Vue {
@State('foo') stateFoo;
@State(state => state.bar) stateBar;
@Getter('foo') getterFoo;
@Action('foo') actionFoo;
@Mutation('foo') mutationFoo;
// If the argument is omitted, use the property name
// for each state/getter/action/mutation type
@State foo;
@Getter bar;
@Action baz;
@Mutation qux;
created() {
this.stateFoo; // -> store.state.foo
this.stateBar; // -> store.state.bar
this.getterFoo; // -> store.getters.foo
this.actionFoo({ value: true }); // -> store.dispatch('foo', { value: true })
this.mutationFoo({ value: true }); // -> store.commit('foo', { value: true })
this.moduleGetterFoo; // -> store.getters['path/to/module/foo']
}
}
</script>
以下はデコレーター部分だけ取り出したものです。
ストア内での名前を文字列で指定し,コンポーネント内で使いたい名前と対応させています。
@State('foo') stateFoo;
@State(state => state.bar) stateBar;
@Getter('foo') getterFoo;
@Action('foo') actionFoo;
@Mutation('foo') mutationFoo;
このままでは対応させたステートやミューテーションはany
のままなので,更にインタフェースを用いて型をあと付けすることができます。
しかしどうでしょうか,そも文字列内の名前を書き間違えてしまうと対応するものがなくなってしまうので動かなくなります。
型安全を手に入れるために文字列を使って安全ではない書き方をしなければならないという新たな危険をはらんでいるのです。
そして,Vuexが肥大化すればするほどVueとTypeScriptの型安全のために大量のインタフェースとデコレーターを書かなければなりません。
Vuexを使わないことのメリット
TypeScriptの知識のみでストアを書ける
Vue 2.6.0から登場したVue.observable()
により簡単にリアクティブなオブジェクトを作ることができるようになりました。
オブジェクトの内容はリアクティブ化する際に付与されるいくつかのプロパティやメソッドを除けば,それ以外は元のオブジェクトと同じです。
つまりは,ストアとして扱いたいオブジェクトとimport
とexport
を書ければ良いのでTypeScriptの知識のみで実装することができ追加のコストがかかりません。
大量の型をあと付けしなくて良い
先に挙げた大量のデコレーターを書く必要がなくなり,import
とexport
を使ってオブジェクトを引き回しても型安全が保たれるのでコードの記述量が減ります。
ファイルサイズが減る
ごくわずか(10KB程度)とはいえVuexを含めなくて良い分ファイルサイズが減ります。
Vuexを使わないことのデメリット
公式の開発ツール拡張と連携できない
Vue公式による開発ツール拡張との連携ができなくなるので,タイムトラベルデバッグやスナップショットを利用できなくなります。
これはVuexを使わないことの大きなデメリットになります。
Vuexの力が必要な規模の開発ではvuex-module-decoratorsを検討しましょう。
ストアの実装
ストアを作る前にVue.observable()
について知っておきます。
公式より以下のように書かれています。
オブジェクトをリアクティブにします。内部的には、Vue は data 関数から返されたオブジェクトに対してこれを使っています。
戻り値のオブジェクトは、描画関数 や 算出プロパティ の中で直接使え、値が変更されたときには適切な更新をトリガーします。単純なシナリオでは、コンポーネントをまたぐ最小の state ストアとして使用することもできます:
つまり,Vue.observable()
にオブジェクトを渡すとリアクティブなオブジェクトにしてくれるというもの。
例えば以下のようなオブジェクトを作ります。
const store = {
count: 0
};
なんの変哲もないただのオブジェクトですが,store
をconsole.log()
等で出力して確認してみます。
これをVue.observable()
に渡してみます。
import Vue from 'vue';
const store = Vue.observable({
count: 0
});
すると以下のようになります。
Observer
オブジェクトの入った__ob__
プロパティとcount
のゲッターメソッドとセッターメソッドが生えました。
count
プロパティへのアクセスはこのゲッターメソッドとセッターメソッドを経由することでリアクティブを実現しているわけですね。
注意ですが公式で次のように書かれています。
Vue 2.x では、Vue.observable は渡されたオブジェクトを直接操作するため、ここでデモされる ように戻り値のオブジェクトと等しくなります。Vue 3.x では、代わりにリアクティブプロキシを返し、元のオブジェクトを直接変更してもリアクティブにならないようにします。そのため、将来の互換性を考えると、Vue.observable に渡したオブジェクトではなく、返されたオブジェクトを使うことを推奨します。
つまり,参照によって引数に与えられたオブジェクトをリアクティブにするので次のようにも書けるわけですね。
const store = {
count: 0
};
Vue.observable(store);
しかし,Vue 3からは元のオブジェクトが変わらないようになるとのことなので,戻り値から受け取ったほうが良さそうです。
さて,このままストアとして利用しても良いですがクラスで書き直します。
クラスで書くことで継承やカプセル化をより簡単に書けるようになります。
一旦Vue.observable()
は使わず以下のようにクラスに書き直しインスタンス化します。
class Store {
private count = 0;
}
const store = new Store();
Store
クラスからインスタンス化したのでStore
オブジェクトができました。
このStore
オブジェクトもオブジェクトですのでVue.observable()
でリアクティブにできるはずです。
以下のようにリアクティブ化します。
import Vue from 'vue';
class Store {
private count = 0;
}
const store = Vue.observable(new Store());
先程と同様にリアクティブに必要な情報が付与されました。
続けてVuexのゲッターやミューテーションと同様のものをクラスに実装します。
プロパティが_count
になっている点に注意してください。
また,他所でimport
するためにexport default
しています。
import Vue from 'vue';
class Store {
private _count = 0;
public get count() {
return this._count;
}
public increment() {
this._count++;
}
public decrement() {
this._count--;
}
}
export default Vue.observable(new Store());
private
修飾子によって_count
は隠蔽され,プロパティの変更はincrement()
とdecrement()
ミューテーションからのみ操作できるようになりました。
次にコンポーネントからストアを呼び出してみます。
<script lang="ts">
import store from '@/store';
import { Component, Vue } from 'vue-property-decorator';
@Component
export default class extends Vue {
private store = store;
private onPlusClicked() {
store.increment();
}
private onMinusClicked() {
store.decrement();
}
}
</script>
<template>
<div>
<p>{{ store.count }}</p>
<button @click="onPlusClicked" type="button">+</button>
<button @click="onMinusClicked" type="button">-</button>
</div>
</template>
さいごに
公式にあるようにシンプルなストアパターンで良い場合は,この方法であれば安全に低コストで実装することができます。
また,ある程度の規模であっても集中型のストアかつ型安全であるが故に,状態やコンテキストの把握が容易になるので開発ツール拡張の補助を必要としない場合にも良い方法だと思います。