作ったもの
1つのEventHandlerから展開される諸々の連続する出来事のまとまりをUseCaseとしてQueueとTaskで表現するもの。
乱暴にいえば配列(Queue)に次の状態を返す関数(task)を突っ込んで状態推移を捌くライブラリ。
Fluxのようなデータフロー表現ではない。
※ XXXアーキテクチャやXXX設計の話にでてくるUseCaseとは違うかも知れない(その話に出て来るUseCaseを理解していない)ので余計な混乱を招いたいたらすいません。
※ 以降の話はReduxを含めてFluxと書きます。
サンプルコード
import createStore from 'quex';
const store = createStore({ count: 0 });
const unsubscribe = store.subscribe((state, error) => {
// ...
})
const task = {
increment: (state count) => ({
count: state.count + count
}),
// AsyncTask
incrementAsync: async (_, count) => {
const n = await Promise.resolve(count);
return (state) => ({
count: state.count + n
});
},
incrementMulti: (state, count) => ({
count: state.count * count
})
};
// usecase(name).use([...task])(params)
store.usecase('INCREMENT').use([
task.increment,
task.incrementAsync,
// 直前の非同期Taskの完了を待ってから実行される
task.incrementMulti,
(state, params) => {
assert.deepEqual(state, { count: 8 });
assert.equal(params, 2)
}
])(2)
経緯とモチベーション
Acitonの正体がわからない
FluxでやってることはわかるがActionの正体がわからない。Viewのイベント抽象とも考えられるし、Storeの間接的な状態更新インターフェースとも考えることができる。
Acitonの正体がわからないので、なにを基準にActionを作って良いのかわからない。
Actionがなにを表現していて、Actionの発行がなにを期待しているのかActionだけでは情報が不十分なように感じる。
ViewとActionだけを見るとそのAcitonの発行がApplicationになにをもたらすのか判断ができない。StoreとActionだけを見るとそのAcitonがいつ発行され、また何を期待しているのかわからない。
View -> Action -> Storeの一連のバケツリレーで初めて1つの意味を成している気がするが、ここには「発行する条件」と「監視していい条件」、「それによって期待すること」など、暗黙のコンテキストが隠されているように感じる。
個々のセクションの実装は疎結合だが、暗黙のコンテキストによる間接的な密結合になっている気がする(この表現は適当ではないかもしれない)。
1つのDOMEventから状態更新までを理解するのに必ずView -> Action -> Storeをたどる必要がある。場合によってはReduxの場合ここにMiddlewareによるAcitonの発行が入る。
このせいで、例えば、buttonをclickした場合になにが起こるのかを知るために壮大な冒険に出かけることになるのがとてもツライ。
Fluxはあくまでデータフロー
ここでさらにあげたい問題はReduxの正しい解釈の話 – Mediumで語られている。
ユーザー入力から、ビジネスロジックと画面反映への手続き的な処理の連続
についてReduxの場合、Middlewareで解決しているが、先に述べた暗黙のコンテキストをもつActionをAcitonから発行することになり、さらに壮大な冒険が始まる。
モチベーション
Fluxを導入するモチベーションとして、コンポーネントツリーに依存しないstateの管理が目的になると思う。そのstate操作を一方通行データフローはよいが、そのたびに繰り広げられる冒険をもっと単純で見通しの良いものにできないものかというのがモチベーション。
どのようになっていれば幸せになれそうか
- 暗黙のコンテキストをもつActionを経由したバケツリレーやめたい
- DOMの1Eventから始まる出来事のリスト(連続する出来事の表現)
Quexについて
QuexのアイディアはDOMEventから起こる出来事を配列につめて1つずつ捌けば連続する出来事のリストによる見通しのよさと単純さを手に入れられるのではというもの。
QueueとTask
Queueはusecase().use([])
によって構成されるただのFunction[]
。
Taskは以下の型を満たす関数。
type T1<S> = (state: S) => Partial<S> | void;
type T2<S> = (state: S) => Promise<T1<S>> | void;
type T3<S, P> = (state: S, params: P) => Partial<S> | void;
type T4<S, P> = (state: S, params: P) => Promise<T3<S, P>> | void;
Taskはstateとparameterを1つ受け取って次の状態を返す関数。返した値が次の状態になる。ただし、EventHandlerから起こる出来事のすべてが状態の更新に関係するものとは限らないので、なにも返さなければstateは更新されない。また、Promise<Task>
を返すことで非同期処理からの状態更新が可能にしている。
QueueとTaskを作る上で注意することは、
- 非同期Taskによる状態を更新したい場合、
Promise<State>
ではなくPromise<Task>
であること - 1つのQueueには同じparams型を受け取るTaskしか入れないようになっていること
なので、FlowかTypeScriptを使うことを推奨する。
// Queueの型
type Q1<S> = (T1<S> | T2<S>)[];
type Q2<S, P> = (T3<S, P> | T4<S, P>)[];
// UseCaseの型
interface UseCase<S> {
(name?: string): {
use: {
(queue: Q1<S>): () => void;
<P>(queue: Q2<S, P>): (params: P) => void;
}
};
}
非同期Taskについて
「非同期Taskによる状態を更新したい場合、Promise<State>
ではなくPromise<Task>
であること」についてもう少し捕捉する。
前提としてstateはimmutableに更新されているとする。
(state: S) => Promise<S>
だと、非同期処理中に他の処理によりstateが更新された瞬間に、非同期関数が参照しているstateが最新のstateではなくなり、Promiseに包んで返すnextStateは古いstateを参照して作られたものになってしまう。
これはReduxがReducerで非同期処理ができない理由でもある。
Quexはこの問題を解消するためにAsyncTaskによる状態更新はPromise<Function>
を強制する。
この場合、Promiseに包まれて返される値はstateではなく関数なのでnextStateを作るフェーズで最新のstateを参照することが可能になる。
Queueの捌き方
Queue内のTaskは非同期処理が走っても必ず直列で捌かれる。つまりTaskは直前のTaskの完了を待つ。これによってUseCaseが実行されたときに起こる展開が直感的に理解できるようになる。
usecase().use([
asyncProcessStart, // viewにくるくるアイコン表示する
DataFetch1, // サーバからdataを引っ張ってきてstoreに格納する
DataFetch2, // 更になんか引っ張ってくる
asyncProcessEnd, // くるくるアイコンを消す
]);
非同期処理の待ちはあくまで1つのqueue内の話であって、他のQueueの実行はブロックしない。
Fluxとの違い
Fluxの場合、EventHanderと状態生成関数をAction(Creator)とDispatch(er)を使って間接的にマッピングしている。
EventHandler -> Action(Creator) -> Dispatch(er) -> (Middleware -> Action) -> Store
Quexの場合、EventHandlerとUseCaseを直接マッピングすることになる。
EventHandler -> UseCase[...Task]
Fluxはデータフローを表現しているのに対して、QuexはEventHanlderが叩かれてからの起こる出来事の流れを表現している。データフローはTaskの中に存在するかもしれないし、EventHandlerからTaskの間に存在するかもしれない。
これでなにが良くなるの
Actionのような暗黙のコンテキストを持つFlux都合で生産されるバケツリレーセクションが消え、1つのEventHanderの発火から起こる出来事をQueueの中に並べるだけなのでFluxと比べて格段に見通しが良くなる。
Queueを見ただけで起こる出来事が直感的に判断できるようになる。
Quexでうまくやってくために必要そうなもの
Entity
1つQueueに入れられるTaskは同一のParams型を持つTaskのみ。このParams型をEntityにすることで、なにが中心にあるUseCaseなのかが明確になり、Taskがより単純なものになる。
TypeScriptまたはFlow
Taskが単純な関数の場合もあれば、関数合成またはfactory関数による動的なTaskの生成になる場合もある。1つQueueに入れられるTaskは同一のParams型を持つTaskのみなので型チェックがあると安心する。安心して壊せるし壊れていることにすぐに気づける。
QuexはTypeScriptで書かれているのでTypeScriptの型はある。
他ライブラリとの相性
QuexはViewのEventHandlerとそこから展開される出来事をUseCaseとして単純なマッピング方法を提供するだけなので、React.jsやVue.js等のViewライブラリやMobxやImmutable.js等の状態操作、生成を行うライブラリと自由に組み合わせることができる。
ただし、導入するライブラリに応じてupdater
を変えてあげる必要がある。
updater
はtaskが返したstateをstoreのstateとしての受け入れ方法を決める関数。updater
が返したstateが最終的にstoreのstateになる。
store.setState()
の挙動もupdater
よって決まる。
(currentState, nextState) => Object.assing({}, currentState, nextState)
がdefaultになっているが、createStore
時にoptionで変更可能になっている。
例えば、Mobxを使った場合、状態はmutableに更新されるのでupdater
は
(currentState, _) => currentState
としたほうが都合がいいかもしれない。
例えば、taskがPartial<State>
ではなく完全なstateを返す場合やImmutable.jsを使う場合、
(_, nextState) => nextState
としたほうが都合がいいかもしれない。
React.jsと使う場合
Quexが返すstoreのインターフェースはReduxに合わせていてusecase
のaliasとしてdispatch
を持っているので、特別な設定を必要とせずreact-reduxがそのまま使える。
const store = createStore({count: 0})
<Provider store={store}>
<Counter />
</Provider>
const mapUseCaseToProps = (usecase) => ({
increment: usecase('INCREMENT').use([
task.increment
])
})
export default connect(mapStateToProps, mapUseCaseToProps)(Counter)
ドキュメントについて
APIを含むドキュメントはREADMEに書いてある。
間違った英語を恥じない強い気持ちで英検5級の実力を存分に発揮して書かれた英語のドキュメントなので、そのあたり察していただけるとありがたいです。
型定義合わせて160行くらいの小さなコードでキモになる部分は30行のQueueを捌くnext関数だけなので、むちゃくちゃな英語を読むよりもコードを読んだほうが早いかもしれない。
これで本当によくなるの
わからない。
わからないけど、本当に状態を更新する過程にEventEmitterライクな方法が必要なのかについて疑問を持っていて、その模索の一環として状態更新にFluxとは別のアプローチで状態更新をするものを書いてみた。