Model View StreamというUIアーキテクチャーパターンのご提案。
短い結論
Model View StreamというUIアーキテクチャーパターンを考えて、やってみたら結構よかったです。
- 良い点:新しいViewの追加が既存Viewを破壊しない
- 悪い点:実装が面倒
背景
リアクティブプログラミングとかunidireciton data flowとか流行っていてめっちゃかっこいいです。
でもReact.jsは敷居が高いです。なんと言ってもVirtual DOMで表現できないものが扱えないのが痛いです。jsPlumb.jsという曲線コネクタを表示するライブラリがあります。svgで描画し、Drop & Dropのイベントも自前で処理します。
React.jsと既存ライブラリを擦り合わせるファサード(層)を作るのも面倒です。jsPlumb.js向けのプレゼンテーションモデルを作るのだったら、Virtual DOMと二重管理になるのが馬鹿らしいです。
React.jsを使いたいわけじゃなくてunidirection data flowをやりたいだけなので、それだけやります。ModelとViewが今まで通りでControllerがStreamになったら素敵じゃないですか。さらにStreamのAPIが比較的メジャーなAPIだったら最高ですよね。
補足
個人的なunidirection data flowの理解はunidirection data flow にしびれる、憧れる理由に書きました。何か気になる点があれば、ご教授願います。
実装方針
モジュール構成
大きく以下の三つに分かれます。
- Model:ドメインロジック
- View:プレゼンテーションロジック
- Stream:アクションを処理する流れ
Streamの種類
- ActionStream:プレゼンテーションロジックのうち入力をアクションに変換してStreamに流す
- ModelStream:入力アクションに応じてModelを操作する。操作結果によってアクションを増やす
- RenderStream:入力アクションに応じてViewを操作する。アクションによってはアクションを増やす
Streamのつなぎ方
ActionStreamはViewごとにあります。複数ある場合は一つのStreamへアクションを流します。
Model以降のStreamは一列につなぎます。
ActionStream1 ┐
ActionStream2 ┼ ModelStream1 - ModelStream2 - RenderStream1 - RenderStream2
ActionStream3 ┘
アクション操作のルール
次のルールを設けます。
- 受け取ったアクションは必ず次のStreamに送る
- 同時に、新規のアクションを次のStreamに送っても良い
- 受け取ったアクションにプロパティを追加してもよい(変更、削除は不可)
ModelStream1とModelStream2などのStreamのインスタンスレベルでは、順序を入れ替えられるようにです。
アクションのプロパティ
以下のプロパティが必須です。
- target:アクションが影響を与える相手
- type:アクションの種類
例
{
target: 'input-node',
type: 'input'
}
他のプロパティは自由に追加します。
StreamのAPI
StreamのAPIはNode.jsのAPIを使います。
RxJSも検討しました。UIのイベントより一段抽象度を上げたかったのでNode.jsのAPIを採用しました。
プロトタイプ実装
実装例
デモ
main処理
次のようにStreamをpipeで繋ぎます。
import polyfill from 'babel/polyfill'
import actionStream from './lib/stream/actionStream'
import modelStream from './lib/stream/modelStream'
import renderStream from './lib/stream/renderStream'
actionStream
.pipe(modelStream)
.pipe(renderStream)
ActionStreamの例
UIのイベントハンドラーでアクションを作ってStreamにpushします。
export default class extends ActionReadable {
constructor(selector) {
super()
let component = inputNodeComponent(selector), // Viewを初期化
container = delegate(component.component)
container.on('click', '.button', e => onSubmit(component, 'node', 'create', this))
let inputHandler = e => this.push({ // アクションをpush
target: 'input-node',
type: 'input'
})
container.on('input', '.label', inputHandler)
container.on('input', '.url', inputHandler)
}
}
ModelStreamの例
export default class extends ActionTransform {
constructor(nodes, edges) {
super()
this._nodes = nodes // Modelへの参照を保持
this._edges = edges
}
_transformAction(action, push) {
if (action.target === 'node') { // アクションのtargetに応じてdispatch
let nodes = this._nodes
switch (action.type) {
case 'create':
createNode(nodes, action, push) // アクションのtypeに応じてmodelを操作
break
case 'update':
updateNode(nodes, action, push)
break
case 'select':
push(extend(action, nodes.get(action.id))) // 場合によってアクションを拡張
}
}
if (action.target === 'edge') {
handleEdge(this._edges, this._nodes, action, push)
}
}
}
RenderSteramの例
export default class extends ActionTransform {
constructor(selector) {
super()
this._component = graphComponent(selector) // viewの参照を保持
}
_transformAction(action, push) {
if (action.target === 'node') {
handleNode(this._component, action, push)
}
}
}
function handleNode(component, action, push) {
switch (action.type) {
case 'create': // アクションのtypeに応じてviewを操作
component.createNode(action.id, action.label, action.url)
break
case 'update':
component.updateNode(action.id, action.label, action.url)
break
case 'select':
component.selectNode(action.id)
break
case 'unselect':
component.unselectNode()
}
}
全ソースコード
graph-editor - A sample of Model View Stream
所感
- Viewを追加したときに、既存のViewを破壊しないのが画期的
- ES6のclass構文は便利
- 入力バリデーションの実装が面倒。「動かしながらのUI設計」がやりづらい
- debugしづらい。どのStreamでの操作が間違っているか特定するための地道なデバッグプリントが必要
反省と課題
- 共通部分はライブラリが作れそう
- アクション名は定義モジュールで一括定義すると、アクションの設計がやりやすそう
- アクションを増やす責務を(fluxの)Dispatcherのようなものにまとめる必要ないのか?
- Node.jsのStreamの実装をそのまま使っている。BufferingやByteArrayを扱う機能は不要