console.lealog();

@leader22のWeb系に関する勉強めもブログですのだ

MobXを使ったアーキテクチャについて

いまさらですが、俺的Real world MobXです。
いちおう半年くらい仕事でも趣味でも触ってきてての今です。

あくまで1つの例ですが、どこかの誰かの何かの参考になれば。

その前に

こんな風に使ってますを紹介する前に、もやもやーっと思ってることを箇条書きにしておきます。

  • 俺が考えた最強のアーキテクチャ
    • そんなものはない
    • 声のでかいやつの言うことを真に受けるな
    • 納得できない部分があるなら採用するな
  • Reduxとの比較
    • Reduxでできたクソコードがあるように、MobXでできたクソコードもありえる
    • 役割が薄い分、そのリスクはMobXの方が高い(設計力が試される)と思ってる
    • コードの記述量は絶対に減るので、書き味は良くなる
      • 書きやすさと無秩序は紙一重
  • Flux的なアーキテクチャを踏襲するべきか
    • Action的なものを投げる必要はないが、投げてもいい
    • Storeも単一でもいいし、複数にしてもいい
    • シングルトンも好き好き
  • その他
    • 1方向なフローと`view = f(state)`はReactの使命であり、アーキテクチャは関係ない
    • なんでもContext経由で渡すのは何を使おうと悪手やと思う

本題

というわけで、こういう感じで最近使ってますのご紹介。
実際のコードで・・って場合は、 https://github.com/leader22/mmss-client なんかが新しいです。Flowで型型してて読みにくいかもですが・・。

ToDoアプリよりは大きいが、ルーターがネストして云々・・という規模のものではないです。
基本的な構成はこんな感じ。

- main.js
- store
  - object
- event.js
- app.jsx
- container
- component

main.js

いわずもがなエントリーポイントです。

mobx.useStrict(true);

const store = new Store(initialState);
const event = new Event(store);

ReactDOM.render(
  <Provider event={event}>
    <App store={store} />
  </Provider>,
  document.getElementById('root')
);

それぞれ、

  • Store: アプリの状態でありMobXのObservableな値たち
  • Event: アプリで起こる操作(によりStoreを更新するハンドラ)
  • View: ReactのViewであり、MobXのObserverに

この3つでだいたい事足りると思います。
`mobx.useStrict(true)`は、Storeで扱うObservableを更新できる箇所を縛るものだと思ってください。
それができるのがEventで、他からはStoreの状態を更新できない、のでそのままViewに渡してます。

`mobx-react`の`Provider`でEventはContext経由で渡すようにするのが最近は好きです。
見た目を構成するために必要なStoreのバケツリレーはある程度まで許容できても、ハンドラはな・・という好みなので人それぞれな気がする。

Store w/ Object

  • 単一Storeである必要はないので、それぞれの債務に応じた状態をそれぞれの場所に配置できる
  • でもStoreAとStoreBの複合状態を、Viewで作るのはちょっと・・となる
  • なら、個別のStoreは子Store(=Object)として、それらをまとめる親Storeに持たせればいいのでは

という感じです。

class Store {
  constructor(initialState) {
    this.ui = new UiObject();
    this.foo = new FooObject();
    this.bar = new BarObject();

    extendObservable(this, {
      isFooAndBar: computed(() => {
        return this.foo.isFoo && this.bar.isBar;
      })
    }, initialState);
  }
}

複数のStoreを持つといえばそうだが、単一であるとも言える・・のは、こういうわけでした。

class Object {
  constructor() {
    extendObservable(this, {
      // ここにstateを定義してく
      selected: null,
      foo: 'foo',
      bar: [],

      hasBar: computed(() => { return this.bar.length !== 0; }),
    });
  }

  setSelected(target) {
    this.selected = target;
  }
}

Store / Objectを作るときに心がけてるのは、いわゆるGetterメソッドを"生やさない"こと。
というのも、それは9割がた`computed`で表現できるはずだから。そうしないならMobX使ってる意味ない。

もうひとつは、Observableである必要のないデータは、`extendObservable()`の外に出すこと。紛らわしいから。
必要に応じて`.shallow`のModifiersを。

あと冒頭で`useStrict(true)`したので、Store / Objectの値を更新するには、`mobx.action`でラップした関数の中でやらないとダメです。

デコレータが使える案件なら、

class FooObject {
  @observable foo = 300;

  @action
  setFoo(foo) { this.foo = foo; }

  @action.bound
  setFoo2(foo) { this.foo = foo * 2; }  
}

てな感じに`action`化できる + `bind(this)`も手軽にできて最高なんですが、そうではない場合、愚直にラップしていくしかないです・・・。

Event

最後の方がこちら。
StoreおよびObjectを変更できる唯一無二の存在。

Viewは表示だけ、Storeは状態だけ、なのでそれ以外はすべてココでやるようにしてます。

class Event {
  constructor(store) {
    this.store = store;
  }

  onClickFooBtn(value) {
    this.store.foo.setFoo(value);
  }

  onChangeBar(id) {
    fetch(`/api/bar/${id}`)
      .then(res => { this.store.bar.updateBar(res.json()); })
      .catch(err => { this.store.showError(err); });
  }
}

全てのStore/Objectのためにこの層が必要か?と言われると微妙だと思いますが、ココに全て寄せる意義はあると思うので、それも好き好きかなーと。
ViewからStoreを直で叩こうとすると、UI絡みの処理やら非同期の処理やらどうすんの・・?ってなりがちなので、こういう層はあってもいいと思う。

この構成だと、`mobx.reaction()`とかもあわせ技的に使えるのでそこは強いと思います。

reaction(
  () => this.store.bar.isBar,
  (changedBar) => {
    fetch(`/api/bar/update`);
    this.store.ui.changeBar();
  }
)

ユーザー入力を起点にして全てのハンドラを手続き的に書いていくのではなく、ユーザー入力によって状態が更新される、それに呼応して勝手に処理されるというイメージで。
これがキマるとMobX最高ってなると思います。

Container / component

これはReactの話なのでサクッと。

  • Container: `context`からEventを受け取る、各Storeも直接受け取る
  • Component: Containerにぶら下がる、`props`で全部もらう

いわゆるプレゼンテーショナルなコンポーネントか、そうでないか。
Containerも出来る限り増やしたくないので薄いツリーを心がけたい気持ちはある・・が。
Storeを受け取るときにそのまま受け取るか、View用のマッピングを通して渡すかとかも好き好きかなーと。

MobXでやる場合に必要なのは、`observer()`でラップすることだけです。

これより大きな規模になったらどうするのか

基本の3本柱は変えないと思います。
Routeも、そういうstateを用意するだけなので・・。

autorun(() => {
  () => this.store.ui.route,
  (route) => location.hash = `!/${route}`;
});

あとは`componentDidMount()`でEventとStoreを初期化すればいいかもしれないし、
Store / Event / Viewのセットを複数用意して、それぞれ疎結合で協調させるようにするだけでもいいかもしれないし。

てかSPAだからといって1コンポーネントだけマウントして全てをやる必要はないし、互いに干渉しないなら分割するべきで、そのへんがエンジニアの仕事なのでは・・と。
というか、昨今のSPA設計における勘所もとい運用が面倒にならないかって、この「いかに小さいコンポーネントに分けられるか」な気がする。

悩んだらVueを

他のフレームワークとの比較 - Vue.js

ここにも引用されてる通り、React x MobXはVue.jsと似たような感覚で使えます。
ということは、Vuexとかの段階的なアーキテクチャ設計が真似できるということで・・・!

  • Storeを直叩きするもよし
  • Mutationを作るもよし
  • GetterとActionを分けるもよし

迷ったら参考にしてみるとよいと思います。

特記事項

MobXである程度まじめにアプリを作る場合の知っ得メモ?です。

Observableな値にも種類がある

一口に`observable(value)`といっても、実は種類があります。

const obj = { a: { b: { c: 1 } } };

// same
observable(obj)
observable.object(obj)
observable.deep(obj)

// modifier
observable.shallow(obj)

`observable()`で基本的になんでもObservableにできますが、それぞれのターゲットを指定する記法もあります。
`.shallow`とか`.deep`とか`.ref`とか、Modifiersってのもあります。(`computed`も`action`も実はModifiers)

Observableな値のタイプを指定するのがModifiersで、まあ知らなくても困らんのでは?って感じ。

mobx/api.md at gh-pages · mobxjs/mobx · GitHub

APIを全部は使わない

The most important MobX api's. Understanding observable, computed, reactions and actions is enough to master MobX and use it in your applications!

と、ドキュメントにもあるように、いろいろAPIはあるけどほとんど使わないで済むはずです。

ライブラリは必要に迫れれてはじめて入れるもんです。
https://github.com/mobxjs/mobx-utils っていうUtil集もあるので、必要になったときに探してみると吉です。

What does MobX react to?

必読です。

Understanding what MobX reacts to | MobX

`autorun`とか`observer`とか、Observableな値の更新に呼応する系の動きを把握するのがMobXを使いこなすポイントで、その仕組が書いてあります。

// コレと
<App>
  <Foo bar={foo.bar} />
</App>

// コレは挙動が違う
<App>
  <Foo foo={foo} />
</App>

`foo.bar`が更新された場合、前者は`App`が`render()`されるのに対し、後者は`Foo`が`render()`されます。
なんとなくわかるかな・・?
つまり、該当するプロパティにアクセスした関数が、トラッキングされて自動で呼ばれます。

なのでパフォーマンスにすごくこだわるならば、これを熟知した上で`props`を渡さないとダメです。
ただコンポーネントに渡す`props`はきっちり限定したいと普通は思うと思うので、まあ差し迫ってからでいいかと。

おわりに

とりあえず書いてみたものの、これがベストではないと思うので、あくまで参考にというわけで・・。

ちなみにMobXを使ったコードは、Qiitaとかで探すよりGitHubでコード検索したほうが絶対にいいです。絶対に。