I remember one of my interview few years ago. I was asked:
Given an object and how can we track when some property is accessed or updated on that object.
Example that was given by interviewer was as follows:
const obj = {name: 'Rick', planet: 'Earth'};
/**
* Case 1: Reading property:
* Whenever I read any property
* it should print
* GETTING value for <property_name>
*/
obj.name; // GETTING value for name
/**
* Case 2: Setting property:
* Whenever I set any property
* it should print
* SETTING <property_name> = <new_value>
*/
obj.planet = 'Mars'
// SETTING planet = Mars
I was like what!!!
But any how I said to my self:
- Let's pass this object into a function
- Function somehow enhance this object and return it.
So I wrote:
function enhance(obj) {
// do something with obj to enhance
}
Interviewer asked me about my thought process on this? After thinking and remembering a lot π€·πΌββοΈ I said, I know we have to some how intercept the read
and write
operation on the object
, but I am not sure how to do it on the fly. I know when we define properties using Object.defineProperty, we have some control on how that property will behave but the object is already defined so I am not sure. Deep inside I was still not sure π I was still thinking ππ»ββοΈ:
Interviewer dropped me a hint that if I am using Object.defineProperty
then I need to redefine the properties on the object. Then after some hit and trial I came up with this:
function enhance(plainObject) {
const keys = Object.keys(plainObject);
const storedObject = {};
keys.forEach((key) => {
storedObject[key] = plainObject[key];
Object.defineProperty(plainObject, key, {
get: function () {
console.log(`GETTING value for [${key}]`);
return storedObject[key];
},
set: function (value) {
console.log(`SETTING ${key} = ${value}`);
storedObject[key] = value;
}
});
});
return plainObject;
}
let obj = { name: "rick", planet: "earth" };
const enhancedObj = enhance(obj);
console.clear();
console.log(enhancedObj.name);
// GETTING value for [name]
// rick
enhancedObj.planet = "Mars";
// SETTING planet = Mars
π΄ Explanation:
- As we know, we have to redefine the properties again. First thing we need to do is to stored all the existing keys in
keys
variable. - Defined a new object
storedObject
- Then for each key we copied everything form
plainObject
tostoredObject
- Next we defined all the properties on
plainObject
again but this time we usedObject.defineProperty
to defined it. - WE have overwritten the get which is called when we
read
property of an object and set which is called when we set a value to a property of an object. - We put the appropriate log there.
- Also whenever
set
is called we will store that value instoredObject
and return fromstoredObject
whenget
is called.
During this time I was explaining my thought process to interviewer and I did a lot of hit and trial as it was hard for me to remember methods on Object
.
My solution has issues some issues:
- If we add a new property on
enhancedObject
it will not betrack
ed. By the way, interviewer asked me this question as well π. But I could not come up with any solution back then. π
....
let obj = {
name: "rick",
planet: "earth"
};
const enhancedObj = enhance(obj);
// this will not be tracked
enhancedObj.age = 30;
I was completely unaware that there is a better solution in ES6
. After coming home when I researched, I found out a solution which is so much easy to implement as well as to understand.
Before we jump into our solution, let's learn a bit about JavaScript Specification of Object
.
π³ Internal Methods
JavaScript specification describes some lower level internal methods on Object
like [[Get]]
, [[SET]]
, [[VALUE]]
, [[Writable]]
, [[Enumerable]]
and [[Configurable]]
. As per the specifications:
Each object in an
ECMAScript
engine is associated with a set ofinternal methods
that defines itsruntime behaviour
.
Point to note: It defines runtime behaviour
But we cannot directly modify the behaviour of the Object
at runtime using this so called Internal Methods
as we cannot access it directly. As per the specifications:
These internal methods are not part of the ECMAScript language. They are defined by this specification purely for expository purposes.
There are some other internal methods as well. You can checkout full list here
But in ES6
we have a way to tap into these Internal methods
at runtime.
π³ Introducing Proxy
Proxy is a middleman
. It does following:
- It
wraps
another object. - It
intercepts
all the operations related toInternal Methods
. - It can handle these operations by its own or it can
forward these operation to the
wrapped object
.
π΄ Anatomy of Proxy
let proxy = new Proxy(target, handler)
Proxy
is an inbuilt object that take two arguments:
- target: An object that we need to wrap
-
handler: An object that defines various methods corresponding to
Internal Methods
that we need to intercept at run time.
Handler methods are often refer to as traps
because it traps or intercept the Internal method
.
Example
const character = {
name: 'Rick',
planet: 'Earth'
};
const proxy = new Proxy(character, {});
// update name
proxy.name = 'morty';
console.log(character.name) // morty
πΎ GOTCHA πΎ : If you do not define any handler function.
Proxy
will pass the all the operations to wrapped object as if it is not there at all.
π΄ Handler methods
For each of the Internal Method
there is a handler method defined on the Proxy object. Some of them are:
Internal Method | Handler Method | Triggered On |
---|---|---|
[[Get]] | get | When reading a property |
[[Set]] | set | When writing a value to a property |
[[HasProperty]] | has | When used with in operator |
[[Delete]] | deleteProperty | When deleting a property with delete operator |
[[Call]] | apply | When we do a function call |
You can refer to full list on MDN Docs and TC39 docs
π΄ Invariants
There are certain condition attached to each of the handle methods. These condition must be fulfilled by the trap or handler methods. These are often referred as Invariants
. You can read more on this in note section here.
As an example for [[SET]] Operation these are the invariants as per TC39
docs:
- The result of [[Set]] is a Boolean value.
- Cannot change the value of a property to be different from the value of the corresponding target object property if the corresponding target object property is a non-writable, non-configurable own data property.
- Cannot set the value of a property if the corresponding target object property is a non-configurable own accessor property that has undefined as its [[Set]] attribute.
π΄ [[SET]] Trap
If we set a trap for [[SET]]
operation and then we can modify the input before setting on original object name
const character = {
name: "Rick",
planet: "Earth"
};
const proxy = new Proxy(character, {
/**
* [[SET]] trap
* target: original object
* that has been wrapped
* prop: Property that has been set
* value: value to set to the property
*/
set: function (target, prop, value) {
// modifying the value
const capitalName = value.toUpperCase();
// setting the value to target
target[prop] = capitalName;
return true;
}
});
// update name
proxy.name = "morty";
// Log is MORTY instead of morty
console.log(character.name); // MORTY
π΄ [[GET]] Trap
Same as [[SET]]
trap we can set the [[GET]]
trap. Suppose when we access a property we want to print the log Getting <property_name>
. We can achieve that by using [[GET]]
trap like this:
const character = {
name: "Rick",
planet: "Earth"
};
const proxy = new Proxy(character, {
/**
* [[GET]] trap
* target: original object
* that has been wrapped
* property: Property name
*/
get: function(target, property) {
// printing the log before setting value
console.log(`Getting: ${property} `);
return target[property];
}
});
const characterName = proxy.name;
console.log(characterName);
// Getting: name
// Rick
π³ Reflect
Before I jump to Proxy
solution of the problem. There is also a sister object of Proxy
, which is known as Reflect
. As per MDN docs
Reflect is a built-in object that provides methods for interceptable JavaScript operations. The methods are the same as those of proxy handlers. Reflect is not a function object, so it's not constructible.
Point to note here is
- It has methods same as Proxy
- It is not a function
- It is not constructible i.e you
cannot
use it likenew Reflect
All the methods on Reflect are static
so you can directly call them like
- Reflect.get(...)
- Reflect.set(...)
π΄ Relationship between Reflect and Proxy:
All the methods that you can define on the
Proxy
,Reflect
has asame method
withsame argument
.Reflect can invoke the
Internal Method
by using the methods defined on it.
Proxy method | Reflect call | Internal method |
---|---|---|
get(target, property, receiver) | Reflect.get(target, property, receiver) | [[Get]] |
set(target, property, value, receiver) | Reflect.set(target, property, value, receiver) | [[Set]] |
delete(target, property) | Reflect.deleteProperty(target, property)) | [[Delete]] |
You can check other methods of Reflect on MDN Reflect Docs
π΄ What do we need Reflect for
We know there are a lot of Invariants
that we need to deal with when we trap some operation in Proxy and forward it to the original wrapped object. Remembering every rule can be hard.
We can simply use
Reflect
to forward any operation on original object and it will take care of all the Invariants
So now our [[SET]]
and [[GET]]
trap will change like this:
const character = {
name: "Rick",
planet: "Earth"
};
const proxy = new Proxy(character, {
set: function (target, prop, value, receiver) {
const capitalName = value.toUpperCase();
return Reflect.set(target, prop, capitalName, receiver)
},
get: function(target, property, receiver) {
console.log(`Getting: ${property} `);
return Reflect.get(target, property, receiver);
}
});
π³ Solution to original Problem:
With Proxy
and Reflect
now we can build our solution like this:
const proxyObject = function (obj) {
return new Proxy(obj, {
set: function (target, property, value, receiver) {
console.log(`SETTING ${property} = ${value}`);
return Reflect.set(target, property, value, receiver);
},
get: function (target, property, receiver) {
console.log(`GETTING value for [${property}]`);
return Reflect.get(target, property, receiver);
}
});
};
let character = { name: "morty", planet: "earth" };
character = proxyObject(character);
console.log(character.name);
// GETTING value for [name]
// morty
character.planet = "Mars";
// SETTING planet = Mars
/**
* ES5 solution does not work if we set new properties
* ES6 Proxy will work for new properties as well and
* print our log
*/
charter.grandParent = 'Rick';
// SETTING grandParent = Rick
π Browser Support for Proxy
As you can see most of latest browsers already support Proxy
except IE, Baidu and Opera. So if you do not care about these three, you can use it like a breeze.
π¦Ύ Practical Usage
You might be thinking, hmmmmm... this ok but what is the practical usage of this. During my research for this article I came across an example of a JavaScript framework that is utilising the powers of Proxy and that frameworkkkkkk isssss....
-
Vue 3 :
Vue 3
uses Proxy to be reactive and yes you got it right,Vue 3
does not support IE π. Vue 3 usesProxy
for change detection and firing side effects.
If you are not tired after reading my blog I will highly recommend you watch this free video to see full potential of Proxy
.
You can play with my solution here
π³ What to read next
JavaScript Jungle: Curious case of sparse array in JS
Vikas yadav for XenoX γ» Sep 19 '21
JavaScript Jungle: Convert any object to Iterable
Vikas yadav for XenoX γ» Oct 4 '21
Thank you for reading.
Follow me on twitter
Top comments (3)
This is an amazing write up! I really wonder how the interviewer came by this knowledge
He might have read somewhere too. π
TIL, Wonderful write up Vikas. π