一起聊聊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啥的方法中,使用装饰器,可以更好的将无关逻辑进行解耦,更好的进行维护。

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

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 201,312评论 5 473
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,578评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 148,337评论 0 333
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,134评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,161评论 5 363
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,303评论 1 280
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,761评论 3 393
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,421评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,609评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,450评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,504评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,194评论 3 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,760评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,836评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,066评论 1 257
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,612评论 2 348
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,178评论 2 341

推荐阅读更多精彩内容