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?

trusktr
  • 44,284
  • 53
  • 191
  • 263
mpen
  • 272,448
  • 266
  • 850
  • 1,236
  • you mean something like this? https://github.com/MaxArt2501/object-observe – Ratan Kumar Apr 03 '17 at 06: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 Apr 03 '17 at 06:41
  • @RatanKumar That's been deprecated and has too many caveats. Not even sure it's "deep" like I need. – mpen Apr 03 '17 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 Apr 03 '17 at 16:45
  • Thanks - PS: I meant "searching for proxy in google will return a lot of noise" – mplungjan Apr 03 '17 at 17:02

3 Answers3

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.

mpen
  • 272,448
  • 266
  • 850
  • 1,236
  • This should work on array values on the object as well, right? – Yuval A. Oct 30 '17 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 `Date`s though. Turns out `Proxy` hijacks the `this.` context of method calls. – mpen Oct 30 '17 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. – Brian Cannard Dec 14 '17 at 07: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. – Marcus Pope Jul 12 '19 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 Jul 16 '19 at 05:08
  • It does not work when a property in your object has `null` as a value – Julez Jul 21 '21 at 12:03
  • @Julez Ahah..so you are correct. I fixed that just now. Still not sure I'd recommend using proxies though; there are too many subtle gotchas. – mpen Jul 22 '21 at 06:33
7

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.

joe
  • 3,752
  • 1
  • 32
  • 41
trusktr
  • 44,284
  • 53
  • 191
  • 263
  • 1
    very elegant solution! – niryo Jan 30 '22 at 15:08
  • 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 Apr 06 '23 at 05: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'
Exodus 4D
  • 2,207
  • 2
  • 16
  • 19