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

backbone.stickit を使って Backbone でデータバインディング

javascript

最近また AngularJS が盛り上がってる気がする。 AngularJS のデータバインディングは魅力的だけど、自分は Backbone 派。 でも Backbone でもデータバインディング使いたい。 そこで New York Times 製の Backbone プラグイン backbone.stickit を試してみた。

基本的な使い方

ビューでバインディングを宣言。 CSS セレクタとモデルの属性名をマッピングする。

var TaskView = Backbone.View.extend({
  template: _.template($("#task_template").html()),

  // バインディングの宣言
  bindings: {
    ".js-task-name": "name"
  },

  render: function() {
    this.$el.html(this.template({}));

    // テンプレートを描画したあと呼び出す
    this.stickit();
    return this;
  }
});

レンダリングしたあと stickit() を呼ぶと、バインディングが適用される。

DOM 要素の表示/非表示

モデルの属性が変更されたとき、 バインドしている DOM 要素を表示するかどうか指定できる。

bindings: {
  ".js-edit-view": {
    observe: "editing",
    // editing が true のとき表示
    visible: function(val, options) {
      return !!val;
    }
  }
}

One way バインディング

input にモデルの属性をバインドして表示したいけど、 編集されても即時にモデル反映してほしくないとき、 One way バインディングが使える。

bindings: {
  ".js-name-input": {
    observe: "name",
    updateModel: false  // モデルを更新しない
  }
}

複数の属性に依存

「firstName と lastName のどちらかが変更されたら表示を更新する」みたいに、 複数の属性とバインドすることもできる。

bindings: {
  ".js-name-input": {
    observe: ["name" "editing"],
    onGet: function(values) {
      return values[0];
    }
  }
}

TODO アプリ

最後に TODO アプリのサンプルを backbone.stickit を使って書いてみた。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>stickit sample</title>
  </head>
  <body>
    <h1>Todo</h1>
    <div id="main">
    </div>
    <div id="new_task">
      <input class="js-name-input" type="text"/>
      <button class="js-add-button">Add</button>
    </div>

    <!--タスク描画用テンプレート-->
    <script id="task_template" type="text/template">
      <div class="js-normal-view">
        <input class="js-completed-input", type="checkbox">
          <span class="js-task-name js-task-uncompleted"></span>
          <del class="js-task-completed"><span class="js-task-name"></span></del>
        </input>
        <a class="js-edit-task" href="javascript:void(0);">[edit]</a>
      </div>

      <!--その場編集用 DOM 要素-->
      <div class="js-edit-view">
        <input class="js-name-input" type="text"/>
        <a class="js-save-task" href="javascript:void(0);">[save]</a>
        <a class="js-delete-task" href="javascript:void(0);">[del]</a>
        <a class="js-cancel-task" href="javascript:void(0);">[cancel]</a>
      </div>
    </script>

    <script type="text/javascript" src="jquery-1.9.1.js"></script>
    <script type="text/javascript" src="underscore.js"></script>
    <script type="text/javascript" src="backbone.js"></script>
    <script type="text/javascript" src="backbone.stickit.js"></script>
    <script>
    // タスク
    var Task = Backbone.Model.extend({
      defaults: {
        name: "",
        completed: false,
        editing: false
      },
      beginEdit: function() {
        this.set("editing", true);
      },
      endEdit: function() {
        this.set("editing", false);
      },
      destroy: function() {
        this.trigger("destroy", this);
      }
    });

    // タスクのコレクション
    var TaskList = Backbone.Collection.extend({
      model: Task,

      initialize: function() {
        this.on("add", this.onAdd, this);
      },

      onAdd: function(model) {
        model.on("destroy", this.onDestroy, this);
      },

      onDestroy: function(model) {
        model.off("add", this.onAdd);
        model.off("destroy", this.onDestroy);
        this.remove(model);
      }
    });

    // タスクを表示するビュー
    var TaskView = Backbone.View.extend({
      template: _.template($("#task_template").html()),

      // バインディングの設定
      bindings: {
        // タスク名を表示する
        ".js-task-name": "name",

        // タスクの完了・未完了をチェックボックスで切り替える
        ".js-completed-input": "completed",

        // その場編集モードを切り替えたとき input の値をリセットする
        ".js-name-input": {
          observe: ["name", "editing"],
          onGet: function(values) {
            return values[0];
          },
          updateModel: false
        },

        // 未完了タスク名表示用 DOM 要素の表示を切り替える
        ".js-task-uncompleted": {
          observe: "completed",
          visible: function(val, options) {
            return !val;
          }
        },

        // 完了したタスクの打ち消し線の表示を切り替える
        ".js-task-completed": {
          observe: "completed",
          visible: function(val, options) {
            return !!val;
          }
        },

        // 通常モード用要素の表示を切り替える
        ".js-normal-view": {
          observe: "editing",
          visible: function(val, options) {
            return !val;
          }
        },

        // その場編集モード用要素の表示を切り替える
        ".js-edit-view": {
          observe: "editing",
          visible: function(val, options) {
            return !!val
          }
        }
      },

      events: {
        "click .js-edit-task": "onEdit",
        "click .js-save-task": "onSave",
        "click .js-delete-task": "onDelete",
        "click .js-cancel-task": "onCancel"
      },

      // input の値を取り出してモデルを更新
      onSave: function(e) {
        var name = this.$el.find(".js-name-input").val();
        this.model.set("name", name);
        this.model.endEdit();
      },

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

      onEdit: function(e) {
        this.model.beginEdit();
      },

      onCancel: function(e) {
        this.model.endEdit();
      },

      initialize: function() {
          this.model.on("destroy", function(model) {
              this.remove();
          }, this);
      },

      render: function() {
        var html = this.template({});
        this.$el.html(html);
        this.stickit();
        return this;
      }
    });

    // タスクの一覧を表示するビュー
    var TaskListView = Backbone.View.extend({
      initialize: function() {
        this.collection.on("add", this.addOne, this);
      },

      addOne: function(task) {
        var view = new TaskView({ model: task });
        view.render();
        this.$el.append(view.el);
      },

      render: function() {
        this.$el.empty();
        this.collection.each(this.addOne, this);
        return this;
      }
    });

    // 登録フォーム
    var TaskFormView = Backbone.View.extend({
        el: $("#new_task"),

        bindings: {
          ".js-name-input": "name"
        },
        events: {
          "click .js-add-button": "onAdd"
        },

        initialize: function() {
          this.model = new Task();
        },

        onAdd: function(e) {
          var task = this.model.clone();
          this.collection.add(task);
          this.model.set("name", "");
        },

        render: function() {
          this.stickit();
          return this;
        }
    });

    // ルーター
    // エントリポイントでもある
    var MainRouter = Backbone.Router.extend({
      routes: {
        "": "index"
      },

      initialize: function() {
        this.taskList = new TaskList();
        this.taskList.add({ name: "foo" });
        this.taskList.add({ name: "bar", completed: true });

        this.taskForm = new TaskFormView({ collection: this.taskList });
        this.taskForm.render();
      },

      index: function() {
        var view = new TaskListView({ collection: this.taskList });
        view.render();
        $("#main").html(view.el);
      }
    });

    $(function() {
      window.router = new MainRouter();
      Backbone.history.start();
    });
    </script>
  </body>
</html>

その場編集機能を結構無理やり実装したため、テンプレートがカオスになっている。 もっと良いやり方がありそうだけど思いつかなかった。