77

I have this code in js bin:

var validator = {
  set (target, key, value) {
    console.log(target);
    console.log(key);
    console.log(value);
    if(isObject(target[key])){

    }
    return true
  }
}


var person = {
      firstName: "alfred",
      lastName: "john",
      inner: {
        salary: 8250,
        Proffesion: ".NET Developer"
      }
}
var proxy = new Proxy(person, validator)
proxy.inner.salary = 'foo'

if i do proxy.inner.salary = 555; it does not work.

However if i do proxy.firstName = "Anne", then it works great.

I do not understand why it does not work Recursively.

http://jsbin.com/dinerotiwe/edit?html,js,console

user3840170
  • 26,597
  • 4
  • 30
  • 62
Aflred
  • 4,435
  • 6
  • 30
  • 43
  • 2
    Nested means "multiple objects", which means that you need multiple proxies to detect all property accesses on every object not only the root one. – Bergi Dec 23 '16 at 11:09

5 Answers5

99

You can add a get trap and return a new proxy with validator as a handler:

var validator = {
  get(target, key) {
    if (typeof target[key] === 'object' && target[key] !== null) {
      return new Proxy(target[key], validator)
    } else {
      return target[key];
    }
  },
  set (target, key, value) {
    console.log(target);
    console.log(key);
    console.log(value);
    return true
  }
}


var person = {
      firstName: "alfred",
      lastName: "john",
      inner: {
        salary: 8250,
        Proffesion: ".NET Developer"
      }
}
var proxy = new Proxy(person, validator)
proxy.inner.salary = 'foo'
Michał Perłakowski
  • 88,409
  • 26
  • 156
  • 177
  • Thanks, What if target[key] is an Array of objects? I guess we can map validator? – Rusty Rob Jul 26 '17 at 05:24
  • 2
    @robertking Array is an object too, so it's an object inside an object, and this code should work with deeply nested objects. – Michał Perłakowski Jul 26 '17 at 15:15
  • 1
    Dates and Arrays didn't quite work for me, perhaps because angular ngFor and datePipes use the prototype properties. I've posted my modified solution below which seems to work ok – Rusty Rob Aug 01 '17 at 01:21
  • 3
    Thanks. But this way each time it returns a new Proxy instance. Is there anyway to return the same Proxy instance if it's created? –  Aug 12 '19 at 05:45
  • would this work if i did `const inner = person.inner; inner.salary = 1000;` ? (edit: oh i see.. the proxy would be returned from the first get now, so inner would be a proxy. cool. Are there any other drawbacks or loopholes to this or can it be expected to work 100% with any amount of deeply nested objects?) – Joel M Apr 01 '21 at 18:17
  • is there a way to return from `proxy.inner` to the actual object if it is the whole expression? yes you can get and set just like this is the `proxy.inner`, but maybe wanted to delete a property? There is one way that I can think of is to implement `.data` at the end of the proxy, so It'll return the actual value of the last prop when you get `.data`, but that's not elegant :/ – FLAW Apr 07 '22 at 02:31
  • This is the best way to perform a proxy for nested objects. – kollein Nov 15 '22 at 07:33
28

A slight modification on the example by Michał Perłakowski with the benefit of this approach being that the nested proxy is only created once rather than every time a value is accessed.

If the property of the proxy being accessed is an object or array, the value of the property is replaced with another proxy. The isProxy property in the getter is used to detect whether the currently accessed object is a proxy or not. You may want to change the name of isProxy to avoid naming collisions with properties of stored objects.

Note: the nested proxy is defined in the getter rather than the setter so it is only created if the data is actually used somewhere. This may or may not suit your use-case.

const handler = {
  get(target, key) {
    if (key == 'isProxy')
      return true;

    const prop = target[key];

    // return if property not found
    if (typeof prop == 'undefined')
      return;

    // set value as proxy if object
    if (!prop.isProxy && typeof prop === 'object')
      target[key] = new Proxy(prop, handler);

    return target[key];
  },
  set(target, key, value) {
    console.log('Setting', target, `.${key} to equal`, value);

    // todo : call callback

    target[key] = value;
    return true;
  }
};

const test = {
  string: "data",
  number: 231321,
  object: {
    string: "data",
    number: 32434
  },
  array: [
    1, 2, 3, 4, 5
  ],
};

const proxy = new Proxy(test, handler);

console.log(proxy);
console.log(proxy.string); // "data"

proxy.string = "Hello";

console.log(proxy.string); // "Hello"

console.log(proxy.object); // { "string": "data", "number": 32434 }

proxy.object.string = "World";

console.log(proxy.object.string); // "World"
James Coyle
  • 9,922
  • 1
  • 40
  • 48
  • 3
    i believe .isBindingProxy should be ,isProxy ? – citykid Sep 03 '18 at 07:28
  • 3
    If you're using Node v10+, you can also use [util.types.isProxy](https://nodejs.org/api/util.html#util_util_types_isproxy_value) instead of manually "setting" isProxy – Rafael Sofi-zada Apr 09 '21 at 00:37
  • 3
    For browser approach, A suggestion: declare: `const isProxy = Symbol("isProxy")`. Then use `key === isProxy` instead. – ikhvjs Nov 01 '21 at 08:03
17

I published a library on GitHub that does this as well. It will also report to a callback function what modifications have taken place along with their full path.

Michal's answer is good, but it creates a new Proxy every time a nested object is accessed. Depending on your usage, that could lead to a very large memory overhead.

Elliot B.
  • 17,060
  • 10
  • 80
  • 101
  • This is something I spotted too, Proxy's are of course Objects, and there is no way to tell if an Object is a Proxy,.. The way I've got around this is keep track of the proxies inside a `WeakMap`.. – Keith Jul 25 '18 at 15:50
  • This lib doesn't actually do deep copy unless you hardcode all the object paths. – FirstVertex Sep 18 '19 at 18:15
  • @HDog Hmm, which library are you referring to? The purpose of Observable Slim is not to deep copy objects -- it's purpose is to observe changes on objects and changes on any deeply nested child objects. – Elliot B. Sep 18 '19 at 19:01
  • I tried to access proxy.inner.salary 100 Million times and didn't see any Memory rising. I think this answer is just not true and puts bad reputation on Michal's answer. Garbage collection seems to work in this case. – Kilian Hertel Oct 15 '20 at 08:24
  • 5
    @KilianHertel Take a look at the first `if` statement in Michal's answer. It creates a new `Proxy` if the accessed property is a not null `object`. So of course, depending on your usage, creating a bunch of new Proxy objects could very well result in increased memory usage. Your mileage will vary depending on garbage collection. The other answer provided by James also addresses that very issue. Why does my answer "put bad reputation" on Michal's answer? I said his answer is good and I even upvoted it myself... – Elliot B. Oct 16 '20 at 00:33
4

I have also created a library type function for observing updates on deeply nested proxy objects (I created it for use as a one-way bound data model). Compared to Elliot's library it's slightly easier to understand at < 100 lines. Moreover, I think Elliot's worry about new Proxy objects being made is a premature optimisation, so I kept that feature to make it simpler to reason about the function of the code.

observable-model.js

let ObservableModel = (function () {
    /*
    * observableValidation: This is a validation handler for the observable model construct.
    * It allows objects to be created with deeply nested object hierarchies, each of which
    * is a proxy implementing the observable validator. It uses markers to track the path an update to the object takes
    *   <path> is an array of values representing the breadcrumb trail of object properties up until the final get/set action
    *   <rootTarget> the earliest property in this <path> which contained an observers array    *
    */
    let observableValidation = {
        get(target, prop) {
            this.updateMarkers(target, prop);
            if (target[prop] && typeof target[prop] === 'object') {
                target[prop] = new Proxy(target[prop], observableValidation);
                return new Proxy(target[prop], observableValidation);
            } else {
                return target[prop];
            }
        },
        set(target, prop, value) {
            this.updateMarkers(target, prop);
            // user is attempting to update an entire observable field
            // so maintain the observers array
            target[prop] = this.path.length === 1 && prop !== 'length'
                ? Object.assign(value, { observers: target[prop].observers })
                : value;
            // don't send events on observer changes / magic length changes
            if(!this.path.includes('observers') && prop !== 'length') {
                this.rootTarget.observers.forEach(o => o.onEvent(this.path, value));
            }
            // reset the markers
            this.rootTarget = undefined;
            this.path.length = 0;
            return true;
        },
        updateMarkers(target, prop) {
            this.path.push(prop);
            this.rootTarget = this.path.length === 1 && prop !== 'length'
                ? target[prop]
                : target;
        },
        path: [],
        set rootTarget(target) {
            if(typeof target === 'undefined') {
                this._rootTarget = undefined;
            }
            else if(!this._rootTarget && target.hasOwnProperty('observers')) {
                this._rootTarget = Object.assign({}, target);
            }
        },
        get rootTarget() {
            return this._rootTarget;
        }
    };

    /*
    * create: Creates an object with keys governed by the fields array
    * The value at each key is an object with an observers array
    */
    function create(fields) {
        let observableModel = {};
        fields.forEach(f => observableModel[f] = { observers: [] });
        return new Proxy(observableModel, observableValidation);
    }

    return {create: create};
})();

It's then trivial to create an observable model and register observers:

app.js

// give the create function a list of fields to convert into observables
let model = ObservableModel.create([
    'profile',
    'availableGames'
]);

// define the observer handler. it must have an onEvent function
// to handle events sent by the model
let profileObserver = {
    onEvent(field, newValue) {
        console.log(
            'handling profile event: \n\tfield: %s\n\tnewValue: %s',
            JSON.stringify(field),
            JSON.stringify(newValue));
    }
};

// register the observer on the profile field of the model
model.profile.observers.push(profileObserver);

// make a change to profile - the observer prints:
// handling profile event:
//        field: ["profile"]
//        newValue: {"name":{"first":"foo","last":"bar"},"observers":[{}
// ]}
model.profile = {name: {first: 'foo', last: 'bar'}};

// make a change to available games - no listeners are registered, so all
// it does is change the model, nothing else
model.availableGames['1234'] = {players: []};

Hope this is useful!

jonny
  • 3,022
  • 1
  • 17
  • 30
1

I wrote a function based on Michał Perłakowski code. I added access to the path of property in the set/get functions. Also, I added types.

    const createHander = <T>(path: string[] = []) => ({
        get: (target: T, key: keyof T): any => {
            if (key == 'isProxy') return true;
            if (typeof target[key] === 'object' && target[key] != null)
                return new Proxy(
                    target[key],
                    createHander<any>([...path, key as string])
                );
            return target[key];
        },
        set: (target: T, key: keyof T, value: any) =>  {
            console.log(`Setting ${[...path, key]} to: `, value);
            target[key] = value;
            return true;
        }
    });
    
    const proxy = new Proxy(obj ,createHander<ObjectType>());
Mohamad Khani
  • 332
  • 4
  • 13