wiki/content/20201022094207-javascript_proxies.md

8.2 KiB
Raw Permalink Blame History

date id title
2020-10-22 c5010eb1-4ce2-4415-8421-7710daecad0a JavaScript Proxies

Introduction

ECMAScript 6 proxies bring intercession to JavaScript. They work as follows. There are many operations that you can perform on an object obj. For example:

  • Getting the property prop of an object obj (obj.prop)
  • Checking whether an object obj has a property prop ('prop' in obj)

Proxies are special objects that allow you customize some of these operations. A proxy is created with two parameters:

  • handler: For each operation, there is a corresponding handler method that if present performs that operation. Such a method intercepts the operation (on its way to the target) and is called a trap (a term borrowed from the domain of operating systems).
  • target: If the handler doesnt intercept an operation then it is performed on the target. That is, it acts as a fallback for the handler. In a way, the proxy wraps the target.

Examples

In the following example the handler intercepts the operations get and has:

const target = {};
const handler = {
  /** Intercepts: getting properties */
  get(target, propKey, receiver) {
    console.log(`GET ${propKey}`);
    return 123;
  },

  /** Intercepts: checking whether properties exist */
  has(target, propKey) {
    console.log(`HAS ${propKey}`);
    return true;
  },
};
const proxy = new Proxy(target, handler);

console.log(proxy.foo); // 123
console.log("hello" in proxy); // true

proxy.bar = "abc";
console.log(target.bar); // "abc"

Use cases for proxies

Tracing property acesses (get, set)

function tracePropAccess(obj, propKeys) {
  const propKeySet = new Set(propKeys);
  return new Proxy(obj, {
    get(target, propKey, receiver) {
      if (propKeySet.has(propKey)) {
        console.log("GET " + propKey);
      }
      return Reflect.get(target, propKey, receiver);
    },
    set(target, propKey, value, receiver) {
      if (propKeySet.has(propKey)) {
        console.log("SET " + propKey + "=" + value);
      }
      return Reflect.set(target, propKey, value, receiver);
    },
  });
}

class Point {
  constructor(x, y) {
    this.x = x;
    this.y = y;
  }
  toString() {
    return `Point(${this.x}, ${this.y})`;
  }
}
// Trace accesses to properties `x` and `y`
let p = new Point(5, 7);
p = tracePropAccess(p, ["x", "y"]);

// GET x
// 4
console.log(p.x);

// SET x=21
// 21
console.log((p.x = 21));

// GET x
// GET y
// Point(21, 7)
console.log(p.toString());

Warning about unknown properties (get,set)

When it comes to accessing properties, JavaScript is very forgiving. For example, if you try to read a property and misspell its name, you dont get an exception, you get the result undefined. You can use proxies to get an exception in such a case. This works as follows. We make the proxy a prototype of an object.

If a property isnt found in the object, the get trap of the proxy is triggered. If the property doesnt even exist in the prototype chain after the proxy, it really is missing and we throw an exception. Otherwise, we return the value of the inherited property. We do so by forwarding the get operation to the target (the prototype of the target is also the prototype of the proxy).

Unknown Property Examples

  1. Object

    const PropertyChecker = new Proxy(
      {},
      {
        get(target, propKey, receiver) {
          if (!(propKey in target)) {
            throw new ReferenceError("Unknown property: " + propKey);
          }
          return Reflect.get(target, propKey, receiver);
        },
      }
    );
    
    const obj = { __proto__: PropertyChecker, foo: 123 };
    // 123
    console.log(obj.foo);
    
    // ReferenceError: Unknown property: fo
    obj.fo
    
    // [object Object]
    obj.toString()
    
  2. Class

    If we turn PropertyChecker into a constructor, we can use it for classes via extends:

    function PropertyChecker() {}
    PropertyChecker.prototype = new Proxy(/*···*/);
    
    class Point extends PropertyChecker {
      constructor(x, y) {
        super();
        this.x = x;
        this.y = y;
      }
    }
    
    const p = new Point(5, 7);
    console.log(p.x); // 5
    console.log(p.z); // ReferenceError
    

Negative Array indices (get)

Some array prototype methods let you refer to the last element via -1, to the second-to-last element via -2, etc. For example:

console.log(["a", "b", "c"].slice(-1)); // ['c']

Alas, that doesnt work when accessing elements via the bracket operator ([]). We can, however, use proxies to add that capability. The following function createArray() creates Arrays that support negative indices. It does so by wrapping proxies around Array instances. The proxies intercept the get operation that is triggered by the bracket operator.

function createArray(...elements) {
  const handler = {
    get(target, propKey, receiver) {
      // Sloppy way of checking for negative indices
      const index = Number(propKey);
      if (index < 0) {
        propKey = String(target.length + index);
      }
      return Reflect.get(target, propKey, receiver);
    },
  };
  // Wrap a proxy around an Array
  const target = [];
  target.push(...elements);
  return new Proxy(target, handler);
}

const arr = createArray("a", "b", "c");
console.log(arr[-1]); // c

Data binding (set)

function createObservedArray(callback) {
    const array = [];
    return new Proxy(array, {
        set(target, propertyKey, value, receiver) {
            callback(propertyKey, value);
            return Reflect.set(target, propertyKey, value, receiver);
        }
    });    
}
const observedArray = createObservedArray(
    (key, value) => console.log(`${key}=${value}`));
observedArray.push('a');

Accessing a restful web service

function httpGet(url) {
  return new Promise((resolve, reject) => {
    const request = new XMLHttpRequest();
    Object.assign(request, {
      onload() {
        if (this.status === 200) {
          // Success
          resolve(this.response);
        } else {
          // Something went wrong (404 etc.)
          reject(new Error(this.statusText));
        }
      },
      onerror() {
        reject(new Error("XMLHttpRequest Error: " + this.statusText));
      },
    });
    request.open("GET", url);
    request.send();
  });
}

function createWebService(baseUrl) {
  return new Proxy(
    {},
    {
      get(target, propKey, receiver) {
        // Return the method to be called
        return () => httpGet(baseUrl + "/" + propKey);
      },
    }
  );
}

const service = createWebService("http://example.com/data");
// Read JSON data in http://example.com/data/employees
service.employees().then((json) => {
  const employees = JSON.parse(json);
});

Recoverable references

Revocable references work as follows: A client is not allowed to access an important resource (an object) directly, only via a reference (an intermediate object, a wrapper around the resource). Normally, every operation applied to the reference is forwarded to the resource. After the client is done, the resource is protected by revoking the reference, by switching it off. Henceforth, applying operations to the reference throws exceptions and nothing is forwarded, anymore.

In the following example, we create a revocable reference for a resource. We then read one of the resources properties via the reference. That works, because the reference grants us access. Next, we revoke the reference. Now the reference doesnt let us read the property, anymore.

function createRevocableReference(target) {
  const handler = {}; // forward everything
  const { proxy, revoke } = Proxy.revocable(target, handler);
  return { reference: proxy, revoke };
}

const resource = { x: 11, y: 8 };
const { reference, revoke } = createRevocableReference(resource);

// Access granted
console.log(reference.x); // 11

revoke();

// Access denied
console.log(reference.x); // TypeError: Revoked

See also