43 Metaprogramming Empowering Metaprogramming Through Proxies and Reflect

43 Metaprogramming Empowering Metaprogramming Through Proxies and Reflect #

Hello, I’m Ishikawa.

Today, we come to the final lesson of this unit. In the previous two lessons, we learned about the two trends of micro frontends and big frontends. Today, let’s take a look at “metaprogramming”. So, what does the “meta” in metaprogramming mean? “Meta” means “above” or “beyond ordinary restrictions”. It sounds a bit mysterious, but in reality, understanding and using metaprogramming is not difficult. In fact, you may be using it every day without even realizing it. So today, let’s step by step understand the concept and usage of metaprogramming.

In JavaScript, we can categorize the features of metaprogramming into several types: the first type is related to finding and adding object properties; the second type is creating specific domain-specific languages (DSLs); and the third type is using proxies as decorators for objects. Today, let’s take a look at each of them one by one.

Attributes of Object Properties #

1. Setting Object Properties #

First, let’s take a look at how we can find and add object properties. We all know that JavaScript object properties consist of a name and a value. But in addition to that, we also need to understand that each property itself has three related attributes: writable, enumerable, and configurable.

These three attributes specify how the property behaves and what we can do with it. The writable attribute determines whether the value of the property can be changed; the enumerable attribute determines whether the property can be enumerated by for/in loops and the Object.keys() method; and the configurable attribute specifies whether the property can be deleted or have its attributes changed.

It is worth noting that the object literals we define and the object properties defined by assignment are all writable, enumerable, and configurable, but many object properties defined in the JavaScript standard library are not. From this, you should be able to see that if you have used for/in, then congratulations, you have basically used metaprogramming.

So what is the use of querying and setting attributes of properties? This is important for many third-party library developers, as it allows them to add methods to prototype objects and make them non-enumerable, just like many built-in methods in the standard library. At the same time, it also allows developers to “lock” objects, making their property definitions unchangeable or non-deletable. Now let’s take a look at the APIs that can empower JavaScript third-party library development by querying and setting property attributes.

Properties can be divided into two categories: “data properties” and “accessor properties”. If we consider value, getter, and setter as values, then data properties include value, writable, enumerable, and configurable attributes, while accessor properties include get, set, enumerable, and configurable attributes. The writable, enumerable, and configurable attributes are boolean values, and the get and set attributes are function values.

We can use object.getOwnPropertyDescriptor() to get the property descriptor of an object property. As the name suggests, this method only applies to getting own properties of an object. If you want to query the attributes of inherited properties, you need to traverse the prototype chain using the Object.getPrototypeOf() method. If you want to set the attributes of a property or create a new property with specific attributes, you need to use the Object.defineProperty() method.

2. Object Extensibility #

In addition to getting and setting object properties, we can also set the extensibility of the object itself. We can make an object extensible using Object.isExtensible(). Similarly, we can make an object non-extensible using Object.preventExtensions().

However, it is worth noting that once we make an object non-extensible, we can not only no longer set properties on the object, but we also cannot make the object extensible again. Also, this non-extensibility only affects the properties of the object itself and does not affect the properties on the object’s prototype object.

The extensibility of an object is commonly used to lock the object’s state. Usually, we combine it with the writable and configurable attributes in property settings. In JavaScript, we can combine non-extensible and non-configurable attributes using Object.seal(), and combine non-extensible, non-configurable, and non-writable attributes using Object.freeze().

For the development of JavaScript third-party libraries, if the library passes objects to its callback functions, we can use Object.freeze() to prevent modifications by user code. However, it is worth noting that such methods may interfere with JavaScript testing strategies.

3. Object’s Prototype Object #

Just like Object.freeze(), Object.seal(), and property setting methods mentioned earlier, they only apply to the object itself and do not affect the object’s prototype. We know that objects created by new use the prototype value of the constructor function as their prototype, and objects created by Object.create() use the first argument as the prototype of the object.

We can use Object.getPrototypeOf() to get the prototype of an object; we can use isPrototypeOf() to determine if an object is the prototype of another object; and if we want to modify the prototype of an object, we can use Object.setPrototypeOf(). However, it is worth noting that once a prototype has been set, it is rarely changed, and using Object.setPrototypeOf() may have performance implications.

Template Tags for DSL #

As we know, in JavaScript, strings enclosed in backticks are called template literals. When an expression with a function value is followed by a template literal, it turns into a function invocation, and we call it a “tagged template literal”.

Why do we say that defining a new tag function for tagged template literals can be considered a form of metaprogramming? Because tagged templates are commonly used to define DSLs, which stands for domain-specific languages. In this way, defining a new tag function is like adding new syntax to JavaScript. Tagged template literals have been adopted by many frontend JavaScript libraries. GraphQL query language, for example, allows queries to be embedded into JavaScript code using the gql tag function. Emotion library uses the css tag function to embed CSS styles into JavaScript as well.

When a function expression is followed by a template literal, the function is called. The first parameter is an array of strings, followed by zero or more additional parameters, which can have values of any type. The number of parameters depends on the number of values inserted into the template literal. The values in the template literal are always strings, but the values in a tagged template literal are the values returned by the tag function. It can be a string, but when implementing a DSL using a tag function, the return value is often a non-string data structure, which represents the parsed representation of the string.

Templates are very useful when we want to safely insert a value into an HTML string. Taking html`` as an example, before constructing the final string using the tag, the tag will perform HTML escaping on each value.

function html(str, ...val) {
    var escaped = val.map(v => String(v)
                                  .replace("&", "&")
                                  .replace("'", "'"));
    var result = str[0];
    for(var i = 0; i < escaped.length; i++) {
        result += escaped[i] + str[i+1];
    }
    return result;
}

var operator = "&";
html`<b>x ${operator} y</b>`             // => "<b>x &amp; y</b>"

Next, let’s take a look at the Reflect object. Reflect is not a class, similar to the Math object, its properties simply define a set of related functions. These functions added in ES6 are all in one namespace, they imitate the behavior of the core language and replicate various features that already exist in object functions.

Although Reflect functions do not provide any new functionality, they do combine these functionalities into a convenient API. For example, the setting of object properties, extensibility, and access to an object’s prototype object mentioned earlier all have corresponding methods in Reflect, such as Reflect.set(), Reflect.isExtensible(), and Reflect.getPrototypeOf(), and so on. Next, we will see that the Reflect function set and the handler methods of Proxy can also correspond one-to-one.

Proxy and Reflect #

The Proxy class provided in ES6 and higher versions can be considered as one of the most powerful metaprogramming features in JavaScript. It allows us to write code that changes the fundamental behavior of JavaScript objects. The Reflect API, mentioned earlier, is a set of functions that allow us to directly access a set of basic operations on JavaScript objects. When we create a Proxy object, we specify two other objects: the target object and the handler object.

var target = {
  message1: "hello",
  message2: "world",
};
var handler = {};

var proxy = new Proxy(target, handler);

The generated proxy object has no own state or behavior. Whenever an operation is performed on it (reading property, writing property, defining new property, looking up prototype, invoking it as a function), it dispatches these operations to either the handler object or the target object. The operations supported by the proxy object are the same as those defined in the Reflect API. The working mechanism of Proxy is that if the handler is empty, then the proxy object is just a transparent decorator. So in the example above, if we perform operations on the proxy, the result it returns is originally owned by the target object.

console.log(proxy.message1); // hello
console.log(proxy.message2); // world

Usually, we use Proxy and Reflect together. The benefit of doing this is that for the parts we don’t want to customize, we can use Reflect to invoke the object’s built-in methods.

const target = {
  message1: "hello",
  message2: "world",
};

const handler = {
  get(target, prop, receiver) {
    if (prop === "message2") {
      return "Jackson";
    }
    return Reflect.get(...arguments);
  },
};
var proxy = new Proxy(target, handler);
console.log(proxy.message1); // hello
console.log(proxy.message2); // Jackson

Summary #

In today’s lesson, we saw that JavaScript objects’ properties themselves can have properties that can be looked up and added to. Moreover, we learned that JavaScript-defined functions allow us to traverse an object’s prototype chain and even modify the object’s prototype.

Tagged template literals are a function call syntax that allows us to define new tag functions, somewhat like adding new literal syntax to the language. By defining a tag function that parses its template string argument, we can embed DSLs in JavaScript code. Tag functions also provide access to the raw, unescaped form of string literals, where backslashes have no special meaning.

Finally, we looked at the Proxy class and the related Reflect API. Proxies and Reflect allow us to have low-level control over the basic behavior of objects in JavaScript. Proxy objects can be used as optional, revocable wrappers to improve code encapsulation, and they can also be used to implement non-standard object behaviors, such as those defined by some APIs in early web browsers.

Thought Question #

We know that the value of properties of the Symbol object can be used to define the names of properties or methods of objects and classes. This allows us to control how objects interact with JavaScript language features and core libraries. So, do you think Symbol is a form of metaprogramming in this use case?

Feel free to share your thoughts, exchange learning experiences, or ask questions in the comment section. If you find it helpful, you are also welcome to share today’s content with more friends. See you in the next lesson!