ジンジャー研究室

長めのつぶやき。難しいことは書きません。

GUIをツリーにする話(前篇)

English

前置き

JavaScriptでリッチクライアントを作りましょう」等という無茶を皆がこぞってやり始めるようになってしまったのは、どう考えてもGoogle様のせいという気がしてならないのだが、もう後戻りできないしどうせなら流行に乗って一発当てよう。

ところで先日、AngularJS勉強会というものがあったらしい。

私は参加していないのだが、ビデオを見ていたらdirectiveとかWebComponentsの話が出ていて触発された(かつて情熱を燃やしていた日々の記憶が蘇った)ので、ここでひとつ自分の意見を書いておこうと思う。題して「GUIをツリーにする話」である。
アイデア自体は前からあったのだが、「ブログであーだこーだ言うよりも使えるものをいきなりバーンと出した方が格好良いよね」という確固たる信念があり、今日まで延ばしてきた。しかし色々と実験を重ねてみた結果なかなか出来上がらない事に心底参り、この度ブログであーだこーだ言うことにした。

前提

  • リッチな(業務系寄りな)Webアプリを作りたい。Webサイトやゲームではない。
  • サーバサイドの処理もそれなりに多い。
  • 明日に役立つ知識ではない。

一応、JavaScriptを前提としているが、GUIを作っていたら同じような問題に直面することが多いのではないかと思われる。

以降の話がどういう道筋を辿るかというと、「コンポーネントをツリー状に組み立てたら全体の統制が取れるんじゃね」⇒「ひとつのモデルを複数のビューが見る場合に処理がカオスに…やっぱりMVC的な何かが必要」⇒「実装方法が色々あるけど、どれを選んでも懸念点が多くてヤバイ」という風になる。
駄文長文を読んでも結論は出ずに終わるので、そのつもりで…。

コンポーネントをツリー状に組み立てる

Angularのdirectiveや、WebComponentsと呼ばれる技術を使うと、独自に定義した要素を部品として使うことが出来るようになる
(な、なんだってー。)
それは確かに素晴らしいことなのかもしれない。しかし別に概念自体は新しくないし、XMLにしろオブジェクト指向にしろ、何か小さいものを組み合わせて大きいものを作ろうとする時には必須になる考え方である。
何はともあれ、そのような仕組みが出来上がってくるのは嬉しいのでなんとかそのメリットを享受したい。

というわけで、早速コンポーネントを組み合わせて何か作ってみる。脳内で。
下の図は、左の一覧から何かを選択すると、右にその詳細が表示されるという良くある画面の構造を表している。

f:id:jinjor:20131208195513p:plain

これは継承関係ではなく所持の関係で、上位のコンポーネントが下位のコンポーネントを所持している形になる*1。また、疎結合にするために兄弟や親にあたるコンポーネントを直接参照しないように注意する。

上位のコンポーネントは下位のコンポーネントのイベントをコールバックで受け取り、それを元に別のコンポーネントにそれを通知する。*2

実はこれ、いつもjQueryでやっている「あれがそれした時にこれするよー」という話の延長で、ただ粒度を大きくしたに過ぎないのだが、このように見立てることで全てフラットに扱うよりも構造が分かりやすくなる。

ここまでの話、「そうするのが正しいよ」というわけではなく、思考実験のプロセスを示しているので全くもって完成形ではない。しかしひとつの考えの軸として、議論の際に何も無いよりはマシになる

調子に乗って地雷を踏む

上の例は分かりやすかったので、このノリでどこまでもいけそうな気がしてくる。
そこでもっと込み入った(とはいえありふれた)例を挙げてみることにする。

f:id:jinjor:20131208201241p:plain

なんらかの事情から、その場で登録したメンバーから彼らの支払う金額の合計を計算する必要が生じたとする。金額がなんらかの基準を満たさなかったら、OKボタンを無効にし、警告を表示する。

ここで、先ほどの方法を適用すると、次のようなコードが書かれるはずである。

// イベントを受け取り
var memverView = new MemberView().on("change", updateSubmitView);
memberViewList.add(memverView);

function updateSubmitView(){
    var memberList = memberViewList.map(function(view){
        return view.member;
    });
    submitView.update(memberList);// データを渡す
}

submitView(ツリーの右)にmemberListという粒度の大きめなものを渡しているのはカプセル化のためだ。警告を表示する際にそれぞれのメンバーの情報も欲しくなったり、合算のロジックが設定によって切り替わったりするかもしれないので、それはsubmitViewの責務にしてしまおう。

しかし後になって、「支払う金額が平均値から50%離れているメンバーは対応するmemberView(ツリーの左)を赤色表示にし、さらにそういうメンバーが一人でもいる場合も同様にボタン(右)を無効にしてくれ」という話になったりする。

しかし合計値を求める処理はsubmitView(右)の中に入れてしまったから、そこから値を取り出してmemberViewList(左)にフィードバックし、memberViewListの中からエラーになったメンバーがいるかどうかを判定して再びボタン(右)の有効・無効を切り替えよう。

var memverView = new MemberView().on("change", updateSubmitView);
memberViewList.add(memverView);

function updateSubmitView(){
    var memberList = memberViewList.map(function(memberView){
        return memberView.member;
    });
    submitView.update(memberList);
    var sum = submitView.getSum();
    memberViewList.forEach(function(memberView){
        memberView.updateBySum(sum);
    });
    var errorFlg = false;
    memberViewList.forEach(function(memberView){
        errorFlg = errorFlg || memberView.getErrorFlg();
    });
    submitView.updateByErrorFlg(errorFlg);
}

これはひどい。
ふむふむ、なるほどオブジェクト指向とはデータ同士が協調して動作しながらプログラムを進め…って、いくらなんでもこんなデータのキャッチボールがしたいわけではないだろう。

そしてモデルドリブンな世界へ

結局のところ、データをそれぞれのViewに分けて持たせてしまったのが敗因である。
こうなると、そもそもコンポーネントに分けたのが間違いだったのではないかという気がしてくるが、そうではない。Modelはひとつでありながら、複数のViewがそれを参照することが可能である。実はViewがModelを参照するという話は、以前AngularとBackboneの話で出したのだが、ひとつのModelに対して複数のViewという話にはしていなかったと思う。(しかし一番ここが面白い部分だ!)

つまりこうなる。

f:id:jinjor:20131208211758p:plain

ViewがModelの更新を感知し、それを自分に反映する。それをツリーのあらゆる場所でやる。
…ということが出来れば、とりあえず私にとっての理想世界が完成する。


長くなったのでここで終わり。

次回があれば実装の話。

*1:下位のコンポーネントを切り替えたい時は同じインターフェイスを持つコンポーネントを初期パラメータで渡してもらうか、DIのような仕組みを使ってショートカットすることも出来る。

*2:イベント通知の仕組みは2種類考えられる。 (A)共通のViewを継承して、fire("someEvent", "hoge", 1);などとする。 (B)専用のコールバックをコンストラクタで受け取る (A)の方法は基盤がしっかりしていれば手軽な方法で、使用方法も統一感を持たせられる。 (B)の方法は、それぞれの部品に固有のイベントをしっかり静的型付けで定義したい場合に役立つ。