DEV Community

Todd McNeal
Todd McNeal

Posted on • Originally published at reflect.run

Reflection at Reflect: The Reflect and Proxy APIs

Reflect & Proxy

Reflect and Proxy are both standard built-in objects introduced as part of the ES6 spec and are supported in all modern browsers. Broadly speaking, they formalize the concept of metaprogramming in the context of Javascript by combining existing introspection and intercession APIs, and expanding upon them. In this article we'll explore how these objects work using examples that approximate real-world requirements.

Introduction

Javascript engines have object internal methods like [[GetOwnProperty]], [[HasProperty]], and [[Set]], some of which were already exposed for reflection in earlier versions of the spec. If you've worked with Javascript before, you're probably familiar with some of these developer-accessible equivalents. For example...

const foo = { firstName: 'SomeFirstName', age: 99 }
Object.defineProperty(foo, 'lastName', { value: 'SomeLastName', enumerable: true })
const bar = Object.keys(foo) // ['firstName', 'age', 'lastName']
const baz = Object.values(foo) // ['SomeFirstName', 99, 'SomeLastName']
Object.hasOwnProperty.call(foo, 'lastName') // true
Enter fullscreen mode Exit fullscreen mode

The examples above demonstrate static introspection methods defined on the global Object. They only represent a subset of the useful engine-internal methods we'd like to access, and they're tacked on to a prototype. Together, the Reflect and Proxy APIs unify and simplify these existing methods, expand upon their introspection capabilities, and expose intercession APIs that were previously not possible.

Instead of covering every function defined on each of these objects in this article, we'll focus on the functions we use most often at Reflect. To learn more about each we recommend reading through the MDN guides.

Simple Reflect Example

Let's imagine a scenario in which you'd like to log some information every time a field on some global object was accessed. You could find every instance of a get() call
throughout your app and send the information manually...

// app.ts
// On pageload, we fetch the global session
window.globalSession = fetchSession()

// file1.ts
// We've accessed a field on globalSession, and the developer has logged that
const firstName = globalSession.firstName
console.log('GOT FIELD firstName')

// file2.ts
// Same applies here
const lastName = globalSession.lastName
const age = globalSession.age
const firstRelative = globalSession.relatives[0]
console.log('GOT FIELD lastName')
console.log('GOT FIELD age')
console.log('GOT FIELD relatives[0]')
Enter fullscreen mode Exit fullscreen mode

This pattern is flawed for a number of reasons

  1. It requires proprietary knowledge: Developers are responsible for remembering that every time they access some field on globalSession, they must also include a call to console.log(). This is difficult to enforce and easy to forget.
  2. It does not scale: If the name of a field on globalSession changes, refactoring would be a nightmare. If you'd like to implement the same policy for some object other than globalSession, you'd need to repeat the entire original process and further expand upon the proprietary knowledge needed to develop in the codebase.
  3. It doesn't account for more complex scenarios: The example above demonstates simple access patterns, but what happens when you have something like the following?
// file3.ts
// Point another global to the global session
window.activeSession = globalSession

// file4.ts
// Don't forget that activeSession points to the same object as globalSession, you
// still need to call console.log()!
const middleName = activeSession.middleName
Enter fullscreen mode Exit fullscreen mode

The flaws in the approach above illustrate a disconnect between what we're trying to express and how we've implemented our solution. We want to log some information to the console every time a field on some object is accessed. We've solved this by enforcing a rule which requires manually calling a function.

The Proxy object allows us to solve the problem by expressing the desired behavior rather than trying to enforce a flimsy policy. Here's how that would work.

// makeStoreAccessProxy.ts
const makeStoreAccessProxy = (obj: Object) => {
  return new Proxy(obj, {
    get(target, key, receiver) {
      console.log(`GOT FIELD ${key}`)
      return Reflect.get(target, key)
    },
  })
}

// app.ts
window.globalSession = makeStoreAccessProxy(fetchSession())
Enter fullscreen mode Exit fullscreen mode

Every time anyone accesses any field on globalSession (directly or indirectly), that access will automatically be logged to the console.

This solves the flaws in the pattern above

  1. There is no proprietary knowledge needed: Developers can access fields on globalSession without remembering to store information about said access.
  2. It scales: Refactoring globalSession is as easy as refactoring any other object, and the same makeStoreAccessProxy function can be used on any object in the entire codebase at any time.
  3. It accounts for more complex scenarios: If you get() some field on globalSession by way of some other object that points to it, the access will still be logged to the console.

Note that we've leveraged both the Proxy and Reflect APIs in order to achieve the desired result. We'll review this piece by piece:

const makeStoreAccessProxy = (obj: Object) => {
  // This function returns a proxy of the provided 'obj'. Without defining the second
  // 'handler' argument, this is a transparent passthrough to 'obj' and would behave as
  // though it _were_ the original 'obj'.
  return new Proxy(obj, {
    // We then define a 'get' function in the handler. This means that we're redefining
    // the fundamental get operation on 'obj'
    get(target, key, receiver) {
      // We've redefined 'get' to log information in the console
      console.log(`GOT FIELD ${key}`)
      // And finally, we're calling 'get' on the original unwrapped 'obj'. We could
      // instead return 'target[key]', but this demonstrates the consistency between
      // the Proxy and Reflect APIs
      return Reflect.get(target, key)
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

The consistency between the Proxy's get() method in its handler and the Reflect.get function holds for all functions on both objects. Every method you can define on a Proxy handler has an equivalent function on the Reflect object. You could create a completely pointless proxy which just acted as a passthrough by overriding every supported method and simply calling the Reflect equivalent...

const p = new Proxy({}, {
  defineProperty() { return Reflect.defineProperty(...arguments) },
  getPrototypeOf() { return Reflect.getPrototypeOf(...arguments) },
  get() { return Reflect.get(...arguments) },
  set() { return Reflect.set(...arguments) },
  ... // etc
})
Enter fullscreen mode Exit fullscreen mode

Advanced Reflect Example

In this case, the code we're writing needs to keep track of all images on the page that are loaded dynamically by some web application we do not control. Since we cannot manipulate the underlying application's code directly, we need some mechanism by which we'll trap access to the src attribute transparently...

// First we'll store a reference to the original property descriptor for the
// HTMLImageElement's src field
const originalImgSrc = Reflect.getOwnPropertyDescriptor(HTMLImageElement.prototype, 'src')

// Then we'll overwrite the HTMLImageElement prototype's "src" property and trap
// calls to that field's get() and set() methods
Reflect.defineProperty(HTMLImageElement.prototype, 'src', {
  get() {
    // When <someImg>.src is called anywhere, we'll log some information, then call the
    // target's get() method also using the Reflect API
    console.log('getting the src')
    return Reflect.apply(originalImgSrc.get, this, [])
  },
  set(value) {
    // When <someImg>.src = 'something' is called anywhere, we'll log some information, then call the
    // target's set() method also using the Reflect API
    console.log(`setting src to ${value}`)
    return Reflect.apply(originalImgSrc.set, this, [value])
  },
})
Enter fullscreen mode Exit fullscreen mode

From an application's perspective, this change is transparent. The src attribute of any <img> node can be manipulated as though this override didn't exist. We're only intercepting access to these fields, taking some action, then carrying on as though nothing happened. The underlying app would require not knowledge of such a change and would remain functionally unchanged.

Proxy Example

How could we leverage the Proxy object? We may need to trap behaviors captured deep in the internals of some library or framework in order to redefine them entirely. Let's imagine a scenario in which a framework has two internal methods that manipulate the DOM. Both methods achieve the same end result, but one is asynchronous while the other is not. The asynchronous version may be the better choice for most apps for performance reasons, but in order to accurately track every action a user is taking we'd prefer it if developers only used the synchronous version.

With Proxy, this isn't a problem, and it's something we can control entirely ourselves without the need for applications to change their own source.

const someFramework = document.querySelector('#framework-root').framework

someFramework.blockingUpdate = new Proxy(someFramework.blockingUpdate, {
  apply(target, thisArg, argArray) {
    // Here we'll take some action whenever a call to blockingUpdate() is made
    console.log('Intercepted a call to blockingUpdate()')
    Reflect.apply(target, thisArg, argArray)
  },
})

someFramework.asyncUpdate = new Proxy(someFramework.asyncUpdate, {
  apply(target, thisArg, argArray) {
    // Here we'll redefine calls to asyncUpdate() to instead invoke blockingUpdate()
    Reflect.apply(someFramework.blockingUpdate, thisArg, argArray)
  },
})
Enter fullscreen mode Exit fullscreen mode

Conclusion

It's important to be thoughtful when using the APIs described in this article. In general, web applications should not be redefining core web APIs (we think Reflect's use-case is an exception), but when Proxy and Reflect are the right tools for the job, it's also important to understand how they work. For instance, in the past we've used the Reflect.defineProperty function to redefine a global 3rd party property that exists on many sites on the web, but when we did so we forgot to include the enumerable: true field. One site in particular was relying on that property being enumerable, and so when we redefined it some functionality on their site stopped working in the context of using the Reflect app.

Reflect (the application) can be thought of as a top-to bottom reflective web application container that ideally is transparent to the web application it is observing and manipulating. If you'd like to learn more about how Reflect works, we'd love to hear from you! You can reach us at info@reflect.run. Happy testing!

Top comments (0)