Two way to detect data change on JavaScript

Johann Cynic
Jul 30, 2017 · 7 min read

If you like data-driven programming, you will need to detect change of data. There are multiple ways to that. In this article, I will introduce two of them.

The first way come to my mind is Proxy. If you know little about Proxy. You may better read this on MDN at first.

Use proxy on plain object

Most of our data is stored in an object.It’s easy to obeserve a plain object. We assume the data change is to set or delete the data. So we add traps on set and deleteProperty.

const obj = {};
const fn = () => console.log('we capture a change');
const proxy = new Proxy(obj, {
set (target, property, value) {
target[property] = value;
fn();
return true;
},
deleteProperty (target, property) {
delete target[property];
fn();
return true;
}
});
proxy.a = 2; // we capture a change
console.log(obj); // {a: 2}

Now you can change obj through proxy, which will do the same thing and tell you the change. If you need to observe other changes, you can read handlers on MDN.

However, our data is not always stored in plain object, so we must support observation on multi-level object.

Use proxy on multi-layer object

We can implement this through recursion easily.

In the example above, we set trap on set and deleteProperty. Now we set one more trap on get. When we find one of our property contain an object, we replace it with its proxy.

We also need to remember whether the property has been proxied. Otherwise we may cause unnecessary triggering. So I create an empty object mapStore.

  1. If mapStore[property] === undefined, it means this property has not been proxied.
  2. if mapStore[property] === true, it means this property has been proxied.
  3. For the rest, the property has not been proxied. But the mapStore has its proxy value.
function isObject (obj) {
return typeof obj === 'object';
}
function deepProxy (obj, hook) {
const mapStore = {};
return new Proxy(obj, {
get (target, property) {
const value = target[property];
// if this property has been proxied, just return
if(mapStore[property] === true) return value;
// if it's an non-proxied object, we return its proxy
if(isObject(value)) {
const proxyValue = mapStore[property] || deepProxy(value, hook);
mapStore[property] = proxyValue;
return proxyValue;
}
// else we just take a mark
mapStore[property] = true;
return value;
},
set (target, property, value) {
const newVal = isObject(value)
? deepProxy(value, hook)
: value;
target[property] = newVal;
mapStore[property] = true;
hook();
return true;
},
deleteProperty (target, propertty) {
delete target[property];
delete mapStore[property];
hook();
return true;
}
});
}
const obj = {
foo: {
a: 1
}
};
const fn = () => console.log('we capture a change');
const proxy = deepProxy(obj, fn);
proxy.foo.a = 2; // we capture a change.

Well done. Now we can detect change in object.

So, how about array?

Use proxy on array

It seems just a piece of cake. How about we just add isArray on handler?

function isArray (arr) {
return Array.isArray(arr);
}
...
get (target, property) {
const value = target[property];
// if this property has been proxied, just return
if(mapStore[property] === true) return value;
// if it's an non-proxied object, we return its proxy
if(isObject(value) || isArray(value)) {
const proxyValue = mapStore[property] || deepProxy(value, hook);
mapStore[property] = proxyValue;
return proxyValue;
}
// else we just take a mark
mapStore[property] = true;
return value;
},
set (target, property, value) {
const newVal = (isObject(value) || isArray(value))
? deepProxy(value, hook)
: value;
target[property] = newVal;
mapStore[property] = true;
hook();
return true;
},
...

Let’s do a test.

const obj = {
foo: [1, 2, 3]
};
const fn = () => console.log('we caputre a change');
const proxy = deepProxy(obj, fn);
proxy.foo[1] = 4; // we capture a change
proxy.foo.unshift(2);
// we capture a change
// we capture a change
// we capture a change
// we capture a change

What happened?? It captures four changes!!

It’s ok. Just calm down. Think about it carefully. What has unshfit done?

Assume that original array is like the table below.

0–1 , 1–4, 2–3

When you execute unshift , it move like this.

  1. Change the data on the first position
  2. Move the original first data to the second position
  3. Move the original second data to the third position
  4. Move the original third data to the new forth position

So, we actually get 4 changes! That’s right.

But in some situation, we consider them only one change. We need to do some change here.

We add a flag called arrayChanging for the Array's method. When we are executing the method, we don't trigger any change. We only trigger when we finish executing the method.

And of course, we will override the method.

const arrayChangeMethod = ['push', 'pop', 'unshift', 'shift', 'splice', 'sort', 'reverse'];
function isObject (obj) {
return typeof obj === 'object';
}
function isArray (arr) {
return Array.isArray(arr);
}
function deepProxy (obj, hook) {
const mapStore = {};
let arrayChanging = false;
return new Proxy(obj, {
get (target, property, receiver) {
const value = target[property];
if(isArray(target) && arrayChangeMethod.indexOf(property) > -1) {
// we override the array's method
return (...args) => {
arrayChanging = true;
value.bind(receiver)(...args);
arrayChanging = false;
hook();
};
}
if(mapStore[property] === true) return value;
if(isObject(value) || isArray(value)) {
const proxyValue = mapStore[property] || deepProxy(value, hook);
mapStore[property] = proxyValue;
return proxyValue;
}
mapStore[property] = true;
return value;
},
set (target, property, value) {
const newVal = (isObject(value) || isArray(value))
? deepProxy(value, hook)
: value;
target[property] = newVal;
mapStore[property] = true;
if(!arrayChanging) hook();
return true;
},
deleteProperty (target, propertty) {
delete target[property];
delete mapStore[property];
if(!arrayChanging) hook();
return true;
}
});
}

Let’s do the test again!

const obj = {
foo: [1, 2, 3]
};
const fn = () => console.log('we caputre a change');
const proxy = deepProxy(obj, fn);
proxy.foo[1] = 4; // we capture a change
proxy.foo.unshift(2); // we capture a change

It works~

Unless you are writing nodejs or your manager agree that your page only support new browser, you have to take care about compatibility.

Let’s take a look at caniuse.

Well, you may find that the browser you need to support do not have this wonderful function. So do we have any polypill ? Unfortunately, Proxy can't be polyfilled.

We need a fallback strategy here.

Fallback to Object.defineProperty

Object.defineProperty offer us authority to add getter setter on a property. Through this, we can observe the data change. If you know little about Object.defineProperty, you can read this.

It’s easy to build deepObserve by Object.defineProperty. Through this, we can detect any changes on the exist property.

const {getOwnPropertyNames, getOwnPropertySymbols, defineProperty, getOwnPropertyDescriptor} = Object;
function isObject (obj) {
return typeof obj === 'object';
}
function isArray (arr) {
return Array.isArray(arr);
}
function isFunction (fn) {
return typeof fn === 'function';
}
const getOwnKeys = isFunction(getOwnPropertySymbols)
? function (obj) {
return getOwnPropertyNames(obj).concat(getOwnPropertySymbols(obj));
}
: getOwnPropertyNames;
function deepObserve (obj, hook) {
const mapStore = {};
if(isObject(obj) || isArray(obj)) {
getOwnKeys(obj).forEach(key => {
let value = obj[key];
const desc = getOwnPropertyDescriptor(obj, key);
if(desc && desc.configurable === false) return;
defineProperty(obj, key, {
get () {
if(mapStore[key]) return value;
if(isObject(value) || isArray(value)) {
deepObserve(value, hook);
}
mapStore[key] = true;
return value;
},
set (val) {
if(isObject(val) || isArray(val)) deepObserve(val, hook);
mapStore[key] = true;
hook();
return val;
},
enumerable: desc.enumerable,
configurable: true
})
});
}
return obj;
}
const fn = () => console.log('we capture a change');
const obj = deepObserve({a: 1, b: [1, 2, 3]}, fn);
obj.a = 2; // we capture a change
obj.b[2] = 4; // we caputure a change

Override the array’s method

If you try to use some array’s method like push, pop etc. It may behave weird. Sometimes it capture change, but sometimes not. That's because we only observer some of the property.

So we need to override the array’s method.

const arrayChangeMethod = ['push', 'pop', 'unshift', 'shift', 'splice', 'sort', 'reverse'];
const {getOwnPropertyNames, getOwnPropertySymbols, defineProperty, getOwnPropertyDescriptor} = Object;
function isObject (obj) {
return typeof obj === 'object';
}
function isArray (arr) {
return Array.isArray(arr);
}
function isFunction (fn) {
return typeof fn === 'function';
}
const getOwnKeys = isFunction(getOwnPropertySymbols)
? function (obj) {
return getOwnPropertyNames(obj).concat(getOwnPropertySymbols(obj));
}
: getOwnPropertyNames;
function deepObserve (obj, hook) {
const mapStore = {};
let arrayChanging = false;
function wrapProperty (key) {
let value = obj[key];
const desc = getOwnPropertyDescriptor(obj, key);
if(desc && desc.configurable === false) return;
defineProperty(obj, key, {
get () {
if(mapStore[key]) return value;
if(isObject(value) || isArray(value)) {
deepObserve(value, hook);
}
mapStore[key] = true;
return value;
},
set (val) {
if(isObject(val) || isArray(val)) deepObserve(val, hook);
mapStore[key] = true;
if(!arrayChanging) hook();
return val;
},
enumerable: desc.enumerable,
configurable: true
});
}
if(isObject(obj) || isArray(obj)) {
getOwnKeys(obj).forEach(key => wrapProperty(key));
}
if(isArray(obj)) {
arrayChangeMethod.forEach(key => {
const originFn = obj[key];
defineProperty(obj, key, {
value (...args) {
const originLength = obj.length;
arrayChanging = true;
originFn.bind(obj)(...args);
arrayChanging = false;
if(obj.length > originLength) {
const keys = new Array(obj.length - originLength)
.fill(1)
.map((value, index) => (index + originLength).toString());
keys.forEach(key => wrapProperty(key));
}
hook();
},
enumerable: false,
configurable: true,
writable: true
})
})
}
return obj;
}
const fn = () => console.log('we capture a change');
const obj = deepObserve({a: 1, b: [1, 2, 3]}, fn);
obj.b.push(4); // we caputure a change

Now we support array’s method too. But we can’t detect the change when you delete or add a new property.

Well, that’s one of the reasons that why we can’t polyfill the proxy.

So, if you want to detect change on delete or property adding. You should excute your own function which will trigger the hook too. I skip it here~

Can we use watch as decorator?

Well, of course we can. I have do this in toxic-decorators. You can have a try.

If you only build one instance , you can try this.

import {watch} from 'toxic-decorators';
class Foo {
@watch(() => console.log('we capture a change'))
bar = {
a: 1
}
}
const foo = new Foo();
foo.bar.a = 2; // we capture a change.

Welcome to a place where words matter. On Medium, smart voices and original ideas take center stage - with no ads in sight. Watch
Follow all the topics you care about, and we’ll deliver the best stories for you to your homepage and inbox. Explore
Get unlimited access to the best stories on Medium — and support writers while you’re at it. Just $5/month. Upgrade