作って覚える Backbone.js

Web サービスにリッチな UI を実装したいけど、jQuery だけだとコードがスパゲッティになりそうなので、JavaScriptMVC フレームワークを使うことにしました。候補は Backbone.js と Knockout.js の2つ。どちらも最近はてブで注目を浴びましたね。


Backbone.js はあの 37signals がモバイル版 Basecamp で使った実績があります。一方、Knockout.js は .NET 開発者にはおなじみ(?) の MVVM パターンで開発できるといいます。どちらを使おうか迷いましたが、「依存している underscore.js が便利そう」というズレた理由で、今回は Backbone.js を選択。


手を動かさないと覚えない人間なので、サンプルを作ることにしました。作るのは TODO アプリ。サンプルの題材としては定番ですね。Backbone.js 公式ドキュメントでも TODO アプリのサンプルが紹介されていますし。でも、他に良い題材も思いつかない。そっくりそのままというのはマズそうなので、違いを出さないといけないかな。


そこで、TODO アプリに「その場編集機能」を実装します。そのかわり、localStorage にデータを保存しません。登録したタスクは画面を更新すると消えてしまいます。なんというクソ仕様。練習台なんで勘弁してください。あと、入門記事みたいなタイトルですけど、「私が入門した」という記事です。釣りだと感じた方、ごめんなさい。コメントをウザイくらい多めに書いたので、日本語のサンプルとしてはいいかもしれません。

<!DOCTYPE html>
<html lang="ja">
    <head>
        <meta charset="utf-8">
        <title>Backbone Todo</title>
    </head>
    <body>
        <h1>Backbone Todo</h1>
        <div id="main">
            <div id="task-list">
                <!-- ここにタスクリストを表示する -->
            </div>
            <input id="name-input" type="text"/>
            <input id="add-input" type="button" value="add"/>
        </div>

        <!--TaskView の描画に使うテンプレート-->
        <script id="task-template" type="text/html">
            <% if (isEditing) { %>
                <input class="name-input" type="text" value="<%= name %>"/>
                <input class="save-input" type="button" value="save"/>
                <input class="cancel-input" type="button" value="cancel"/>
                <input class="delete-input" type="button" value="delete"/>
            <% } else { %>
                <% if (completed) { %>
                    <input type="checkbox" checked>
                    <del><%= name %></del>
                <% } else { %>
                    <input type="checkbox">
                    <%= name %>
                <% } %>
                </input>
                <a class="edit-link" href="javascript:void(0);">[edit]</a>
            <% } %>
        </script>

        <script type="text/javascript" src="http://ajax.googleapis.com/ajax/libs/jquery/1.6.4/jquery.min.js"></script>
        <script type="text/javascript" src="http://documentcloud.github.com/underscore/underscore-min.js"></script>
        <script type="text/javascript" src="http://documentcloud.github.com/backbone/backbone-min.js"></script>
        <script type="text/javascript">
            // タスクを表すクラス。
            // タスクのデータを格納するモデルなので、Backbone.Model を継承。
            var Task = Backbone.Model.extend({
                // 初期値を指定。
                // new でオブジェクトを生成したとき、まずこの値が attributes に格納される。
                defaults: {
                    name: "",
                    completed: false,
                    isEditing: false
                },
                
                // destroy をオーバーライド。
                // 本来ならサーバーと通信するけど、今回のサンプルではデータを永続化しないから、
                // destroy イベントだけ発生させる。
                destroy: function() {
                    this.trigger("destroy", this);
                },

                // set メソッドに渡されたデータを検証する。
                // 何か値を返すと検証エラー扱いになるので、
                // 不正な値だったときはエラーメッセージなんかを返すといい。
                validate: function(attrs) {
                    // 検証には、underscore の便利メソッドを使っている。
                    if (_.isString(attrs.name) && _.isEmpty(attrs.name)) {
                        return "task name is empty.";
                    }
                }
            });

            // タスクリストを表すクラス。
            // タスクのコレクションを扱うので、Backbone.Collection を継承。
            var TaskList = Backbone.Collection.extend({
                // コレクションが扱うモデルの型。
                // 指定しなくてもいいけど、指定しておくと add に Task 以外のオブジェクトが
                // 渡されたときラップしてくれる。
                model: Task,

                // 初期化。
                initialize: function() {
                    // タスクリストにタスクが追加されたときに発生する add イベントに
                    // ハンドラを登録する。
                    this.bind("add", this._onAdd, this);
                },

                _onAdd: function(task) {
                    task.bind("destroy", this._onDestroy, this);
                },

                // タスクが destroy イベントを発生させたらタスクリストから削除する。
                _onDestroy: function(task) {
                    this.remove(task);
                }
            });

            // タスクを表示するビュー。
            // ビューは Backbone.View を継承。
            var TaskView = Backbone.View.extend({
                // ビューをレンダリングするタグの名前を指定。
                tagName: "div",
                
                // テンプレート。
                // underscore のテンプレートを使用。
                template: _.template($("#task-template").html()),

                // イベントハンドラのマッピング。
                // CSS セレクタにマッチした要素にイベントハンドラを自動でセットしてくれる。
                events: {
                    "change input[type=checkbox]": "_onCheck",
                    "click a.edit-link": "_onEdit",
                    "click input.save-input": "_onSave",
                    "click input.cancel-input": "_onCancel",
                    "click input.delete-input": "_onDelete",
                },

                initialize: function() {
                    // モデルの set メソッドで値が変更されたら change イベントが発生するので、
                    // 再描画する。
                    this.model.bind("change", this.render, this);
                
                    // モデルのバリデーションに失敗したらエラーメッセージを表示。
                    this.model.bind("error", this._onError, this);
                },

                _onError: function(model, error) {
                    alert(error);
                },

                _onCheck: function() {
                    var completed = this.model.get("completed");
                    this.model.set({ completed: !completed });
                },

                _onEdit: function() {
                    this.model.set({ isEditing: true });
                },

                _onSave: function() {
                    var name = $(this.el).find("input.name-input").first().val();
                    this.model.set({ name: name, isEditing: false });
                },

                _onCancel: function() {
                    this.model.set({ isEditing: false });
                },

                _onDelete: function() {
                    this.model.destroy();
                },

                // ビューをレンダリング
                render: function() {
                    // モデルを JSON に変換してからテンプレートに渡す。
                    // { "name": this.model.get("name") } みたいに、
                    // オブジェクトを渡してもいい。
                    var data = this.model.toJSON();
                    var html = this.template(data);
                    $(this.el).html(html);
                }
            });

            // タスクリストを表示するビュー。
            var TaskListView = Backbone.View.extend({
                // ビューをレンダリングする要素。
                el: $("#main"),

                events: {
                    "click #add-input": "_onAddInputClick"
                },

                initialize: function() {
                    // モデル (TaskList) の要素数が変わったら再描画。
                    this.model.bind("add", this.render, this);
                    this.model.bind("remove", this.render, this);
                },
                
                _onAddInputClick: function() {
                    var name = $("#name-input").val();
                    if (_.isEmpty(name)) {
                        alert("task name is empty.");
                        return;
                    }
                    var task = new Task({ "name": name });
                    this.model.add(task);
                    $("#name-input").val("");
                },

                render: function() {
                    var taskListEl = $("#task-list");
                    taskListEl.empty();

                    // Task と TaskView を1対1にする。
                    // テンプレート内で for 文が使えるから、表示するだけなら
                    // Task 1つ1つにビューを作る必要はない。
                    // でも、1対1にしておいた方が、その場編集機能が実装しやすい。
                    this.model.each(function(task) {
                        var view = new TaskView({ model: task });
                        view.render();
                        taskListEl.append(view.el);
                    });
                }
            });

            window.App = new TaskListView({ model: new TaskList() });
        </script>
    </body>
</html>

試すなら、上記のコードをエディタにコピペして保存し、ブラウザで表示してみてください。保存する際は UTF-8 で。Chrome での動作は確認しています。


Backbone.js を使ってみての感想ですけど、モデルやビューの動きを理解するのに苦労するので、ハードルは高いと感じました。でも、慣れさえすれば、この程度のアプリならさくっと作れます。この TODO アプリも1時間くらいで作りました。コードも結構すっきりしています。


「おいおい MVC の C はどこにいったんだよ」と思った方、今回のサンプルは画面遷移しないので、MVC のコントローラーに該当する Backbone.Router を使いませんでした。あとサーバーとの通信もしていません。これらについては今度試してみようと思います。


デスクトップアプリみたいな操作性の UI を JavaScript で実装しようと思ったら、仮に jQuery だけ使った場合でも、モデルとビューを自前で実装したと思います。MVC フレームワークを使うとその手間が省けるのは大きいです。Web サービスの UI がどんどん複雑化してきている以上、JavaScript でもフレームワークを使って開発するという流れになっていくんでしょうね。