I am working on building a 2D renderer with the Javascript canvas API, and I am trying to improve performance by skipping renders when no changes to the state of any renderable objects have occurred.
To this end, I want to build an extensible class (call it Watchable
) that will detect changes to its state while allowing subclasses to remain agnostic to this state tracking. Client code will extend this class to create renderable objects, then register those objects with the renderer.
After some work, I arrived at a partial solution using proxies, and implemented a makeWatchable
function as follows:
function makeWatchable(obj) {
// Local dirty flag in the proxy's closure, but protected from
// external access
let dirty = true;
return new Proxy(obj, {
// I need to define accessors and mutators for the dirty
// flag that can be called on the proxy.
get(target, property) {
// Define the markDirty() method
if(property === "markDirty") {
return () => {
dirty = true;
};
}
// Define the markClean() method
if(property === "markClean") {
return () => {
dirty = false;
};
}
// Define the isDirty() method
if(property === "isDirty") {
return () => {
return dirty;
};
}
return Reflect.get(target, property);
},
// A setter trap to set the dirty flag when a member
// variable is altered
set(target, property, value) {
// Adding or redefining functions does not constitute
// a state change
if(
(target[property] && typeof target[property] !== 'function')
||
typeof value !== 'function'
) {
if(target[property] !== value) dirty = true;
}
target[property] = value;
return true;
}
});
}
This function accepts an object and returns a proxy that traps the object’s setters to set a dirty flag within the proxy’s closure and defines accessors and mutators for the dirty flag.
I then use this function to create the Watchable
base class as follows:
// A superclass for watchable objects
class Watchable {
constructor() {
// Simulates an abstract class
if(this.constructor === Watchable) {
throw new Error(
'Watchable is an abstract class and cannot be instantiated directly'
);
}
return makeWatchable(this);
}
}
Watchable
can then be extended to provide state tracking to its subclasses.
Unfortunately, this approach seems to suffer from two significant limitations:
Watchable
s will only detect shallow changes to their state.Watchable
s will only detect changes to their public state.
I am comfortable passing this first limitation on to client code to deal with. But I want to support tracking of any arbitrary private members included in a subclass of Watchable
without requiring extra work from the subclass.
So, for example, I’d like to be able to define a subclass like this:
class SecretRenderable extends Watchable {
#secret;
constructor() {
super();
this.#secret = 'Shh!';
}
setSecret(newSecret) {
this.#secret = newSecret;
}
}
use it like this:
const obj = new SecretRenderable();
obj.markClean();
obj.setSecret('SHHHHHHHHH!!!!!');
and find that obj.isDirty()
is true.
I would hope the line this.#secret = newSecret
would be caught by the proxy set trap. But it looks like this line bypasses the set trap entirely.
Is there a way I can modify my proxy implementation to achieve private state change detection? If not, is there an alternative approach I should consider?
2
Answers
No, this impossible. Private members are really private and cannot be intercepted by proxies.
Again, the only solution is to make this a client problem, and document the limitation.
As someone else has stated this isn’t possible. I suspect it will also lead to other issues (example: what happens when a class needs some state that does not impact rendering).
If you want to go down this path, then separate the render state from non-render state. Try a pattern like this:
Now your rendering framework can use
component.renderState.isDirty
to check for dirtiness.This has the added advantage that you now have all your render state isolated from non-render state and gives you some future flexibility around saving/loading your render state.