UNPKG

observe

Version:

A powerful, pragmatic implementation of the observer pattern for javascript objects and arrays.

518 lines (422 loc) 21.5 kB
var proto = require("proto") var EventEmitter = require("events").EventEmitter var utils = require("./utils") // emits the event: // change - the event data is an object of one of the following forms: // {data:_, type: 'set', property: propertyList} // {data:_, type: 'added', property: propertyList, index:_, count: numberOfElementsAdded} // {data:_, type: 'removed', property: propertyList, index:_, removed: removedValues} var Observe = module.exports = proto(EventEmitter, function(superclass) { // static members this.init = function(obj) { this.subject = obj this.internalChangeListeners = [] this.setMaxListeners(1000) } // instance members // gets an element or member of the subject and returns another Observee // changes to the returned Observee will be emitted by its parent as well this.get = function(property) { return ObserveeChild(this, parsePropertyList(property)) } // sets a value on the subject // property - either an array of members to select, or a string where properties to select are separated by dots // value - the value to set this.set = function(property, value) { setInternal(this, parsePropertyList(property), value, {}) } this.unset = function(property) { unsetInternal(this, parsePropertyList(property), {}) } // pushes a value onto a list this.push = function(/*value...*/) { pushInternal(this, [], arguments, {}) } this.pop = function() { var elements = spliceInternal(this, [], [this.subject.length-1,1], {}) return elements[0] } this.unshift = function(/*value...*/) { spliceInternal(this, [], [0,0].concat(Array.prototype.slice.call(arguments, 0)), {}) } this.shift = function() { var elements = spliceInternal(this, [], [0,1], {}) return elements[0] } this.reverse = function() { this.subject.reverse() this.emit('change', { type:'set', property: [] }) } this.sort = function() { this.subject.sort.apply(this.subject, arguments) this.emit('change', { type:'set', property: [] }) } // index is the index to remove/insert at // countToRemove is the number to remove // elementsToAdd is a list of elements to add this.splice = function(/*index, countToRemove[, elementsToAdd]*/) { return spliceInternal(this, [], arguments, {}) } // use this instead of concat for mutation behavior this.append = function(arrayToAppend) { appendInternal(this, [], arguments, {}) } this.data = this.id = function(data) { return ObserveeChild(this, [], {data: data}) } /*override*/ this.emit = function(type) { if(type === 'change') { var args = Array.prototype.slice.call(arguments, 1) this.internalChangeListeners.forEach(function(handler) { handler.apply(this, args) }.bind(this)) } superclass.prototype.emit.apply(this, arguments) } // For the returned object, any property added via set, push, splice, or append joins an internal observee together with this observee, so that // the internal observee and the containing observee will both send 'change' events appropriately // collapse - (default: false) if true, any property added will be set to the subject of the value added (so that value won't be an observee anymore // note: only use collapse:true if the observees you're unioning isn't actually an object that inherits from an observee - any instance methods on the observee that come from child classes won't be accessible anymore // e.g. var x = observe({a:5}) // var b = observe({}) // x.subject.a === 5 ;; true // b.union(true).set('x', x) // b.subject.x.a === 5 ;; true // b.subject.x.subject.a === 5 ;; false this.union = function(collapse) { if(collapse === undefined) collapse = false return ObserveeChild(this, [], {union: collapse}) } /* pause and unpause may cause weird affects in certain cases (e.g. if you remove an element at index 4 and *then* add an element at index 2) // pause sending events (for when you want to do a lot of things to an object) this.pause = function() { this.paused = true } this.unpause = function() { this.paused = undefined sendEvent(this) }*/ // private this.onChangeInternal = function(handler) { this.internalChangeListeners.push(handler) } this.offChangeInternal = function(handler) { var index = this.internalChangeListeners.indexOf(handler) this.internalChangeListeners.splice(index,1) } }) function parsePropertyList(property) { if(!(property instanceof Array)) { property = property.toString().split('.') } return property } function getPropertyPointer(subject, propertyList) { var current = subject for(var n=0; n<propertyList.length-1; n++) { current = current[propertyList[n]] } return {obj: current, key:propertyList[n]} } var getPropertyValue = module.exports.getPropertyValue = function(subject, property) { var pointer = getPropertyPointer(subject, property) if(pointer.key !== undefined) { return pointer.obj[pointer.key] } else { return pointer.obj } } // private // options can have the properties: // union - if true, any value set, pushed, appended, or spliced onto the observee is unioned var ObserveeChild = proto(EventEmitter, function() { this.init = function(parent, propertyList, options) { if(options === undefined) this.options = {} else this.options = options if(parent._observeeParent !== undefined) this._observeeParent = parent._observeeParent else this._observeeParent = parent this.property = propertyList this.subject = getPropertyValue(parent.subject, propertyList) var that = this, changeHandler parent.onChangeInternal(changeHandler=function(change) { var answers = changeQuestions(that.property, change, that.options.union) if(answers.isWithin) { if(change.type === 'set' && change.property.length <= that.property.length && that.options.union === undefined) { // if the subject may have been replaced with a new subject var pointer = getPropertyPointer(parent.subject, propertyList) if(pointer.obj !== undefined) { if(pointer.key !== undefined) { that.subject =pointer.obj[pointer.key] } else { that.subject =pointer.obj } } } that.emit('change', { type:change.type, property: change.property.slice(that.property.length), index:change.index, count:change.count, removed: change.removed, data: change.data }) } else if(answers.couldRelocate) { if(change.type === 'removed') { var relevantIndex = that.property[change.property.length] var lastRemovedIndex = change.index + change.removed.length - 1 if(lastRemovedIndex < relevantIndex) { that.property[change.property.length] = relevantIndex - change.removed.length // change the propertyList to match the new index } else if(lastRemovedIndex === relevantIndex) { parent.offChangeInternal(changeHandler) } } else if(change.type === 'added') { var relevantIndex = parseInt(that.property[change.property.length]) if(change.index <= relevantIndex) { that.property[change.property.length] = relevantIndex + change.count // change the propertyList to match the new index } } else if(change.type === 'set') { parent.offChangeInternal(changeHandler) } } }) } this.get = function(property) { var result = this._observeeParent.get(this.property.concat(parsePropertyList(property))) result.options = this.options return result } this.set = function(property, value) { setInternal(this._observeeParent, this.property.concat(parsePropertyList(property)), value, this.options) } this.unset = function(property) { unsetInternal(this._observeeParent, this.property.concat(parsePropertyList(property)), this.options) } this.push = function(/*values...*/) { pushInternal(this._observeeParent, this.property, arguments, this.options) } this.pop = function() { var elements = spliceInternal(this._observeeParent, this.property, [this.subject.length-1,1], this.options) return elements[0] } this.unshift = function(/*value...*/) { spliceInternal(this._observeeParent, this.property, [0,0].concat(Array.prototype.slice.call(arguments,0)), this.options) } this.shift = function() { var elements = spliceInternal(this._observeeParent, this.property, [0,1], this.options) return elements[0] } this.splice = function(index, countToRemove/*[, elementsToAdd....]*/) { return spliceInternal(this._observeeParent, this.property, arguments, this.options) } this.reverse = function() { this.subject.reverse() this.emit('change', { type:'set', property: [] }) } this.sort = function() { this.subject.sort.apply(this.subject, arguments) this.emit('change', { type:'set', property: [] }) } this.append = function(/*[property,] arrayToAppend*/) { appendInternal(this._observeeParent, this.property, arguments, this.options) } this.data = this.id = function(data) { return ObserveeChild(this._observeeParent, this.property, utils.merge({}, this.options, {data: data})) } this.union = function(collapse) { if(collapse === undefined) collapse = false return ObserveeChild(this, [], utils.merge({}, this.options, {union: collapse})) } }) // that - the Observee object function setInternal(that, propertyList, value, options) { if(propertyList.length === 0) throw new Error("You can't set at the top-level, setting like that only works for ObserveeChild (sub-observees created with 'get')") var pointer = getPropertyPointer(that.subject, propertyList) var internalObservee = value if(options.union === true) { value = value.subject } pointer.obj[pointer.key] = value var event = {type: 'set', property: propertyList} if(options.data !== undefined) event.data = event.id = options.data that.emit('change',event) if(options.union !== undefined) unionizeEvents(that, internalObservee, propertyList, options.union) } // that - the Observee object function unsetInternal(that, propertyList, options) { if(propertyList.length === 0) throw new Error("You can't set at the top-level, setting like that only works for ObserveeChild (sub-observees created with 'get')") var pointer = getPropertyPointer(that.subject, propertyList) delete pointer.obj[pointer.key] var event = {type: 'unset', property: propertyList} if(options.data !== undefined) event.data = event.id = options.data that.emit('change',event) } function pushInternal(that, propertyList, args, options) { var array = getPropertyValue(that.subject, propertyList) var originalLength = array.length array.push.apply(array, args) var internalObservees = unionizeList(array, originalLength, args.length, options.union) var event = {type: 'added', property: propertyList, index: originalLength, count: args.length} if(options.data !== undefined) event.data = event.id = options.data that.emit('change', event) unionizeListEvents(that, internalObservees, propertyList, options.union) } function spliceInternal(that, propertyList, args, options) { var index = args[0] var countToRemove = args[1] var array = getPropertyValue(that.subject, propertyList) var result = array.splice.apply(array, args) if(countToRemove > 0) { var event = {type: 'removed', property: propertyList, index: index, removed: result} if(options.data !== undefined) event.data = event.id = options.data that.emit('change', event) } if(args.length > 2) { var event = {type: 'added', property: propertyList, index: index, count: args.length-2} var internalObservees = unionizeList(array, index, event.count, options.union) if(options.data !== undefined) event.data = event.id = options.data that.emit('change', event) unionizeListEvents(that, internalObservees, propertyList, options.union) } return result } // note: I'm not using splice to do this as an optimization (because otherwise the property list would have to be parsed twice and the value gotten twice) - maybe this optimization wasn't worth it but its already done function appendInternal(that, propertyList, args, options) { var arrayToAppend = args[0] if(arrayToAppend.length === 0) return; //nothing to do var array = getPropertyValue(that.subject, propertyList) var originalLength = array.length var spliceArgs = [originalLength, 0] spliceArgs = spliceArgs.concat(arrayToAppend) var oldLength = array.length array.splice.apply(array, spliceArgs) var internalObservees = unionizeList(array, oldLength, array.length, options.union) var event = {type: 'added', property: propertyList, index: originalLength, count: arrayToAppend.length} if(options.data !== undefined) event.data = event.id = options.data that.emit('change', event) unionizeListEvents(that, internalObservees, propertyList, options.union) } // sets a slice of elements to their subjects and // returns the original observee objects along with their indexes function unionizeList(array, start, count, union) { var internalObservees = [] // list of observees and their property path if(union !== undefined) { var afterEnd = start+count for(var n=start; n<afterEnd; n++) { internalObservees.push({obj: array[n], index: n}) if(union === true) array[n] = array[n].subject } } return internalObservees } // runs unionizeEvents for elements in a list // internalObservees should be the result from `unionizeList` function unionizeListEvents(that, internalObservees, propertyList, collapse) { for(var n=0; n<internalObservees.length; n++) { unionizeEvents(that, internalObservees[n].obj, propertyList.concat(internalObservees[n].index+''), collapse) } } // sets up the union change events for an observee with one of its inner properties // parameters: // that - the container observee // innerObservee - the contained observee // propertyList - the propertyList to unionize // collapse - the union option (true for collapse) function unionizeEvents(that, innerObservee, propertyList, collapse) { var propertyListDepth = propertyList.length if(innerObservee.on === undefined || innerObservee.emit === undefined || innerObservee.removeListener === undefined || innerObservee.set === undefined) { throw new Error("Attempting to union a value that isn't an observee") } var innerChangeHandler, containerChangeHandler var ignorableContainerEvents = [], ignorableInnerEvents = [] innerObservee.on('change', innerChangeHandler = function(change) { if(ignorableInnerEvents.indexOf(change) === -1) { // don't run this for events generated by the union event handlers if(collapse) { var property = propertyList.concat(change.property) } else { var property = propertyList.concat(['subject']).concat(change.property) } var containerChange = utils.merge({}, change, {property: property}) ignorableContainerEvents.push(containerChange) that.emit('change', containerChange) } }) that.onChangeInternal(containerChangeHandler = function(change) { var changedPropertyDepth = change.property.length if(collapse) { var propertyListToAskFor = propertyList } else { var propertyListToAskFor = propertyList.concat(['subject']) } var answers = changeQuestions(propertyListToAskFor, change, true) var changeIsWithinInnerProperty = answers.isWithin var changeCouldRelocateInnerProperty = answers.couldRelocate if(changeIsWithinInnerProperty && ignorableContainerEvents.indexOf(change) === -1) { // don't run this for events generated by the union event handlers if(collapse) { var property = change.property.slice(propertyListDepth) } else { var property = change.property.slice(propertyListDepth+1) // +1 for the 'subject' } var innerObserveeEvent = utils.merge({}, change, {property: property}) ignorableInnerEvents.push(innerObserveeEvent) innerObservee.emit('change', innerObserveeEvent) } else if(changeCouldRelocateInnerProperty) { if(change.type === 'set' /*&& changedPropertyDepth <= propertyListDepth - this part already done above*/) { removeUnion() } else if(change.type === 'removed') { var relevantIndex = propertyList[change.property.length] var removedIndexesContainsIndexOfInnerObservee = change.index <= relevantIndex && relevantIndex <= change.index + change.removed.length - 1 var removedIndexesAreBeforeIndexOfInnerObservee = change.index + change.removed.length - 1 < relevantIndex && relevantIndex if(removedIndexesContainsIndexOfInnerObservee && changedPropertyDepth <= propertyListDepth+1) { removeUnion() } else if(removedIndexesAreBeforeIndexOfInnerObservee) { propertyList[change.property.length] = relevantIndex - change.removed.length // change the propertyList to match the new index } } else if(change.type === 'added') { var relevantIndex = propertyList[change.property.length] if(change.index < relevantIndex) { propertyList[change.property.length] = relevantIndex + change.count // change the propertyList to match the new index } } } }) var removeUnion = function() { innerObservee.removeListener('change', innerChangeHandler) that.offChangeInternal(containerChangeHandler) } } // answers certain questions about a change compared to a property list // returns an object like: { // isWithin: _, // true if changeIsWithinInnerProperty // couldRelocate: _ // true if changeCouldRelocateInnerProperty or if innerProperty might be removed // } function changeQuestions(propertyList, change, union) { var propertyListDepth = propertyList.length var unioned = union!==undefined var changeIsWithinInnerProperty = true // assume true until proven otherwise var changeCouldRelocateInnerProperty = true // assume true until prove otherwise for(var n=0; n<propertyListDepth; n++) { // stringifying the property parts so that indexes can either be strings or integers, but must ensure we don't stringify undefined (possible todo: when/if you get rid of dot notation, this might not be necessary anymore? not entirely sure) if(change.property[n] === undefined || change.property[n]+'' !== propertyList[n]+'') { changeIsWithinInnerProperty = false if(n<change.property.length) { changeCouldRelocateInnerProperty = false } } } if(!unioned && change.property.length < propertyListDepth || unioned && (change.type === 'set' && change.property.length <= propertyListDepth // if this is a unioned observee, replacing it actually removes it || change.type !== 'set' && change.property.length < propertyListDepth) ) { changeIsWithinInnerProperty = false } else { changeCouldRelocateInnerProperty = false } return {couldRelocate: changeCouldRelocateInnerProperty, isWithin: changeIsWithinInnerProperty} }