Skip to content

Instantly share code, notes, and snippets.

@wmcmurray
Last active February 20, 2019 05:05
Show Gist options
  • Save wmcmurray/87c3b45ccd38e290ab4519788f82fb50 to your computer and use it in GitHub Desktop.
Save wmcmurray/87c3b45ccd38e290ab4519788f82fb50 to your computer and use it in GitHub Desktop.
Watch changes on objects using Proxy and Reflect (with an API very similar to VueJS watch mechanism)
import _get from 'lodash/get'
import _set from 'lodash/set'
import _cloneDeep from 'lodash/cloneDeep'
import _isArray from 'lodash/isArray'
/**
* A system to watch for changes on an object (with an API very simillar to VueJS watch mechanism)
* @param {Object} obj The object to watch changes on
* @return {Object} Return the proxied object, which you can modify like a normal object
* @TODO never tested with an array instead of object, should it work ?
* @TODO never tested the unwatch
* @TODO implement some sanitization of data observed
* @TODO do some performance benchmarks
*/
const Observable = function(obj) {
var cbs = {};
const watch = function(path, cb, options) {
// reorder arguments in case path was not defined
if(typeof path === 'function' && arguments.length <= 2){
options = cb;
cb = path;
path = '';
}
if(typeof cb === 'function'){
if(typeof options === 'object'){
if(typeof options.immediate === 'boolean' && options.immediate){
var cObj = _cloneDeep(obj);
if(path){
cb(_get(cObj, path), undefined, path);
} else {
cb(cObj, undefined, path);
}
}
if(typeof options.deep === 'boolean' && options.deep){
cb._watchMeDeepPlease = true;
}
}
if(typeof cbs[path] === 'undefined'){
cbs[path] = [];
}
cbs[path].push(cb);
}
}
const unwatch = function(path, cb) {
if(typeof cbs[path] !== 'undefined'){
var index = cbs[path].indexOf(cb);
if(index !== -1) {
cbs[path].splice(index, 1);
}
}
}
const onChange = function(path, nv, ov) {
var parts = path.split('.');
var total = parts.length;
var nObj = _cloneDeep(obj);
var oObj = _set(_cloneDeep(obj), path, ov);
var loopPath, ccbs, ccb;
// "bubble up" the path to trigger watchers from bottom > up
for(var i = 0; i <= total; i++){
loopPath = parts.join('.');
if(typeof cbs[loopPath] !== 'undefined'){
ccbs = cbs[loopPath];
for(var j in ccbs){
ccb = ccbs[j];
if(i === 0){
// where change originated
ccb(nv, ov, loopPath);
} else if(typeof ccb._watchMeDeepPlease === 'boolean' && ccb._watchMeDeepPlease) {
if(i !== total) {
// changes in between
ccb(_get(nObj, loopPath), _get(oObj, loopPath), loopPath);
} else {
// the last item in hierarchy (the object itself)
ccb(nObj, oObj, loopPath);
}
}
}
}
parts.pop();
}
}
const makeHandler = function(basepath){
const getPath = function(property) {
return !basepath ? property : [basepath, property].join('.');
}
return {
get(target, property, receiver) {
if(property === 'watch' && typeof target['watch'] === 'undefined'){
return watch;
}
if(property === 'unwatch' && typeof target['unwatch'] === 'undefined'){
return unwatch;
}
if(_isArray(target) && property === 'splice'){
return function (...args) {
var ov = target;
target[property].apply(target, args);
var nv = target;
onChange(getPath(property), nv, ov);
}
}
if((typeof target[property] === 'object' || typeof target[property] === 'function') && target[property] !== null) {
return new Proxy(target[property], makeHandler( getPath(property) ));
}
return Reflect.get(target, property, receiver);
},
defineProperty(target, property, descriptor) {
var path = getPath(property);
var ov = target[property];
var nv = descriptor.value;
Reflect.defineProperty(target, property, descriptor);
onChange(path, nv, ov);
return target;
},
deleteProperty(target, property) {
var path = getPath(property);
var ov = target[property];
var nv = undefined;
Reflect.deleteProperty(target, property);
onChange(path, nv, ov);
return target;
},
}
};
return new Proxy(obj, makeHandler(''));
};
export default Observable
// some tests / examples
var test = Observable({
a: 1,
b: {
c: 2,
d: {
e: 3
}
}
});
test.watch((nv, ov) => {
console.log('all-changed', nv, ov);
});
test.watch((nv, ov) => {
console.log('all-changed-deep', nv, ov);
}, {deep: true});
test.watch((nv, ov) => {
console.log('all-changed-deep-immediate', nv, ov);
}, {deep: true, immediate: true});
test.watch('a', (nv, ov) => {
console.log('a-changed', nv, ov);
});
test.watch('b', (nv, ov) => {
console.log('b-changed', nv, ov);
});
test.watch('b.c', (nv, ov) => {
console.log('b.c-changed', nv, ov);
});
test.watch('b.d', (nv, ov) => {
console.log('b.d-changed', nv, ov);
});
test.watch('b.d.e', (nv, ov) => {
console.log('b.d.e-changed', nv, ov);
});
setTimeout(() => {
test.a = 5;
test.b.c = 20;
test.b.d.e = 50;
}, 400);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment