Backbone.jsのソースコードを読んでみる
今回はBackboneのModel
対象Version
Backbone.js 1.2.3
コンストラクタまわり
// Modelの今コンストラクタでは初期のattributesとoptionsを渡すことができる
// options.collection: .collectionメンバを持つかどうか、.collectionの仕組みはまだ不明
// options.parse: attributesの内容を加工するための処理を挟ませるかどうか。
// 具体的には.parse()をOverrideさせてユーザーが定義する必要がある
// ちなみにoptionsはそのまま.setのoptionsとして引き渡すことができることも覚えておいたほうが良いです。
var Model = Backbone.Model = function(attributes, options) {
var attrs = attributes || {};
options || (options = {});
// new される度にユニークなIDをインスタンスに割り振る
// 参照の関係を解決するために利用すると思うが具体的には未だ不明
this.cid = _.uniqueId(this.cidPrefix);
this.attributes = {};
if (options.collection) this.collection = options.collection;
// parseオプションが有効な場合はparseを1回挟んでattributesを均す
if (options.parse) attrs = this.parse(attrs, options) || {};
// .defaults()に定義しているデフォルトで利用するattributesを持ってきて引数で与えられたattrsとマージしている
// 引数のattributesの方が優先されます。
attrs = _.defaults({}, attrs, _.result(this, 'defaults'));
// ここでthis.setを使ってattributesをメンバに反映している
// .setを実行しているため、changeイベントがここでも実行されることを覚えておこう
// しかし普通、newしている段階ではまだ、イベントの購読が行われていないはずなので体外空振りになるだろう
this.set(attrs, options);
// .set内で.changedが汚染されるので浄化している
this.changed = {};
// initialize()を実行することでユーザーが定義できるコンストラクタ関数を実行する
// initialize()はnew XXX();の一番最後に評価されることを覚えておこう。
// 要はnewでも.set();をすでに実行しているため、initializeでは.set()が発生しないほうがイベントの発行回数を減らすことができます。
// あと、initializeの戻り値に対しては何も期待していないので、Overrideして何か使っても良いかもしれない。もったいないよね
// Applicationの試用によってはこの辺重宝すると思うんだけどな
this.initialize.apply(this, arguments);
};
- .parseを定義することでパラメータで渡したattributesを加工して扱うことができる
- options引数はそのまま.setの引数としても利用される
- ModelのインスタンスにははユニークなIDが採番される
- デフォルトで扱いたいattributesはdefaultsに定義する(Functionでも可。評価してくれる)
- .setを内部で利用してパラメータで渡ってきたattributesを登録する
- .initializeを最後に実行することでユーザーが定義した初期化処理を実行することができる。ちなみに.initializeはデフォルトでは空になっている.
- .initializeでは戻り値はどうでもいい(戻り値がどうのこうのっていう処理にはなっていない)
initialize: function(){},
細々としたプロパティ・メソッド(1)
// .setした際のdiffを管理するための領域
// こいつを元に最終的にどのattributeのchangeトリガーを実行するかを決定している
changed: null,
// 最後にvalidationを実行した際の結果を保持するための領域
validationError: null,
// モデルを一意に識別するための識別子
// よくあるのがサーバー側の実装に依存してidなどになる
idAttribute: 'id',
// Backbone側がModelのインスタンス1つづに一意なIDを付与する際にプレフィックスとして扱う文字列
cidPrefix: 'c',
// 実はinitializeは標準では何もしていない
// ユーザーが任意にOverrideする必要がある
initialize: function(){},
// attributesをcloneしているだけなのでtoJSONした結果を書き換えてもModelのattributesには影響が出ない
toJSON: function(options) {
return _.clone(this.attributes);
},
// Backbone.syncをModelのコンテキストで実行するためのショートカット
sync: function() {
return Backbone.sync.apply(this, arguments);
},
// 単純にkey:valueを取得するだけなのでlodashの.getみたいな強烈なことはできない。
// Lodash .get: https://lodash.com/docs#get
get: function(attr) {
return this.attributes[attr];
},
// escapeは.getした内容をエスケープさせる
// Backbone.jsのソースコード上ではModel.escapeを呼んでいるところは存在しない
escape: function(attr) {
return _.escape(this.get(attr));
},
// これもgetと同じくlodashみたいなことはできない
has: function(attr) {
return this.get(attr) != null;
},
// _.iterateeに関してはaddMethodsの仕組みを使えないようだ
matches: function(attrs) {
return !!_.iteratee(attrs, this)(this.attributes);
},
- .initializeはユーザーが定義する
- .toJSONは_.cloneしている
- .getは貧弱
.set
// optionsの引数には、unset, silent, validateの3つのパラメタが有る
// unset -> 指定したkeyを削除したい場合に利用する
// silent -> attributes変更時のchangeイベントを発行しないようにする
// validate -> validationを実行させるかどうか。setのときはデフォルトではvalidationは実行されない
// 引数のパターンは(key, val, options), (key-value-Object, options)の2つのパターンをサポートしている
set: function(key, val, options) {
if (key == null) return this;
// key, value. {key: value}の両方のパラメータの受け方をサポートしている
// 要は引数の順番をずらしているだけである
var attrs;
if (typeof key === 'object') {
attrs = key;
options = val;
} else {
// var a;
// (a = {})['hoge'] = 'fuga';
// a -> { hoge: 'fuga' }となる
(attrs = {})[key] = val;
}
options || (options = {});
// validationによってコケた場合はthisではなく、falseがかえるので注意, 他ではすべてthisが帰ってくるのにね・・・・
// しかし、ここで疑問なのが、validationでコケた場合はinvalidイベントが発行されるのでわざわざfalseを返す意味はあるのか
// Validationの仕組みを利用したい場合は.validate()をOverrideすること
// 間違っても._validate()をOverrideしてはいけない。やってしまうと、validイベントが飛ばなくなる
// optionsをvalidate:trueにして引数を渡さないとvalidateは実行されない
// ちなみにコンストラクタでも.setは呼ばれるがoptionsは入っていないのでvalidateは呼ばれない
if (!this._validate(attrs, options)) return false;
// unset -> 指定したkeyを削除したい場合に利用する
// silent -> attributes変更時のchangeイベントを発行しないようにする
var unset = options.unset;
var silent = options.silent;
// 変更したattributeのkeyを記録させていき
// 最終的にchange::イベントを発火させる際の名前に利用するためのもの
var changes = [];
// 一旦_changingをtrueにしてModelを更新中のステータスに切り替える
// 切り替える手前のステータスを知っておき、これを利用する
var changing = this._changing;
this._changing = true;
// 更新中から更新中に変わった際は、attributesを書き換える前に変更前の状態を覚えておく
if (!changing) {
this._previousAttributes = _.clone(this.attributes);
this.changed = {};
}
// 変更中のModelのattributesの状態
var current = this.attributes;
// .set実行時に更新したkey/valueの情報
var changed = this.changed;
// .setする前のModelのattributesの状態
var prev = this._previousAttributes;
// For each `set` attribute, update or delete the current value.
for (var attr in attrs) {
val = attrs[attr];
// 現在のvalueと書き換えるvalueが同じ場合はchangesに記録されないのでchange::keyイベントは発行されなくなる
// 変更を検知したkey名をchangesに貯めておき、最終的にすべてchange:keyのイベントを発行する
if (!_.isEqual(current[attr], val)) changes.push(attr);
if (!_.isEqual(prev[attr], val)) {
changed[attr] = val;
} else {
delete changed[attr];
}
// 実際のattributesの書き換えを行っているのはここ
// currentはthis.attributesの参照
unset ? delete current[attr] : current[attr] = val;
}
// Update the `id`.
if (this.idAttribute in attrs) this.id = this.get(this.idAttribute);
// Trigger all relevant attribute changes.
// 更新したすべてのkeyにて、change::key名のイベントを発行している
if (!silent) {
if (changes.length) this._pending = options;
for (var i = 0; i < changes.length; i++) {
// current[changes[i]]は変更後のvalueが入ってくる
this.trigger('change:' + changes[i], this, current[changes[i]], options);
}
}
// You might be wondering why there's a `while` loop here. Changes can
// be recursively nested within `"change"` events.
if (changing) return this;
// silent: trueをしていない場合はchangeイベントは発火させない
// changesが[]のまま場合、(setした内容がかすりもしなかった場合もここはおそらく評価されない)
if (!silent) {
while (this._pending) {
options = this._pending;
this._pending = false;
this.trigger('change', this, options);
}
}
this._pending = false;
this._changing = false;
// thisを返すのでメソッドチェーンできます
return this;
},
// ルールに反した場合はinvalidイベントが発火する
// _validate自体は.setのときに呼ばれている
_validate: function(attrs, options) {
if (!options.validate || !this.validate) return true;
attrs = _.extend({}, this.attributes, attrs);
var error = this.validationError = this.validate(attrs, options) || null;
if (!error) return true;
this.trigger('invalid', this, error, _.extend(options, {validationError: error}));
return false;
}
- 引数の受け方が2パターン存在する
- 変更する前のattributesの状態を1世代分だけキャッシュするので一様遡ることができる
- options.silent: changeイベントを発火させない。ModelのchangeイベントをViewで購読してDOM要素の書き換えを行うのが一般的なので、このやり方を覚えておくだけで不要なレンダリングコストを払わなくて済むので重要
- options.unset: attributesのkeyの存在を消し去りたい時のアプローチ
- options.validate: .setにてattributeを書き換える前にvalidateメソッドを実行することで引数のattributesの制御を行うことができる。例外が発生した際はinvalidイベントを発行する
- validationの恩恵を受けたい場合は.validateメソッドをユーザーが定義する必要がある
- .setを実行することで発行されるイベントの回数は基本的には『N ? (N+1) : 0』である。※Nは変更を検知したattributesのkey数
- .setを実行することで発行されるイベントは『change:${key}』, 『change』の2パターン存在し、前者のものが先に実行される
Backbone.js/Marionette.jsで開発するイベント駆動のアプリケーションは、.setを元にModelがイベントを発行し、それにつられて色々なインスタンスがcallbackを実行していくでしょう。
自分の書いたコードがどんなイベントを発行するかは理解できないと最適なコードは書けません。
細々としたプロパティ・メソッド(2)
// unsetは特定のメンバを削除する
unset: function(attr, options) {
// keyさえ指定してしまえばよく、valueの値はどうでもいいのでundefinedを送るようにしている
return this.set(attr, void 0, _.extend({}, options, {unset: true}));
},
// clearはすべてのメンバを削除する
// .cidは使いまわされる
clear: function(options) {
var attrs = {};
// すべてのattributesのkeyを事前にひっぱてきておいて、1回の.setで済むようにしている
for (var key in this.attributes) attrs[key] = void 0;
return this.set(attrs, _.extend({}, options, {unset: true}));
},
// attrを指定しない場合はModelすべてのattrを対象に変更履歴が有るかを評価する
hasChanged: function(attr) {
if (attr == null) return !_.isEmpty(this.changed);
return _.has(this.changed, attr);
},
// 渡されたattributeと自身のメンバのattributeの差分を返す
// 差分がない場合はfalseが帰ってくるので注意
changedAttributes: function(diff) {
if (!diff) return this.hasChanged() ? _.clone(this.changed) : false;
var old = this._changing ? this._previousAttributes : this.attributes;
var changed = {};
for (var attr in diff) {
var val = diff[attr];
if (_.isEqual(old[attr], val)) continue;
changed[attr] = val;
}
return _.size(changed) ? changed : false;
},
previous: function(attr) {
if (attr == null || !this._previousAttributes) return null;
return this._previousAttributes[attr];
},
// .setする1つまえのattributesの状態を取得することができる
previousAttributes: function() {
return _.clone(this._previousAttributes);
},
// parseはModelがnewされるとき渡されるatttibutesを加工する仕組みをユーザー自身が都合のいい形に変換するものであり
// ユーザーによってOverrideされることを期待している
// sync関係のAPIを利用してModelの生成を行う場合に重宝される
// .setの際はparseは評価されない。
// コンストラクタの際は明示的にoptionsでparse:trueを指定すれば評価してくれる
parse: function(resp, options) {
return resp;
},
// idの採番が行いクローンを作成するので
// 違うObjectとなる
clone: function() {
return new this.constructor(this.attributes);
},
// サーバーで保存されたものかどうかを評価する
// サーバー側から取得したModelにはidがくっついている、という前提で評価する
isNew: function() {
return !this.has(this.idAttribute);
},
// 今のModelの状態をvalidateするとどうなるかを試すことができる。
// validイベントも発火する
isValid: function(options) {
return this._validate({}, _.defaults({validate: true}, options));
},
- parseをOverrideすることでコンストラクタでのattributesの扱いに手を加えることができる。Backbone.syncを利用する際に便利
- .cloneはid変わり・constructorを評価する・_.clone()とは勝手が違う
- .setする1つまえのattributesの状態を取得することができるが、それ以上の履歴は遡ることができない
RestAPIとの連携
Backbone.ModelにはRestAPIと親和性の高い・データの参照更新用APIが備わっている。
この辺に関しては『Backbone.sync』をやる際にでも振り返ろうと思っているので今回は割愛で
// fetch/save/destroyに関してはRestなAPIの実行するためのヘルパーである
// Backbone.jsのここの仕組みを考慮したRestなAPIでは効果を発揮するがAPIの仕様がそれに合わない場合は何の役にも立たない
// ここの仕組みをあえて、ユーザーのWebStorage領域に対するfetch/save/destroyなどに振る舞いを変えることをすれば別のいい使い方が有るかもしれない
// 実際にそれをやっているっぽいライブラリも有る
// https://github.com/jeromegn/Backbone.localStorage
fetch: function(options) {
options = _.extend({parse: true}, options);
var model = this;
var success = options.success;
options.success = function(resp) {
var serverAttrs = options.parse ? model.parse(resp, options) : resp;
if (!model.set(serverAttrs, options)) return false;
if (success) success.call(options.context, model, resp, options);
model.trigger('sync', model, resp, options);
};
wrapError(this, options);
return this.sync('read', this, options);
},
// Set a hash of model attributes, and sync the model to the server.
// If the server returns an attributes hash that differs, the model's
// state will be `set` again.
save: function(key, val, options) {
// Handle both `"key", value` and `{key: value}` -style arguments.
var attrs;
if (key == null || typeof key === 'object') {
attrs = key;
options = val;
} else {
(attrs = {})[key] = val;
}
options = _.extend({validate: true, parse: true}, options);
var wait = options.wait;
// If we're not waiting and attributes exist, save acts as
// `set(attr).save(null, opts)` with validation. Otherwise, check if
// the model will be valid when the attributes, if any, are set.
if (attrs && !wait) {
if (!this.set(attrs, options)) return false;
} else {
if (!this._validate(attrs, options)) return false;
}
// After a successful server-side save, the client is (optionally)
// updated with the server-side state.
var model = this;
var success = options.success;
var attributes = this.attributes;
options.success = function(resp) {
// Ensure attributes are restored during synchronous saves.
model.attributes = attributes;
var serverAttrs = options.parse ? model.parse(resp, options) : resp;
if (wait) serverAttrs = _.extend({}, attrs, serverAttrs);
if (serverAttrs && !model.set(serverAttrs, options)) return false;
if (success) success.call(options.context, model, resp, options);
model.trigger('sync', model, resp, options);
};
wrapError(this, options);
// Set temporary attributes if `{wait: true}` to properly find new ids.
if (attrs && wait) this.attributes = _.extend({}, attributes, attrs);
var method = this.isNew() ? 'create' : (options.patch ? 'patch' : 'update');
if (method === 'patch' && !options.attrs) options.attrs = attrs;
var xhr = this.sync(method, this, options);
// Restore attributes.
this.attributes = attributes;
return xhr;
},
// Destroy this model on the server if it was already persisted.
// Optimistically removes the model from its collection, if it has one.
// If `wait: true` is passed, waits for the server to respond before removal.
destroy: function(options) {
options = options ? _.clone(options) : {};
var model = this;
var success = options.success;
var wait = options.wait;
var destroy = function() {
model.stopListening();
model.trigger('destroy', model, model.collection, options);
};
options.success = function(resp) {
if (wait) destroy();
if (success) success.call(options.context, model, resp, options);
if (!model.isNew()) model.trigger('sync', model, resp, options);
};
var xhr = false;
if (this.isNew()) {
_.defer(options.success);
} else {
wrapError(this, options);
xhr = this.sync('delete', this, options);
}
if (!wait) destroy();
return xhr;
},
// Default URL for the model's representation on the server -- if you're
// using Backbone's restful methods, override this to change the endpoint
// that will be called.
url: function() {
var base =
_.result(this, 'urlRoot') ||
_.result(this.collection, 'url') ||
urlError();
if (this.isNew()) return base;
var id = this.get(this.idAttribute);
return base.replace(/[^\/]$/, '$&/') + encodeURIComponent(id);
},
Mixin
// 以下のunderscore.jsで提供されているメソッドをModel.で使えるようにしている
// _(args)として与えられる変数はModel.attributesになるのでAPIを実行しやすい
var modelMethods = { keys: 1, values: 1, pairs: 1, invert: 1, pick: 0,
omit: 0, chain: 1, isEmpty: 1 };
addUnderscoreMethods(Model, modelMethods, 'attributes');
- underscore.jsの一部の機能をBackbone.Modelのメソッドして提供している。インタフェースとしてはBackbone.Modelに実装されているが、メソッドのcontextをModel.attributesにしているので気持ちよく実行できる