Skip to content

Instantly share code, notes, and snippets.

@Fordi
Last active August 29, 2015 14:17
Show Gist options
  • Save Fordi/87fc414e4dd16500c3cb to your computer and use it in GitHub Desktop.
Save Fordi/87fc414e4dd16500c3cb to your computer and use it in GitHub Desktop.
Object.watch and Object.getMetadata for all browsers

Object.watch for all major browsers

Object#watch()

The watch() method watches for a property to be assigned a value and runs a function when that occurs.

Syntax

obj.watch(propertyName, handler)

Parameters

String propertyName

The name of a property of the object on which you wish to monitor changes.

Function handler

signature: function (propertyName, oldValue, newValue)

A function to call when the specified property's value changes.

Description

Watches for assignment to a property named prop in this object, calling handler(prop, oldval, newval) whenever prop is set and storing the return value in that property.

To remove a property watcher, use the unwatch() method. By default, the watch method is inherited by every object descended from Object.

Notes for Firefox:

In Firefox, for the sake of performance, I skipped any polyfilling, and used the native method. This was, of course, a mistake of exuberance. The native method does have slightly different behavior from my implementation:

  • Firefox's Object.watch is only called from assignments in script, not from native code. I do not yet know if this is the case for this implementation. I'd prefer it not be the case.
  • Firefox's watch only accepts one watcher; calling it again with another handler overrides the previous one. I intend to fix this soon.
  • Firefox's implementation provides means to filter and modify the new value by returning anything other than undefined. I intend to implement this in the polyfills (it should be easy enough), but for now this doesn't work.

Architectural discussion

My goals here were simple: I wanted a way to observe specific property changes on an object when the change was caused by the assignment operator. This required the presence of __defineSetter__ or Object.defineProperty. Since there are no modern browsers that support the former and not the latter, I opted not to make a __defineSetter__ version.

There is an existing polyfill that uses Object.defineProperty to implement observers, but it breaks properties with extant setters, and doesn't do good detection of read-only properties. I couldn't work out a consistent way to read the property descriptor from a property without walking up the inheritance tree (shaky subject), so I opted instead to overload defineProperty, and make intelligent changes there.

Additionally, Chrome since version 36 supports the ES7 Object.observe - this is a far more powerful (but significantly more awkward) property watcher. Implementing Object.watch using it was almost trivial.

In order to store the listeners for property changes, I needed a way to store metadata on an object, without significantly altering the object. A number of frameworks do something like this, and I followed their lead: define a non-enumerable ID on a high-entropy property name that's generated at load-time, and keep a table of metadata for these IDs. I would like, ultimately, to be able to GC this table, but until I know how to gain insight into when the observed object is collected, I can't manage this.

Additionally, for slightly simpler code, I created a polyfill for Object.assign. The native version of this method operates more quickly than a for/in loop, so using a polyfill seemed like the right way to go.

Object.getMetadata(object)

Obtains or creates a unique metadata object for the referenced object.

Syntax

var metadata = Object.getMetadata(object);

Parameters

Object object

The object for which to get metadata

Description

There's often a need to have side-data for an object, so that you can manage various things associated with that object. This enables that.

Object.getGUID(object)

Obtains or creates a unique ID for the referenced object.

Syntax

var guid = Object.getGUID(object);

Parameters

Object object

The object for which to get an ID

Description

Fetches or creates a unique ID for the referenced object. This is useful for managing things in hashes and arrays against things that aren't strings or numbers.

/***
* Object.watch / Object.unwatch for all browsers
* Plus bonus: Object.assign / Object.getMetadata / Object.getGUID
* @author Bryan Elliott
* @copyright 2015 GPLv2
**/
(function () {
"use strict";
var meta = {},
guid = 1,
guidKey = 'GUID_' + Math.floor(Math.random() * (Number.MAX_SAFE_INTEGER || 9007199254740991)),
nextGuid = function () {
return guid++;
},
defineProperty = Object.defineProperty,
defineProperties = Object.defineProperties,
genericObserver = function (changes) {
var i, id, fire, meta, change;
for (i = 0; i < changes.length; i += 1) {
change = changes[i];
meta = Object.getMetadata(change.object);
if (!meta.listeners) { return; }
fire = meta.listeners[change.name];
if (!fire) { return; }
for (id in fire) {
fire[id].call(change.object, change.name, change.oldValue, change.object[change.name]);
}
}
},
watchList = [ 'add', 'update', 'delete' ];
if (!Object.assign) {
defineProperty(Object, 'assign', {
configurable: true,
writable: false,
enumerable: false,
value: function (obj) {
var i, props, name;
for (i = 1; i < arguments.length; i += 1) {
props = arguments[i];
for (name in props) {
if (props.hasOwnProperty(name)) {
obj[name] = props[name];
}
}
}
return obj;
}
});
}
/**
* Returns a unique identifier for an object
**/
if (!Object.getGUID) {
defineProperty(Object, 'getGUID', {
configurable: true,
writable: false,
enumerable: false,
value: function (obj) {
if (obj === Object.prototype) {
return "(Object.prototype)";
}
if (typeof obj === 'string' || typeof obj === 'number' || typeof obj === 'boolean') {
return '(' + obj + ')';
}
if (!obj[guidKey]) {
defineProperty(this, guidKey, {
enumerable: false,
writeable: false,
configurable: true,
value: guid
});
}
return obj[guidKey];
}
});
}
/**
* Returns a unique Object for any object
**/
if (!Object.getMetadata) {
defineProperty(Object, 'getMetadata', {
configurable: true,
writable: false,
enumerable: false,
value: function (obj) {
var guid = Object.getGUID(obj);
if (!meta[guid]) {
meta[guid] = {};
}
return meta[guid];
}
});
}
// For Firefox, there's no need to add Object.watch
// Chrome 36+, ES7
// Object.watch is a simpler interface than Object.observe; sorry ECMAScript peeps.
if (Object.observe) {
if (!Object.prototype.watch) {
Object.defineProperty(Object.prototype, 'watch', {
configurable: true,
writable: false,
enumerable: false,
value: function (prop, handler) {
var meta = Object.getMetadata(this);
if (!meta.listeners) {
meta.listeners = {};
Object.observe(this, genericObserver, watchList);
}
if (!meta.listeners[prop]) {
meta.listeners[prop] = {};
}
meta.listeners[prop][Object.getGUID(handler)] = handler;
return this;
}
});
}
if (!Object.prototype.unwatch) {
Object.defineProperty(Object.prototype, 'unwatch', {
configurable: true,
writable: false,
enumerable: false,
value: function (prop) {
var meta = Object.getMetadata(this);
if (!meta.listeners) {
return;
}
delete meta.listeners[prop];
if (Object.keys(meta.listeners.length) === 0) {
delete meta.listeners;
Object.unobserve(this, genericObserver, watchList);
}
}
});
}
} else {
/**
* IE9+; Overwrites Object.defineProperty to catch property changes while safely delegating
* setters.
**/
if (!Object.prototype.watch || Object.prototype.unwatch) {
defineProperty(Object, 'defineProperty', {
enumerable: false,
configurable: true,
writable: false,
value: function (obj, name, desc) {
var newDesc,
value,
meta = Object.getMetadata(obj);
if (desc.set) {
newDesc = Object.assign({}, desc);
value = desc.get ? desc.get.call(obj) : undefined;
newDesc.set = function (newValue) {
var id, fire,
oldValue = value,
ret = desc.set.call(obj, newValue);
if (meta.listeners && meta.listeners[name]) {
fire = meta.listeners[name];
value = desc.get.call(obj);
if (value !== oldValue) {
for (id in fire) {
fire[id].call(obj, name, oldValue, value);
}
}
}
return ret;
};
} else if (desc.writable) {
newDesc = Object.assign({}, desc);
value = ("value" in desc) ? desc.value : undefined;
newDesc.set = function (newValue) {
console.log('setting: ' + name);
var id, fire,
oldValue = value;
value = newValue;
if (meta.listeners && meta.listeners[name]) {
fire = meta.listeners[name];
if (newValue !== oldValue) {
for (id in fire) {
fire[id].call(obj, name, oldValue, newValue);
}
}
}
return newValue;
};
newDesc.get = function () {
return value;
};
delete newDesc.value;
delete newDesc.writable;
} else {
newDesc = desc;
}
defineProperty(obj, name, newDesc);
}
});
}
if (!Object.prototype.watch) {
defineProperty(Object.prototype, "watch", {
enumerable: false,
configurable: true,
writable: false,
value: function (prop, handler) {
var meta = Object.getMetadata(this);
if (!(meta.descs && meta.descs[prop])) {
// Use the tapped defineProperty if we're not already paying attention
Object.defineProperty(this, prop, {
enumerable: true,
configurable: true,
writable: true,
value: this[prop]
});
}
if (!meta.listeners) {
meta.listeners = {};
}
if (!meta.listeners[prop]) {
meta.listeners[prop] = {};
}
meta.listeners[prop][Object.getGUID(handler)] = handler;
}
});
}
if (!Object.prototype.unwatch) {
defineProperty(Object.prototype, "unwatch", {
enumerable: false,
configurable: true,
writable: false,
value: function (prop) {
var meta = Object.getMetadata(this);
if (meta.listeners) {
if (Object.keys(meta.listeners.length) === 0) {
delete meta.listeners;
}
}
}
});
}
}
}());
//Simple test...
var y = {};
y.watch('x', function (id, old, gnu) { console.log("%s changed from %s to %s", id, old, gnu); });
y.x = 5;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment