/* jshint proto:true */ var Emitter = require('./emitter'), utils = require('./utils'), // cache methods typeOf = utils.typeOf, def = utils.defProtected, hasOwn = ({}).hasOwnProperty, oDef = Object.defineProperty, slice = [].slice, // types OBJECT = 'Object', ARRAY = 'Array', // fix for IE + __proto__ problem // define methods as inenumerable if __proto__ is present, // otherwise enumerable so we can loop through and manually // attach to array instances hasProto = ({}).__proto__, // lazy load ViewModel // Array Mutation Handlers & Augmentations ------------------------------------ // The proxy prototype to replace the __proto__ of // an observed array var ArrayProxy = Object.create(Array.prototype) // intercept mutation methods ;[ 'push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse' ].forEach(watchMutation) // Augment the ArrayProxy with convenience methods def(ArrayProxy, '$set', function (index, data) { return this.splice(index, 1, data)[0] }, !hasProto) def(ArrayProxy, '$remove', function (index) { if (typeof index !== 'number') { index = this.indexOf(index) } if (index > -1) { return this.splice(index, 1)[0] } }, !hasProto) /** * Intercep a mutation event so we can emit the mutation info. * we also analyze what elements are added/removed and link/unlink * them with the parent Array. */ function watchMutation (method) { def(ArrayProxy, method, function () { var args = slice.call(arguments), result = Array.prototype[method].apply(this, args), inserted, removed // determine new / removed elements if (method === 'push' || method === 'unshift') { inserted = args } else if (method === 'pop' || method === 'shift') { removed = [result] } else if (method === 'splice') { inserted = args.slice(2) removed = result } // link & unlink linkArrayElements(this, inserted) unlinkArrayElements(this, removed) // emit the mutation event this.__emitter__.emit('mutate', '', this, { method : method, args : args, result : result, inserted : inserted, removed : removed }) return result }, !hasProto) } /** * Link new elements to an Array, so when they change * and emit events, the owner Array can be notified. */ function linkArrayElements (arr, items) { if (items) { var i = items.length, item, owners while (i--) { item = items[i] if (isWatchable(item)) { // if object is not converted for observing // convert it... if (!item.__emitter__) { convert(item) watch(item) } owners = item.__emitter__.owners if (owners.indexOf(arr) < 0) { owners.push(arr) } } } } } /** * Unlink removed elements from the ex-owner Array. */ function unlinkArrayElements (arr, items) { if (items) { var i = items.length, item while (i--) { item = items[i] if (item && item.__emitter__) { var owners = item.__emitter__.owners if (owners) owners.splice(owners.indexOf(arr)) } } } } // Object add/delete key augmentation ----------------------------------------- var ObjProxy = Object.create(Object.prototype) def(ObjProxy, '$add', function (key, val) { if (hasOwn.call(this, key)) return this[key] = val convertKey(this, key) // emit a propagating set event this.__emitter__.emit('set', key, val, true) }, !hasProto) def(ObjProxy, '$delete', function (key) { if (!(hasOwn.call(this, key))) return // trigger set events this[key] = undefined delete this[key] this.__emitter__.emit('delete', key) }, !hasProto) // Watch Helpers -------------------------------------------------------------- /** * Check if a value is watchable */ function isWatchable (obj) { ViewModel = ViewModel || require('./viewmodel') var type = typeOf(obj) return (type === OBJECT || type === ARRAY) && !(obj instanceof ViewModel) } /** * Convert an Object/Array to give it a change emitter. */ function convert (obj) { if (obj.__emitter__) return true var emitter = new Emitter() def(obj, '__emitter__', emitter) emitter .on('set', function (key, val, propagate) { if (propagate) propagateChange(obj) }) .on('mutate', function () { propagateChange(obj) }) emitter.values = utils.hash() emitter.owners = [] return false } /** * Propagate an array element's change to its owner arrays */ function propagateChange (obj) { var owners = obj.__emitter__.owners, i = owners.length while (i--) { owners[i].__emitter__.emit('set', '', '', true) } } /** * Watch target based on its type */ function watch (obj) { var type = typeOf(obj) if (type === OBJECT) { watchObject(obj) } else if (type === ARRAY) { watchArray(obj) } } /** * Augment target objects with modified * methods */ function augment (target, src) { if (hasProto) { target.__proto__ = src } else { for (var key in src) { def(target, key, src[key]) } } } /** * Watch an Object, recursive. */ function watchObject (obj) { augment(obj, ObjProxy) for (var key in obj) { convertKey(obj, key) } } /** * Watch an Array, overload mutation methods * and add augmentations by intercepting the prototype chain */ function watchArray (arr) { augment(arr, ArrayProxy) linkArrayElements(arr, arr) } /** * Define accessors for a property on an Object * so it emits get/set events. * Then watch the value itself. */ function convertKey (obj, key) { var keyPrefix = key.charAt(0) if (keyPrefix === '$' || keyPrefix === '_') { return } // emit set on bind // this means when an object is observed it will emit // a first batch of set events. var emitter = obj.__emitter__, values = emitter.values init(obj[key]) oDef(obj, key, { enumerable: true, configurable: true, get: function () { var value = values[key] // only emit get on tip values if (pub.shouldGet) { emitter.emit('get', key) } return value }, set: function (newVal) { var oldVal = values[key] unobserve(oldVal, key, emitter) copyPaths(newVal, oldVal) // an immediate property should notify its parent // to emit set for itself too init(newVal, true) } }) function init (val, propagate) { values[key] = val emitter.emit('set', key, val, propagate) if (Array.isArray(val)) { emitter.emit('set', key + '.length', val.length, propagate) } observe(val, key, emitter) } } /** * When a value that is already converted is * observed again by another observer, we can skip * the watch conversion and simply emit set event for * all of its properties. */ function emitSet (obj) { var type = typeOf(obj), emitter = obj && obj.__emitter__ if (type === ARRAY) { emitter.emit('set', 'length', obj.length) } else if (type === OBJECT) { var key, val for (key in obj) { val = obj[key] emitter.emit('set', key, val) emitSet(val) } } } /** * Make sure all the paths in an old object exists * in a new object. * So when an object changes, all missing keys will * emit a set event with undefined value. */ function copyPaths (newObj, oldObj) { if (typeOf(oldObj) !== OBJECT || typeOf(newObj) !== OBJECT) { return } var path, type, oldVal, newVal for (path in oldObj) { if (!(hasOwn.call(newObj, path))) { oldVal = oldObj[path] type = typeOf(oldVal) if (type === OBJECT) { newVal = newObj[path] = {} copyPaths(newVal, oldVal) } else if (type === ARRAY) { newObj[path] = [] } else { newObj[path] = undefined } } } } /** * walk along a path and make sure it can be accessed * and enumerated in that object */ function ensurePath (obj, key) { var path = key.split('.'), sec for (var i = 0, d = path.length - 1; i < d; i++) { sec = path[i] if (!obj[sec]) { obj[sec] = {} if (obj.__emitter__) convertKey(obj, sec) } obj = obj[sec] } if (typeOf(obj) === OBJECT) { sec = path[i] if (!(hasOwn.call(obj, sec))) { obj[sec] = undefined if (obj.__emitter__) convertKey(obj, sec) } } } // Main API Methods ----------------------------------------------------------- /** * Observe an object with a given path, * and proxy get/set/mutate events to the provided observer. */ function observe (obj, rawPath, observer) { if (!isWatchable(obj)) return var path = rawPath ? rawPath + '.' : '', alreadyConverted = convert(obj), emitter = obj.__emitter__ // setup proxy listeners on the parent observer. // we need to keep reference to them so that they // can be removed when the object is un-observed. observer.proxies = observer.proxies || {} var proxies = observer.proxies[path] = { get: function (key) { observer.emit('get', path + key) }, set: function (key, val, propagate) { if (key) observer.emit('set', path + key, val) // also notify observer that the object itself changed // but only do so when it's a immediate property. this // avoids duplicate event firing. if (rawPath && propagate) { observer.emit('set', rawPath, obj, true) } }, mutate: function (key, val, mutation) { // if the Array is a root value // the key will be null var fixedPath = key ? path + key : rawPath observer.emit('mutate', fixedPath, val, mutation) // also emit set for Array's length when it mutates var m = mutation.method if (m !== 'sort' && m !== 'reverse') { observer.emit('set', fixedPath + '.length', val.length) } } } // attach the listeners to the child observer. // now all the events will propagate upwards. emitter .on('get', proxies.get) .on('set', proxies.set) .on('mutate', proxies.mutate) if (alreadyConverted) { // for objects that have already been converted, // emit set events for everything inside emitSet(obj) } else { watch(obj) } } /** * Cancel observation, turn off the listeners. */ function unobserve (obj, path, observer) { if (!obj || !obj.__emitter__) return path = path ? path + '.' : '' var proxies = observer.proxies[path] if (!proxies) return // turn off listeners obj.__emitter__ .off('get', proxies.get) .off('set', proxies.set) .off('mutate', proxies.mutate) // remove reference observer.proxies[path] = null } // Expose API ----------------------------------------------------------------- var pub = module.exports = { // whether to emit get events // only enabled during dependency parsing shouldGet : false, observe : observe, unobserve : unobserve, ensurePath : ensurePath, copyPaths : copyPaths, watch : watch, convert : convert, convertKey : convertKey }