先日の投稿に続き第4弾です。MobXは柔軟で分かり易いため、多くを語らずともだいたいのことは出来てしまいます。
MobXはObservableな値を導入するためのライブラリであり、フレームワークではありません。実装がシンプルになる一方、設計方針と規約を明確にしなければ、独自設計が入り乱れ、保守の難しいコードになってしまいます。既出の設計論をみつつ、考慮出来るポイントをいくつか紹介していきます。
既存の React MobX 設計論
React-MobXにおけるコンポーネント実装の正は明確で、迷うことはほぼありません。小さく組み立てていき、あとは stateless か stateful かを選択するぐらいでしょう。MobXにおいて設計のキモは Store に集約するものだとすぐに気付きます。mobx.js.org でも Store設計がどうあるべきか、という記事が掲載されています。https://mobx.js.org/best/store.html
ここに記されている Singletonパターンは冒頭で明記されている通り、単なる1つの方法であり、MobX = Singleton
ではないことを誤解なきようお願いします。
Singletonパターン考察
class定義ファイル内でインスタンスを1つ生成して export、class定義は exportしない。こうすることで、アプリケーションにおいて唯一のインスタンスが約束されます。以下の様な実装です。
class WindowStore {
}
const singleton = new WindowStore()
export default singleton
ウィンドウリサイズで observable値を変更するものに書き換えてみましょう。幅と高さが observableになります。
class WindowStore {
@observable width
@observable height
constructor() {
window.addEventListener('resize', e => this.onResize(e))
}
@action onResize = () => {
this.width = window.innerWidth
this.height = window.innerHeight
}
}
const singleton = new WindowStore()
export default singleton
ここで定義されている observable値を参照している異なる Storeをみてみましょう。computedで反応することが出来ます。
import WindowStore from '~/stores/singletons/WindowStore.js'
export default class GlobalUIStore {
@computed get windowArea() {
return `${WindowStore.width * WindowStore.height}px`
}
}
const singleton = new GlobalUIStore()
export default singleton
ただし、別Storeに密になりすぎたり、いくつものStoreを跨ぐ参照は控えた方が良いので、それぞれの役割を明確にしましょう。また、このままではテストコードが必要な場合に困ります。
参照しあうStoreの設計
先ほどのサンプルコードでは、画面サイズのモックを作成することが不可能でした。この点、少し工夫をすることでクリアすることが出来ます。
export class WindowStore { // ※1 exportしてしまう
@observable width
@observable height
constructor(props) {
Object.assign(this, props) // ※2 instantiate時に引数をとる
window.addEventListener('resize', e => this.onResize(e))
}
@action onResize = () => {
this.width = window.innerWidth
this.height = window.innerHeight
}
}
const singleton = new WindowStore()
export default singleton // ※3 通常 importされるのはこっち
// ※4 シングルトンインスタンスを import
import WindowStore from '~/stores/singletons/WindowStore.js'
export class GlobalUIStore {
constructor(props) {
Object.assign(this, props) // ※6 instantiate時に引数をとる
}
@computed get windowArea() {
// ※7 この段階ではシングルトンインスタンスを参照
return `${this.WindowStore.width * this.WindowStore.height}px`
}
}
// ※5 依存Store(シングルトンインスタンス)への参照を保持させる。(ブラウザ実行時の挙動)
const singleton = new GlobalUIStore({ WindowStore })
export default singleton
// ※8 それぞれシングルトンインスタンスではなく class定義を import
import { WindowStore } from '~/stores/singletons/WindowStore.js'
import { GlobalUIStore } from '~/stores/singletons/GlobalUIStore.js'
const globalUIStore = new GlobalUIStore({
// ※9 モックを挿入することが出来る様になった
WindowStore: new WindowStore({ width: 640, height: 480 })
})
// ...中略
it('Find screen area', () => {
expect(globalUIStore.windowArea).to.be.eql("307200px")
})
class定義をexportしてしまっている段階で、シングルトンパターンとしては破綻していますが、ここは規約に定めて矯正する他なさそうです。Store同士の依存関係をコンストラクタで渡すことで、テスト可能なStoreにすることが出来ました。
SingletonStore の inject
mobx-react の API である inject は、紐づけられた Providerが保持している Storeに限った参照をできるものだと思っていましたが、今のところそうでは無い様子です。SingletonStoreインスタンス を component で直接 inject しても反応するので、設計次第では活用出来るかもしれません。
import { inject } from 'mobx-react'
import SomeStore from '~/stores/singletons/someStore'
const SomeComponent = ({ valueA, valueB, flag }) => {
return (
<div className="some-component">
{ flag ? valueA : valueB }
</div>
)
}
export default inject(() => ({
valueA: SomeStore.observableValueA,
valueB: SomeStore.observableValueB,
flag: SomeStore.observableFlag
}))(observer(SomeComponent))
SingletonStore の instantiate順
これまでのサンプルコードの場合、SingletonStore を importした時点で初めてインスタンスが生成されます。そのため、参照関係が前後してしまうと、インスタンス生成前に参照エラーが発生します。SingletonStoreは一元管理し、エントリーポイントに近い位置で import しておくと良いです。
これまでのサンプルコードを、最終的なものに書き換えると以下の様になります。
import '~/stores/singletons'
import _SettingsStore from '~/stores/singletons/settingsStore'
import _WindowStore from '~/stores/singletons/windowStore'
import _SomeStore from '~/stores/singletons/someStore'
export const SettingsStore = new _SettingsStore()
export const WindowStore = new _WindowStore({ SettingsStore })
export const SomeStore = new _SomeStore({ SettingsStore, WindowStore })
export default class WindowStore {
@observable width
@observable height
constructor(props) {
Object.assign(this, props)
window.addEventListener('resize', e => this.onResize(e))
}
@action onResize = () => {
this.width = window.innerWidth
this.height = window.innerHeight
}
}
// const singleton = new WindowStore() // ※各ファイルでは、インスタンスを生成しない
// export default singleton