13

How can I create a deep/recursive Proxy?

Specifically, I want to know whenever a property is set or modified anywhere in the object tree.

Here's what I've got so far:

function deepProxy(obj) {
    return new Proxy(obj, {
        set(target, property, value, receiver) {
            console.log('set', property,'=', value);
            if(typeof value === 'object') {
                for(let k of Object.keys(value)) {
                    if(typeof value[k] === 'object') {
                        value[k] = deepProxy(value[k]);
                    }
                }
                value = deepProxy(value);
            }
            target[property] = value;
            return true;
        },
        deleteProperty(target, property) {
            if(Reflect.has(target, property)) {
                let deleted = Reflect.deleteProperty(target, property);
                if(deleted) {
                    console.log('delete', property);
                }
                return deleted;
            }
            return false;
        }
    });
}

And here's my test:

const proxy = deepProxy({});
const baz = {baz: 9, quux: {duck: 6}};

proxy.foo = 5;
proxy.bar = baz;
proxy.bar.baz = 10;
proxy.bar.quux.duck = 999;

baz.quux.duck = 777;
delete proxy.bar;
delete proxy.bar; // should not trigger notifcation -- property was already deleted
baz.quux.duck = 666;  // should not trigger notification -- 'bar' was detached

console.log(proxy);

And the output:

set foo = 5
set bar = { baz: 9, quux: { duck: 6 } }
set baz = 10
set duck = 999
set duck = 777
delete bar
set duck = 666
{ foo: 5 }

As you can see, I've just about got it working, except baz.quux.duck = 666 is triggering the setter even though I've removed it from proxy's object tree. Is there any way to de-proxify baz after the property has been deleted?

5
  • you mean something like this? github.com/MaxArt2501/object-observe Commented Apr 3, 2017 at 6:39
  • What is a good use case for such a proxy and how does one search for more information on this? Proxy will return a lot of noise. Is this just a handwritten "Watch" ?
    – mplungjan
    Commented Apr 3, 2017 at 6:41
  • @RatanKumar That's been deprecated and has too many caveats. Not even sure it's "deep" like I need.
    – mpen
    Commented Apr 3, 2017 at 16:43
  • 2
    @mplungjan Use case? See React, reactive programming, MobX, or any other scenario where you want something to refresh when an object has been modified. In my particular case, I want to write the object back to disk and use it like a persistent database.
    – mpen
    Commented Apr 3, 2017 at 16:45
  • Thanks - PS: I meant "searching for proxy in google will return a lot of noise"
    – mplungjan
    Commented Apr 3, 2017 at 17:02

3 Answers 3

16

Fixed a bunch of bugs in my original question. I think this works now:

function createDeepProxy(target, handler) {
  const preproxy = new WeakMap();

  function makeHandler(path) {
    return {
      set(target, key, value, receiver) {
        if (value != null && typeof value === 'object') {
          value = proxify(value, [...path, key]);
        }
        target[key] = value;

        if (handler.set) {
          handler.set(target, [...path, key], value, receiver);
        }
        return true;
      },

      deleteProperty(target, key) {
        if (Reflect.has(target, key)) {
          unproxy(target, key);
          let deleted = Reflect.deleteProperty(target, key);
          if (deleted && handler.deleteProperty) {
            handler.deleteProperty(target, [...path, key]);
          }
          return deleted;
        }
        return false;
      }
    }
  }

  function unproxy(obj, key) {
    if (preproxy.has(obj[key])) {
      // console.log('unproxy',key);
      obj[key] = preproxy.get(obj[key]);
      preproxy.delete(obj[key]);
    }

    for (let k of Object.keys(obj[key])) {
      if (obj[key][k] != null && typeof obj[key][k] === 'object') {
        unproxy(obj[key], k);
      }
    }

  }

  function proxify(obj, path) {
    for (let key of Object.keys(obj)) {
      if (obj[key] != null && typeof obj[key] === 'object') {
        obj[key] = proxify(obj[key], [...path, key]);
      }
    }
    let p = new Proxy(obj, makeHandler(path));
    preproxy.set(p, obj);
    return p;
  }

  return proxify(target, []);
}

let obj = {
  foo: 'baz',
}


let proxied = createDeepProxy(obj, {
  set(target, path, value, receiver) {
    console.log('set', path.join('.'), '=', JSON.stringify(value));
  },

  deleteProperty(target, path) {
    console.log('delete', path.join('.'));
  }
});

proxied.foo = 'bar';
proxied.deep = {}
proxied.deep.blue = 'sea';
proxied.null = null;
delete proxied.foo;
delete proxied.deep; // triggers delete on 'deep' but not 'deep.blue'

You can assign full objects to properties and they'll get recursively proxified, and then when you delete them out of the proxied object they'll get deproxied so that you don't get notifications for objects that are no longer part of the object-graph.

I have no idea what'll happen if you create a circular linking. I don't recommend it.

7
  • This should work on array values on the object as well, right?
    – Yuval A.
    Commented Oct 30, 2017 at 17:21
  • @YuvalA. Yeah, I'm pretty sure I tested it on arrays. Note that it does have problems with more complex objects like Dates though. Turns out Proxy hijacks the this. context of method calls.
    – mpen
    Commented Oct 30, 2017 at 17:32
  • Thank you very much! Your code helped me to debug my code a lot! In the documentation about callbacks, they state a lot that Proxy callbacks are executed upon any inherited from Proxy class, but it doesn't work for the deleteProperty callback, unfortunately. Commented Dec 14, 2017 at 7:05
  • 1
    @BrianHaak I just ran into that deleteProperty prototype problem myself. Seems like a bug since get and set work as the docs imply. The workaround in my case was obj.someprop = undefined but that isn't a universal solution. Commented Jul 12, 2019 at 19:46
  • @MarcusPope Hm? Are you guys talking about recursive deletes or something else? deleteProperty does appear to be triggered, but not deeply. Might be fixable but I've stopped using proxies because they're sketchy.
    – mpen
    Commented Jul 16, 2019 at 5:08
10

Here's a simpler one that does what I think you wanted.

This example allows you to get or set any properties deeply, and calls a change handler on any property (deep or not) to show that it works:

let proxyCache = new WeakMap();
function createDeepOnChangeProxy(target, onChange) {
  return new Proxy(target, {
    get(target, property) {
      const item = target[property];
      if (item && typeof item === 'object') {
        if (proxyCache.has(item)) return proxyCache.get(item);
        const proxy = createDeepOnChangeProxy(item, onChange);
        proxyCache.set(item, proxy);
        return proxy;
      }
      return item;
    },
    set(target, property, newValue) {
      target[property] = newValue;
      onChange();
      return true;
    },
  });
}

let changeCount = 0
const o = createDeepOnChangeProxy({}, () => changeCount++)

o.foo = 1
o.bar = 2
o.baz = {}
o.baz.lorem = true
o.baz.yeee = {}
o.baz.yeee.wooo = 12
o.baz.yeee === o.baz.yeee // proxyCache ensures that this is true

console.log(changeCount === 6)

const proxy = createDeepOnChangeProxy({}, () => console.log('change'))
const baz = {baz: 9, quux: {duck: 6}};

proxy.foo = 5;
proxy.bar = baz;
proxy.bar.baz = 10;
proxy.bar.quux.duck = 999;

baz.quux.duck = 777;
delete proxy.bar;
delete proxy.bar; // should not trigger notifcation -- property was already deleted
baz.quux.duck = 666;  // should not trigger notification -- 'bar' was detached

console.log(proxy);

In the part that uses your code sample, there are no extra notifications like your comments wanted.

2
  • 1
    very elegant solution!
    – niryo
    Commented Jan 30, 2022 at 15:08
  • 1
    This is... perfect?! Handles everything - including e.g. setting a property to an array then pushing an item to that array, then editing that new item, and so on. Brilliant!
    – joe
    Commented Apr 6, 2023 at 5:40
4

@mpen answer ist awesome. I moved his example into a DeepProxy class that can be extended easily.

class DeepProxy {
    constructor(target, handler) {
        this._preproxy = new WeakMap();
        this._handler = handler;
        return this.proxify(target, []);
    }

    makeHandler(path) {
        let dp = this;
        return {
            set(target, key, value, receiver) {
                if (typeof value === 'object') {
                    value = dp.proxify(value, [...path, key]);
                }
                target[key] = value;

                if (dp._handler.set) {
                    dp._handler.set(target, [...path, key], value, receiver);
                }
                return true;
            },

            deleteProperty(target, key) {
                if (Reflect.has(target, key)) {
                    dp.unproxy(target, key);
                    let deleted = Reflect.deleteProperty(target, key);
                    if (deleted && dp._handler.deleteProperty) {
                        dp._handler.deleteProperty(target, [...path, key]);
                    }
                    return deleted;
                }
                return false;
            }
        }
    }

    unproxy(obj, key) {
        if (this._preproxy.has(obj[key])) {
            // console.log('unproxy',key);
            obj[key] = this._preproxy.get(obj[key]);
            this._preproxy.delete(obj[key]);
        }

        for (let k of Object.keys(obj[key])) {
            if (typeof obj[key][k] === 'object') {
                this.unproxy(obj[key], k);
            }
        }

    }

    proxify(obj, path) {
        for (let key of Object.keys(obj)) {
            if (typeof obj[key] === 'object') {
                obj[key] = this.proxify(obj[key], [...path, key]);
            }
        }
        let p = new Proxy(obj, this.makeHandler(path));
        this._preproxy.set(p, obj);
        return p;
    }
}

// TEST DeepProxy


let obj = {
    foo: 'baz',
}


let proxied = new DeepProxy(obj, {
    set(target, path, value, receiver) {
        console.log('set', path.join('.'), '=', JSON.stringify(value));
    },

    deleteProperty(target, path) {
        console.log('delete', path.join('.'));
    }
});


proxied.foo = 'bar';
proxied.deep = {}
proxied.deep.blue = 'sea';
delete proxied.foo;
delete proxied.deep; // triggers delete on 'deep' but not 'deep.blue'

Not the answer you're looking for? Browse other questions tagged or ask your own question.