メインコンテンツへ移動

双方向データバインディングの3つの実現方法

無料2017-02-12#JS#双向数据绑定#data binding#angular dirty checking#vue data binding#angular data binding#js数据绑定

データモデルの制御、setterによる変更の監視、ダーティチェックの3つの異なる方法で双方向データバインディングを実装します。

はじめに

ステート(状態)がなかった時代から、手動でステートを管理しDOMを操作してビューを更新する時代へ、そしてその後の双方向データバインディングへと、フロントエンドの各種ソリューションはデータとビューの関連付けを簡素化する方法を追求し続けてきました。現在のトレンドは、DOMを弱体化させ、データのステートを強化し、「真の」ロジックに注目することです。

1. マイルストーン

Server-Side Rendering: Reset The Universe

There is no change. The universe is immutable.

ステートがなく、フロントエンドが弱い時代。

ビューの制御やロジック機能はすべてサーバー側で提供され、フロントエンドはフォームのバリデーションなどのインタラクションのみを処理し、ユーザーの行動を直接サーバーに伝えます:

フロントエンド:『次へ』ボタンが押されました。フロントエンド終了。
サーバー:了解、これが次のページです。

ロジックのステートはすべてサーバー側にあり、フロントエンドのコードは1行だけです:if (isValid) form.submit(); else alert('invalid')

P.S. SSR(サーバーサイドレンダリング)は久しく見ていませんね...

First-gen JS: Manual Re-rendering

I have no idea what I should re-render. You figure it out.

フロントエンドでステートを管理し、手動でDOMを操作してビューを更新する時代。

フロントエンドのコードが増え、いくつかのステートを管理する必要が出てきました。この時期のフロントエンドフレームワーク(Backbone.js、Ext JS、Dojoなど)は、「ベストプラクティス」に従ってMVCを分離しようとしました:

フレームワーク:データをください、テンプレートをください、レンダリングしてあげます。
フロントエンドエンジニア:はい、どうぞ。
フレームワーク:ユーザーがボタンを押しましたが、どこを再レンダリングすべきか分かりません。自分でやってください。
フロントエンドエンジニア:document.getXXX().xxx()

フレームワークはデータとビューの分離を助けますが、その後のステート更新には手動のDOM操作が必要です。なぜなら、フレームワークは初回レンダリングしか担当せず、ステートの変化を追跡・監視しないからです。

Ember.js: Data Binding

I know exactly what changed and what should be re-rendered because I control your models and views.

手動でのDOM操作は面倒なので、簡素化する方法を考えます。

フレームワークはDOM操作を弱体化させ、データだけに集中したいと考えました。そのためには、データからビューへのマッピング関係を知る必要があります:

フレームワーク:データをください、テンプレートをください、レンダリングしてあげます。
フロントエンドエンジニア:はい、どうぞ。
フレームワーク:それではなく、データは私が提供する『箱』に入れなければなりません。
フロントエンドエンジニア:できました、どうぞ。
フロントエンドエンジニア:データを変更しました。
フレームワーク:了解、ページの内容を更新しました。

フレームワークがデータモデルを提供し、データを包み込みます。これにより、その後の追加・削除・変更はすべてフレームワークのAPIを経由する必要があり、フレームワークはデータが変わったことを知って対応するビューを更新できます。フレームワークがデータの変化を監視しているのです。

AngularJS: Dirty Checking

I have no idea what changed, so I'll just check everything that may need updating.

フレームワークが提供するデータモデルを使うのは面倒なので、ステートの変化を直接追跡する方法を考えます。

フレームワーク:データをください、テンプレートをください、レンダリングしてあげます。
フロントエンドエンジニア:はい、どうぞ。
フロントエンドエンジニア:data.name を監視して、変更されたら h1 のテキストを更新してください。
フレームワーク:了解。
フレームワーク:変わりましたね、どうすべきか分かります。以前は xxx でしたが、今は aaa なので、h1 の内容を aaa に変更します。

フレームワークは前回の値を記録しておき、データが変更された可能性があるとき(DOM操作や手動通知など)に、最新の値と前回の値を比較します。異なれば最新の値でビューを更新し、ビューの状態とデータの状態を一致させます。

React: Virtual DOM

ダーティチェックは大変(少しの変化で全検査が必要)なので、別の方法を考えます。

I have no idea what changed so I'll just re-render everything and see what's different now.

面倒なデータモデルは嫌いで、ダーティチェックはスマートではないので、データの変化を監視せずに済むか検討します:

フレームワーク:データをください、テンプレートをください、レンダリングしてあげます。
フロントエンドエンジニア:はい、どうぞ。
フロントエンドエンジニア:データを入れ替えました。適当にビューを更新してください。
フレームワーク:了解、ここから下へ通知します。子孫たちは自分でどう更新するか判断してください。単純なもの(属性の更新)は自分で、面倒なもの(構造の変更)は私に伝えてください。
フレームワーク:おっと、span を1つ削除して li を2つ挿入ですね。完了です。

フレームワークは自発的にいつデータが変わったかを追跡せず、手動でフレームワークにステートの変更を通知する必要があります。フレームワークは下層を確認すれば何をすべきか分かります。

Vue

データの変化を監視する最もシンプルで直接的な方法は、setter を定義することではないでしょうか?

しかし、setter の定義ではすべてのデータの変化を監視できるわけではありませんが、大部分のシナリオはカバーできます。少数の例外については、制約を設けます:

フレームワーク:データをください、テンプレートをください、レンダリングしてあげます。
フロントエンドエンジニア:はい、どうぞ。
フロントエンドエンジニア:data.value = 'new value'
フレームワーク:setter が値が変わったと言っているので、ビューを更新します。
フロントエンドエンジニア:data.newKey = 'new key value'
フレームワーク:zzZ
フロントエンドエンジニア:$set('newKey', 'new key value')
フレームワーク:新しい属性が追加されましたね、ビューを更新します。

フレームワークがデータの変化を追跡して、ビューを更新します。一部のシナリオでは手動でステートの変更を通知する必要があります。

2. setterによる変更の監視

Vueはこの方式を採用しており、基本的な考え方は以下の通りです:

  1. データからビューへ:data をトラバースして setter を定義し、setter 内で update view を行います。
  2. ビューからデータへ:input など双方向バインディングが必要な要素を監視し、関連するイベントをリッスンして、handler 内で update data を行います。

ビューからデータへの処理は単純で、選択肢も議論の余地もありません。データからビューへの処理では、setter で対応できないシナリオを考慮する必要があります:

  • オブジェクトに新しいプロパティを追加する場合(data.newKey = 'value')、setter では検知できません。

  • delete で既存のプロパティを削除する場合、setter では検知できません。

  • 配列の変化を検知できません。

1つ目の問題は ES6 の Proxy が使えるようになるまで解決不可能でしたObject.observe()Array.observe() は廃止されました)。そのため、手動で追加を通知するための追加APIを提供する必要があります。例えば:

var data = {a: 1};
var vm = new Vue({
  data: data
});
// 追加APIを通じてフレームワークに新しい属性の追加を通知する
vm.$set('b', 2);

2つ目の問題はそれほど重要ではありません。delete が必要なシーンは少なく、undefined を代入して少し判定を追加すれば delete を避けられます。

3つ目の問題は比較的深刻で解決が難しいため、Vue はネイティブの Array メソッドを改ざんする(配列の内容を変更するメソッドをすべてフックする)ことで補っています

/**
 * Intercept mutating methods and emit events
 */
;[
  'push',
  'pop',
  'shift',
  'unshift',
  'splice',
  'sort',
  'reverse'
]
.forEach(function (method) {
  // cache original method
  const original = arrayProto[method]
  def(arrayMethods, method, function mutator () {
    //...
    // 監視部分を注入
    if (inserted) ob.observeArray(inserted)
    // 変更を通知
    ob.dep.notify()
    return result
  })
})

出典:https://github.com/vuejs/vue/blob/dev/src/core/observer/array.js

これにより、配列メソッドによる内容の変更もカバーできますが、まだ2つの問題が残っています:

  • arr.length=3 のように長さを変更することによるデータの変化は検知できません。

  • arr[0] = 1 のようにインデックスを介した値の変更も検知できません。

オブジェクトのように lengthsetter を定義すればいいのでは?いいえ、配列の lengthObject.defineProperty() による改ざんが許可されていません:

var a = [1, 2, 3];
Object.defineProperty(a, 'length', {
    set(newValue) {
        console.log('change to ' + newValue);
        return newValue;
    },
    get: x => x
});
// Uncaught TypeError: Cannot redefine property: length

length による配列内容の変更は他の方法(splice など)で代用できるため、それほど重要ではありません。これについては一旦置いておきます

インデックスを介した配列要素の変更は非常によく使われるため、対処が必要です。そのため、追加のAPIを導入し、指定した要素の変更をサポートする必要があります:

// かなり古いバージョンでは監視対象の配列プロトタイプに $set() と $remove() が追加されていました
// 現在は廃止されたようで、グローバルの Vue.set() を使用します
Vue.set(vm.arr, 0, 'setted')

これでデータの変化の監視はようやく基本形が整います。

実装の試み

配列は少し面倒なので一旦置いておき、ここでは inputdiv とシンプルなデータオブジェクトの双方向バインディングを実装します。

重要な setter による変化監視の部分:

var bind = function(node, data) {
    var key = node.getAttribute('data-bind');
    if (!key) return console.error('no data-bind key');

    // cache
    var cache = cacheData(data);

    // data to view
    if (cache._cachedNodes) {
        cache._cachedNodes.push(node);
    }
    else {
        cache._cachedNodes = [node];
        // setter: data to view
        Object.defineProperty(data, key, {
            enumerable: true,
            set: function(newValue) {
                cache[key] = newValue;
                // update view
                updateView(cache, newValue);
                return cache[key];
            },
            get: function() {
                return cache[key];
            }
        });
    }

    // init view
    updateView(cache, cache[key]);
};
// bind
bind($input, data);
bind($output, data);

setter 内で update view を行い、データからビューへのバインディングを完了します。

次に input 要素の input イベントをリッスンして、ビューからデータへのバインディングを完了します:

// event: view to data
$input.addEventListener('input', function() {
    data[$input.getAttribute('data-bind')] = this.value;
});

もちろん、実際のシナリオでは inputcheckboxselect など、様々なビューの変化がステートに影響を与えるケースを考慮する必要があります。

データを直接変更すると setter によって検知され、ビューが更新されます:

// 手動で変数の値を変更する
$('#btn').onclick = function() {
    data.value = 'updated value ' + Date.now();
};

完全なデモのアドレス:http://ayqy.net/temp/data-binding/setter.html

3. データモデルの提供

Ember.jsはこの方式を採用しています。

フレームワークが一連のデータモデルを提供し、実際のデータを包み込みます。その後の更新は必ずデータモデルのAPIを経由しなければならないため、フレームワークはすべてのデータの変化を把握でき、データからビューへのバインディングが完了します。比較的古い方法であり、使用するのが面倒なため、特筆��べきことはありません。

実装の試み

同様に、ここでは inputdiv と文字列の双方向バインディングを実装してみます。

まずデータモデルを提供し、データモデルの内部でビューを更新します:

// データモデルを提供
var MString = function(str) {
    this.value = str;
    this.nodes = [];
};
MString.prototype = {
    bindTo: function(node) {
        this.nodes.push(node);
        this.updateView();
    },
    updateView: function() {
        var VALUE_NODES = ['INPUT', 'TEXTAREA'];

        var nodesLen = this.nodes.length;
        if (nodesLen > 0) {
            for (var i = 0; i < nodesLen; i++) {
                if (VALUE_NODES.indexOf(this.nodes[i].tagName) !== -1) {
                    // inputへの代入でカーソルが末尾に飛ぶのを避ける
                    if (this.nodes[i].value !== this.value) {
                        this.nodes[i].value = this.value;
                    }
                }
                else {
                    this.nodes[i].innerText = this.value;
                }
            }
        }
    },
    set: function(str) {
        if (str !== this.value) {
            this.value = str;
            // update view
            this.updateView();
        }
    },
    get: function() {
        return this.value;
    }
};

データモデルの提供は、実際には setter を定義することに相当します。同じく変化を監視するためですが、データモデル一式を提供する方法は万全であり、サポートされているデータ操作はすべて監視でき、サポートされていない操作は変化を引き起こせません。そのため、処理は非常に単純です。

そして、関連付けを行います:

// setter: data to view
data[$input.getAttribute('data-bind')].bindTo($input);
data[$output.getAttribute('data-bind')].bindTo($output);

// event: view to data
$input.addEventListener('input', function() {
    data[$input.getAttribute('data-bind')].set(this.value);
});

その後のデータの更新は、データモデルのAPIを経由する必要があります:

// 手動で変数の値を変更する
$('#btn').onclick = function() {
    data.value = 'updated value ' + Date.now();
};

完全なデモのアドレス:http://ayqy.net/temp/data-binding/model.html

4. ダーティチェック

Angularはこの方式を採用しています。

データの変化を監視するのではなく、時々データが変わったかどうかをチェックし、変わっていれば対応するビューを更新します。

ここで問題となるのは:

  • いつチェックするのか?

    データの変化が起こりそうなとき、すべてチェックします。例えばDOM操作後、インタラクションイベント発生後、データが不整合になった可能性があると感じたときに、変わったかどうかを一度チェックします。

  • どこが変わったか、どうやって知るのか?

    どこが変わったか分からないため、ビューにバインドされているすべてのデータをチェックする必要があります。もちろん、「すべて」と言ってもページ全体ではなく、コンポーネントレベル($scope)の話なので、パフォーマンスはそれほど良くありませんが、多くのシナリオでは問題ありません。

手動でデータを変更し、すぐにビューを更新したい場合、ダーティチェックのメカニズムが実行されるタイミングまで待つ必要があり、それでは不十分なため、手動でダーティチェックを実行しなければなりません。この点は不便です。

実装の試み

Angular風にシンプルなものを自作してみます:

var Scope = function() {
    this.$$watchers = [];
};

Scope.prototype.$watch = function(watchExp, listener) {
    this.$$watchers.push({
        watchExp: watchExp,
        listener: listener || function() {}
        // 後でダーティチェック時に oldValue をキャッシュするための last 属性が追加されます
    });
};

Scope.prototype.$digest = function() {
    var dirty;

    do {
        dirty = false;

        // watcher をトラバースして、last が汚れていないかチェックします
        for(var i = 0; i < this.$$watchers.length; i++) {
            // 値取得メソッド watchExp で値を再取得します
            // キーを直接記録して取得するのではなく、この方が柔軟です(watchExp 内で手動で新しい値をラップでき、値が誰に紐付いているかを気にする必要がありません)
            var newValue = this.$$watchers[i].watchExp(),
                oldValue = this.$$watchers[i].last;

            if(oldValue !== newValue) {
                // 記録していた last が汚れたため、コールバックを実行します
                // 最初の digest 時、last は undefined なので、少なくとも一度は listener が実行されます
                this.$$watchers[i].listener(newValue, oldValue);
                // 外層を制御して、後でもう一度チェックし、listener の実行が別の変化を引き起こしていないか確認します
                //! 汚れが見つかったら、digest 完了後に last とデータが一致することを保証するため、もう一度チェックする必要があります
                dirty = true;
                // キャッシュ値を更新します
                this.$$watchers[i].last = newValue;
            }
        }
    } while (dirty);
};

watch() 時に値取得メソッドとコールバック関数を記録し、digest() プロセスで watcher をトラバースして値を再取得し、キャッシュ値が汚れているかチェックします。

一度のトラバースで少しでも汚れが見つかれば、もう一度チェックする必要があります。データとキャッシュ値が完全に一致することが確認されるまで繰り返すため、無限ループ(2つのデータの変化が相互に関連している場合など)が発生する可能性があります。ですので、少なくとも最大連続チェック回数の制限(TTL)を設けて無限ループを避けるべきですが、ここでは手間を省くため追加しません。

P.S. Angular の実装の詳細な解析は参考資料を参照してください。

ダーティチェックメカニズムを使用してデータからビューへのバインディングを実装します:

var bind = function(scope, node) {
    scope._nodes = scope._nodes || [];
    scope._nodes.push(node);
    var key = node.getAttribute('data-bind');
    if (!key) return console.error('no data bing key');

    // init view
    updateView(scope, scope[key]);

    // data to view
    scope.$watch(function(){
        return scope[key];
    }, function(newValue, oldValue) {
        // console.log(oldValue, newValue);
        updateView(scope, newValue);
    });
};
// bind
bind($scope, $input);
bind($scope, $output);

同様に、ビューからデータへのバインディングを実装します:

// view to data
$input.addEventListener('input', function() {
    $scope.value = $input.value;
    $scope.$digest();
});

勝手にデータを変更し、すぐにビューを更新したい場合は、手動でダーティチェックを実行する必要があります:

// 手動で変数の値を変更する
$('#btn').onclick = function() {
    $scope.value = 'updated value ' + Date.now();
    $scope.$digest();
};

完全なデモのアドレス:http://ayqy.net/temp/data-binding/dirty-checking.html

5. 仮想DOM

3つのデータバインディング方式については以上です。なぜなら、仮想DOMは双方向データバインディングを実現するための別の方式とは言えないからです(仮想DOMは単方向データバインディングを実現していますが)。

Reactはこの方式を採用しており、仮想DOMツリーの更新を考慮します:

  • 属性の更新は、コンポーネント自身が処理します。

  • 構造の更新は、サブツリー(仮想DOM)を再「レンダリング」し、最小限の変更ステップを特定し、DOM操作をまとめて実際のDOMツリーにパッチを当てます。

単純にデータバインディングという観点で見ると、Reactの仮想DOMにはデータバインディングはありません。なぜなら、setState() は前のステートを保持せず(ステートの破棄)、変化を追跡しないため、バインディングとは言えません。

データの更新メカニズムという観点で見ると、Reactはデータモデルを提供する方式(必ず state を通じて更新する)に似ています。

P.S. 構造の更新は、サブツリーを作成し、既存のサブツリーと diff を行い、最小限の変更ステップを記録してDOM操作をまとめ、実際のDOMを更新することとも言えます。しかし実際には、一度の再帰的なダウンワードチェックプロセスの中で、仮想DOMツリーを更新しながら最小限の変更ステップを記録しているのです。そのため、上記では2つのツリーの diff よりも、サブツリーのダウンワードチェックを強調しています。

双方向データバインディングがない場合、input のシナリオはどう実装するのでしょうか?

var App = React.createClass({
  render: function() {
    return (
      <div>
        <input type="text" value={this.state.text} onChange={this.onChange} />
        <div>{this.state.text}</div>
      </div>
    )
  },
  getInitialState: function() {
    return {text: this.props.text}
  },
  onChange: function(e) {
    this.setState({
      text: e.target.value
    });
  }
});

ReactDOM.render(
  <App text="hoho" />,
  document.getElementById('container')
);

フレームワークが提供するAPIを通じて手動でデータの変化を通知します。これはDOM操作の方法とよく似ています:

var text = 'hoho';
window.onInput = function(e) {
    text = e.target.value;
    document.getElementById('output').innerText = text;
};

document.getElementById('container').innerHTML = '<div><input type="text" value="' + text + '" oninput="onInput(event)" /><div id="output">' + text + '</div></div>';

6. 更新効率の最適化

初回レンダリングはともかく、その後の更新時には、データの更新をいかに実際のDOMの更新に対応させるかという問題に直面します。

Reactでは、setState() はデータが以前のものかどうか、変わったかどうかを気にしません。ステートが渡されれば、前のステートを破棄します。つまり、元のデータオブジェクトをそのまま再度 setState() しても、データの変更がなくビューの更新が不要であるという結論を出すために、サブツリー全体をダウンワードチェックしなければなりません。ステートの破棄メカニズムのため、データの変化の詳細を追跡せず、同じデータを渡してもすぐにステートが変わっていないと判断することはできません。

Vueも同様の問題に直面しますが、状況は少し良いです。setter が変化を検知した後、そのステートの更新がサブツリーにどのような影響を与えるかは分かりません。しかし、データのステートを管理し変化を追跡する方式の明らかな利点は、各データがどのビューに関連付けられているかを知っていることです。つまり、特定のデータに関連する実際のノードリストが既知であるため、そのデータに依存するすべての実際のノードを直接更新すれば済みます(もちろん、ステートが「劇的に」変化した際に、更新を維持するよりも全体を差し替える方が効率的であるといった最適化も考慮する必要があります)。そのため、Vue の watcher には依存関係収集メカニズムがあり、ダウンワードチェックを高速化(非依存項目はチェックしない)しています。

Angularは、ステートが変わったかどうかを確認するために、現在の $scope$$watchers 配列をチェックする必要があります。これにより、多くの不要なチェックが行われます。単純な値の属性更新が1つのテキストノードに対応しているだけの場合でも、$scope 内のすべてのデータとビューのバインディング関係をすべてチェックしなければなりません。これもステート破棄メカニズムの一種であり、ダーティチェックは値のみを気にし、データ構造は気にしません。手動でデータを変更し、ダーティチェックに変更を通知すると、ダーティチェックは値を一つずつ再取得して比較するしかありません。なぜなら、ダーティチェックは先ほど変更されたデータと、ダーティチェックが取得すべき値との対応関係を知らないため、範囲を絞り込むことができないからです。

Reactの場合、変更されていないデータのチェックをスキップできる簡単な最適化案があります。それは、不変(Immutable)なデータ構造を使用することです。例えば Immutable.js などです。

これにより、ステートの属性を一つずつチェックして変更がないことを確認する必要がなく、まず equals() で比較し、等しければそれ以上下層へ行く必要がなくなります。

Om はこのように実装されています:

I know exactly what didn't change.

不変なデータ構造を使用することで、変更されていない部分を素早く排除し、変更があった部分のみを抽出できるため、diff の効率が向上します。

P.S. もちろん、ダーティチェックにおいて不変���データ構造を使用することに意味はありません。結局、値をトラバースして取得しなければ変化したかどうか分からないからです。ダーティチェックはデータ構造を全く気にしません。setter による監視で不変なデータ構造を使うと、柔軟性が損なわれ、監視の粒度が粗くなり、利点が失われます。データモデルを提供する方式に至っては言うまでもありません。データモデル自体が不変であれば、監視すべき変化がないからです。

7. 参考資料

今回の参考資料は非常に価値が高いため、引き続き本文として一つずつ読むことをお勧めします。

コメント

コメントはまだありません

コメントを書く