preface

In Mobx, the decorator design pattern is used to implement observations, so you need to have some knowledge of the decorator pattern in order to understand Mobx. This article explores the implementation details of ES7 decorator syntax, from design pattern triggering to the application of decorator syntax in ES7 to observing the transformation @ syntax through Babel.

Decorator pattern for javascript design pattern

First, what is the decorator pattern

1.1 Definition and characteristics of decorator mode

Definition: To dynamically add additional responsibilities to an object while the program is running without changing the original object. Try the original object to meet the user’s more complex needs.

Features:

  • Add functionality without changing the original structure of the original object
  • Decorator objects have the same interface as the original objects, allowing users to use them in the same way as the original objects
  • A decorative object is a wrapped object of the original object

1.2 Problems to be solved

To understand a design pattern correctly, you must first understand the problem for which it is proposed.

In traditional object-oriented languages, we usually add responsibilities to objects through inheritance, which has many disadvantages:

  • Superclass and subclass are strongly coupled, and changes in the parent class result in changes in the subclass
  • Internal details of the parent class are visible to subclasses, breaking encapsulation
  • It is possible to create too many subclasses while implementing functionality reuse

Here’s an example: A coffee shop there are four types of coffee beans, if we are four different types of coffee beans defines four class, but we still need to add them different flavors (a total of five kinds of taste), so if by inheritance will need to create different types of coffee displayed 4 x5 (20) classes (not including mixed taste), With decorator mode, you only need to define five different flavor classes and dynamically add them to coffee beans.

From the examples above, we can see that the decorator pattern allows you to dynamically and flexibly add responsibilities to objects without explicitly modifying the original code, greatly reducing the number of classes that need to be created, compensating for inheritance, and solving the problem of sharing methods between different classes

Its specific usage scenarios are as follows:

  1. You need to extend the functionality of an object or add additional responsibilities to an object
  2. You need to add functions to an object dynamically and remove them dynamically
  3. The need to permutations some basic functions into one giant function makes inheritance impractical

1.3 Simple Implementation

Take a character in a game. It is well known that characters in a game have initial attributes (HP, def, attack), and we augment them by equipping them with equipment.

var role = {
	showAttribute: function() {console.log(' initial attribute: HP: 100 def: 100 attack: 100 ')}}Copy the code

At this point we can increase the character’s stats by dressing him in decorative gear, which we can do.

var showAttribute = role.showAttribute
var wearArmor = function() {console.log(' equipped armor attribute: HP: 200 def: 200 attack: 100 ')} role-showattribute =function() {
    showAttribute()
    wearArmor()
}
var showAttributeUpgrade = role.showAttribute

var wearWepeon = function() {console.log(' equipped weapon attribute: HP: 200 def: 200 attack: 200 ')} role-showattribute =function() {
    showAttributeUpgrade()
    wearWepeon()
}
Copy the code

By putting one object into another, you dynamically add responsibilities to the object without changing the object itself, where wearArmor and wearWepeon are decorator functions that decorate the showAttribute method on the role object to form a chain of decorators. At this point, The request is automatically forwarded to the next object.

In addition, we can observe that in decorator mode, we cannot extend showAttribute without knowing the implementation details of the original method, and the methods of the original object can be called intact.

Decorated pattern scenarios — AOP programming

In JS, we can easily extend properties and methods to objects, but if we want to add additional functionality to functions, we will inevitably need to change the source code of the function, such as:

function test() {
    console.log('Hello foo')}Copy the code
function test() {
    console.log('Hello foo')
    console.log('Hello bar')}Copy the code

This violates the open and closed principle of object-oriented design, and it is a bad practice to extend functionality by infringing on the source code of a module.

A common solution to this problem is to set up an intermediate variable cache function reference, which can be modified as follows:

var test = function() {
    console.log('Hello foo')
}
var _test = test
test = function() {
    _test()
    console.log('Hello bar')}Copy the code

Function expansion is achieved by caching function references, but there are still problems in this way: in addition to introducing too many intermediate variables in the case of too long decorative chain, it is difficult to maintain, and it will also cause an invisible bug when this hijacking occurs. Although this hijacking problem can be corrected by call, it is still too troublesome.

To address these pain points, we can introduce the AOP(aspect oriented programming) pattern. So what is aspect oriented programming? In short, it is to extract some functions unrelated to the core business logic and add them into the business module in a dynamic way, by which the code of the core business module is kept clean and highly cohesive and reusable. This pattern is widely used in logging systems and error handling.

Implementing them is as simple as extending two functions to a function prototype:

Function.prototype.before = function(beforeFunc) {
    let that = this
    return function() {
		beforeFunc.apply(this,arguments)
		return that.apply(this,arguments)
    }
}

Function.prototype.after = function(afterFunc) {
    let that = this
    return function() {
        let ret = that.apply(this,arguments)
        afterFunc.apply(this,arguments)
        return ret
    }
}
Copy the code

Suppose there is a requirement to print the corresponding log before and after updating the database. With AOP we can do this:

function updateDb() {
    console.log(`update db`)
}
function beforeUpdateDb() {
    console.log(`before update db`)
}
function afterUpdateDb() {
    console.log(`updated db`)
}
updateDb = updateDb.before(beforeUpdateDb).after(afterUpdateDb)
Copy the code

In this way we can flexibly achieve the extension of the function, avoid the function and business irrelevant code intrusion, increase the coupling degree of the code.

The decorator pattern itself is a very good design concept, but you can see that the implementation of the above code is still too bloated and not as clean and concise as a language like Python that supports decorators to implement decorators at the language level. Fortunately, javascript now introduces this concept and we can use it with Babel.

Explore the Decorator in ECMAScript

The concept of decorators was also introduced in ES7 and is well supported through Babel. In essence, a decorator is just a syntactic sugar like a class, but it is so useful that any decorator pattern code can be implemented in a much cleaner way.

Tools to prepare

First you need to install Babel:

npm install babel-loader  babal-core  babel-preset-es2015 babel-plugin-transform-decorators-legacy
Copy the code

Create a new. Babelrc file in the workspace directory

{
  "presets"[// Convert ES6 to ES5'es2015'], // Handle decorator syntax"plugins": ['transform-decorators-legacy']}Copy the code

With that done, you can use Babel to convert code with decorators into ES5 code

babel index.js > index.es5.js
Copy the code

Or we can run the code directly through babel-node index.js to output the result

The principle behind

Decorators make it possible to modify classes and attributes at code time, and take advantage of ES5

Object.defineProperty(target,key,descriptor)
Copy the code

One of the core ones is descriptor — property descriptor.

Property descriptors fall into two categories: data descriptors and accessor descriptors. The descriptor must be one of the two, but cannot contain both. We can use the ES5 Object. GetOwnPropertyDescriptor attributes of the Object to obtain specific descriptors:

Data descriptor:

var user = {name:'Bob'}
Object.getOwnPropertyDescriptor(user,'name'// output /** {"value": "Bob"."writable": true."enumerable": true."configurable": true} * * /Copy the code

Accessor descriptors:

var user = {
    get name() {
        return name
    },
    setName (val) {name = val}}"get": f name(),
  "set": f name(val),
  "enumerable": true."configurable": true} * * /Copy the code

To look at a simple ES6 class:

class Coffee {
    toString() {
        return `sweetness:${this.sweetness} concentration:${this.concentration}`}}Copy the code

Coffee.prototype registers a toString property similar to the following:

Object.defineProperty(Coffee.prototype, 'toString', {
  value: [Function],
  enumerable: false,
  configurable: true,
  writable: true
})
Copy the code

When we annotate the Coffee class with a decorator to make it a read-only property, we can do this:

class Coffee {
    @readonly
    toString() {
        return `sweetness:${this.sweetness} concentration:${this.concentration}`}}Copy the code

This code is equivalent to:

let descriptor = {
  value: [Function],
  enumerable: false,
  configurable: true,
  writable: true
};
descriptor = readonly(Coffee.prototype, 'toString', descriptor) || descriptor;
Object.defineProperty(Coffee.prototype, 'toString', descriptor);
Copy the code

The decorator intercepts object.defineProperty for coffee. prototype before it registers the toString property for it, and executes a decorator called readOnly. Its function signature is the same as object.defineProperty, indicating:

  • Objects for which attributes need to be defined — decorated classes
  • The name of the property that can be defined or modified – the name of the property to be decorated
  • Descriptors of properties defined and modified — the description object of properties

This function changes the data description attribute writable of descroptor from true to false, making the properties of the target object immutable.

The specific use

Suppose we need to add a method to increase the sweetness and strength of the coffee class, which can be implemented like this:

Act on class methods

functionaddSweetness(target, key, descriptor) { const method = descriptor.value descriptor.value = (... args) => { args[0] += 10 const ret = method.apply(target, args);return ret
	}
	return descriptor
}

functionaddConcentration(target, key, descriptor) { const method = descriptor.value descriptor.value = (... args) => { args[1] += 10 const ret = method.apply(target, args)return ret
	}
	returndescriptor } class Coffee { constructor(sweetness = 0, concentration=10) { this.init(sweetness, concentration) } @addSweetness @addConcentration init(sweetness, < this. Sweetness = sweetness; } / / concentrationtoString() {
		return `sweetness:${this.sweetness} concentration:${this.concentration}`
    }
}

const coff = new Coffee()
console.log(`${coff}`)

Copy the code

So if we first look at the output howminuti :10 concentration:20, we can see that it is added inside the init method by means of addConcentration and addConcentration, Get the init method using descriptor. Value and cache the intermediate variables. Then assign a proxy function to Descriptor. At this point we have successfully implemented the requirement in the form of a decorator.

Here we can see the advantages of the decorator pattern, which allows you to superimpose a method without being overly intrusive, easy to reuse and quick to add and delete.

Student: Acting on the class

When adding ice to a coffee class gives it a new property, you can enhance the class by applying a decorator to it.

function addIce(target) {
	target.prototype.iced = true} @addIce class Coffee { constructor(sweetness = 0, concentration = 10) { this.init(sweetness, concentration); } init(sweetness, concentration) { this.sweetness = sweetness; // this. Concentration = concentration; } / / concentrationtoString() {
    return `sweetness:${this.sweetness} concentration:${this.concentration} iced:${this.iced}`;
  }
}

const coff = new Coffee()
console.log(`${coff}`)
Copy the code

Sweetness :0 concentration:10 ICED :true Adds properties to the prototype of the class by using the decorator applied to it. When a decorator works on a class, it passes in only one parameter, the class itself, adding attributes to it by changing the class’s prototype in the decorator method.

Decorators can also be factory functions

When we want a single decorator to behave differently on different targets, we can implement the decorator with the factory pattern:

function decorateTaste(taste) {
    return function(target) {
        target.taste = taste;
    }
}

@decorateTaste('bitter')
class Coffee {
    toString() {
        return `taste:${Coffee.taste}`;
    }
}

@decorateTaste('sweet')
class Milk {
    toString() {
        return `taste:${Milk.taste}`; }}Copy the code

The practical application

The decorator is just syntactic sugar, but it has a lot of application scenarios. Here is a brief example of an AOP application scenario, as well as a contrast to the previously mentioned version of ES5 implementation.

function AOP(beforeFn, afterFn) {
    return function(target, key, descriptor) { const method = descriptor.value descriptor.value = (... args) => {letret beforeFn && beforeFn(... args) ret = method.apply(target, args)if (afterFn) {
                ret = afterFn(ret)
            }
            returnRet}}} // add +1 for each argument to sumfunctionbefore(... args) {returnArgs. map(item => item + 1)function after(sum) {
    returnsum + 66 } class Calculate { @AOP(before, after) static sum(... args) {return args.reduce((a, b) => a + b)
    }
}

console.log(Calculate.sum(1, 2, 3, 4, 5, 6))
Copy the code

By applying AOP’s decorator functions to class methods, we can pre-process the parameters of the function and then post-process the output of the objective function. Compared with the ES5 implementation, it avoids the contamination of function prototypes and is implemented in a clear and flexible way, reducing the amount of code.

How does Babel implement the @ syntax for decorators

Now that you know the basics of decorator patterns and decorators, it’s time to get down to business: how to decorate @ syntax inside Babel.

An example from a simple website:

import { observable, computed, action } from "mobx";

class OrderLine {
    @observable price = 0;   
    @observable amount = 1;

    @computed get total() {
        return this.price * this.amount;
    }
    
    @action.bound
    increment() {
        this.amount++
    }
}
Copy the code

Convert it to ES5 code using the Babel decorator plug-in, observe the result of the @ syntax being transformed, and examine the code logic after the transformation. (Conversion of this code requires installing the babel-preset-mobx preset)

Let’s start with the decorator syntax for the price attribute:

@observable price = 0;   
Copy the code

The main thing this code does is declare a property member price, and then apply the decorator function to that property to decorate it. The pseudocode is as follows:

/ / _initializerDefineProperty method is through the Object. DefineProperty orderLine the members of the class definition attributes, / / and the _descriptor property descriptor for after decoration, The value by _applyDecoratedDescriptor method according to the reference return / / after a particular decorators decoration modifier _initializerDefineProperty (this,"price", _descriptor, this);
_descriptor = _applyDecoratedDescriptor(_class.prototype, "price", [observable], {
      configurable: true,
      enumerable: true,
      writable: true,
      initializer: function () {
        return0; }})Copy the code

You can see that the key for Babel to transform the @ syntax is through the _applyDecoratedDescriptor method, which I’m going to focus on parsing.

This function is signed as:

function _applyDecoratedDescriptor(target, property, decorators, descriptor, context)
Copy the code

The function parameters have the following meanings:

  • target: OrderLine.prototype
  • Property: Specifies the name of the property
  • — Different decorators are different, such as decorators decorated with @obServerable[observable]A decorator decorated with the @computed decorator is[computed]
  • Descriptor: property descriptors, the thing to notice here is that classes can be divided into property members and method members, where property members haveinitializerThis attribute defines the initial value, and method members do not have this attribute, so it is used to distinguish attribute members from method members, as reflected in the internal logic of the function
  • Context: Run context

After the function signature is explained, the function logic begins.

The core logic of this function is to apply the decorator loop to the original property as follows:

desc = decorators.slice().reverse().reduce(function (desc, decorator) { 
    return decorator(target, property, desc) || desc; 
  }, desc); 
Copy the code

Suppose we pass in a decorator [a, b, C], then we apply formula A (b(c(property)), i.e., decorators C, B, a act on target attributes, and decorators have the same function signature as Object.defineProperty. It is used to modify the descriptor of the target property.

Now the essence of the conversion from Babel to @ syntax has been explained, and the core of it is the _applyDecoratedDescriptor method, and all that this method does is apply the decorator loop to the target property.

To summarize, the @ syntax works like this:

  1. Get the original descriptor of the property member from the object,
  2. Pass the original descriptor to the decorator method to get the modified property descriptor,
  3. throughObject.definePropertyApply the modified property descriptor to the target property,
  4. Repeat the process if there are multiple decorators.