Recently I started a New Node project that uses TypeScript and a lot of decorators for database and routing management, which I found to be a really good thing. Decorators are a draft feature. There is currently no environment that directly supports this syntax, but you can use Babel to convert to the old syntax, so it’s safe to use @decorators in TypeScript.

What is a decorator

A decorator is a decoration on a class, function, property, etc., to which additional behavior can be added. In general, you can think of it as wrapping a layer of processing logic around the original code. Personally, I think decorators are a solution, not a narrow @decorator, which is just syntax sugar.

Examples of decorators around can be seen everywhere. For a simple example, the bubbler above the faucet is a decorator. After being installed, it will mix air into the water and mix a lot of bubbles into the water. But whether the bubbler is installed or not has no effect on the faucet itself. Even if the bubbler is removed, it will work as usual. The role of the faucet lies in the control of the valve, and whether the water is doped with bubbles is not the faucet’s concern.

Therefore, decorators can simply be understood as non-intrusive behavior modification.

Why decorators

There may be times when we determine the type of parameters passed in, sort and filter return values, add throttling, buffering, or other functional code to functions, inherit from multiple classes, and all kinds of repetitive code that has nothing to do with the logic of the function itself.

Function in a function

Imagine that we have a utility class that provides a function to retrieve data:

class Model1 {
  getData() {
    // Omit the logic for getting the data
    return [{
      id: 1.name: 'Niko'
    }, {
      id: 2.name: 'Bellic'}}}]console.log(new Model1().getData())     // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]
console.log(Model1.prototype.getData()) // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]
Copy the code

Now we want to add a feature that records how long the function takes to execute. Since this function is used by many people, it is not desirable to add time statistics logic to the caller, so we will modify it in Model1:

class Model1 {
  getData() {
+ let start = new Date().valueOf()
+ try {Return [{id: 1, name: 'Niko'}, {id: 2, name: 'Bellic'}] return [id: 1, name: 'Niko'}, {id: 2, name: 'Bellic'}]+ } finally {
+ let end = new Date().valueOf()
+ console.log(`start: ${start} end: ${end} consume: ${end - start}`)
+}
  }
}

// start: XXX end: XXX consume: XXX
console.log(new Model1().getData())     // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]
// start: XXX end: XXX consume: XXX
console.log(Model1.prototype.getData()) // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]
Copy the code

This way we can see the time-consuming output in the console after the method is called. However, there are several problems with directly modifying the original function code:

  1. There is no point relationship between the code related to the statistical time and the logic of the function itself, which affects the understanding of the original function itself and causes destructive modifications to the structure of the function
  2. If there are more similar functions later that require statistically time-consuming code, adding such code to each function is obviously inefficient and expensive to maintain

So, in order to make the logic of statistics time more flexible, we will create a new utility function that wraps functions that need to set statistics time. Generic time statistics are achieved by passing the Class and the name of the target function to the function:

function wrap(Model, key) {
  // Get the stereotype corresponding to the Class
  let target = Model.prototype

  // Get the descriptor corresponding to the function
  let descriptor = Object.getOwnPropertyDescriptor(target, key)

  // Generate a new function to add time statistics logic
  let log = function (. arg) {
    let start = new Date().valueOf()
    try {
      return descriptor.value.apply(this, arg) // Call the previous function
    } finally {
      let end = new Date().valueOf()
      console.log(`start: ${start} end: ${end} consume: ${end - start}`)}}// Redefine the modified function to the prototype chain
  Object.defineProperty(target, key, { ... descriptor,value: log      // Override the value of the descriptor weight
  })
}

wrap(Model1, 'getData')
wrap(Model2, 'getData')

// start: XXX end: XXX consume: XXX
console.log(new Model1().getData())     // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]
// start: XXX end: XXX consume: XXX
console.log(Model2.prototype.getData()) // [ { id: 1, name: 'Niko'}, { id: 2, name: 'Bellic' } ]
Copy the code

Next, we want to control that one of the Model’s functions cannot be overridden by others, so add some new logic:

function wrap(Model, key) {
  // Get the stereotype corresponding to the Class
  let target = Model.prototype

  // Get the descriptor corresponding to the function
  let descriptor = Object.getOwnPropertyDescriptor(target, key)

  Object.defineProperty(target, key, { ... descriptor,writable: false      // The set attribute cannot be modified
  })
}

wrap(Model1, 'getData')

Model1.prototype.getData = 1 / / is invalid
Copy the code

As you can see, there are quite a few overlaps in the two wrap functions, and the logic to modify the program’s behavior actually relies on the three arguments passed in the object.defineProperty. So, let’s make a change to wrap and make it a generic class conversion:

function wrap(decorator) {
  return function (Model, key) {
    let target = Model.prototype
    let dscriptor = Object.getOwnPropertyDescriptor(target, key)

    decorator(target, key, descriptor)
  }
}

let log = function (target, key, descriptor) {
  // Redefine the modified function to the prototype chain
  Object.defineProperty(target, key, { ... descriptor,value: function (. arg) {
      let start = new Date().valueOf()
      try {
        return descriptor.value.apply(this, arg) // Call the previous function
      } finally {
        let end = new Date().valueOf()
        console.log(`start: ${start} end: ${end} consume: ${end - start}`)}}})}let seal = function (target, key, descriptor) {
  Object.defineProperty(target, key, { ... descriptor,writable: false})}// Convert the parameters
log = wrap(log)
seal = warp(seal)

// Add time statistics
log(Model1, 'getData')
log(Model2, 'getData')

// The set attribute cannot be modified
seal(Model1, 'getData')
Copy the code

At this point, we can call log and SEAL decorators, allowing us to conveniently add behavior to some functions. The split functionality can be used where it may be needed in the future, without having to redevelop the same logic.

Function in Class

As mentioned above, inheriting multiple classes in JS is a headache at the moment. There is no direct syntax for inheriting multiple classes.

class A { say () { return 1}}class B { hi () { return 2}}class C extends A.B {}        // Error
class C extends A extends B {} // Error

// This is ok
class C {}
for (let key of Object.getOwnPropertyNames(A.prototype)) {
  if (key === 'constructor') continue
  Object.defineProperty(C.prototype, key, Object.getOwnPropertyDescriptor(A.prototype, key))
}
for (let key of Object.getOwnPropertyNames(B.prototype)) {
  if (key === 'constructor') continue
  Object.defineProperty(C.prototype, key, Object.getOwnPropertyDescriptor(B.prototype, key))
}

let c = new C()
console.log(c.say(), c.hi()) / / 1. 2
Copy the code

So, in React, we have the concept of a mixin, which is used to copy the functionality of multiple classes onto a new Class. The general idea is what’s listed above, but this mixin is a built-in action in React that we can convert to a more similar decorator implementation. Copy attributes from other classes without changing the original Class:

function mixin(constructor) {
  return function (. args) {
    for (let arg of args) {
      for (let key of Object.getOwnPropertyNames(arg.prototype)) {
        if (key === 'constructor') continue // Skip the constructor
        Object.defineProperty(constructor.prototype, key, Object.getOwnPropertyDescriptor(arg.prototype, key))
      }
    }
  }
}

mixin(C)(A, B)

let c = new C()
console.log(c.say(), c.hi()) // 1, 2
Copy the code

That’s how decorators are implemented on functions and classes (at least for now), but there’s one particularly sweet syntax candy in the draft: @decorator. Can save you a lot of tedious steps to use decorators.

@decorator

The draft decorator, or TS implementation decorator, encapsulates the above two further, splitting them into finer decorator applications that are currently supported in the following ways:

  1. Class
  2. function
  3. Get Set accessor
  4. Instance properties, static functions, and properties
  5. Function parameters

The syntax for @decorators is simple, via the @ sign followed by a reference to a Decorator function:

@tag
class A { 
  @method
  hi () {}
}

function tag(constructor) {
  console.log(constructor === A) // true
}

function method(target) {
  console.log(target.constructor === A, target === A.prototype) // true, true
}
Copy the code

The functions tag and method are executed when class A is defined.

@decorator in Class

This decorator is called before the class definition, and if the function returns a value, it is considered a new constructor to replace the previous one.

The function takes one argument:

  1. Constructor before constructor

We can make some modifications to the original constructor:

Add some attributes

If you want to add some properties or something, there are two options:

  1. Create a new oneclassInherit fromclass, and add properties
  2. In view of the currentclassmodified

The latter has a narrower scope and is closer to the mixin approach.

@name
class Person {
  sayHi() {
    console.log(`My name is: The ${this.name}`)}}// Create an anonymous class that inherits from Person
// Return and replace the original constructor
function name(constructor) {
  return class extends constructor {
    name = 'Niko'}}new Person().sayHi()
Copy the code

Modify the descriptor for the original attribute

@seal
class Person {
  sayHi() {}
}

function seal(constructor) {
  let descriptor = Object.getOwnPropertyDescriptor(constructor.prototype, 'sayHi')
  Object.defineProperty(constructor.prototype, 'sayHi', { ... descriptor,writable: false
  })
}

Person.prototype.sayHi = 1 / / is invalid
Copy the code

Use closures to enhance the power of decorators

This is called a decorator factory in the TS documentation

Since the @ sign is followed by a reference to a function, we can easily implement mixins using closures:

class A { say() { return 1}}class B { hi() { return 2 } }

@mixin(A, B)
class C {}function mixin(. args) {
  // The called function returns the function where the decorator is actually applied
  return function(constructor) {
    for (let arg of args) {
      for (let key of Object.getOwnPropertyNames(arg.prototype)) {
        if (key === 'constructor') continue // Skip the constructor
        Object.defineProperty(constructor.prototype, key, Object.getOwnPropertyDescriptor(arg.prototype, key))
      }
    }
  }
}

let c = new C()
console.log(c.say(), c.hi()) // 1, 2
Copy the code

Application of multiple decorators

It is possible to apply more than one decorator at a time (otherwise they would lose their original meaning). The usage is as follows:

@decorator1
@decorator2
class {}Copy the code

The order of execution is decorator2 -> Decorator1, with the nearest class definition being executed first. Think of it as a nested function:

decorator1(decorator2(class {}))
Copy the code

@decorator used in Class members

@decorators on class members are probably the most widely used. Functions, properties, get, and set accessors are all considered class members. In the TS document, it is divided into Method decorators, Accessor decorators, and Property decorators.

For this type of decorator, it takes three arguments:

  1. If the decorator is mounted on a static member, it returns the constructor, and if mounted on an instance member, it returns the class stereotype
  2. The name of the member mounted by the decorator
  3. Member descriptor, i.eObject.getOwnPropertyDescriptorThe return value of the

Property decorators do not return a third argument, but they can be manually obtained if they are static members, not instance members. Because decorators are executed when a class is created, and instance members are executed when a class is instantiated, there is no way to get descriptor

The difference in return values between static and instance members

To clarify the difference between static members and instance members:

class Model {
  // Instance members
  method1 () {}
  method2 = (a)= > {}

  // Static members
  static method3 () {}
  static method4 = (a)= >{}}Copy the code

Method1 and method2 are instance members, method1 exists on prototype, and method2 only exists after instantiating the object. The difference between method3 and Method4, which are static members, is whether or not the descriptor Settings can be enumerated, so it’s easy to think of the above code as looking like this when converted to the ES5 version:

function Model () {
  // Members are assigned only on instantiation
  this.method2 = function () {}}// Members are defined on the prototype chain
Object.defineProperty(Model.prototype, 'method1', {
  value: function () {}, 
  writable: true.enumerable: false.// The Settings cannot be enumerated
  configurable: true
})

// Members are defined on constructors and can be enumerated by default
Model.method4 = function () {}

// Members are defined on constructors
Object.defineProperty(Model, 'method3', {
  value: function () {}, 
  writable: true.enumerable: false.// The Settings cannot be enumerated
  configurable: true
})
Copy the code

You can see that only method2 is assigned when it’s instantiated, and a Property that doesn’t exist doesn’t have one descriptor, so that’s why TS doesn’t pass a third argument to Property decorators, and why static members don’t pass one descriptor, No logical explanation has been found, but it can be obtained manually if it is explicitly used.

As in the above example, after we add decorators for all four members, the first argument for method1 and method2 is model. prototype, and the first argument for method3 and method4 is Model.

class Model {
  // Instance members
  @instance
  method1 () {}
  @instance
  method2 = (a)= > {}

  // Static members
  @static
  static method3 () {}
  @static
  static method4 = (a)= >{}}function instance(target) {
  console.log(target.constructor === Model)
}

function static(target) {
  console.log(target === Model)
}
Copy the code

The difference between functions, accessors, and property decorators

function

The return value of the function decorator will default as the value descriptor of the property. If the return value is undefined, it will be ignored. Use the previous descriptor reference as the function descriptor. So the logic for our initial statistical time can be done as follows:

class Model {
  @log1
  getData1() {}
  @log2
  getData2() {}
}

// Option 1 returns a new value descriptor
function log1(tag, name, descriptor) {
  return{... descriptor, value(... args) {let start = new Date().valueOf()
      try {
        return descriptor.value.apply(this, args)
      } finally {
        let end = new Date().valueOf()
        console.log(`start: ${start} end: ${end} consume: ${end - start}`)}}}}// Option 2: Modify the existing descriptor
function log2(tag, name, descriptor) {
  let func = descriptor.value // Get the previous function first

  // Modify the corresponding value
  descriptor.value = function (. args) {
    let start = new Date().valueOf()
    try {
      return func.apply(this, args)
    } finally {
      let end = new Date().valueOf()
      console.log(`start: ${start} end: ${end} consume: ${end - start}`)}}}Copy the code

accessor

Accessors are functions prefixed with GET and set, which are used to control the assignment and value operations of attributes. They are not different from functions in usage, and even in the handling of return values. We just need to set the corresponding get or set descriptor:

class Modal {
  _name = 'Niko'

  @prefix
  get name() { return this._name }
}

function prefix(target, name, descriptor) {
  return {
    ...descriptor,
    get () {
      return `wrap_The ${this._name}`}}}console.log(new Modal().name) // wrap_Niko
Copy the code

attribute

If we want to modify a static property, we need to get descriptor:

class Modal {
  @prefix
  static name1 = 'Niko'
}

function prefix(target, name) {
  let descriptor = Object.getOwnPropertyDescriptor(target, name)

  Object.defineProperty(target, name, { ... descriptor,value: `wrap_${descriptor.value}`
  })
  
  return target
}

console.log(Modal.name1) // wrap_Niko
Copy the code

For an instance property, there is no way to modify it directly, but we can combine some decorators to save the curve.

For example, if we have a class that passes in the name and age as initialization parameters, then we need to set the format validation for these two parameters:

const validateConf = {} // Store the verification information

@validator
class Person {
  @validate('string')
  name
  @validate('number')
  age

  constructor(name, age) {
    this.name = name
    this.age = age
  }
}

function validator(constructor) {
  return class extends constructor {
    constructor(... args) {super(... args)// Iterate over all the verification information for verification
      for (let [key, type] of Object.entries(validateConf)) {
        if (typeof this[key] ! == type)throw new Error(`${key} must be ${type}`)}}}}function validate(type) {
  return function (target, name, descriptor) {
    // Pass the name and type of the property to verify to the global object
    validateConf[name] = type
  }
}

new Person('Niko'.'18')  // throw new error: [age must be number]
Copy the code

First, add the @validator decorator to the top of the class. Then add the @validator decorator to the two parameters that need to be validated. The two decorators are used to pass information to a global object to record which properties need to be validated. Then, the validator inherits the original class object from the validator, and iterates over all the validation information just set after the instantiation. If a type error is found, an exception is thrown directly. The operation of this type validation is almost invisible to the original Class.

Function parameter decorators

Finally, there is a decorator for function arguments. This decorator is just like an instance property, and there is no way to use it alone. After all, functions are called at run time, while any decorator is called when the class is declared (which can be considered pseudo-compile time).

The function argument decorator takes three arguments:

  1. Similar to the above operations, the class prototype or the class constructor
  2. The name of the function to which the argument belongs
  3. The position of the argument in the function parameter (the number of arguments in the function signature)

As a simple example, we can use a function decorator to convert function parameters:

const parseConf = {}
class Modal {
  @parseFunc
  addOne(@parse('number') num) {
    return num + 1}}// Perform the formatting operation before the function call
function parseFunc (target, name, descriptor) {
  return{... descriptor, value (... arg) {// Get the formatting configuration
      for (let [index, type] of parseConf) {
        switch (type) {
          case 'number':  arg[index] = Number(arg[index])             break
          case 'string':  arg[index] = String(arg[index])             break
          case 'boolean': arg[index] = String(arg[index]) === 'true'  break}}return descriptor.value.apply(this, arg)
    }
  }
}

// Add formatting information to the global object
function parse(type) {
  return function (target, name, index) {
    parseConf[index] = type
  }
}

console.log(new Modal().addOne('10')) / / 11
Copy the code

Use decorators to implement an interesting Koa encapsulation

For example, when writing a Node interface, you might use koA or Express. Generally, you might have to deal with a lot of request parameters, from headers, from body, from Query, from cookie. So it’s possible that the first line at the beginning of the router will be something like this:

router.get('/'.async (ctx, next) => {
  let id = ctx.query.id
  let uid = ctx.cookies.get('uid')
  let device = ctx.header['device']})Copy the code

And if we have a lot of interfaces, we might have a lot of router.get, router.post. And if you want to categorize modules, there might be a lot of new Router operations.

This code is independent of the business logic itself, so we should keep it as simple as possible, and using decorators can help us achieve this.

Preparation of decorators

// First, we need to create several global lists to store information
export const routerList      = []
export const controllerList  = []
export const parseList       = []
export const paramList       = []

// We do need a decorator to create a Router instance
// However, the decorator is not created directly. Instead, it is registered once the decorator is executed
export function Router(basename = ' ') {
  return (constrcutor) = > {
    routerList.push({
      constrcutor,
      basename
    })
  }
}

// Then we create a decorator for the corresponding Get/Post request listener
// Again, we are not going to change any of its properties, just to get a reference to the function
export function Method(type) {
  return (path) = > (target, name, descriptor) => {
    controllerList.push({
      target,
      type,
      path,
      method: name,
      controller: descriptor.value
    })
  }
}

// Next we need decorators to format the parameters
export function Parse(type) {
  return (target, name, index) = > {
    parseList.push({
      target,
      type,
      method: name,
      index
    })
  }
}

// And finally we need to deal with the various parameters to get
export function Param(position) {
  return (key) = > (target, name, index) => {
    paramList.push({
      target,
      key,
      position,
      method: name,
      index
    })
  }
}

export const Body   = Param('body')
export const Header = Param('header')
export const Cookie = Param('cookie')
export const Query  = Param('query')
export const Get    = Method('get')
export const Post   = Method('post')
Copy the code

Processing of Koa services

We’ve created all the decorators we need, but we’ve only saved the information we need. How to use the decorators is the next step:

const routers = []

// Iterates through all classes and creates the corresponding Router object
routerList.forEach(item= > {
  let { basename, constrcutor } = item
  let router = new Router({
    prefix: basename
  })

  controllerList
    .filter(i= > i.target === constrcutor.prototype)
    .forEach(controller= > {
      router[controller.type](controller.path, async (ctx, next) => {
        let args = []
        // Get the parameter of the current function
        paramList
          .filter( param= > param.target === constrcutor.prototype && param.method === controller.method )
          .map(param= > {
            let { index, key } = param
            switch (param.position) {
              case 'body':    args[index] = ctx.request.body[key] break
              case 'header':  args[index] = ctx.headers[key]      break
              case 'cookie':  args[index] = ctx.cookies.get(key)  break
              case 'query':   args[index] = ctx.query[key]        break}})// Get the parameter format for the current function
        parseList
          .filter( parse= > parse.target === constrcutor.prototype && parse.method === controller.method )
          .map(parse= > {
            let { index } = parse
            switch (parse.type) {
              case 'number':  args[index] = Number(args[index])             break
              case 'string':  args[index] = String(args[index])             break
              case 'boolean': args[index] = String(args[index]) === 'true'  break}})// Call the actual function to handle the business logic
        letresults = controller.controller(... args) ctx.body = results }) }) routers.push(router.routes()) })const app = new Koa()

app.use(bodyParse())
app.use(compose(routers))

app.listen(12306, () = >console.log('server run as http://127.0.0.1:12306))
Copy the code

The above code sets up a Koa wrapper and includes the handling of various decorators. Here are the decorators in action:

import { Router, Get, Query, Parse } from ".. /decorators"

@Router(' ')
export default class {
  @Get('/')
  index (@Parse('number') @Query('id') id: number) {
    return {
      code: 200,
      id,
      type: typeof id
    }
  }

  @Post('/detail')
  detail (
    @Parse('number') @Query('id') id: number, 
    @Parse('number') @Body('age') age: number
  ) {
    return {
      code: 200.age: age + 1}}}Copy the code

It is very easy to implement a router to create, path, method processing, including various parameters to obtain, type conversion. Leave all the non-business logic related code to the decorator, and the function itself handles its own logic. Here’s the full code: GitHub. You can see the effect after installing the dependency on NPM Start.

The benefits of this development are that the code is more readable and you are more focused on what you should be doing in the function. And decorators themselves, if well named, can be treated as documentation comments to a certain extent (Java has a similar thing called annotations).

conclusion

Reasonable use of decorators can greatly improve the development efficiency, some non-logical related code packaging refining can help us quickly complete repetitive work, save time. But sugar is again good, also don’t eat too much, easy to bad teeth, the same abuse decorators can also make the code itself logic becomes complicated, if to determine a piece of code don’t need to in other places, or a function of the core logic is the code, then there is no need to remove it as a decorator to exist.

The resources

  1. typescript | decorators
  2. Koa sample of the original, simplified code for easy examples

One more thing

Our company is now hiring a large number of people, front-end, Node direction HC company name: Blued, the main technology stack of Chaoyang Shuangjing is React, and there will also be opportunities to play ReactNative and Electron Node direction 8.X version + KOA new project will be mainly TS. Interested partners can contact me for more details: Email: [email protected] wechat: github_jiasm