How to create a Deep Proxy (aka Proxy Membrane)?
Asked Answered
F

3

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?

Forsyth answered 3/4, 2017 at 6:29 Comment(5)
you mean something like this? github.com/MaxArt2501/object-observeAjani
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" ?Riesman
@RatanKumar That's been deprecated and has too many caveats. Not even sure it's "deep" like I need.Forsyth
@Riesman 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.Forsyth
Thanks - PS: I meant "searching for proxy in google will return a lot of noise"Riesman
F
17

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.

Forsyth answered 6/4, 2017 at 23:19 Comment(7)
This should work on array values on the object as well, right?Tier
@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.Forsyth
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.Ramsey
@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.Morry
@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.Forsyth
It does not work when a property in your object has null as a valueStemware
@Stemware 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.Forsyth
M
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.

Man answered 21/11, 2019 at 20:30 Comment(2)
very elegant solution!Bligh
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!Mull
P
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'
Pneumonia answered 18/5, 2020 at 11:26 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.