Edited at

作って分かるJavaScriptでデータバインド

More than 5 years have passed since last update.


はじめに

JavaScriptにおけるデータバインドの実装方法を双方向データバインドライブラリのKnockoutJSのソースコードを読み、自分で以下のような最低限の機能を実装をしていきます。


  • 変更をsubscribeできるobservableオブジェクト

  • 他のobservableオブジェクトの値の変更を検知して、自身の値を変えるdependentObservableオブジェクト

上記の機能を実現するために、KnockoutJSの以下のオブジェクトの仕組みを解析しました。


  • ko.subscribable

  • ko.observable

  • ko.dependencyDetection

  • ko.dependentObservable(ko.computed)

この記事のソースコードはgithubで提供されています。

それぞれの機能をいくつかのステップに分けて説明します。各ステップはstep0からディレクトリに分けられています。ステップ毎にスケルトンコードと説明(README.md)があります。スケルトンコードにはTODOが書かれており、埋める事でコードを完成させることが出来ます。なお、各ステップの解答例はanswers以下に同じディレクトリ構成で置かれています。

なお、この内容はKnockoutJSについて11月のALM(社内勉強会)で発表したものを基にしています。

勉強会のスライドはこちら。


STEP 0: 前準備

まずは、前準備をしましょう。

このステップでは、特にTODOはありません。

他のステップで使う、いくつかの関数やオブジェクトを用意しましょう。

はじめに、グローバルオブジェクトを極力汚さないように、名前空間を用意しましょう。

名前空間の名前は特に意味はありませんが、KnockoutJSのkoをマネして、2文字くらいが使いやすいでしょう。

今回は、無作為に2文字選びました。

var go = {};

つぎに、デバッグ用のlog関数を用意しましょう。

console.logを使ってログをコンソールに出力し、ブラウザを使った場合には、JSON形式でHTMLのリストでも出力する関数です。

var global = this;

// log
(function (selector) {
function log() {
console.log.apply(console, arguments);
var args = [].slice.apply(arguments);
var logStr = (args.length > 1)? JSON.stringify(args) : JSON.stringify(args[0]);
if (selector && global.document) {
var lst = document.querySelector(selector + '>' + 'ul');
if (!lst) {
lst = document.createElement('ul');
document.querySelector(selector).appendChild(lst);
}
lst.innerHTML += '<li>'+logStr+'</li>';
}
}
go.log = log;
})('#result');

さいごに、継承を行なうための関数を用意しましょう。

go.extendは第1引数で渡したオブジェクトのプロパティを第2引数で渡したオブジェクトにコピーする関数です。継承とはちょっと異なりますが、ここでは簡単の為にこうしておきましょう。この関数は深くコピーすることに注意しておきましょう。コピーしたプロパティのオブジェクト(配列)は別のオブジェクト(配列)となります。

// extend

(function () {
function extend(_super, sub) {
for (var k in _super) {
if (!_super.hasOwnProperty(k)) {
continue;
}

if (Array.isArray(_super[k])) {
sub[k] = _super[k].slice(0);
for(var i = 0; i < _super[k].length; i++) {
if (typeof _super[k][i] === 'object') {
sub[k][i] = {};
extend(_super[k][i], sub[k][i]);
}
}
} else if (typeof _super[k] === 'object') {
sub[k] = {};
extend(_super[k], sub[k]);
} else {
sub[k] = _super[k];
}
}
}
go.extend = extend;
})();


STEP 1: subscribenotify


バインディングの魔法

私はバインディングという機能が好きで、JavaFXやAngularJS、そしてKnockoutJSのバインディングを色々ためしました。また、面白そうだったので、自分でバインディングライブラリを作ってみたりしました。

KnockoutJSのバインディングはHTMLとJSのオブジェクトを双方向バインドすることができます。双方向なので、片方が変更されるともう片方も変更されます。

たとえば、以下の例を見てみると、inputタグのvalueに、vm.countがバインディングされています。vm.countの値は、タイマーでカウントアップされ、その結果がinputvalueへと反映されます。また、inputvalueをフォームから変更することもでき、その変更はvm.countへと反映されます。

JSFiddleで見る

counter: <input type="number" id="counter" data-bind="value: count" />

var vm = {

count: ko.observable(0),
};

ko.applyBindings(vm, document.body);

setInterval(function() {
vm.count(parseInt(vm.count(), 10) + 1);
}, 1000);

上記の例を見ると、KnockoutJSのバインディングは魔法のようにオブジェクトとHTMLをバインドしているようです。

もちろん、魔法などではありません。バインドされているもう一方に変更を伝える仕組みが働いていて、変更に追従しているのです。

この変更を伝える仕組みをKockoutJSでは、ko.subcribableが担っています。

KnockoutJSでメインに使われるko.observableko.observableArrayko.computed(ko.dependentObservableのエイリアス)はこのオブジェクトの機能を継承しています。


subscribenotify

KnockoutJSのko.subcribableは変更を伝える役目を担っており、以下の2つの機能から成っています。



  • subscribe:変更を通知してもらうオブジェクト(subscripter)を登録する


  • notifySubscripter:登録しているsubscripterに通知する

KnockoutJSのソースコードを参考にしながら、TODOを埋めていきましょう。

// subscribable

(function () {
var subscribable = {
_subscriptions: [],
subscribe: function (callback, callbackTarget) {
var _subscribable = this;
var subscription = {
callback: /* TODO: callbackTargetがあったらバインドしてコールバックを設定*/,
dispose: function () {
// TODO: 破棄したというフラグを立てておく

// TODO: _subscribable._subscriptionsの中に自身(this)を発見したら配列から取り除く
},
isDisposed: false
};
this._subscriptions.push(subscription);
return subscription;
},
notifySubscripters: function (valueToNotify) {
var _subscriptions = this._subscriptions.slice(0);
for (var i = 0; i < _subscriptions.length; i++) {
var subscription = _subscriptions[i];
// TODO: disposeされていなかったら、callback呼び出す
}
}
};
go.subscribable = subscribable;
})();

subscriptioncallbackdisposeというプロパティを持っており、callbackは引数で渡されたcallbackcallbackTargetFunction.bindしたものです。一方、dispose_subscriptoionsから自身を取り除く処理を行なう関数です。_subscriptionsから取り除いただけでは、subscriptionオブジェクトから分からないので、フラグを立てておきます。subscribesubscriptionを返すので、登録を破棄したければ、disposeを呼べば破棄されます。

つづいて、notifySubscriptersを考えてみましょう。notifySubscriptersは、_subscriptionsに登録されているsubscriptioncallbackを呼び出す事によって、subscriptしているオブジェクト(subscripter)に通知を送るメソッドです。notifySubscriptersの引数は通知する値です。引数で渡された値はsubscriptioncallback関数の引数として渡されます。

TODOをすべて埋めたらindex.htmlをブラウザで開いて、以下のテストプログラムの動作を確認してみましょう。

// Test

(function () {

// 継承してみる
var subscribable = {};
go.extend(go.subscribable, subscribable);

// 通知がきたら、ログに出力する
var subscription = subscribable.subscribe(function (value) {
go.log(value);
});

// subscripterに'hoge'を通知する
subscribable.notifySubscripters('hoge');

// disposeして破棄する
subscription.dispose();

// subscripterに'piyo'を通知する
// しかし、subscriptionはdisposeされているので、通知されない
subscribable.notifySubscripters('piyo');
})();

以下のように出力されているはずです。


  • "hoge"

コメントにも書かれていますが、1回目のnotifySubscriptersでは、subscriptioncallbackに通知されます。しかし、2回目は通知するsubscripterがないので、どのオブジェクトにも通知されません。


STEP 2: 値の更新を通知するオブジェクト


observableオブジェクト

subscribableオブジェクトは、通知の登録(subscribe)と通知(notify)はできましたが、値そのものを保持することはできませんでした。

そこで、このステップでは内部に値を保持できるobservableオブジェクトを作る関数を実装してみましょう。

KnockoutJSのko.observableは、内部に値を保持できるオブジェクトを作る関数です。このオブジェクトは、関数オブジェクトで引数を渡して呼び出すと書き込み(write)になり、引数なしで呼び出すと読み出し(read)になります。ko.observableでは、内部に保持する値の変更をsubscribeすることで、検知することができます。subscribeの機能は、ko.subscribableを継承することで実現しています。つまり、書き込み時にnotifySubscriptersを実行する事で、書き込みを通知します。具体的な使い方を見てみましょう。

var foo = ko.observable(100);

foo.subscribe(function(v) {
alert(v);
});
// 200とalertされる
foo(200);
// 200と出力される
console.log(foo());

上記の例ではvar foo = ko.observable(100);で初期化が行なわれ、observableオブジェクトが作られています。この時初期値として、100が設定されています。つづいて、subcribableで、変更を検知して、alertを呼び出すコールバック仕掛けます。foo(200)で書き込みが行なわれると、仕掛けたコールバックが呼ばれて、alertが行なわれます。foo()のように引数なしで呼ばれると、内部に保持している値をそのまま返すため、コンソールに200が出力されます。

それでは、KnockoutJSのソースコードを参考にしながら、TODOを埋めていきましょう。

// observable

(function () {
var observable = function (initialValue) {
var lastValue = initialValue;

function observable() {
if (/* TODO: 引数があれば書き込み */) {
// write
var newValue = arguments[0];
// TODO: 値が変わっていれば、更新して、nofityする
} else {
// read
return lastValue;
}
}
// TODO: go.subscribableを継承する
// TODO: observableなオブジェクトを返す
};
go.observable = observable;
})();

observableオブジェクト(値が保持できて、変更の検知ができる)を作る関数をgo.observableとします。go.observableの引数は、observableオブジェクトに設定する初期値を受取りましょう。戻り値はもちろんobservableオブジェクトです。

observableという関数が2つ出てきてややこしいですが、外側の関数(go.observable)はko.observableにあたる関数で、内側の関数が実際に値を保持するobservableなオブジェクトになります。go.observableが呼び出される度に、内側のobservable関数(observableオブジェクト)が作られます。lastValueobservableオブジェクト内に保持する値を入れる変数で、go.observableの引数で貰った値を初期値としています。通知する機能はgo.subscribableで作っているため、observableオブジェクトは、go.subscribableを継承しています。

observableオブジェクトは2種類の呼ばれ方をしています。ひとつめがfoo(200)のような引数がある呼ばれ方、ふたつめがfoo()のような引数がない呼ばれ方です。前者が書き込みで後者が読み出しです。JavaScriptの関数の中では、argumentsという引数を保持する配列っぽい特別なオブジェクトが使えます。argumentsは、複数のパターンの引数をとる場合によく使われます。この場合は、arguments.lengthを見て、引数の有無で書き込み処理と読込み処理を分ければ良いでしょう。読み込み処理の場合は、単に最後に設定された値(lastValue)を返すだけでいいでしょう。書き込み処理も、第1引数の値をlastValueに入れれば良さそうです。書き込み処理では、もうひとつ大切な処理を行なう必要があります。新しい値が設定された場合、その事をsubscripterに通知する処理です。これはgo.subscribableから継承したnotifySubscriptersを使えば済みます。

TODOをすべて埋めたらindex.htmlをブラウザで開いて、以下のテストプログラムの動作を確認してみましょう。

// Test

(function () {
var observable = go.observable('hoge');
var count = 0;
var subscription = observable.subscribe(function () {
count++;
go.log(observable(), count);
});
observable('foo');
observable('foo');
observable('bar');
subscription.dispose();
observable('piyo');
})();

以下のように出力されているはずです。


  • ["foo",1]

  • ["bar",2]

値が新しい値に変更された場合のみ、通知が行なわれていることが分かります。


STEP 3: 依存関係の検出とdependencyDetection


依存関係の検出

KnockoutJSには、ko.computedという他のko.observableの値に依存したようなobservableなオブジェクトがあります。ko.computedは以下のように使います。

JSFiddleで見る

<div id="result"></div>

var hoge = ko.observable(100);

var piyo = ko.observable(200);
var foo = ko.computed(function(){
return hoge() + piyo();
});
foo.subscribe(function(v) {
document.querySelector('#result').innerText = v;
});
hoge(300);

fooの値(引数なしで評価された値)は、hogepiyoが変更された場合、自動的に更新されます。もちろん、subscribeしておけば、その変更は通知されます。

不思議ですよね?KnockoutJSはどのようにして、foohogepiyoに依存していることを知っているのでしょう?

この不思議を解明するために、以下のように、ko.computedに渡す関数が評価される回数を数えてみましょう。

JSFiddleで見る

<div id="result"></div>

var hoge = ko.observable(100);

var piyo = ko.observable(200);
var count = 0;
var foo = ko.computed(function () {
count++;
document.querySelector('#result').innerText = count;
return hoge() + piyo();
});
hoge(300);

直感的には、hoge(300)の変更の時に1度呼ばれているだけの様な気がしますが、結果は2となっています。何故でしょう?

実は、この関数はko.computedに渡されたときに、1度評価されます。そして、その評価の時に評価されたobservableオブジェクト(ここでは、hogepiyo)を記録することで、依存関係を検知しています。また、その時の評価値を初期値として設定しています。

これはKnockoutJSを使う上では非常に大切なことです。なぜならば、このko.computedに渡す関数内で副作用のある処理をすると、意図しない結果になる場合があるからです。事前に呼び出されることをちゃんと知った上で、使わないと思わぬバグになってしまいます。


dependencyDetectionオブジェクト

KnockoutJSのko.dependencyDetectionは、前述の依存関係の検出を行なうオブジェクトです。ko.dependencyDetection.beginを実行した後に、評価されたobsrvableオブジェクトを登録します。dependencyDetectionオブジェクトには、依存関係の検出を始めるbeginメソッドと、依存関係にsubscribableオブジェクトを追加するregisterDependencyメソッドが必要です。また、go.observableの方にも修正が必要です。読込みの場合に、依存関係を記録する必要があるため、引数なしで呼び出された場合の処理に追加しなければなりません。

それでは、KnockoutJSのソースコードを参考にしながら、TODOを埋めていきましょう。

// observable

(function () {
var observable = function (initialValue) {
var lastValue = initialValue;

function observable() {
if (arguments.length > 0) {
// write
var newValue = arguments[0];
if (newValue !== lastValue) {
lastValue = newValue;
observable.notifySubscripters(lastValue);
}
} else {
// read
// TODO: 依存関係に追加(go.dependencyDetectionを使う)
return lastValue;
}
}
go.extend(go.subscribable, observable);
return observable;
};
go.observable = observable;
})();

// dependencyDetection
(function () {
var distinctDependencies, callback;
var dependencyDetection = {
begin: function (_callback) {
// TODO: callbackを初期化
// TODO: distinctDependenciesを初期化
},
registerDependency: function (subscribable) {
// TODO: すでに依存関係にあった場合と1度もbeginが呼ばれていない場合は無視

// TODO: subscribableを依存関係に追加
// TODO: コールバックが設定してあったら呼び出す
}
};
go.dependencyDetection = dependencyDetection;
})();

TODOをすべて埋めたらindex.htmlをブラウザで開いて、以下のテストプログラムの動作を確認してみましょう。

// Test

(function () {
var a = go.observable(100);
var b = go.observable(200);
var observables = [];
go.dependencyDetection.begin(function(observable) {
if (a === observable) {
go.log('a');
} else if (b === observable) {
go.log('b');
} else {
go.log(observable);
}
});
a();
a();
b();
})();

以下のように出力されているはずです。


  • "a"

  • "b"

observableオブジェクトの値が読み込まれた時に、go.dependencyDetection.registerDependencyが呼び出されているのが分かります。また、1度登録されたobservableオブジェクトは重複して登録されていません。これで十分に動くように見えますが、実はうまく動きません。しかし、dependentObservable(computed)を実装する際に、必要となってくるので、ここはこのまま進みましょう。


STEP 4: 計算結果の変更を検出する


dependentObservableオブジェクト

前述の通り、KnockoutJSにはko.computed関数を使うと他のobservableオブジェクトを使って計算した結果を自身の値とするdependentObservableオブジェクトを作ることができます。また、dependentObservableオブジェクトが他のdependentObservableに依存しても問題ありません。もちろん、通常のobservableオブジェクトと同様に、subscribeできる必要があります。そのため、以下のように読込み処理の時に毎回値を評価する方法では、効率が悪く、subscribeしても依存するobservableオブジェクトの値が更新されても、次の読込み処理が走るまでnotifyすることができません。

// dependentObservable

(function () {
var lastValue;
var dependentObservable = function (readFunction) {
function dependentObservable() {
if (arguments.length > 0) {
// write
throw new Error('Cannot write to computed');
} else {
// read
var newValue = readFunction();
if (newValue !== lastValue) {
lastValue = newValue;
dependentObservable.notifySubscripters(lastValue);
}
go.dependencyDetection.registerDependency(dependentObservable);
return lastValue;
}
}

go.extend(go.subscribable, dependentObservable);
return dependentObservable;
};
go.dependentObservable = dependentObservable;
go.computed = dependentObservable;
})();

依存関係を検出するには、STEP 3で作成したgo.dependencyDetectionを使います。検出された依存するobservableオブジェクトをsubscribeして変更された場合にreadFunctionを読み込みlastValueを更新します。

それでは、KnockoutJSのソースコードを参考にしながら、TODOを埋めていきましょう。

// dependentObservable

(function () {
var dependentObservable = function (readFunction) {
var lastValue = null;
var isBeingEvaluated = false;
var hasBeenEvaluated = false;
var subscriptionsToDependencies = [];

function evaluateImmediate() {
// TODO: evaluateImmediateが既に呼び出し済み(呼び出し済みフラグがtrue)なら何もしない

// TODO: 呼び出し済みフラグをtrueにする

// TODO: 記録しているsubscriptionsをdisposeして配列を空にする

go.dependencyDetection.begin(function (subscribable) {
// TODO: 検知されたsubscribableをsubscribeして、そのsubscriptionを記録
});

var newValue = readFunction();
// TODO: 1度でも評価されているかどうかを表すフラグをtrueにする

if (newValue !== lastValue) {
lastValue = newValue;
dependentObservable.notifySubscripters(lastValue);
}

// TODO: 呼び出し済みフラグをfalseにする
}

function dependentObservable() {
if (arguments.length > 0) {
// write
throw new Error('Cannot write to computed');
} else {
// read
if (/* TODO: 一度も評価されていない場合 */) {
evaluateImmediate();
}
go.dependencyDetection.registerDependency(dependentObservable);
return lastValue;
}
}

go.extend(go.subscribable, dependentObservable);
// TODO: 最初の評価と初期値の設定
return dependentObservable;
};
go.dependentObservable = dependentObservable;
go.computed = dependentObservable;
})();

evaluateImmediatereadFunctionを呼び出し、保持しているobservableの値を更新します。また、その際にgo.dependencyDetectionを使って、依存するsubscribable(observable)を検知します。検知したsubscribablesubscribeする事で、更新された時に再度evaluateImmediateを呼び出されるようにします。evaluateImmediateが呼び出される度に、検知した依存関係を破棄し、subscriptionも破棄(dispose)するために、配列subscriptionsToDependenciesで保持しています。dependentObservableオブジェクトもobservableオブジェクトの一種なので、値が変更された場合はnotifySubscriptersで変更を通知します。hasBeenEvaluatedは、一番最初の場合は問答無用でevaluateImmediateを呼び出すために使われます。

TODOをすべて埋めたらindex.htmlをブラウザで開いて、以下のテストプログラムの動作を確認してみましょう。

// Test

(function () {
var a = go.observable(100);
var b = go.observable(200);
var c = go.computed(function () {
return a() + b();
});
go.log(a(), b(), c());
a(400);
go.log(a(), b(), c());
})();

以下のように出力されているはずです。


  • [100,200,300]

  • [400,200,600]

うまく動いているように見えます。しかし、これではうまく動かない場合があります。

次のステップでその部分を改良しましょう。


STEP 5: 問題点の改善

このステップでは、STEP 4での実装をうまく動かない例を示して、問題点を改善していきます。


入れ子にした場合

STEP 4までの実装では、以下のようにcomputedが入れ子になっている場合にうまく動作しません。最初の出力では、100 200、2番目の出力では150 300と出力されて欲しいところですが、実際には2番目の出力は150 200となります。STEP 4までの実装では、readFunctionの中でgo.dependenctyDetection.beginを多重に呼び出すことを前提としていません。そのため、2度beginを呼び出すと前回の呼び出しの分のdistinctDependenciesが消えてしまいます。そこで、distinctDependenciesをスタックで管理するようにしましょう。スタックで管理すれば、入れ子に呼び出されても問題ありません。

    var a = go.observable(100);

var b = go.computed(function () {
var c = go.computed(function () {
return a() * 2;
});
return c();
});

go.log(a(),b());
a(150);
go.log(a(),b());

それでは、KnockoutJSのソースコードを参考にしながら、TODOを埋めていきましょう。

// dependencyDetection

(function () {
var frames = [];
var dependencyDetection = {
begin: function (callback) {
// TODO: 新しいframeをpushして、依存関係の検出を開始する。callbackが無かったら、undefinedをpushする
},
end: function () {
// TODO: 先頭のframeをpopして、依存関係の検出を終了する
},
registerDependency: function (subscribable) {
if (frames.length > 0) {
var topFrame = frames[frames.length - 1];
// TODO: topFrameがundefinedの場合とすでにsubscribableが登録してあったら何もしない

topFrame.distinctDependencies.push(subscribable);
topFrame.callback(subscribable);
}
}
};
go.dependencyDetection = dependencyDetection;
})();

// dependentObservable
(function () {
var dependentObservable = function (readFunction) {
var lastValue = null;
var isBeingEvaluated = false;
var hasBeenEvaluated = false;
var subscriptionsToDependencies = [];

function evaluateImmediate() {
if (isBeingEvaluated) {
return;
}
isBeingEvaluated = true;
for (var i = 0; i < subscriptionsToDependencies.length; i++) {
subscriptionsToDependencies[i].dispose();
}
subscriptionsToDependencies = [];
go.dependencyDetection.begin(function (subscribable) {
subscriptionsToDependencies.push(subscribable.subscribe(evaluateImmediate));
});
var newValue = readFunction();
hasBeenEvaluated = true;
if (newValue !== lastValue) {
lastValue = newValue;
dependentObservable.notifySubscripters(lastValue);
}
// TODO: 依存関係の検出を終了する
isBeingEvaluated = false;
}

function dependentObservable() {
if (arguments.length > 0) {
// write
throw new Error('Cannot write to computed');
} else {
// read
if (!hasBeenEvaluated) {
evaluateImmediate();
}
go.dependencyDetection.registerDependency(dependentObservable);
return lastValue;
}
}

go.extend(go.subscribable, dependentObservable);
evaluateImmediate();
return dependentObservable;
};
go.dependentObservable = dependentObservable;
go.computed = dependentObservable;
})();

スタックをframesという名前にし、beginでスタックに積み、endでスタックから取り除くようにしています。また、dependentObservableevaluateImmediateも循環で呼び出されないようにしましょう。beginしたらendするのを忘れないようにしましょう。

TODOをすべて埋めたらindex.htmlをブラウザで開いて、以下のテストプログラムの動作を確認してみましょう。

// Test

(function () {
var a = go.observable(100);

// 入れ子にしても問題ないか?
var b = go.computed(function () {
var c = go.computed(function () {
return a() * 2;
});
return c();
});

go.log(a(),b());
a(150);
go.log(a(),b());
})();

以下のように出力されているはずです。


  • [100,200]

  • [150,300]

bの依存関係とbreadFunctionの中で作られているcの依存関係はスタックを使うことで別物として扱うことができるようになりました。aを変更すると、bcに変更が通知され、それぞれのevaluateImmediateが実行されます。もちろん、cbreadFunctionが呼ばれる度に生成されるので、普通はこのような呼び方はしないでしょう。


まとめ

KnockoutJSの以下のオブジェクトの簡易実装を作ってみました。KnockoutJSのデータバインドはdependenctyDetectionがキモで、この機構があるおかげで、明示的に依存関係を定義する必要がありません。しかしながら、その仕組みをあまり理解せずに使用していると、思わぬバグに遭遇して困るのではないでしょうか。ここでは、説明が複雑になるので、ko.applyBindingsを使ったHTMLとの双方向バインディングの仕組みについて触れませんでしたが、ソースを軽く見た感じではcomputedをうまく使って実装されているようです。また時間があれば、そちらの実装の方もまとめたいと思います。これを書いている間もmasterブランチのソースコードが結構変わっているので、内容が古くなるかもしれませんが、このまとめが誰かの役に立てば幸いです。