前置き
この記事はGUIをツリーにする話(前篇)の続きである。
ざっと振り返ると、GUIにおいてViewをツリー状に配置する際、複数のViewに同じModelをそれぞれ別個に管理させると、イベント発生時に同期を取るのがつらいという話だった。
というわけで、Modelはひとつにしたい。
実装方針
ひとつのModelを2つのViewが共有する。
しかし、片方のViewでModelを更新したところで、黙っていれば他のViewが更新を画面に反映してくれるわけではない。そこで、片方のViewがModelを更新したときに、無事両方の見た目が更新されるには、どのような実装をしたら良いだろうか。
例をぐっと簡単にして、
の2つがあるとする。memberはこの際ただの数値にしてしまう。
追加ボタンが押されると、リストはもちろん増えるし、合計値も変化する。
以下の実装方針のうち、どれが好みだろうか。
1: 全部Viewの外で面倒を見る
var list = []; var view1 = new View1(); var view2 = new View2(); view1.on("add", function(e){ list.add(new Model()); var sum = sumOf(list); view1.render(list); view2.render(sum); });
潔い。Viewはイベント発火と、外から言われた通りにレンダリングする。合算ロジックも外でやる。
ところがカプセル化の観点からすると、データを追加するロジックや合算のロジックはViewの外でやりたくない。そもそも追加や合算をしてもらうためのViewを用意したのだから、「仕事しろ」という気分になるし、詳細の変更(例えばsum以外にも何か表示する)に強くするメリットも享受したい。
2: 更新通知だけ飛んでくる
var list = []; var view1 = new View1(list); var view2 = new View2(list); view1.on("add", function(e){ view1.render(); view2.render(); });
ViewがModelに対して更新処理を走らせ、「更新したよ」という通知だけを外部に知らせるパターン。
カプセル化の問題は解決したとして、何がどうなったときにレンダリングを走らせるかという判断がまだ外に居る。
これが例えば、view1からview2、view2からview3とview4、view4からview2…という風に通知のバリエーション増えていくことを想定すると頭が痛くなる。いつか配線が漏れそうな気がしてくる。
3: Modelから更新通知を受け取る
var list = []; var view1 = new View1(list); var view2 = new View2(list); list.on("add", function(e){ view1.render(); view2.render(); });
どんな経路でModelが更新されようと、Model自体を監視していれば問題無いという発想になる(上のコードはもちろん動かない)。
ところで、誰にレンダリングさせるかについては、まだ外で指定している。
4: View自身がModelから更新通知を受け取る
var list = []; var view1 = new View1(list); var view2 = new View2(list);
というわけで、イベントの受け取るをViewに任せてみる。こうすれば漏れないだろう。
細かいこととして、Modelから受け取るイベントの種類によってViewがレンダリングの戦略を選択できるという改善がある。
これが良さそうだ。これにしよう。*1
5: View自身に、そもそも扱うModelを選ばせてしまう
list = []; var view1 = new View1(); var view2 = new View2();
「必要なものはグローバルから勝手に取って来い」作戦。これは流石にやりすぎである。
一般的に言われているグローバル変数の邪悪さを全て取り込んでしまう。
- 変数名がコンテクスト依存になってしまう
- 何に依存するのか外から分からない
Backbone.jsで4を実装してみる
Backbone.jsのModelはObservableなので、上のような実装は容易に出来る。
「listViewでmembersを更新すると、sumViewがレンダリングされる」ことに注意してほしい。
<div id="all"> </div>
var ListView = Backbone.View.extend({ tagName: 'div', template: _.template('<button class="add">add</button>\ <ul>\ <% list.each(function(value) { %>\ <li><%= value.get("value") %></li>\ <% }); %>\ </ul>\ '), initialize: function(options){ this.list = options.list; }, events: { "click .add": "add" }, add: function(){ var value = this.list.length + 1; this.list.add(new Member({value: value}));//リスト更新 this.render(); }, render: function(){ var $el = this.$el.empty(); return $el.html(this.template({ list: this.list })); } }); var SumView = Backbone.View.extend({ tagName: 'div', template: _.template('<div>sum: <%= sum %></div>'), initialize: function(options){ this.list = options.list; this.listenTo(this.list, 'add', this.render);//リスナ登録 }, render: function(){ var $el = this.$el.empty(); var sum = this.list.reduce(function(memo, value){ return memo + value.get('value'); }, 0); return $el.html($(this.template({ sum: sum }))); } }); var Member = Backbone.Model.extend({}); var members = new Backbone.Collection(); members.add(new Member({value: 1})); members.add(new Member({value: 2})); members.add(new Member({value: 3})); $('#all') .append(new ListView({list: members}).render()) .append(new SumView({list: members}).render());
Memberモデルの実装が若干不自然になっているが、もっと良い書き方があるかもしれない。
ともかく、上の4番の方法を素直な実装で実現できた。
AngularJSのdirectiveで2に近いことをする
AngularJSの場合は、ModelがObservableではない。
その代わりに、イベントに委譲された一連の操作が終了するとレンダリングは自動で行われるので、変更をこちらで明示的に監視する必要はない*2。
という意味で、裏でやっている事は2に近いのだが、フレームワークが隠蔽してくれているので実際に書いている内容は上とほとんど同じになる。
今度は「<list>でmembersを更新すると、<sum>がレンダリングされる」ことに注意して欲しい。
<div ng-app="app"> <div ng-controller="Ctrl"> <list values="members"></list> <sum values="members"></sum> </div> </div>
angular.module('app', []) .directive('list', function() { return { restrict: 'E', template: '<div><button ng-click="add()">add</button>\ <ul><li ng-repeat="v in values">{{v}}</li></ul></div>', scope: { values: '=' },// 引数 link: function($scope) { $scope.add = function(){ var value = $scope.values.length + 1; $scope.values.push(value);// リストを更新 } } }; }) .directive('sum', function() { return { restrict: 'E', template: '<div>sum: {{sum()}}</div>', scope: { values: '=' }, link: function($scope) { $scope.sum = function(){ return $scope.values.reduce(function(memo, v){ return memo + v; },0); }; } }; }) .controller('Ctrl', function($scope) { $scope.members = [1, 2, 3]; });
「モデルの更新を受け取る」という風にはなっていないが、「片方で更新してもう片方に反映する」ということは実現できた。