読者です 読者をやめる 読者になる 読者になる

ジンジャー研究室

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

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

English

前置き

この記事はGUIをツリーにする話(前篇)の続きである。

ざっと振り返ると、GUIにおいてViewをツリー状に配置する際、複数のViewに同じModelをそれぞれ別個に管理させると、イベント発生時に同期を取るのがつらいという話だった。
というわけで、Modelはひとつにしたい。

f:id:jinjor:20131208211758p:plain

実装方針

ひとつのModelを2つのViewが共有する。
しかし、片方のViewでModelを更新したところで、黙っていれば他のViewが更新を画面に反映してくれるわけではない。そこで、片方のViewがModelを更新したときに、無事両方の見た目が更新されるには、どのような実装をしたら良いだろうか。

例をぐっと簡単にして、

の2つがあるとする。memberはこの際ただの数値にしてしまう。

f:id:jinjor:20131210030223p:plain

追加ボタンが押されると、リストはもちろん増えるし、合計値も変化する。
以下の実装方針のうち、どれが好みだろうか。

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がレンダリングされる」ことに注意してほしい。

デモ(JSFiddle)

<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>がレンダリングされる」ことに注意して欲しい。

デモ(JSFiddle)

<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];
});

「モデルの更新を受け取る」という風にはなっていないが、「片方で更新してもう片方に反映する」ということは実現できた。

考察

この記事ではフレームワークについて語ることを目的にしているのではないので、実装の詳細には触れない。
パフォーマンス事情を完全に無視し*3、記述としての理想を言うなら、個人的にはAngularJSのdirectiveに軍配が上がる。なぜならば、人類は常にカスタムタグを求めてきたからだ。
もう少し簡潔に書けても良いような気がするが、あくまでフレームワークなので仕方が無い。


次回があれば、パフォーマンス上の懸念などについて書く。

*1:この手の話、依存関係がどうとか保守性がどうとか開発効率がどうとか色々言ってみても、結局のところ多くの開発者が「この方法で書いたら幸せそうだ」という意識を共有できなければ意味が無い。だから一方的に「これがいい」と言ってみているが必ずしも正解ではないし、喧嘩になるのは本望ではないからまあ仲良くやりたい。

*2:明示的にやるには$watchというユーティリティ関数がある

*3:調べていない