一起聊聊JavaScript装饰器(decorator)

随着转译变得司空见惯,我们会经常在一些实际代码或教程中遇到新的语言特性。这些特性中,装饰器绝对是让人第一次碰到时会摸不着头脑的特性之一。

装饰器在其他语言比如Python、Java中早就存在了。而在JavaScript中,直到目前仍处于stage2阶段的提案,这表示虽然未来应该会成为语言的一部分,但现在浏览器或Node都还不支持该特性,必须依赖于转译器。

JavaScript中的装饰器提案

刚才说了在JavaScript中,装饰器还是个处于stage2阶段的提案,这表示什么意思呢。

要成为一个ES的新特性,从标准到落地是个漫长的过程,TC39的规范过程分为 Stage 0 ~ 4,共 5 个阶段,分别代表 「起草、提案、草案、候选、完成。」

  • stage0(strawman):任何TC39的成员都可以提交。

  • stage1(proposal):进入此阶段就意味着这一提案被认为是正式的了,需要对此提案的场景与API进行详尽的描述。

  • stage2(draft):演进到这一阶段的提案如果能最终进入到标准,那么在之后的阶段都不会有太大的变化,因为理论上只接受增量修改。

  • state3(candidate):这一阶段的提案只有在遇到了重大问题才会修改,规范文档需要被全面的完成。

  • state4(finished):这一阶段的提案将会被纳入到ES每年发布的规范之中。

TC39是一个推动 JavaScript 发展的委员会,由各个主流浏览器厂商的代表构成。他们就是负责制定ECMAScript标准并实现,目前最为大家熟知的就是2015年发布的ES6。

JavaScript其实很早就有装饰器这个提案,但是迟迟无法落地。

最早的时候,装饰器的提案与现在不同,而Typescript早早实现了装饰器,正好angular2彻底使用了Typescript来重构,大量使用了在当时还是提案的装饰器。

但是到现在,装饰器的提案早已与当时不同,被改的面目全非,在这种前提下,angular团队以及后来的nest团队,肯定是不同意新的装饰器提案的。如果强行推行,对于之前已经使用旧方案的项目,完全是破坏性的打击,所以angular、nest包括typescript团队,在tc39上肯定是不同意通过提案的。于是就持续搁置,提案一直处于stage2。当前的提案可以在 proposal-decorators看到。

由于该提案和现在在用的装饰器语法差距很大,提案里自己也提到现在最好还是继续使用旧的东西。建议继续使用babel的"legacy"装饰器或者TypeScript的"experimental"装饰器。

装饰器是什么

说到现在,那装饰器到底是什么呢。这可以聊聊我们的穿着,我们可以根据不同的天气或场合,选择不同的服饰来搭配自己。大冬天的不得套上个大棉袄,外加手套围巾帽子,进了屋暖和了,脱掉外套和帽子手套啥的。夏天肯定就不会再穿秋衣秋裤啊,穿上凉快的短袖凉鞋,出门再打个遮阳伞。运动穿运动装、在家家居服、结婚穿西装婚纱等等。再说哪个小姐姐的衣柜不是满满的呀,说不定根据心情一天还能换个好几套呢~~

那你说服饰是什么呢,首先肯定不属于你自己的身体的一部分,不会把你真地变成另一个人(只是看上去不一样),同一件衣服还可以给不同的人穿。我们根据需要选择一件或多件来装扮自己,或者说包装自己,当然也可以随时脱掉他们。

那JavaScript装饰器呢,就是对类、类属性、类方法之类的一种装饰,可以理解为在原有代码外层又包装了一层处理逻辑。这样就可以做到不直接修改代码,就实现某些功能。

这个概念其实在标准的javascript中就有,叫做函数组合或高阶函数,只需要通过调用一个函数来包装另一个函数:

function doSomething(name) {
  console.log('Hello, ' + name);
}

function loggingDecorator(wrapped) {
  return function() {
    console.log('Starting');
    const result = wrapped.apply(this, arguments);
    console.log('Finished');
    return result;
  }
}

const wrapped = loggingDecorator(doSomething);

上面这个例子中生成了一个新的函数wrapped,它的调用方式与 doSomething函数完全相同,并且会做同样的事情。 不同之处在于调用wrapped函数之前和之后进行一些日志记录:

doSomething('Graham');
// Hello, Graham

wrapped('Graham');
// Starting
// Hello, Graham
// Finished

怎样使用装饰器

装饰器在JavaScript中使用一种特殊语法,即它们以@符号为前缀,并放在被装饰的代码之前。可以根据需要在同一段代码上使用尽可能多的装饰器。例如:

@log() // 第一个类装饰器
@immutable() // 第二个类装饰器
class Example {
  @time('demo') // 类方法装饰器
  doSomething() {
    //
  }
}

如上所述,官方还没有实现这个提案,所以目前要使用,通常有以下两种做法:

  • 使用babel插件

  • 使用Typescript

我这里是使用的babel插件。

由于babel7升级后,所有插件只要还在提案中的都重命名为-proposal来表明该提案未正式发布。

所以之前的 transform-decorators-legacy改为了plugin-proposal-decorators。

1、安装插件

npm install --save-dev @babel/plugin-proposal-decorators

2、修改配置文件(.bablerc)

{
  "plugins": [
    [
      "@babel/plugin-proposal-decorators",
      {
        "version": "legacy"
      }
    ]
  ]
}

为什么要使用装饰器

虽然在JavaScript中已经可以实现函数式组合,但要将相同的技术应用在其他代码段(例如类和类属性)要困难得多,甚至是不可能的。装饰器提案主要就是提出了对类和类属性装饰器的支持。

同时,使用装饰器来包装代码,其语法更加简单优雅,编写的代码可读性更高也更易于理解。

@testable
class Person {
  @readonly
  @nonenumerable
  name() { return `${this.first} ${this.last}` }
}

从上面的代码里,一眼就可以看出,Person类是可测试的,而name方法是只读和不可枚举的。

装饰器类型

目前装饰器支持的类型是类和类的成员,包括属性、方法、访问器、参数。

装饰器的写法:普通装饰器(无法传参)、装饰器工厂(可传参)

装饰器的使用

装饰器的使用非常简单,其本质上就是一个函数。

1、类装饰器

类装饰器在类声明之前被声明(紧靠着类声明)。 类装饰器应用于类构造函数,可以用来监视,修改或替换类定义。

类装饰器表达式会在运行时当作函数被调用,类的构造函数作为其唯一的参数。

如果类装饰器返回一个值,它会使用提供的构造函数来替换类的声明。

上面是官方给的定义,还是来点例子看的更清楚:

给目标类增加静态属性test

const decoratorClass = (targetClass) => {
    targetClass.test = '123'
}
@decoratorClass
class Test {}
Test.test; // '123'

来看看babel转译后的代码

babel转译后代码.png

可以看出,装饰器就是一个对类进行处理的函数,其参数就是所要装饰的目标类。如果有返回值则替换原来的类。

2、前面的例子是为类添加一个静态属性,如果想添加实例属性,可以通过目标类的prototype对象操作。

function speak(target) {
  target.prototype.speak = function() {
    console.log(`I can speak ${this.language}`);
  }
}

@speak
class People {
  constructor(lan) {
    this.language = lan;
  }
}

const p1 = new People('Chinease');
p1.speak(); // I can speak Chinease

3、还可以让装饰器接受参数,这就等于可以修改装饰器的行为了,这也叫做装饰器工厂。装饰器工厂是通过在装饰器外面再封装一层函数来实现。

function speak(language) {
  return function(target) {
    target.prototype.speak = function() {
      console.log(`I can speak ${language}`);
    }
  }
}

@speak('Chinese')
class People {}

const p1 = new People();
p1.speak(); // I can speak Chinease

注意,装饰器对类的行为的改变,是代码编译时发生的,而不是在运行时。这意味着,装饰器能在编译阶段运行代码。也就是说,装饰器本质就是编译时执行的函数。

4、下面看个实现类型Vue混入的功能例子

const Foo = {
  logger() {
    console.log('记录日志')
  }
}

function mixins(obj) {
  return function (target) {
    Object.assign(target.prototype, obj)  
  }
}

@mixins(Foo)
class Login{
  login() {}
  logout() {}
}

const obj = new Login();
obj.logger(); // 记录日志

一般来说类装饰器没有类成员装饰器有用,因为在这里做的一切都可以通过一个简单的函数调用就完成相同的事情。

下面就看看类成员装饰器吧

2、类属性装饰器

类属性装饰器可以用在类的单个成员上,无论是类的属性、方法、get/set函数。该装饰器函数有3个参数:

  • target:成员所在的类

  • name:类成员的名字

  • descriptor:属性描述符。

使用类属性装饰器可以做很多有意思的事情,最经典的例子就是@readonly

function readonly(target, name, descriptor) {
  descriptor.writable = false;
  return descriptor;
}
class Example {
  a() {}
  @readonly
  b() {}
}

const e = new Example();
e.a = 1;
e.b = 2;

当然还可以做的更好,实际上我们可以用不同的行为来替换装饰函数。例如,记录所有的输入和输出:

function log(target, name, descriptor) {
  const original = descriptor.value;
  if (typeof original === 'function') {
    descriptor.value = function(...args) {
      console.log(`${name} Arguments: ${args}`);
      try {
        const result = original.apply(this, args);
        console.log(`${name} Result: ${result}`);
        return result;
      } catch (e) {
        console.log(`${name} Error: ${e}`);
        throw e;
      }
    }
  }
  return descriptor;
}

class Example {
  @log
  sum(a, b) {
    return a + b;
  }
}

const e = new Example();
e.sum(1, 2);
// Arguments: 1,2
// Result: 3

还可以用来统计一个函数的执行时间,以便于后期做一些性能优化:

function time(target, name, descriptor) {
  const func = descriptor.value;
  if (typeof func === 'function') {
      descriptor.value = function(...args) {
          console.time();
          const results = func.apply(this, args);
          console.timeEnd();
          return results;
      }
  }
}

const e = new Example()
e.sum(1,2)
// sum: 0.0478515625 ms

3、装饰器组合

多个装饰器可以同时应用到一个声明上,就像下面的示例:

// 1、书写在同一行上
@f @g x

// 2、书写在多行上
@f
@g
x

当多个装饰器应用在一个声明上时会进行如下步骤的操作:

  1. 写了工厂函数,从上到下依次求值,目的是求得装饰器函数。

  2. 装饰器函数的执行顺序是由下至上依次执行。

function f() {
  console.log("f(): evaluated");
  return function (target) {
      console.log("f(): called");
  }
}

function g() {
  console.log("g(): evaluated");
  return function (target) {
      console.log("g(): called");
  }
}

class C {
  @f()
  @g()
  method() {}
}

//输出结果
f(): evaluated
g(): evaluated
g(): called
f(): called

再来看下上面给的日志和执行时间的实际例子:

class Example {
  @time
  @log
  sum(a, b) {
    return a + b;
  }
}

const e = new Example()
e.sum(1,2)
// 结果从下至上,先执行log,再time
// sum Arguments: 1,2
// sum Result: 3
// sum: 0.263916015625 ms

总结

尽管装饰器还属于不稳定的语法,但已经在很多地方被广泛使用的。例如

  • core-decorators:很棒的库,提供了一些非常有用且常用的装饰器,如readonlydebouncethrottle等等

  • react:react库很好的利用了高阶组件的概念。装饰器(高阶组件)接受一个 React 组件作为参数,然后返回一个新的 React 组件。实现很简单,可能就是包裹了一层 div,添加了一个 style。以后所有被它装饰的组件都会具有这个特征。

装饰器这个东西,java工程师们一直在使用,其实做为前端工程师在日常工作中不妨也试试。在一些需要执行log啥的方法中,使用装饰器,可以更好的将无关逻辑进行解耦,更好的进行维护。

当然由于标准没定下,谁也不能保证后面的语法会变成啥样,毕竟现在最新的提案可就完全不一样了,不过装饰器这个概念还是通用的,这种高解耦、高复用的设计模式至少得学会。

你可能感兴趣的:(一起聊聊JavaScript装饰器(decorator))