RequireJS と Backbone.js を組み合わせてモデルとビューをモジュール化してみる

はじめに

Backbone.js を使って UI を実装しているけど、モデルやビューが増えてきた。1ファイルに収めるには多いんで、ファイルを分割して実装し、最終的には結合したい。

そこで、RequireJS を使ってモデルやビューをモジュールにして、ファイルを分割しつつ開発できるようにしてみる。

RequireJS とは

RequireJS は JavaScript のファイルやモジュールを非同期でロードできる JavaScript ライブラリ。

最適化機能もあり、公開時はモジュールをひとまとめにできる。

RequireJS と Backbone.js を組み合わせてみる

以前作った TODO アプリのサンプルで練習してみる。モデルとビューとテンプレートを1ファイルに記述していたので、それを別ファイルに分割する。

ディレクトリ構成

RequireJS の導入でディレクトリ構成は次のようになった。

C:\WORK\BACKBONE_SAMPLE
│  project.html
│
└─scripts
    │  main.js
    │  models.js
    │  require.js
    │  views.js
    │
    └─lib
            backbone.js
            jquery.js
            underscore.js

lib ディレクトリには、使っているライブラリを置いている。それ以外のファイルについて説明していく。

project.html

唯一の HTML ファイル。テンプレートも記述している。script タグで読み込むのは require.js だけ。data-main で指定したスクリプトファイルがエントリポイントになる。

<!DOCTYPE html>
<html>
    <head>
        <title>RequireJS Sample</title>
        <script data-main="scripts/main" src="scripts/require.js"></script>
    </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>
    </body>
</html>
main.js

エントリポイント。先頭で、RequireJS のモジュールが jQuery や Backbone を使えるように設定している。
その後、モデルとビューのモジュールを読み込んで、アプリを開始。

// サードパーティのライブラリをモジュールとして読み込めるようにする
requirejs.config({
    shim: {
        "lib/jquery": {
            exports: "jQuery"
        },
        "lib/underscore": {
            exports: "_"
        },
        "lib/backbone": {
            deps: ["lib/jquery", "lib/underscore"],
            exports: "Backbone"
        }
    }
});

// モデルとビューのモジュールを読み込んでアプリケーションを開始
require(["models", "views"], function(models, views) {
    window.App = new views.TaskListView({ model: new models.TaskList() });
});
models.js

モデルを定義しているモジュール。Underscore.js と Backbone.js に依存している。モデルは main モジュールの中で使うから require ではなく define で公開。

// underscore と backbone に依存
define(["lib/underscore", "lib/backbone"], function(_, Backbone) {
    // タスクを表すクラス。
    // タスクのデータを格納するモデルなので、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);
         }
     });

     // この models モジュールが公開するクラスを返す
     return {
         "Task": Task,
         "TaskList": TaskList
     };
});
views.js

ビューを定義しているモジュール。jQuery と Underscore と Backbone、あと models に依存。こちらも main で使うから、define で公開している。

// jquery、underscore、backbone、models に依存
define(["lib/jquery", "lib/underscore", "lib/backbone", "models"], function($, _, Backbone, models) {
    var Task = models.Task;
    var TaskList = models.TaskList;

    // タスクを表示するビュー。
    // ビューは 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);
             });
         }
     });

     // この views モジュールが公開するクラスを返す
     return {
         "TaskView": TaskView,
         "TaskListView": TaskListView
     };
});
これで完成

RequireJS はモジュールを HTTP GET で遅延読み込みするから、デバッグするには HTTP サーバーで動かさないといけない。
Ruby を使っているなら

に書いたスクリプトで HTTP サーバーを起動できる。これを使ってデバッグすればいい。

まとめ

Backbone.js と RequireJS を組み合わせて、モデルやビューをモジュール化してみた。UI が大規模になってくると、モデルやビューのファイル数が増えてくるし、複数のモデルに依存するビューだったり、複数のビューを内包する複合ビューだったりと、依存関係も複雑になってくる。今回のサンプル程度では面倒なだけだけど、ファイル数が増えるにつれて恩恵を感じると思う。

RequireJS には最適化する機能もあり、最終的にはそれで結合するだろうから、モデルやビューを1ファイル1クラス定義、みたいな開発ルールが使えるな。最適化機能はまた今度試してみる。