Backbone.js ではじめるクライアントサイド MVC プログラミング

MVC と言えば Apache Struts をはじめとするサーバサイド・フレームワークを想像しますが、 今回は JavaScript による大規模開発の際に採用されるクライアントサイド MVC フレームワーク「Backbone.js」の使い方についてまとめてみました。 (厳密にはクライアントサイドの場合、MVC とは呼ばず MVVM とか MV* とか呼ばれてるようです。)

前提

Backbone.js の構成を簡単に言ってしまうと

  • 単一データの管理を行うモデル
  • 複数件のモデルの管理を行うコレクション
  • 画面の管理を行うビュー

の3つの主要モジュールを軸に構成されており、Underscore.js、jQuery(Zepto)に依存するかたちで動作するようになっています。

利用の際は、underscore.js、jquery.js、backbone.js の順で読み込みます。

//cdnjs.cloudflare.com/ajax/libs/underscore.js/1.7.0/underscore-min.js
//ajax.googleapis.com/ajax/libs/jquery/1.10.2/jquery.min.js
//cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.2/backbone-min.js

今回は簡単な TODO アプリケーションを実装してみます。 入力データは Backbone.localStorage.js を利用する事で、ローカルストレージに保存することができるので、こちらも読み込みます。

//cdnjs.cloudflare.com/ajax/libs/backbone-localstorage.js/1.1.14/backbone.localStorage-min.js

モデルの定義

まずモデルの定義を行います。 TODO アプリなので

  • タスク名(title)
  • 完了状態フラグ(closed)

の2つを定義する事とします。

//モデルの定義
var Task = Backbone.Model.extend({
    defaults: {
        title: '',
        closed: false
    }
});
//インスタンスの生成
var task = new Task({
    title: 'backbone.jsの勉強'
});


以下のよう toJSON() メソッドで中身を確認することができます。 console.log の出力結果は Chrome の場合 F12 → Console タブより確認できます。

console.log(task.toJSON());


alert で確認する場合は、以下のように JSON.stringify() を使用すると見やすく整形されます。(RUN をクリックしてみてください)

code demo edit noAuto

jsFile noCode
http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.7.0/underscore-min.js
jsFile noCode
http://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.2/backbone-min.js
jsFile noCode
http://cdnjs.cloudflare.com/ajax/libs/backbone-localstorage.js/1.1.14/backbone.localStorage-min.js
script noCode
Task = Backbone.Model.extend({
    defaults: {
        title: '',
        closed: false
    }
});
task = new Task({
    title: 'backbone.jsの勉強'
});
script
alert(JSON.stringify(task.toJSON(), null, '    '));

モデルに validate を定義する

次にモデルに validate を定義してみます。 タスク名に値が設定されてない場合は、エラーメッセージを返すようにしてみます。 _.isEmpty() は underscore.js の機能です。

var Task = Backbone.Model.extend({
    defaults: {
        title: '',
        closed: false
    },
    validate: function(attrs){
        if(_.isEmpty(attrs.title)){
            return 'タスク名が指定されてません';
        }
    }
});


validate() は set() メソッドでモデルに値を設定した際に起動させる事ができます。 以下の例では title を設定してないのでエラーとなります。

var task = new Task();
var ret = task.set({close: true}, {validate: true});
console.log(ret); // false


エラーメッセージは invalid トリガー内で取得できます。

var task = new Task();
task.on("invalid", function(model, error) {
    alert(error); // タスク名が指定されてません
});
task.set({close: true}, {validate: true});


実際の実装の際は、エラーメッセージのみでなくエラーとなったフィールド等もろもろの関連情報が必要になったり、 エラー表示処理においてもアプリ固有の汎用処理としての実装上の工夫が必要になるかと思います。

簡単な例ですが invalid トリガーを使用しなくても、以下のような記述で set() メソッド実行時に、 validate 判定とエラー情報の取得/表示を行うことができます。(RUN をクリックしてみてください)

code demo edit noAuto

script
var Task = Backbone.Model.extend({
    defaults: {
        title: '',
        closed: false
    },
    validate: function(attrs){
        if(_.isEmpty(attrs.title)){
            var ret = {
                name: 'title',
                msg: 'タスク名が指定されてません'
            }
            this.set('errinfo', ret)
            return ret;
        }
    }
});
var task = new Task();
var sts = task.set({}, {validate: true});
if(!sts){
    var info = task.get('errinfo');
    alert(info.msg + ' => ' + info.name);
}

みなさんいろいろ工夫されてるようです。

モデルのメソッドとトリガー

モデルの持つメソッドを実行すると、関連するトリガーが起動されます。 例えば、set()、save()メソッドで値を変更した場合、change トリガーが起動されます。

var task = new Task();
task.on('change', function(task){
    alert(task.get('title')); // task-set → task-save
})
task.set({title: 'task-set'});
task.save({title: 'task-save'});


destroy() メソッドではデータが削除され、destroy トリガーが起動されます。

task.on('destroy', function(task){
    alert('destroy');
})
task.destroy();

コレクションの定義

つづいて、複数件のモデルの管理を行うコレクションの定義を行います。 model パラメータに管理対象となるモデルを指定します。

var Tasks = Backbone.Collection.extend({
    model: Task
});


インスタンス生成時に任意の数のモデルを生成できます。

var tasks = new Tasks([
    {title: 'task-1'},
    {title: 'task-2'},
    {title: 'task-3'}
]);
console.log(tasks.length); // 3

コレクションのメソッドとトリガー

create() メソッドでは新しいモデルをコレクションに追加することができます。 その際 add トリガーが起動され、 トリガー内にて追加されたモデルを参照することができます。

tasks.on('add', function(task){
    alert(task.get('title')); // task-4
})
tasks.create({title: 'task-4'});


Backbone.localStorage.js を読み込むことで、モデルをローカルストレージに保存することができます。

<script type='text/javascript' src='http://cdnjs.cloudflare.com/ajax/libs/backbone-localstorage.js/1.1.14/backbone.localStorage-min.js'></script>


コレクションの定義で localStorage の保存領域を指定します。 この指定により、create() メソッドでモデルを追加した際、対象のモデルがローカルストレージに保存されるようになります。

var Tasks = Backbone.Collection.extend({
    model: Task,
    localStorage: new Store('backbone-task')
});


ローカルストレージに保存されたモデルをコレクションに展開したい場合は、fetch() メソッドを使用します。 その際、create() メソッド同様に add トリガーが起動されます(読み込んだモデルの件数分起動されます)。

tasks.fetch();


ここまでの情報を元に、リスト項目の追加と全削除を行う簡単な画面を作ってみます。 ローカルストレージにモデルを保存してるので、F5 でページをリロードしてもリストは再現されます。

code demo

jsFile noCode
http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.7.0/underscore-min.js
jsFile noCode
http://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.2/backbone-min.js
jsFile noCode
http://cdnjs.cloudflare.com/ajax/libs/backbone-localstorage.js/1.1.14/backbone.localStorage-min.js
html
<button class="add">add</button>
<button class="clear">clear</button>
<ul></ul>
script
//-----------------------------
// モデル
//-----------------------------
var Task = Backbone.Model.extend({
    defaults: {title:''}
});
//-----------------------------
// コレクション
//-----------------------------
var Tasks = Backbone.Collection.extend({
    model: Task,
    localStorage: new Store('backbone-local-storage-task')
});
//-----------------------------
// ビュー(画面全体)
//-----------------------------
//コレクションを生成しトリガーを割り当てる
var tasks = new Tasks();
tasks.on('add', function(task){
    //-----------------------------
    // ビュー(リストアイテム部分)
    //-----------------------------
    var taskView = $('<li/>').text(task.get('title')).prependTo($DEMO.find('ul'))
    task.on('destroy', function(){
        taskView.remove();
    });
})
//画面上のパーツにトリガーを割り当てる
$DEMO.find('.add').on('click', function(){
    tasks.create({title: 'task-'+(tasks.length+1)});
});
$DEMO.find('.clear').on('click', function(){
    tasks.each(function(task){
        tasks.first().destroy();
    });
});
//ローカルストレージの保存モデルの読み込み
tasks.fetch();

ポイントは画面操作に対して行う処理はモデル(コレクション)の変更のみで、 画面の描画は、モデルに割り当てられたトリガーを経由してのみ行われてるという部分です。

ビューの定義(リストアイテム部分)

先のサンプルのビューの部分を Backbone.View で定義し直してみます。 まず、リストアイテム部分(LI)のビューを作ります。

修正前

var taskView = $('<li/>').text(task.get('title')).prependTo($DEMO.find('ul'))
task.on('destroy', function(){
    taskView.remove();
});

修正後

var TaskView = Backbone.View.extend({
    tagName: 'li', //(1)
    initialize: function(){
        this.model.on('destroy', this.remove, this); //(2)
    },
    template: _.template('<span><%-title%></span>'), //(3)
    render: function(){
        var html = this.template(this.model.toJSON());
        this.$el.html(html); //(4)
        return this;
    }
});
  • (1) tagName
    • 生成するビューのルート要素を指定します。
  • (2) this.model
    • このビューのインスタンス生成時に指定されるモデル(task)を指します。
  • (3) _.template
    • Underscore.js の機能で、画面テンプレートを生成する function を返します。
  • (4) $el
    • ビューのルート要素を jQuery 形式で参照できます(この例では (1) の li)。

ビューの定義(画面全体部分)

次に画面全体部分のビューを定義します。

修正前

//コレクションを生成しトリガーを割り当てる
var tasks = new Tasks();
tasks.on('add', function(task){
    // ビュー(リストアイテム部分)の生成
})
//画面上のパーツにトリガーを割り当てる
$DEMO.find('.add').on('click', function(){
    tasks.create({title: 'task-'+(tasks.length+1)});
});
$DEMO.find('.clear').on('click', function(){
    tasks.each(function(task){
        tasks.first().destroy();
    });
});
//ローカルストレージの保存モデルの読み込み
tasks.fetch();

修正後

var TaskApp = Backbone.View.extend({
    events: { // (1)
        'click .add': 'addTask',
        'click .clear': 'clearTask'
    },
    initialize: function(){
        var ul = this.$el.find('ul');
        this.collection.on('add', function(task){ // (2)
            var taskView = new TaskView({model: task});
            ul.prepend(taskView.render().el)
        })
        this.collection.fetch();
    },
    addTask : function(){
        this.collection.create({title: 'task-'+(this.collection.length+1)});
    },
    clearTask : function(){
        var collection = this.collection;
        this.collection.each(function(task){
            collection.first().destroy();
        });
    }
});
  • (1) events
    • トリガーをまとめて記述できます。’click .add': ‘addTask’ という記述は、ビューのルート要素内の .add クラスを持つ要素をクリックした場合、addTask() メソッドを実行するという意味になります。
  • (2) this.collection
    • このビューのインスタンス生成時に指定されるコレクション(tasks)を指します。

以下のように実行します。

var taskApp = new TaskApp({
    el: $DEMO,
    collection: new Tasks()
});

code demo

jsFile noCode
http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.7.0/underscore-min.js
jsFile noCode
http://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.2/backbone-min.js
jsFile noCode
http://cdnjs.cloudflare.com/ajax/libs/backbone-localstorage.js/1.1.14/backbone.localStorage-min.js
html
<button class="add">add</button>
<button class="clear">clear</button>
<ul></ul>
script
//モデル
var Task = Backbone.Model.extend({
    defaults: {title:''}
});
//コレクション
var Tasks = Backbone.Collection.extend({
    model: Task,
    localStorage: new Store('backbone-local-storage-task')
});
// ビュー(リストアイテム部分)
var TaskView = Backbone.View.extend({
    tagName: 'li',
    initialize: function(){
        this.model.on('destroy', this.remove, this);
    },
    template: _.template('<span><%-title%></span>'),
    render: function(){
        var html = this.template(this.model.toJSON());
        this.$el.html(html);
        return this;
    }
});
// ビュー(画面全体)
var TaskApp = Backbone.View.extend({
    events: {
        'click .add': 'addTask',
        'click .clear': 'clearTask'
    },
    initialize: function(){
        var ul = this.$el.find('ul');
        this.collection.on('add', function(task){
            var taskView = new TaskView({model: task});
            ul.prepend(taskView.render().el)
        })
        this.collection.fetch();
    },
    addTask : function(){
        this.collection.create({title: 'task-'+(this.collection.length+1)});
    },
    clearTask : function(){
        var collection = this.collection;
        this.collection.each(function(task){
            collection.first().destroy();
        });
    }
});
var taskApp = new TaskApp({
    el: $DEMO,
    collection: new Tasks()
});

TODO アプリの実装

前述のサンプルをベースに TODO アプリを実装してみます。

code demo

jsFile noCode
http://cdnjs.cloudflare.com/ajax/libs/underscore.js/1.7.0/underscore-min.js
jsFile noCode
http://cdnjs.cloudflare.com/ajax/libs/backbone.js/1.1.2/backbone-min.js
jsFile noCode
http://cdnjs.cloudflare.com/ajax/libs/backbone-localstorage.js/1.1.14/backbone.localStorage-min.js
css
.taskApp input.title{
    padding:8px;
    border-radius: 4px;
    width:20em;
    border: solid 1px #ccc;
}
.taskApp ul.taskList{
    padding-left: 0;
}
.taskApp ul.taskList li{
    list-style:none;
}
.taskApp li.closed .title{
    color: gray;
    text-decoration: line-through;
}
.taskApp .del{
    color: #ff5577;
    text-decoration: none;
    font-weight: bold;
}
.taskApp .error{
    color: red;
}
html
<div class="taskApp">
    <h1>TODO</h1>
    <form class="addTask">
        <input type="text" class="title" placeholder="タスクを入力してください">
        <span class="error"></span>
    </form>
    <ul class="taskList"></ul>
    <button class="clear">全て削除</button>
</div>
<script type="text/template" id="temp-taskItem">
    <input type="checkbox" class="toggle" <%= closed ? 'checked' : '' %> >
    <span class="title"><%-title%></span>
    <a class="del" href="#">x</a>
</script>
script
//モデル
var Task = Backbone.Model.extend({
    defaults: {
        title: '',
        closed: false
    },
    validate: function(attrs){
        if(_.isEmpty(attrs.title)){
            return 'タスク名が指定されてません';
        }
    }
});
//コレクション
var Tasks = Backbone.Collection.extend({
    model: Task,
    localStorage: new Store('task-app')
});
// ビュー(リストアイテム部分)
var TaskView = Backbone.View.extend({
    tagName: 'li',
    events: {
        'click .toggle': 'toggleTask',
        'click .del': 'delTask'
    },
    initialize: function(){
        this.model.on('destroy', this.remove, this);
        this.model.on('change', this.render, this); // (1)
    },
    template: _.template($('#temp-taskItem').html()), // (2)
    render: function(){
        var html = this.template(this.model.toJSON());
        this.$el.html(html)[
            this.model.get('closed') ? 'addClass' : 'removeClass'
        ]('closed');
        return this;
    },
    toggleTask : function(){
        this.model.set('closed', !this.model.get('closed')).save();
    },
    delTask : function(e){
        e.preventDefault();
        if(confirm('削除しますか?')){
            this.model.destroy();
        }
    }
});
// ビュー(画面全体)
var TaskApp = Backbone.View.extend({
    events: {
        'submit .addTask': 'addTask',
        'click .clear': 'clearTask'
    },
    initialize: function(){
        var o = this;
        o.$title = o.$el.find('input.title')
        o.$list = o.$el.find('ul.taskList');
        o.$error = o.$el.find('.error');
        o.collection.on('add', function(task){
            var taskView = new TaskView({model: task});
            o.$list.prepend(taskView.render().el)
        })
        o.collection.fetch();
        o.collection.on("invalid", function(task, error) { // (3)
            o.$error.text(error);
        });
    },
    addTask : function(e){
        var o = this;
        e.preventDefault();
        var sts = o.collection.create(
            {title: o.$title.val()},
            {validate: true}
        );
        if(sts){
            o.$title.val('')
            o.$error.text('');
        }
    },
    clearTask : function(){
        var o = this;
        if(confirm('全て削除してよろしいですか?')){
            o.collection.each(function(task){
                o.collection.first().destroy();
            });
        }
    }
});
var taskApp = new TaskApp({
    el: $('div.taskApp'),
    collection: new Tasks()
});
  • (1) this.model.on(‘change’, this.render, this)
    • チェックボックスがオンになった場合、項目に取り消し線を引くため対象のリスト項目のみを再描画しています。
  • (2) _.template($(‘#temp-taskItem’).html())
    • html の script タグ内で定義したテンプレートを読み込んでます。
  • (3) o.collection.on(“invalid”, function(task, error) {
    • コレクションに invalid トリガーを定義してます。モデルでエラーになった場合、伝播されこのトリガーが起動されます。

所感

一見すると複雑そうに見えますが順をおって見てくと、内部処理も想像しやすく逃げ道も作りやすいシンプルなフレームワークだと感じます。

クライアントサイドでがっつりロジックを組む場合、MVC を意識した保守性の高いコードをいかにして書くかはプログラマの腕の見せ所であったわけですが、 こうしたフレームワークの適用により一定のルール下の実装が強要されるので、チーム開発や、運用者と開発者が異なる場合、開発標準規約としての効果は大きいかもしれません。