JavaScript フレームワークがデータバインディングを実現する4通りの手法

最近流行りの JavaScript MV* フレームワークは、どれもデータバインディングをサポートしているが、実現方法はフレームワークによって異なる。

この記事では、各種フレームワークがどのようにモデルの変更を検知しているかを次の 4 つのパターンに分類して紹介する。

  1. モデル クラス方式 (Ember.js、Backbone.js、Ractive.js、Knockout.js など)
  2. 力ずく方式 (AngualrJS)
  3. モデル書き換え方式 (Vue.js)
  4. Object.observe 方式 (Polymer)

パターン名は私が勝手に名づけたものだけど、このへんの雰囲気が理解できれば、フレームワークごとの個性が分かるだろうし、利用イメージもわきやすいんじゃないかと思っている。

1. モデル クラス方式

「モデルとして扱えるのはフレームワークが用意したモデル クラスのインスタンスだけ」という制約を課すのがこの方式。

たとえば、Ember.js では {title: "てっく煮"} という情報をデータバインディングで利用しようと思ったら、次のようにしてモデル クラスのインスタンスを作る必要がある。

var a = Ember.Object.create({title: "てっく煮"})
console.log(a.get('title')); // てっく煮

モデルを変更するには set() メソッドを使う。

a.set('title', '!!!!');
console.log(a.get('title')); // !!!!

当然、フレームワーク側はモデルの値が変更されたことを検知できる。検知したら、あとはフレームワークは新しい値に応じて DOM を書き換えればよい。とてもシンプルな構造だ。

使う側の視点でみると、get()set() を呼ぶのが面倒だし、ラッパーの API は JavaScript の ObjectArray と扱い方が違うし、さらに、既存のモデルがある場合は流用できない (作成・取得・変更処理を全部置き換える必要がある) し・・・、と不便な印象がある。

これ以降の方式は、ネイティブな ObjectArray などをモデルとして扱えるように工夫している。

この方式を採用するフレームワークが多い

現時点においては、この方式を採用するフレームワークが多数派である。

Backbone.jsなどのライブラリのgetter, setterがダサい理由と、その解消方法 - Qiita によると、Backbone.js、Knockout.js、Ractive.js も同じ方式を採用しているらしい。

Backbone.js と Ractive.js は get("hoge")set("hoge", value) という形式。 Knockout.js は少しマシで hoge() で取得し hoge(value) で設定する形式。

2. 力ずく方式

お次は AngularJS が採用しているヤツを説明しよう。英語では "dirty check" と呼んでいる。

AngularJS はネイティブな ObjectArray をモデルとして渡せるし、独自クラスのオブジェクトだって渡せる。

たとえば、モデルが {title: 'てっく煮'} だったとしよう。モデルはネイティブなオブジェクトなので、変更をプッシュ通知で受け取るのは諦めている。

そんな前提なので、AngularJS は 何かあるごとに title の値が変化したかどうかを自力でチェックする。変化してれば DOM を書き換える。オブジェクトの参照が変わってしまうこともあるので、「前回の値」はディープコピーして覚えている。

とても力技なので処理にはオーバーヘッドがある。ソースコードも複雑になっている。

「何かあるごとに」はいつか?

さて、さきほど「何かあるごとに」と書いたが、前回の値と今の値を比較するタイミングはいつだろうか。

正解は「AngularJS 経由で JavaScript のコードを実行したとき」である。

たとえば、ボタンのクリックイベントに関しては <button ng-click="foo()"> のように書くと、AngularJS 経由で foo() が呼ばれる。setTimeout() の処理であれば、$timeout を使えば AngularJS 経由でコールバックが呼ばれる。

コールバックを読んだとき、モデルの値は変更されるかもしれないし、実際には変更されないかもしれない。変更されたか調べるために、呼び終わったあとに、力ずくで調べるのだ。

直接 DOM イベントを監視するようなときには AngularJS を経由させるのは難しい。そういうときには $scope.$apply() に関数を渡すと、関数を呼んだあとに力ずくの比較処理をやってくれる。

AngularJS を使う側の視点からすると、モデルへの変更を HTML に確実に反映させるには、ng-click$timeout$scope.$apply() など使って AngularJS 経由でモデルを変更する必要がある。

力ずく方式のまとめ

  • ObjectArray や独自クラスのインスタンスをモデルにできる
  • 比較処理がトリガーされるかどうかを意識する必要がある
  • 比較処理のオーバーヘッドがある

3. モデル書き換え方式

こちらは Vue.js が採用している方式である。

Vue.js もネイティブな ObjectArray をモデルとして渡せる。

たとえば、オブジェクトのキーの変更を検知するために、キーを (ECMAScript 5 的な) プロパティーに書き換える。配列の変更を検知するために、Array.prototype を書き換えて push() メソッドなどを置き換える。

AngularJS のように力ずくの比較は行わないので動作は速いのが利点。Vue.js のサイトでも 他のフレームワークより速いこと を自慢している。

一方の弱点は、次の通り。

  • モデルとして受け取ったオブジェクトを書き換えてしまう
  • 完全に変更を検知できない (完全に検知できないので、一部の変更処理については、$set()$add() といったメソッドを使う必要がある。ラッパー オブジェクト的な要素が残っているといえる)。

詳しくは Vue.js が data に渡した値を激しく書き換える件について に書いたので見てほしい。

4. Object.observe() 方式

現在、ECMAScript には「オブジェクトの変更を検知する」という機能を持つ Object.observe() というメソッドが提案されていて、仕様決定に先立って Google Chrome 36 ではデフォルトで有効 になっている。

ためしに Google Chrome 36 の JavaScript コンソールで使ってみる。

> a = {foo: 1}
> Object.observe(a, function(changes) { console.log(changes); })
> a.foo = 3
  [Object]
      0: Object
          name: "foo"
          object: Object
              foo: 3
          oldValue: 1
          type: "update"
> a.bar = 10
  [Object]
      0: Object
          name: "bar"
          object: Object
              bar: 10
              foo: 3
          type: "add"

まさにデータバインディングで使ってくれといっているようなメソッドである。

PolymerObject.observe() を前提としている。さらに、先ほどから紹介していた各種ライブラリーについても、AngularJS は 2.0 での対応を検討している し、Vue.js は v0.11.x で対応予定 となっている。

ECMAScript に Object.observe() が取り込まれれば、ここまで紹介したようなややこしいデータバインディングの仕組みは不要となる。まさに、データバインディングのための API であるが、ECMAScript 6 にも入っておらず、すべてのブラウザーで使えるようになるには時間がかかりそうだ。 Object.observe() については以下のページが詳しかった。

まとめ

MV* フレームワークが「どのようにモデルの変更を検知しているか」を 4 通り紹介した。

  • オブジェクト指向的なモデル クラスを使う方式が主流。
  • AngularJSVue.js はネイティブな値をモデルとして扱うために頑張っている。
  • Object.observe() が使えるようになれば、フレームワークの苦しみが減る。Polymer はそこを見据えている。