initial merge of tgriesser's change cleanup

This commit is contained in:
Jeremy Ashkenas
2012-11-28 17:04:27 -05:00
3 changed files with 104 additions and 61 deletions

View File

@@ -184,22 +184,20 @@
var Model = Backbone.Model = function(attributes, options) {
var defaults;
var attrs = attributes || {};
if (options && options.collection) this.collection = options.collection;
this.attributes = {};
this._escapedAttributes = {};
this.cid = _.uniqueId('c');
this.changed = {};
this._changes = {};
this._pending = {};
this.attributes = {};
this._escapedAttributes = {};
this._modelState = [];
if (options && options.collection) this.collection = options.collection;
if (options && options.parse) attrs = this.parse(attrs);
if (defaults = _.result(this, 'defaults')) {
attrs = _.extend({}, defaults, attrs);
}
this.set(attrs, {silent: true});
// Reset change tracking.
this.changed = {};
this._changes = {};
this._pending = {};
this._cleanChange = true;
this._modelState = [];
this._currentState = _.clone(this.attributes);
this._previousAttributes = _.clone(this.attributes);
this.initialize.apply(this, arguments);
};
@@ -210,17 +208,26 @@
// A hash of attributes whose current and previous value differ.
changed: null,
// A hash of attributes that have changed since the last time `change`
// was called.
_changes: null,
// Whether there is a pending request to fire in the final `change` loop.
_pending: false,
// A hash of attributes that have changed since the last `change` event
// began.
_pending: null,
// Whether the model is in the midst of a change cycle.
_changing: false,
// A hash of attributes with the current model state to determine if
// a `change` should be recorded within a nested `change` block.
_changing : null,
// Whether there has been a `set` call since the last
// calculation of the changed hash, for efficiency.
_cleanChange: true,
// The model state used for comparison in determining if a
// change should be fired.
_currentState: null,
// An array queue of all changes attributed to a model
// on the next non-silent change event.
_modelState: null,
// A hash of the model's attributes when the last `change` occured.
_previousAttributes: null,
// The default name for the JSON `id` attribute is `"id"`. MongoDB and
// CouchDB users may want to set this to `"_id"`.
@@ -276,6 +283,7 @@
// Extract attributes and options.
var silent = options && options.silent;
var unset = options && options.unset;
if (attrs instanceof Model) attrs = attrs.attributes;
if (unset) for (attr in attrs) attrs[attr] = void 0;
@@ -285,38 +293,26 @@
// Check for changes of `id`.
if (this.idAttribute in attrs) this.id = attrs[this.idAttribute];
var changing = this._changing;
var now = this.attributes;
var escaped = this._escapedAttributes;
var prev = this._previousAttributes || {};
var esc = this._escapedAttributes;
// For each `set` attribute...
for (attr in attrs) {
val = attrs[attr];
// If the new and current value differ, record the change.
if (!_.isEqual(now[attr], val) || (unset && _.has(now, attr))) {
delete escaped[attr];
this._changes[attr] = true;
}
// If an escaped attr exists, and the new and current value differ, remove the escaped attr.
if (esc[attr] && !_.isEqual(now[attr], val) || (unset && _.has(now, attr))) delete esc[attr];
// Update or delete the current value.
unset ? delete now[attr] : now[attr] = val;
// If the new and previous value differ, record the change. If not,
// then remove changes for this attribute.
if (!_.isEqual(prev[attr], val) || (_.has(now, attr) !== _.has(prev, attr))) {
this.changed[attr] = val;
if (!silent) this._pending[attr] = true;
} else {
delete this.changed[attr];
delete this._pending[attr];
if (!changing) delete this._changes[attr];
}
if (changing && _.isEqual(now[attr], changing[attr])) delete this._changes[attr];
// Track any action on the attribute.
this._modelState.push(attr, val, unset);
}
// Signal that the model's state has potentially changed.
this._cleanChange = false;
// Fire the `"change"` events.
if (!silent) this.change(options);
return this;
@@ -463,36 +459,22 @@
// Calling this will cause all objects observing the model to update.
change: function(options) {
var changing = this._changing;
var current = this._changing = {};
this._changing = true;
// Silent changes become pending changes.
for (var attr in this._changes) this._pending[attr] = true;
// Generate the changes to be triggered on the model.
var triggers = this._changeCenter(true);
this._pending = triggers.length;
// Trigger 'change:attr' for any new or silent changes.
var changes = this._changes;
this._changes = {};
// Set the correct state for this._changing values
var triggers = [];
for (var attr in changes) {
current[attr] = this.get(attr);
triggers.push(attr);
for (i = triggers.length - 2; i >= 0; i -= 2) {
this.trigger('change:' + triggers[i], this, triggers[i + 1], options);
}
for (var i=0, l=triggers.length; i < l; i++) {
this.trigger('change:' + triggers[i], this, current[triggers[i]], options);
}
if (changing) return this;
// Continue firing `"change"` events while there are pending changes.
while (!_.isEmpty(this._pending)) {
this._pending = {};
// Trigger a `change` while there have been changes.
while (this._pending) {
this._pending = false;
this.trigger('change', this, options);
// Pending and silent changes still remain.
for (var attr in this.changed) {
if (this._pending[attr] || this._changes[attr]) continue;
delete this.changed[attr];
}
this._previousAttributes = _.clone(this.attributes);
}
@@ -503,6 +485,7 @@
// Determine if the model has changed since the last `"change"` event.
// If you specify an attribute name, determine if that attribute has changed.
hasChanged: function(attr) {
if (!this._cleanChange) this._changeCenter();
if (attr == null) return !_.isEmpty(this.changed);
return _.has(this.changed, attr);
},
@@ -523,6 +506,42 @@
return changed;
},
// Calculates and handles any changes in `this._modelState`,
// checking against `this._currentState` to determine current changes.
_changeCenter: function (change) {
this.changed = {};
var local = {};
var triggers = [];
var modelState = this._modelState;
var currentState = this._currentState;
// Loop through the current queue of potential model changes.
for (var i = modelState.length - 3; i >= 0; i -= 3) {
var key = modelState[i], val = modelState[i + 1], unset = modelState[i + 2];
// If the item hasn't been set locally this round, proceed.
if (!local[key]) {
local[key] = val;
// Check if the attribute has been modified since the last change,
// and update `this.changed` accordingly.
if (currentState[key] !== val || (_.has(currentState, key) && unset)) {
this.changed[key] = val;
// Triggers & modifications are only created inside a `change` call.
if (!change) continue;
triggers.push(key, val);
(!unset) ? currentState[key] = val : delete currentState[key];
}
}
modelState.splice(i,3);
}
// Signals `this.changed` is current to prevent duplicate calls from `this.hasChanged`.
this._cleanChange = true;
return triggers;
},
// Get the previous value of an attribute, recorded at the time the last
// `"change"` event was fired.
previous: function(attr) {

View File

@@ -903,4 +903,18 @@ $(document).ready(function() {
expect(0);
});
test("silent changes in last `change` event back to original does not trigger change", 2, function() {
var changes = [];
var model = new Backbone.Model();
model.on('change:a change:b change:c', function(model, val) { changes.push(val); });
model.on('change', function() {
model.set({a:'c'}, {silent:true});
});
model.set({a:'a'});
deepEqual(changes, ['a']);
model.set({a:'a'}, {silent:true});
model.change();
deepEqual(changes, ['a']);
});
});

View File

@@ -42,4 +42,14 @@
keyModel.set({number: Math.random()});
});
var silentModel = new Backbone.Model;
silentModel.on('change', fn);
JSLitmus.test('Model: silent sets with rand(), with an attribute observer', function () {
for (var i=0;i<10;i++) {
silentModel.set({number:Math.random()}, {silent:true});
}
silentModel.set({number:'one'});
});
})();