Search code examples
javascriptes6-classprivate-memberses6-proxy

How can I detect changes to arbitrary private members with an ES6 proxy in Javascript?


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 (new.target === 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:

  1. Watchables will only detect shallow changes to their state.
  2. Watchables 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?


Solution

  • 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:

    class RenderState extends Watchable {
      // add anything you think is relevant all renderable things
    }
    
    // base class for render components
    class Component {
      renderState = new RenderState()
    }
    
    class MyComponent extends Component {
      constructor() {
        this.renderState.foo = "initial value for foo"
        this.stateThatDoesNotAffectRendering = 32
      }
    
      // this will mark us dirty
      foo() {
        ++this.renderState.foo
      }
    
      // this will not mark us dirty
      bar() {
        this.stateThatDoesNotAffectRendering = 44
      }
    }
    
    

    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.