MobXは簡単で理解し易い点が一番の魅力だと思います。将来的に複雑になってしまっても耐えれるための下準備として、構成をどの様にすればよいのか?という疑問に、一つの手法を提案します。複雑といってもStoreの構成は、最初から最後まで、互いに疎結合なドメインモデルを並列に複数配備するだけです。
ドメインモデルとは何か?
ここでは「特定の課題を解決する、メソッドを持ち合わせたデータソース」として解説します。粒度の目安としては、配列をひとつと、いくつかのオプションを保持しているぐらいの、小さなものです。
class Item {
constructor ({ name = null, price = null, shop =null }) {
this.name = name
this.price = price
this.shop = shop
}
}
class DomainModel {
@observable items = [];
}
一見シンプルですが、配列に含まれるインスタンス次第で、このドメインモデルは多くの事を語る様になります。これに問い合わせて得られる値は、上記コードから始まった場合、以下の様なものに成長していきます。
- A: 価格が X円以上、Y円未満の商品件数
- B: 価格が null で無い商品件数
- C: 特定の文字列を含む商品件数
- D: AかつCの件数
- E: Dが10件以上か否か
ドメインモデルの粒度について
ドメインモデルには、ひとつの配列を取り扱う様な特定の関心ごとに絞り、それ以上の仕事をさせない。これにより、バグを発見し易く、再利用性を高く保ち、課題領域(ドメイン)を一元管理することを目的とします。最初はドメインモデルがバラバラなことを冗長に感じるかもしれませんが、以下のシーンで細かい粒度が活きてきます。
- 単体ドメインモデルが複雑になった時
- 扱う課題が他ドメインの横断的関心事になった時
粒度が細かいことで発生する問題は、何か一つの事を行うにしても、ドメインモデルを複数掛け合わせなければいけないということです。本稿の主題は、この掛け合わせの部分を取りまとめる「サービス層」についてです。
サービス層とは何か?
ドメインモデル同士を疎結合に保つため、横断的関心事を注入する作業が必要です。Aで得られる値を、Bの値であったかの様に変換する作業です。これを「サービス層」というstateless な層に配備します。簡単な例を見て行きます。過去の記事で紹介したサンプルと同じもので、reactions
がサービス層です。
import { observable, action } from 'mobx'
export default class Model {
@observable count = 0
constructor ({ count = 0 }) {
this.count = count
}
@action.bound increment () {
this.count++
}
@action.bound setCount (count) {
this.count = count
}
}
const modelA = new Model()
const modelB = new Model()
const modelC = new Model()
reactions({ modelA, modelB, modelC })
export default function ({ modelA, modelB, modelC }) {
reaction(
() => modelA.count,
count => { modelC.count = count * modelB.count }
)
reaction(
() => modelB.count,
count => { modelC.count = count * modelA.count }
)
}
この例では簡単なだけあって、単純な作業しか出来ません。加えて、あまり柔軟な組み替えが出来ないことが分かります。
サービス層の構造化
ここからが本番です。Promise を発行する、preaction
という helper関数を定義します。データの変更に一度だけ reaction し、反応後に得られた値を resolve(v)
で返し、第2引数の r.dispose()
で reactionを破棄するものです。
export function preaction (fn) {
return new Promise (resolve => {
reaction (fn, (v, r) => { r.dispose(); resolve(v) })
})
}
この関数を使う事で以下の様に、加算を行い「C」に結果を注入するサービスが出来ます。
import { reaction } from 'mobx'
import { preaction } from './helper'
async function add ({ modelA, modelB, modelC }) {
while (true) {
await Promise.race([
preaction(() => modelA.count),
preaction(() => modelB.count)
])
modelC.count = modelA.count + modelB.count
}
}
// Export
export default function ({ modelA, modelB, modelC }) {
add({ modelA, modelB, modelC })
}
Promise.race は、引数配列内のいずれかの Promise が resolve
もしくは reject
された値のみ返す Promise です。preaction
の resolve で返された値はここでは取り扱っていませんが、無限ループはこの Promise.race
が解決するまで止まっています。AかBどちらかが変化するのをひたすら待っているという状態です。
Promise.race
を都度書くのも冗長なので、もうひとつ helper関数を生やします。
export function preaction (fn) {
return new Promise (resolve => {
reaction (fn, (v, r) => { r.dispose(); resolve(v) })
})
}
export function preactions () {
return Promise.race([].slice.call(arguments).map(fn => preaction(fn)))
}
import { reaction } from 'mobx'
import { preactions } from './helper'
async function add ({ modelA, modelB, modelC }) {
while (true) {
await preactions(
() => modelA.count,
() => modelB.count,
)
modelC.count = modelA.count + modelB.count
}
}
async function multiplicate ({ modelA, modelB, modelD }) {
while (true) {
await preactions(
() => modelA.count,
() => modelB.count
)
modelD.count = modelA.count * modelB.count
}
}
// Export
export default function ({ modelA, modelB, modelC, modelD }) {
add({ modelA, modelB, modelC })
multiplicate({ modelA, modelB, modelD })
}
なぜ非同期処理で書くのか?
一度だけ reaction して Promise.resolve することで、MobX従来の課題であったトランザクション構築に自由度がもたらされます。例えば、値の変化に反応しXHRを投げて、レスポンスを変換する場合や、反応して欲しくないシーンで役立ちます。
reaction を待つということは、非同期処理の結果を待っている状態と同じと捉えることが出来ます。await のチェーンで非同期 reaction を構築するも良し、単純に reaction するも良しですね。(ドメインによってはサービス層は不要です)
ライブラリ不問のモデリングパターン
この手法は Redux とヘキサゴナルアーキテクチャを融合する手法と同じものです。MobX でもサービス層を構えておくことで、複雑なねじれや依存性を回避できることを解説しました。
仮想DOMやデータバインディングを提供するだけのフレームワークは、Storeの構造化を示してはくれません。裏を返すと、データフローアーキテクチャ(ReduxのAction、MobXのReaction)とは別物なので、ライブラリ不問の手法であり、フロントの流行り廃りの速さは気にならなくなってきます。