Reputation: 36018
//clazz.js:
class Clazz {
constructor() {
this.name = "name";
this.num= 8;
}
}
export default Clazz;
//main.js
import Clazz from "./clazz"
let oc = Clazz.prototype.constructor;
Clazz.prototype.constructor = function(){
oc.apply(this,arguments)
this.num= 9
}
let c = new Clazz()
console.info(c)
While I expect the num
of the c
will be 9
, but it is still 8.
What's going one? And is it possible to fix that?
Upvotes: 0
Views: 2093
Reputation: 646
The best you can do to achieve the desired result is to use Proxy
with a handler implementing the construct()
method which is a trap for the new
operator. This way you get objects that are indistinguishable from objects you would get by calling the constructor before using the proxy. In particular, the constructor
property of objects created using the proxy is equal to the original class, and calling Object.getPrototypeOf()
on them gives us back its prototype.
Interestingly, even objects created using the original constructor will be considered instances of the proxy. I guess it has to do with the fact that accessing the Symbol.hasInstance
property of the proxy gives us back the corresponding property of the original class (this is the default behavior we get because we do not implement the get()
method of the proxy handler). But this goes far beyond the scope of this question…
Now let us take a look at the code:
class Clazz {
constructor() {
this.name = "name";
this.num = 8;
}
}
let oc = Clazz.prototype.constructor;
Clazz = new Proxy(Clazz, {
construct(target, args, newTarget) {
const result = Reflect.construct(target, args, newTarget);
result.num = 9;
return result;
}
});
let c = new Clazz();
console.log(c);
console.log(c instanceof Clazz);
console.log(c instanceof oc);
console.log(c.constructor === oc);
console.log(Object.getPrototypeOf(c) === oc.prototype);
console.log(new oc() instanceof Clazz);
console.log(Clazz[Symbol.hasInstance] === oc[Symbol.hasInstance]);
console.log(Clazz !== oc);
This solution works perfectly for your class and there is no need for improvement, so you can stop reading the answer here because what follows are a lot of technical details which are irrelevant for your basic example.
But if you want a general solution, there is one little problem left to consider. As you have probably noticed, I use Reflect.construct()
to simulate creating new instances of the class as if they were created using the original constructor. The interesting part of it is the third parameter which gets assigned the value new.target
will have in the constructor we are calling.
new.target
is a feature of ES6 we rarely have to care about, but we need it here, so I recommend you read about it by following the link if it is the first time you see it.
As you can see in the code, I just pass the newTarget
parameter our trap receives down to the original constructor. You will probably ask why we even have to care about it given that we know it is always going to be equal to Clazz
whenever we call new Clazz()
, and omitting the new
operator results in an error anyway, so it cannot be undefined
. (Even if there were no error, it would not be caught by the construct()
trap, but by the apply()
one if we specified it.)
The reason we care is the power Reflect.construct()
gives us. This method is not limited to proxy handlers and can be used to simulate construction of class instances anywhere in our code, which means that we can force new.target
to be equal to an arbitrary function (that qualifies as a constructor, i.e. has a [[Construct]]
internal method) since we can pass such a function to Reflect.construct()
in the third parameter I have mentioned above.
I hope this small example makes it clear:
class MyClass {
constructor() {
console.log("new.target ===", new.target);
}
}
const args = [];
const newTarget = function Arbitrary() {};
const myObject = Reflect.construct(MyClass, args, newTarget);
console.log(myObject instanceof newTarget);
So, in order to make constructing new instances with the proxy as close as possible to constructing them with the original constructor, we have to pass the newTarget
parameter our proxy receives down to the original constructor, and the only way to do it I know of is, again, by calling Reflect.construct()
.
Okay, but the code above already does that. So why do we have to talk about it?
Well, I have already mentioned that newTarget
is going to be equal to Clazz
whenever we call new Clazz()
, and that presents a significant flaw if we want to stay as close as possible to the original constructor, since Clazz
is not equal to the original class after we assign the proxy to the Clazz
variable (i.e. Clazz !== oc
), which means that calling new Clazz()
before and after assigning the proxy could produce different results. Consider the following quirky class as an example:
class Clazz {
constructor() {
if (new.target !== Clazz) {
throw new TypeError("Forbidden!");
}
this.name = "name";
this.num = 8;
}
}
class Clazz {
constructor() {
if (new.target !== Clazz) {
throw new TypeError("Forbidden!");
}
this.name = "name";
this.num = 8;
}
}
Clazz = new Proxy(Clazz, {
construct(target, args, newTarget) {
const result = Reflect.construct(target, args, newTarget);
result.num = 9;
return result;
}
});
let c = new Clazz();
The constructor throws an error whenever new.target
is not equal to the original class, so it will not be possible to construct class instances using the new
operator once we create the proxy, because new.target
will be equal to the proxy, and not the original class.
The fix is pretty straightforward: we just pass the original class as new.target
to the constructor if the newTarget
parameter our proxy handler receives is equal to our proxy, which is now known under the name Clazz
. Like so:
const result = Reflect.construct(
target, args, newTarget === Clazz ? target : newTarget
);
But we have to be careful here because Clazz
could be reassigned later, which would break the handler function. Here is a code snippet to demonstrate the problem:
class Clazz {
constructor() {
if (new.target !== Clazz) {
throw new TypeError("Forbidden!");
}
this.name = "name";
this.num = 8;
}
}
Clazz = new Proxy(Clazz, {
construct(target, args, newTarget) {
console.log("Clazz is now:", Clazz);
const result = Reflect.construct(
target, args, newTarget === Clazz ? target : newTarget
);
result.num = 9;
return result;
}
});
const ClazzRef = Clazz;
Clazz = "Sike";
let c = new ClazzRef();
We can solve this by using an Immediately Invoked Function Expression to form a closure, and this is going to be our final solution:
class Clazz {
constructor() {
if (new.target !== Clazz) {
throw new TypeError("Forbidden!");
}
this.name = "name";
this.num = 8;
}
}
Clazz = (function() {
const proxy = new Proxy(Clazz, {
construct(target, args, newTarget) {
const result = Reflect.construct(
target, args, newTarget === proxy ? target : newTarget
);
result.num = 9;
return result;
}
});
return proxy;
})();
const ClazzRef = Clazz;
Clazz = "Sike";
let c = new ClazzRef();
console.log(c);
It is possible to wrap this solution in a library function, and what follows is my attempt to do so as well as an interesting example of how it could be used.
function hookClass(target, hookObject, className) {
const handler = {
construct(target, args, newTarget) {
console.log(`Called new ${newTarget.name}(${args.join(", ")})`);
const result = Reflect.construct(
target, args, newTarget === proxy ? target : newTarget
);
Reflect.apply(
hookObject, // call this function
result, // -> this
arguments // <- argument list
);
return result;
}
};
// We have to allow custom name property for logging purposes
if (typeof className === "string") {
const classNameTarget = {};
Object.defineProperty(classNameTarget, "name", {
value: className,
writable: false,
enumerable: false,
configurable: true
});
Object.assign(handler, {
get(target, key, receiver) {
if (key === "name") {
target = classNameTarget;
} else if (receiver === proxy) {
receiver = target;
}
return Reflect.get(target, key, receiver);
},
// We must also specify other handler methods
// in order to cover all possible use cases,
// but I am going to skip that part here...
});
}
const proxy = new Proxy(target, handler);
return proxy;
}
/* Example with your class */
class Clazz {
constructor() {
this.name = "name";
this.num = 8;
}
}
Clazz = hookClass(Clazz, function() {
this.num = 9;
});
let c = new Clazz();
console.log(c);
/* A more sophisticated example */
class Counter {
constructor(initialValue) {
this.value = initialValue;
}
increase() {
this.value++;
}
}
// Boosting a counter class by X means that every step of
// the original class counts as X steps in the boosted one.
// What follows is definitely not the best way to create counters
// with an arbitrary step length, but it gives us a good example.
// boostCounterBy is a function factory / higher order function that returns
// a hook function that boosts a newly created counter class instance by X.
function boostCounterBy(boost) {
return function(target, args, newTarget) {
const [initialValue] = args;
if (this.value === initialValue) {
console.log("Has not been boosted yet");
} else {
console.log("Has already been boosted by", this.value / initialValue);
}
console.log("Boosting by", boost);
this.value *= boost;
const oldIncrease = this.increase.bind(this);
// Note that we have to create a new method for each object instead of
// working with the prototype. We cannot change the prototype because
// it would affect the existing instances of the original counter class
this.increase = function increase() {
for (let i = 0; i < boost; i++) {
oldIncrease();
}
};
};
}
// We do not have to reassign Counter.
// That lets us create more than one
// proxy of the same class (see below)
const DoubleCounter = hookClass(Counter, boostCounterBy(2), "DoubleCounter");
// Hooking into a proxy!!!
// Not something you would want to do
// in real code because of the overhead
const SextupleCounter = hookClass(DoubleCounter, boostCounterBy(3), "SextupleCounter");
// Reassigning the original Counter variable
// still works and does not break anything
Counter = hookClass(Counter, boostCounterBy(5));
[Counter, DoubleCounter, SextupleCounter].forEach(Counter => {
console.log("\n");
const counter = new Counter(1);
console.log(counter.value);
counter.increase();
console.log(counter.value);
counter.increase();
console.log(counter.value);
});
console.log("\n");
DoubleCounter.property = {};
console.log(DoubleCounter.property === Counter.property); // true
// Is this behavior desired?..
// If not, here is a solution idea:
// create proxy on a deep copy of the class but call
// Reflect.construct() with original class as target
Classes in JavaScript are essentially just functions (constructor functions, to be specific), and since there is no way to change a function's code once it has been created, there is also no way to update the constructor of a class while keeping its identity. But the meta programming concepts introduced to JavaScript in ES6 such as Proxy
and Reflect
make the transition from a class to its new version as seamless as it can get.
I know this answer is kind of late, but I hope you found it useful nonetheless :)
Upvotes: 0
Reputation: 664297
Replacing the .constructor
property of the prototype object doesn't help with anything. The constructor is Clazz
itself, and you are calling it directly through new Clazz()
- it doesn't create an object and invoke a "constructor method" on it.
Is it possible to fix that?
Not really, no. All you can do is to create a new function (a constructor even) that calls the old one (e.g. by subclassing), and then ensure that you only call the new one with new
.
Upvotes: 2