diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..dfa2290 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014 Massimo Artizzu (MaxArt2501) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/object-observe.js b/object-observe.js new file mode 100644 index 0000000..4e35b79 --- /dev/null +++ b/object-observe.js @@ -0,0 +1,387 @@ +Object.observe || (function(O, A, root) { + "use strict"; + + var isNode = root.Node ? function(node) { + return node && node instanceof root.Node; + } : function(node) { + // Duck typing + return node && typeof node === "object" + && typeof node.nodeType === "number" + && typeof node.nodeName === "string"; + }, + isArray = A.isArray || (function(toString) { + return function (object) { return toString.call(object) === "[object Array]"; }; + })(O.prototype.toString), + + // 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) { + var desc = O.getOwnPropertyDescriptor(object, prop); + return desc ? "get" in desc || "set" in desc : false; + } : function() { return false; }, + + inArray = A.prototype.indexOf ? function(pivot, array, start) { + return array.indexOf(pivot, start); + } : function(pivot, array, start) { + for (var i = start || 0; i < array.length; i++) + if (array[i] === pivot) + return i; + return -1; + }, + + getKeys = Object.keys || function(obj) { + // Misses checks on obj, don't use as a replacement of Object.keys + var keys = [], prop; + for (prop in obj) + if (obj.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 + 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 = [], + + defaultObjectAccepts = [ "add", "update", "delete", "reconfigure", "setPrototype", "preventExtensions" ], + defaultArrayAccepts = [ "add", "update", "delete", "splice" ], + + doObserve = function(object, handler, acceptList) { + + var idx = inArray(object, observed), + data, cbdata, cbidx; + + if (!isArray(acceptList)) + acceptList = isArray(object) ? defaultArrayAccepts : defaultObjectAccepts; + + if (idx === -1) { + observed.push(object) - 1; + addCallback(handler, acceptList, object); + data = { + handlers: [ handler ], + frozen: O.isFrozen ? O.isFrozen(object) : false, + extensible: O.isExtensible ? O.isExtensible(object) : true + }; + objectData.push(data); + retrieveNotifier(object, data); + + collectProperties(object, data); + setTimeout(function worker() { + // If this happens, the object has been unobserved + if (inArray(object, observed) === -1) return; + + performPropertyChecks(object, data); + broadcastChangeRecords(object, data); + + if (!data.frozen) + 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); + }, + + updateValues = function(object, data) { + var props = data.properties, + values = data.values = [], + i = 0; + while (i < props.length) + values[i] = object[props[i++]]; + }, + + performPropertyChecks = function(object, data, except) { + if (!data.handlers.length) return; + + var props, proplen, keys, + values = data.values, + i = 0, idx, key, handler; + + // If the object isn't extensible, we don't need to check for new + // or deleted properties + if (data.extensible) { + + props = data.properties.slice(); + proplen = props.length; + keys = getKeys(object); + + while (i < keys.length) { + key = keys[i++]; + idx = inArray(key, props); + if (idx === -1) { + addChangeRecord(object, data, { + name: key, + type: "add", + object: object + }, except); + data.properties.push(key); + values.push(object[key]); + } else { + props[idx] = null; + proplen--; + if (!isAccessor(object, key) && !areSame(object[key], values[idx])) { + addChangeRecord(object, data, { + name: key, + type: "update", + object: object, + oldValue: values[idx] + }, except); + values[idx] = object[key]; + } + } + } + + // Checks if some property has been deleted + for (i = props.length; proplen && i--;) + if (props[i] !== null) { + addChangeRecord(object, data, { + name: props[i], + type: "delete", + object: object, + oldValue: values[i] + }, except); + data.properties.splice(i, 1); + values.splice(i, 1); + proplen--; + } + + if (O.isExtensible && !O.isExtensible(object)) { + data.extensible = false; + addChangeRecord(object, data, { + type: "preventExtensions", + object: object + }, except); + + if (!data.frozen) + data.frozen = O.isFrozen(object); + } + + } else if (!data.frozen) { + + // If the object is not extensible, but not frozen, we just have + // to check for value changes + while (i < props.length) { + key = props[i++]; + if (!areSame(object[key], values[i])) { + addChangeRecord(object, data, { + name: key, + type: "update", + object: object, + oldValue: values[i] + }); + values[i] = object[key]; + } + } + + if (O.isFrozen(object)) + data.frozen = true; + } + + }, + + 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] = []; + } + } + }, + + retrieveNotifier = function(object, data) { + if (!data) + data = objectData[inArray(object, observed)]; + var notifier = data && data.notifier; + + if (notifier) return notifier; + + notifier = { + // https://github.com/arv/ecmascript-object-observe/blob/master/NewInternalsSpecification.md#notifierprototypenotifychangerecord + 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)]; + if (data) { + var recordCopy = { object: object }, prop; + for (prop in changeRecord) + if (prop !== "object") + recordCopy[prop] = changeRecord[prop]; + addChangeRecord(object, data, recordCopy); + } + }, + + // https://github.com/arv/ecmascript-object-observe/blob/master/NewInternalsSpecification.md#notifierprototypeperformchangechangetype-changefn + performChange: function(changeType, func/*, thisObj*/) { + if (typeof changeType !== "string") + throw new TypeError("Invalid non-string changeType"); + + if (typeof func !== "function") + throw new TypeError("Cannot perform non-function"); + + var data = objectData[inArray(object, observed)], + prop, changeRecord, + result = func.call(arguments[2]); + + data && performPropertyChecks(object, data, changeType); + + // If there's no data, the object has been unobserved + if (data && result && typeof result === "object") { + changeRecord = { object: object, type: changeType }; + for (prop in result) + if (prop !== "object" && prop !== "type") + changeRecord[prop] = result[prop]; + addChangeRecord(object, data, changeRecord); + } + } + }; + if (data) data.notifier = notifier; + + 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: [ [] ] + }); + } 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; + } + }, + + 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; + + if (inArray(changeRecord.type, cbdata.acceptLists[idx]) > -1) + cbdata.changeBundles[idx].push(changeRecord); + } + }; + + // https://github.com/arv/ecmascript-object-observe/blob/master/PublicApiSpecification.md#objectobserveo-callback-accept--undefined + O.observe = function observe(object, handler, acceptList) { + if (object === null || typeof object !== "object") + throw new TypeError("Object.observe cannot observe non-object"); + + if (typeof handler !== "function") + throw new TypeError("Object.observe cannot deliver to non-function"); + + if (O.isFrozen && O.isFrozen(handler)) + throw new TypeError("Object.observe cannot deliver to a frozen function object"); + + doObserve(object, handler, acceptList); + }; + + // https://github.com/arv/ecmascript-object-observe/blob/master/PublicApiSpecification.md#objectunobserveo-callback + O.unobserve = function unobserve(object, handler) { + if (object === null || typeof object !== "object") + throw new TypeError("Object.unobserve cannot unobserve non-object"); + + 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); + } + }; + + // https://github.com/arv/ecmascript-object-observe/blob/master/PublicApiSpecification.md#objectgetnotifier + O.getNotifier = function getNotifier(object) { + if (object === null || typeof object !== "object") + throw new TypeError("Object.getNotifier cannot getNotifier non-object"); + + if (O.isFrozen && O.isFrozen(object)) return null; + + 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 + 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] = []; + } + } + } + }; + +})(Object, Array, this); \ No newline at end of file diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..ec9cb68 --- /dev/null +++ b/readme.md @@ -0,0 +1,168 @@ +Object.observe polyfill +======================= + +`Object.observe` is one very nice [EcmaScript 7 feature](https://github.com/arv/ecmascript-object-observe) that has landed on Blink-based browsers (Chrome 36+, Opera 23+) in the [first part of 2014](http://www.html5rocks.com/en/tutorials/es7/observe/). Node.js delivers it too in version 0.11.x. + +In short, it's one of the things web developers wish they had 10-15 years ago: it notifies the application of any changes made to an object, like adding, deleting or updating a property, changing its descriptor and so on. It even supports custom events. Sweet! + +The problem is that most browsers still doesn't support `Object.observe`. While technically it's *impossible* to perfectly replicate the feature's behaviour, something useful can be done keeping the same API. + +After giving a look at other polyfills, like [jdarling's](https://github.com/jdarling/Object.observe) and [joelgriffith's](https://github.com/joelgriffith/object-observe-es5), and taking inspiration from them, I decided to write one myself trying to be more adherent to the specifications. + +## Installation + +This polyfill extends the native `Object` and doesn't have any dependencies, so loading it is pretty straightforward: + +```html + +``` + +Or in node.js: + +```js +require("object-observe.js"); +``` + +That's it. If the environment doesn't already support `Object.observe`, the shim is installed and ready to use. + +## Under the hood + +Your intuition may have led you to think that this polyfill is based on polling the properties of the observed object. In other words, "dirty checking". If that's the case, well, you're correct: we have no better tools at the moment. + +Even Gecko's [`Object.prototype.watch`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/watch) is probably not worth the effort. First of all, it just checks for updates to the value of a *single* property (or recreating the property after it's been deleted), which may save some work, but not much really. Furthermore, you can't watch a property with two different handlers, meaning that performing `watch` on the same property *replaces* the previous handler. + +Regarding value changes, changing the property descriptors with `Object.defineProperty` has similar issues. Moreover, it makes everything slower - if not *much* slower - when it comes to accessing the property. It would also prevent future implementations of the `"reconfigure"` event. + +And of course, Internet Explorer's legacy [`propertychange` event](http://msdn.microsoft.com/en-us/library/ms536956%28VS.85%29.aspx) isn't very useful either, as it works only on DOM elements, it's not fired on property deletion, and... well, let's get rid of it already, shall we? + +[Proxies](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy), currently implemented in Gecko-based browsers, is the closest thing we could get to `Object.observe`. And a *very* powerful thing too: it can trap property additions, changes, deletions, possession checks, or object changes in extensibility, prototype, and so on, even *before* they're made! Awesome! Sounds like the perfect tool, huh? + +Too bad proxies are sort of *copies* of the original objects, with the behaviour defined by the script. Changes should be made to the *proxied* object, not the original one. In short, this doesn't trigger the proxy's trap: + +```js +var object = { foo: null }, + proxy = new Proxy(object, { + set: function(object, property, value, proxy) { + console.log("Property '" + property + "' is set to: " + value); + } + }); + +object.foo = "bar"; +// Nothing happens... +``` + +Instead, proxies are meant to *apply* the changes to the original object, after eventual computing made by the traps. This is the correct usage of a proxy: + +```js +var object = { foo: null }, + proxy = new Proxy(object, { + set: function(object, property, value, proxy) { + object[property] = String(value).toUpperCase(); + } + }); + +proxy.foo = "bar"; +console.log(object.foo); // => "BAR" +``` + +So, yeah, dirty checking. `setTimeout(..., 17)`. I know, it sounds lame, but now you know why I had to resolve to this. + +On a side note, it's better not using `setImmediate` (supported by node.js and IE 10+) because it clogs the CPU with continuous computations. Doing a check at 60 fps at best should be enough for most cases. + +## Limitations + +* Because properties are polled, when more than one change is made synchronously to the same property, it won't get caught. This means that this won't notify any event: + + ```js + var object = { foo: null }; + Object.observe(object, function(changes) { + console.log("Changes: ", changes); + }); + + object.foo = "bar"; + object.foo = null; + ``` + + `Object.prototype.watch` could help in this case, but it would be a partial solution. + +* When a property is created used `Object.defineProperty` and set to not enumerable, it's basically invisible to the polyfill: + + ```js + Object.defineProperty(object, "bar", { + value: "hello", enumerable: false, writable: true + }); + // Nothing happens + + object.bar = "hi"; + // Still nothing... + ``` + + Also, if the `enumerable` descriptor property is subsequently set to `true`, it will trigger an `"add"` event. + + There's no way to prevent this limitation. + +* It doesn't work correctly on DOM nodes or other *host* objects. Nodes have a lot of enumerable properties that `Object.observe` should *not* check. + +* Finally, dirty checking can be intensive. Memory occupation can grow to undesirable levels, not to mention CPU load. Pay attention to the number of objects your application needs to observe, and consider whether a polyfill is actually good for you. + +## What's provided + +### `Object.observe` and `Object.unobserve` + +Well, I couldn't call this an `Object.observe` polyfill without these ones. + +It "correctly" (considering the above limitations) supports the `"add"`, `"update"`, `"delete"` and `"preventExtensions"` events. Some work has to be done to support `"reconfigure"` and `"setPrototype"`, but not before some tests and considerations on the performances and memory load that it would involve. + +Type filtering works too when an array is passed as the third argument of `Object.observe`. Handlers don't get called if the change's type is not included in their list. + +### `Object.getNotifier` + +This function allows to create user defined notifications. And yes, it pretty much works, delivering the changes to the handlers that accept the given type. + +Both the `notify` and `performChange` methods are supported. + +### `Object.deliverChangeRecords` + +This method allows to get deliver the notifications currently collected for the given handler *synchronously*. Yep, this is supposed to work too. + +## Browser support + +This polyfill has been tested (and is working) in the following environments: + +* Firefox 35 stable and 37 Developer Edition +* Internet Explorer 11 +* Internet Explorer 5, 7, 8, 9, 10 (as IE11 in emulation mode) +* node.js 0.10.33 + +## To do + +* `Array.observe` and `Array.unobserve` - they're pretty much doable, I just have to figure out the best way to support them; +* consider and eventually deliver support for `reconfigure` and `setPrototype` events, maybe creating a "full" and a "light" version of the polyfill; +* some deeper considerations about whether using `Object.prototype.watch` or not; +* support for DOM nodes; +* consider taking advantage of `Map` whenever possible; +* code tests, documentation, optimization and cleanup. + +## License + +The MIT License (MIT) + +Copyright (c) 2015 Massimo Artizzu (MaxArt2501) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file