From 141195893c525de0dbf5b31907f8a4ba8b6e8548 Mon Sep 17 00:00:00 2001 From: MaxArt2501 Date: Tue, 20 Jan 2015 01:44:47 +0100 Subject: [PATCH] Using Map, bugfixes, code doc See changelog.md for more details --- changelog.md | 17 ++ object-observe.js | 490 ++++++++++++++++++++++++++++++------------ object-observe.min.js | 10 + readme.md | 28 ++- 4 files changed, 400 insertions(+), 145 deletions(-) create mode 100644 changelog.md create mode 100644 object-observe.min.js diff --git a/changelog.md b/changelog.md new file mode 100644 index 0000000..4bd0afe --- /dev/null +++ b/changelog.md @@ -0,0 +1,17 @@ +## v0.1.1 + +2015-01-19 + +* Fixed bugs in `Object.unobserve` +* Fixed bugs in `notifier.performChange` +* Used `Map`s when available - *much* cleaner code! +* Added some code documentation + +## v0.1.0 + +2015-01-18 - First preliminary version + +* `Object.observe`, `Object.unobserve` +* `Object.getNotifier`, `Object.deliverChangeRecords` +* No support for `"reconfigure"` and `"setPrototype"` events +* No `Array.observe`/`unobserve` yet \ No newline at end of file diff --git a/object-observe.js b/object-observe.js index 4e35b79..a4adc29 100644 --- a/object-observe.js +++ b/object-observe.js @@ -1,6 +1,70 @@ +/*! + * Object.observe polyfill + * by Massimo Artizzu (MaxArt2501) + * + * https://github.com/MaxArt2501/object-observe + * + * Licensed under the MIT License + * See LICENSE for details + */ + +// Some type definitions +/** + * This represents the data relative to an observed object + * @typedef {Object} ObjectData + * @property {Map} handlers + * @property {String[]} properties + * @property {*[]} values + * @property {Notifier} notifier + * @property {Boolean} frozen + * @property {Boolean} extensible + */ +/** + * Function definition of a handler + * @callback Handler + * @param {ChangeRecord[]} changes +*/ +/** + * This represents the data relative to an observed object and one of its + * handlers + * @typedef {Object} HandlerData + * @property {String[]} acceptList + * @property {ChangeRecord[]} changeRecords + */ +/** + * Type definition for a change. Any other property can be added using + * the notify() or performChange() methods of the notifier. + * @typedef {Object} ChangeRecord + * @property {String} type + * @property {Object} object + * @property {String} [name] + * @property {*} [oldValue] + * @property {Number} [index] + */ +/** + * Type definition for a notifier (what Object.getNotifier returns) + * @typedef {Object} Notifier + * @property {Function} notify + * @property {Function} performChange + */ +/** + * Function called with Notifier.performChange. It may optionally return a + * ChangeRecord that gets automatically notified, but `type` and `object` + * properties are overridden. + * @callback Performer + * @returns {ChangeRecord|undefined} + */ + Object.observe || (function(O, A, root) { "use strict"; + /** + * Checks if the argument is a Node object. Uses duck typing if Node is + * not defined. + * @function isNode + * @param {?*} node + * @returns {Boolean} + */ var isNode = root.Node ? function(node) { return node && node instanceof root.Node; } : function(node) { @@ -9,10 +73,24 @@ Object.observe || (function(O, A, root) { && typeof node.nodeType === "number" && typeof node.nodeName === "string"; }, + + /** + * Checks if the argument is an Array object. Polyfills Array.isArray. + * @function isArray + * @param {?*} object + * @returns {Boolean} + */ isArray = A.isArray || (function(toString) { return function (object) { return toString.call(object) === "[object Array]"; }; })(O.prototype.toString), + /** + * Checks is a property of an object is actually an accessor + * @function isAccessor + * @param {Object} object + * @param {String} prop + * @returns {Boolean} + */ // The correct method to check would be getOwnPropertyDescriptor, but // it's supported by IE8 but for DOM elements only. Useless. isAccessor = O.freeze ? function(object, prop) { @@ -20,6 +98,15 @@ Object.observe || (function(O, A, root) { return desc ? "get" in desc || "set" in desc : false; } : function() { return false; }, + /** + * Returns the index of an item in a collection, or -1 if not found. + * Uses Array.prototype.indexOf is available. + * @function inArray + * @param {*} pivot Item to look for + * @param {Array} array + * @param {Number} [start=0] Index to start from + * @returns {Number} + */ inArray = A.prototype.indexOf ? function(pivot, array, start) { return array.indexOf(pivot, start); } : function(pivot, array, start) { @@ -29,75 +116,141 @@ Object.observe || (function(O, A, root) { return -1; }, - getKeys = Object.keys || function(obj) { - // Misses checks on obj, don't use as a replacement of Object.keys + /** + * Returns an instance of Map, or a Map-like object is Map is not + * supported or doesn't support forEach() + * @function createMap + * @returns {Map} + */ + createMap = typeof root.Map === "undefined" || !Map.prototype.forEach ? function() { + // Lightweight shim of Map. Lacks clear(), entries(), keys() and + // values() (the last 3 not supported by IE11, so can't use them), + // it doesn't handle the constructor's argument (like IE11) and of + // course it doesn't support for...of. + // Chrome 31-35 and Firefox 13-24 have a basic support of Map, but + // they lack forEach(), so their native implementation is bad for + // this polyfill. (Chrome 36+ supports Object.observe.) + var keys = [], values = []; + + return { + size: 0, + has: function(key) { return inArray(key, keys) > -1; }, + get: function(key) { return values[inArray(key, keys)]; }, + set: function(key, value) { + var i = inArray(key, keys); + if (i === -1) { + keys.push(key); + values.push(value); + this.size++; + } else values[i] = value; + }, + "delete": function(key) { + var i = inArray(key, keys); + if (i > -1) { + keys.splice(i, 1); + values.splice(i, 1); + this.size--; + } + }, + forEach: function(callback/*, thisObj*/) { + for (var i = 0; i < keys.length; i++) + callback.call(arguments[1], values[i], keys[i], this); + } + }; + } : function() { return new Map(); }, + + /** + * Simple shim for Object.keys is not available + * Misses checks on object, don't use as a replacement of Object.keys + * @function getKeys + * @param {Object} object + * @returns {String[]} + */ + getKeys = Object.keys || function(object) { var keys = [], prop; - for (prop in obj) - if (obj.hasOwnProperty(prop)) + for (prop in object) + if (object.hasOwnProperty(prop)) keys.push(prop); return keys; }, - // This can be used as an exact polyfill of Object.is - // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is + /** + * Polyfill for Object.is if not available + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/is + * @function areSame + * @param {*} x + * @param {*} y + * @returns {Boolean} + */ areSame = O.is || function(x, y) { if (x === y) return x !== 0 || 1/x === 1/y; return x !== x && y !== y; }, - observed = [], - objectData = [], - - callbacks = [], - callbackData = [], + /** + * Relates observed objects and their data + * @type {Map>} + */ + handlers, defaultObjectAccepts = [ "add", "update", "delete", "reconfigure", "setPrototype", "preventExtensions" ], defaultArrayAccepts = [ "add", "update", "delete", "splice" ], + /** + * Sets up the observation of an object + * @function doObserve + * @param {Object} object + * @param {Handler} handler + * @param {String[]} [acceptList] + */ doObserve = function(object, handler, acceptList) { - var idx = inArray(object, observed), - data, cbdata, cbidx; + var data = observed.get(object); if (!isArray(acceptList)) - acceptList = isArray(object) ? defaultArrayAccepts : defaultObjectAccepts; - - if (idx === -1) { - observed.push(object) - 1; - addCallback(handler, acceptList, object); - data = { - handlers: [ handler ], + acceptList = isArray(object) + ? defaultArrayAccepts : defaultObjectAccepts; + + if (data) + data.handlers.set(handler, + setHandler(handler, object, acceptList)); + else { + observed.set(object, data = { + handlers: createMap(), frozen: O.isFrozen ? O.isFrozen(object) : false, - extensible: O.isExtensible ? O.isExtensible(object) : true - }; - objectData.push(data); + extensible: O.isExtensible ? O.isExtensible(object) : true, + properties: isNode(object) ? [] : getKeys(object) + }); + data.handlers.set(handler, + setHandler(handler, object, acceptList)); retrieveNotifier(object, data); + updateValues(object, data); - collectProperties(object, data); + // Let the observation begin! setTimeout(function worker() { // If this happens, the object has been unobserved - if (inArray(object, observed) === -1) return; + if (!observed.has(object)) return; performPropertyChecks(object, data); - broadcastChangeRecords(object, data); + broadcastChangeRecords(data); - if (!data.frozen) - setTimeout(worker, 17); + setTimeout(worker, 17); }, 17); - } else { - data = objectData[idx]; - addCallback(handler, acceptList, object); - if (inArray(handler, data.handlers) === -1) - data.handlers.push(handler); } }, - collectProperties = function(object, data) { - data.properties = isNode(object) ? [] : getKeys(object); - updateValues(object, data); - }, - + /** + * Updates the stored values of the properties of an observed object + * @function updateValues + * @param {Object} object + * @param {ObjectData} data + */ updateValues = function(object, data) { var props = data.properties, values = data.values = [], @@ -106,8 +259,16 @@ Object.observe || (function(O, A, root) { values[i] = object[props[i++]]; }, + /** + * Performs basic property value change checks on an observed object + * @function performPropertyChecks + * @param {Object} object + * @param {ObjectData} data + * @param {String} [except] Doesn't deliver the changes to the + * handlers that accept this type + */ performPropertyChecks = function(object, data, except) { - if (!data.handlers.length) return; + if (!data.handlers.size || data.frozen) return; var props, proplen, keys, values = data.values, @@ -125,7 +286,7 @@ Object.observe || (function(O, A, root) { key = keys[i++]; idx = inArray(key, props); if (idx === -1) { - addChangeRecord(object, data, { + addChangeRecord(data, { name: key, type: "add", object: object @@ -136,7 +297,7 @@ Object.observe || (function(O, A, root) { props[idx] = null; proplen--; if (!isAccessor(object, key) && !areSame(object[key], values[idx])) { - addChangeRecord(object, data, { + addChangeRecord(data, { name: key, type: "update", object: object, @@ -150,7 +311,7 @@ Object.observe || (function(O, A, root) { // Checks if some property has been deleted for (i = props.length; proplen && i--;) if (props[i] !== null) { - addChangeRecord(object, data, { + addChangeRecord(data, { name: props[i], type: "delete", object: object, @@ -163,7 +324,7 @@ Object.observe || (function(O, A, root) { if (O.isExtensible && !O.isExtensible(object)) { data.extensible = false; - addChangeRecord(object, data, { + addChangeRecord(data, { type: "preventExtensions", object: object }, except); @@ -179,7 +340,7 @@ Object.observe || (function(O, A, root) { while (i < props.length) { key = props[i++]; if (!areSame(object[key], values[i])) { - addChangeRecord(object, data, { + addChangeRecord(data, { name: key, type: "update", object: object, @@ -195,47 +356,63 @@ Object.observe || (function(O, A, root) { }, - broadcastChangeRecords = function(object, data) { - var handlers = data.handlers, - i = 0, j, idx, - cbdata, changeRecords; - - for (; i < handlers.length; i++) { - cbdata = callbackData[inArray(handlers[i], callbacks)]; - idx = inArray(object, cbdata.objects); - changeRecords = cbdata.changeBundles[idx]; - - if (changeRecords.length) { - handlers[i](changeRecords); - cbdata.changeBundles[idx] = []; + /** + * Calls the handlers with their relative change records + * @function broadcastChangeRecords + * @param {ObjectData} data + */ + broadcastChangeRecords = function(data) { + data.handlers.forEach(function(hdata, handler) { + if (hdata.changeRecords.length) { + handler(hdata.changeRecords); + hdata.changeRecords = []; } - } + }); }, + /** + * Returns the notifier for an object - whether it's observed or not + * @param {Object} object + * @param {ObjectData} [data] + * @returns {Notifier} + */ retrieveNotifier = function(object, data) { if (!data) - data = objectData[inArray(object, observed)]; + data = observed.get(object); var notifier = data && data.notifier; if (notifier) return notifier; + /** @type {Notifier} */ notifier = { - // https://github.com/arv/ecmascript-object-observe/blob/master/NewInternalsSpecification.md#notifierprototypenotifychangerecord + /** + * @method notify + * @see https://github.com/arv/ecmascript-object-observe/blob/master/NewInternalsSpecification.md#notifierprototypenotifychangerecord + * @memberof Notifier + * @param {ChangeRecord} changeRecord + */ notify: function(changeRecord) { changeRecord.type; // Just to check the property is there... // If there's no data, the object has been unobserved - var data = objectData[inArray(object, observed)]; + var data = observed.get(object); if (data) { var recordCopy = { object: object }, prop; for (prop in changeRecord) if (prop !== "object") recordCopy[prop] = changeRecord[prop]; - addChangeRecord(object, data, recordCopy); + addChangeRecord(data, recordCopy); } }, - // https://github.com/arv/ecmascript-object-observe/blob/master/NewInternalsSpecification.md#notifierprototypeperformchangechangetype-changefn + /** + * @method performChange + * @see https://github.com/arv/ecmascript-object-observe/blob/master/NewInternalsSpecification.md#notifierprototypeperformchangechangetype-changefn + * @memberof Notifier + * @param {String} changeType + * @param {Performer} func The task performer + * @param {*} [thisObj] Used to set `this` when calling func + */ performChange: function(changeType, func/*, thisObj*/) { if (typeof changeType !== "string") throw new TypeError("Invalid non-string changeType"); @@ -243,7 +420,8 @@ Object.observe || (function(O, A, root) { if (typeof func !== "function") throw new TypeError("Cannot perform non-function"); - var data = objectData[inArray(object, observed)], + // If there's no data, the object has been unobserved + var data = observed.get(object), prop, changeRecord, result = func.call(arguments[2]); @@ -255,7 +433,7 @@ Object.observe || (function(O, A, root) { for (prop in result) if (prop !== "object" && prop !== "type") changeRecord[prop] = result[prop]; - addChangeRecord(object, data, changeRecord); + addChangeRecord(data, changeRecord); } } }; @@ -264,48 +442,67 @@ Object.observe || (function(O, A, root) { return notifier; }, - addCallback = function(callback, acceptList, object) { - var idx = inArray(callback, callbacks), - oidx, data; - - if (idx === -1) { - callbacks.push(callback); - callbackData.push({ - objects: [ object ], - acceptLists: [ acceptList ], - changeBundles: [ [] ] + /** + * Register (or redefines) an handler in the collection for a given + * object and a given type accept list. + * @function setHandler + * @param {Handler} handler + * @param {Object} object + * @param {String[]} acceptList + * @returns {HandlerData} + */ + setHandler = function(handler, object, acceptList) { + var data = handlers.get(handler), hdata; + + if (data) { + hdata = data.get(object); + if (hdata) hdata.acceptList = acceptList; + else data.set(object, hdata = { + acceptList: acceptList, + changeRecords: [] }); } else { - data = callbackData[idx]; - oidx = inArray(object, data.objects); - if (oidx === -1) { - data.objects.push(object); - data.acceptLists.push(acceptList); - data.changeBundles.push([]); - } else data.acceptLists[oidx] = acceptList; + data = createMap(); + data.set(object, hdata = { + acceptList: acceptList, + changeRecords: [] + }); + handlers.set(handler, data); } - }, - - addChangeRecord = function(object, data, changeRecord, except) { - var handlers = data.handlers, - i = 0, cbdata, idx; - - while (i < handlers.length) - if (cbdata = callbackData[inArray(handlers[i++], callbacks)]) { - idx = inArray(object, cbdata.objects); - // If except is defined, a Notifier.performChange has been - // performed, with except as the type. - // All the handlers that accepts that type are skipped. - if (except != null && inArray(except, cbdata.acceptLists[idx]) > -1) - continue; + return hdata; + }, - if (inArray(changeRecord.type, cbdata.acceptLists[idx]) > -1) - cbdata.changeBundles[idx].push(changeRecord); - } + /** + * Adds a change record in a given ObjectData + * @function addChangeRecord + * @param {ObjectData} data + * @param {ChangeRecord} changeRecord + * @param {String} [except] + */ + addChangeRecord = function(data, changeRecord, except) { + data.handlers.forEach(function(hdata) { + // If except is defined, Notifier.performChange has been + // called, with except as the type. + // All the handlers that accepts that type are skipped. + if ((except == null || inArray(except, hdata.acceptList) === -1) + && inArray(changeRecord.type, hdata.acceptList) > -1) + hdata.changeRecords.push(changeRecord); + }); }; - // https://github.com/arv/ecmascript-object-observe/blob/master/PublicApiSpecification.md#objectobserveo-callback-accept--undefined + observed = createMap(); + handlers = createMap(); + + /** + * @function Object.observe + * @see https://github.com/arv/ecmascript-object-observe/blob/master/PublicApiSpecification.md#objectobserveo-callback-accept--undefined + * @param {Object} object + * @param {Handler} handler + * @param {String[]} [acceptList] + * @throws {TypeError} + * @returns {Object} The observed object + */ O.observe = function observe(object, handler, acceptList) { if (object === null || typeof object !== "object") throw new TypeError("Object.observe cannot observe non-object"); @@ -317,9 +514,18 @@ Object.observe || (function(O, A, root) { throw new TypeError("Object.observe cannot deliver to a frozen function object"); doObserve(object, handler, acceptList); + + return object; }; - // https://github.com/arv/ecmascript-object-observe/blob/master/PublicApiSpecification.md#objectunobserveo-callback + /** + * @function Object.unobserve + * @see https://github.com/arv/ecmascript-object-observe/blob/master/PublicApiSpecification.md#objectunobserveo-callback + * @param {Object} object + * @param {Handler} handler + * @throws {TypeError} + * @returns {Object} The given object + */ O.unobserve = function unobserve(object, handler) { if (object === null || typeof object !== "object") throw new TypeError("Object.unobserve cannot unobserve non-object"); @@ -327,31 +533,37 @@ Object.observe || (function(O, A, root) { if (typeof handler !== "function") throw new TypeError("Object.unobserve cannot deliver to non-function"); - var oidx = inArray(object, observed), hidx, idx, - handlers, i, cbdata; - - if (oidx > -1) { - handlers = objectData[oidx].handlers; - for (i = 0; i < handlers.length; i++) { - hidx = inArray(handlers[i], callbacks); - cbdata = callbackData[hidx]; - - if (cbdata.objects.length === 1 && cbdata.objects[0] === object) { - callbacks.splice(hidx, 1); - callbackData.splice(hidx, 1); - } else { - idx = inArray(object, cbdata.objects); - cbdata.object.splice(idx, 1); - cbdata.acceptLists.splice(idx, 1); - cbdata.changeBundles.splice(idx, 1); - } - } - observed.splice(oidx, 1); - objectData.splice(oidx, 1); + var data = observed.get(object), cbdata; + + if (data && data.handlers.has(handler)) { + performPropertyChecks(object, data); + setTimeout(function() { + broadcastChangeRecords(data); + }, 0); + + cbdata = handlers.get(handler); + + // In Firefox 13-18, size is a function, but createMap should fall + // back to the shim for those versions + if (cbdata.size === 1 && cbdata.get(object)) + handlers["delete"](handler); + else cbdata["delete"](object); + + if (data.handlers.size === 1) + observed["delete"](object); + else data.handlers["delete"](handler); } + + return object; }; - // https://github.com/arv/ecmascript-object-observe/blob/master/PublicApiSpecification.md#objectgetnotifier + /** + * @function Object.getNotifier + * @see https://github.com/arv/ecmascript-object-observe/blob/master/PublicApiSpecification.md#objectgetnotifier + * @param {Object} object + * @throws {TypeError} + * @returns {Notifier} + */ O.getNotifier = function getNotifier(object) { if (object === null || typeof object !== "object") throw new TypeError("Object.getNotifier cannot getNotifier non-object"); @@ -361,27 +573,25 @@ Object.observe || (function(O, A, root) { return retrieveNotifier(object); }; - // https://github.com/arv/ecmascript-object-observe/blob/master/PublicApiSpecification.md#objectdeliverchangerecords - // https://github.com/arv/ecmascript-object-observe/blob/master/NewInternalsSpecification.md#deliverchangerecordsc + /** + * @function Object.deliverChangeRecords + * @see https://github.com/arv/ecmascript-object-observe/blob/master/PublicApiSpecification.md#objectdeliverchangerecords + * @see https://github.com/arv/ecmascript-object-observe/blob/master/NewInternalsSpecification.md#deliverchangerecordsc + * @param {Handler} handler + * @throws {TypeError} + */ O.deliverChangeRecords = function deliveryChangeRecords(handler) { if (typeof handler !== "function") throw new TypeError("Object.deliverChangeRecords cannot deliver to non-function"); - - var idx = inArray(handler, callbacks), oidx, - bundles, cbdata, object, i = 0; - if (idx > -1) { - cbdata = callbackData[idx]; - bundles = cbdata.changeBundles; - for (i = 0; i < bundles.length; i++) { - object = cbdata.objects[i]; - oidx = inArray(object, observed); - performPropertyChecks(object, objectData[oidx]); - if (bundles[i].length) { - handler(bundles[i]); - cbdata.changeBundles[i] = []; - } + + var cbdata = handlers.get(handler); + cbdata && cbdata.forEach(function(hdata, object) { + performPropertyChecks(object, observed.get(object)); + if (hdata.changeRecords.length) { + handler(hdata.changeRecords); + hdata.changeRecords = []; } - } + }); }; })(Object, Array, this); \ No newline at end of file diff --git a/object-observe.min.js b/object-observe.min.js new file mode 100644 index 0000000..6ef0ee3 --- /dev/null +++ b/object-observe.min.js @@ -0,0 +1,10 @@ +Object.observe||function(e,u,t){var B=t.Node?function(a){return a&&a instanceof t.Node}:function(a){return a&&"object"===typeof a&&"number"===typeof a.nodeType&&"string"===typeof a.nodeName},v=u.isArray||function(a){return function(b){return"[object Array]"===a.call(b)}}(e.prototype.toString),C=e.freeze?function(a,b){var c=e.getOwnPropertyDescriptor(a,b);return c?"get"in c||"set"in c:!1}:function(){return!1},n=u.prototype.indexOf?function(a,b,c){return b.indexOf(a,c)}:function(a,b,c){for(c=c||0;c< +b.length;c++)if(b[c]===a)return c;return-1},q="undefined"!==typeof t.Map&&Map.prototype.forEach?function(){return new Map}:function(){var a=[],b=[];return{size:0,has:function(b){return-1