都2020年了,你还不会JavaScript 装饰器?

21,124 阅读11分钟

俗话说,人靠衣装,佛靠金装。大街上的小姐姐都喜欢把自己打扮得美美的,让你忍不住多看几眼,这就是装饰的作用。

1. 前言

装饰器是最新的 ECMA 中的一个提案,是一种与类(class)相关的语法,用来注释或修改类和类方法。装饰器在 Python 和 Java 等语言中也被大量使用。装饰器是实现 AOP(面向切面)编程的一种重要方式。

code.png-98.4kB

下面是一个使用装饰器的简单例子,这个 @readonly 可以将 count 属性设置为只读。可以看出来,装饰器大大提高了代码的简洁性和可读性。

class Person {
    @readonly count = 0;
}

由于浏览器还未支持装饰器,为了让大家能够正常看到效果,这里我使用 Parcel 进行了一下简单的配置,可以去 clone 这个仓库后再来运行本文涉及到的所有例子,仓库地址:learn es6

本文涉及到 Object.defineProperty、高阶函数等知识,如果之前没有了解过相关概念,建议先了解后再来阅读本文。

2. 装饰器模式

在开始讲解装饰器之前,先从经典的装饰器模式说起。装饰器模式是一种结构型设计模式,它允许向一个现有的对象添加新的功能,同时又不改变其结构,是作为对现有类的一个包装。

一般来说,在代码设计中,我们应当遵循「多用组合,少用继承」的原则。通过装饰器模式动态地给一个对象添加一些额外的职责。就增加功能来说,装饰器模式相比生成子类更为灵活。

2.1 一个英雄联盟的例子

下班回去和朋友愉快地开黑,当我正在用亚索「面对疾风吧」的时候,突然想到,如果让我设计亚索英雄,我该怎么实现呢?

image_1e41h3e4e62nnd0hv0f02q5136.png-242.1kB

我灵光一闪,那肯定会先设计一个英雄的类。

class Hero {
    attack() {}
}

然后,再实现一个 Yasuo 的类来继承这个 Hero 类。

class Yasuo extends Hero {
    attack() {
        console.log("斩钢闪");
    }
}

我还在想这个问题的时候,队友已经打了大龙,我的亚索身上就出现了大龙 buff 的印记。我突然想到,那该怎么给英雄增加大龙 buff 呢?那增加个大龙 buff 的属性不行吗?

当然不太行,要知道,英雄联盟里面的大龙 buff 是会增加收益的。

嗯,聪明的我已经想到办法了,再继承一次不就好了吗?

class BaronYasuo extends Yasuo {}

厉害了,但是如果亚索身上还有其他 buff 呢?毕竟 LOL 里面是有红 buff、蓝 buff、大龙 buff 等等存在,那岂不是有多少种就要增加多少个类吗?

image_1e3brvbln129jcn7bo111jfal09.png-37.6kB

可以换种思路来思考这个问题,如果把 buff 当做我们身上的衣服。在不同的季节,我们会换上不同的衣服,到了冬天,甚至还会叠加多件衣服。当 buff 消失了,就相当于把这件衣服脱了下来。如下图所示:

image.png-27.3kB

衣服对人来说起到装饰的作用,buff 对于亚索来说也只是增强效果。那么,你是不是有思路了呢? 没错,可以创建 Buff 类,传入英雄类后获得一个新的增强后的英雄类。

class RedBuff extends Buff {
    constructor(hero) {
        this.hero = hero;
    }
    // 红buff造成额外伤害
    extraDamage() {
    }
    attack() {
        return this.hero.attack() + this.extraDamage();
    }
}
class BlueBuff extends Buff {
    constructor(hero) {
        this.hero = hero;
    }
    // 技能CD(减10%)
    CDR() {
        return this.hero.CDR() * 0.9;
    }
}
class BaronBuff extends Buff {
    constructor(hero) {
        this.hero = hero;
    }
    // 回城速度缩短一半
    backSpeed() {
        return this.hero.backSpeed * 0.5;
    }
}

定义好所有的 buff 类之后,就可以直接套用到英雄身上,这样看起来是不是清爽了很多呢?这种写法看起来很像函数组合。

const yasuo = new Yasuo();
const redYasuo = new RedBuff(yasuo); // 红buff亚索
const blueYasuo = new BlueBuff(yasuo); // 蓝buff亚索
const redBlueYasuo = new BlueBuff(redYasuo); // 红蓝buff亚索

image_1e41h45cn12r220all5mos1pet3j.png-324.3kB

3. ES7 装饰器

decorator(装饰器)是 ES7 中的一个提案,目前处于 stage-2 阶段,提案地址:JavaScript Decorators

装饰器与之前讲过的函数组合(compose)以及高阶函数很相似。装饰器使用 @ 作为标识符,被放置在被装饰代码前面。在其他语言中,早就已经有了比较成熟的装饰器方案。

3.1 Python 中的装饰器

先来看一下 Python 中的一个装饰器的例子:

def auth(func):
    def inner(request,*args,**kwargs):
        v = request.COOKIES.get('user')
        if not v:
            return redirect('/login')
        return func(request, *args,**kwargs)
    return inner

@auth
def index(request):
    v = request.COOKIES.get("user")
    return render(request,"index.html",{"current_user":v})

image_1e3c0hva03om1v6im6gjne5lj9.png-32.2kB

这个 auth 装饰器是通过检查 cookie 来判断用户是否登录的。auth 函数是一个高阶函数,它接收了一个 func 函数作为参数,返回了一个新的 inner 函数。

inner 函数中进行 cookie 的检查,由此来判断是跳回登录页面还是继续执行 func 函数。

在所有需要权限验证的函数上,都可以使用这个 auth 装饰器,很简洁明了且无侵入。

3.2 JavaScript 装饰器

JavaScript 中的装饰器和 Python 的装饰器类似,依赖于 Object.defineProperty,一般是用来装饰类、类属性、类方法。

使用装饰器可以做到不直接修改代码,就实现某些功能,做到真正的面向切面编程。这在一定程度上和 Proxy 很相似,但使用起来比 Proxy 会更加简洁。

注意:装饰器目前还处于 stage-2,意味着语法之后也许会有变动。装饰器用于函数、对象等等已经有一些规划,请看:Future built-in decorators

3.3 类装饰器

装饰类的时候,装饰器方法一般会接收一个目标类作为参数。下面是一个给目标类增加静态属性 test 的例子:

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

除了可以修改类本身,还可以通过修改原型,给实例增加新属性。下面是给目标类增加 speak 方法的例子:

const withSpeak = (targetClass) => {
    const prototype = targetClass.prototype;
    prototype.speak = function() {
        console.log('I can speak ', this.language);
    }
}
@withSpeak
class Student {
    constructor(language) {
        this.language = language;
    }
}
const student1 = new Student('Chinese');
const student2 = new Student('English');
student1.speak(); // I can speak  Chinese

student2.speak(); // I can speak  Englist

利用高阶函数的属性,还可以给装饰器传参,通过参数来判断对类进行什么处理。

const withLanguage = (language) => (targetClass) => {
    targetClass.prototype.language = language;
}
@withLanguage('Chinese')
class Student {
}
const student = new Student();
student.language; // 'Chinese'

如果你经常编写 react-redux 的代码,那么也会遇到需要将 store 中的数据映射到组件中的情况。connect 是一个高阶组件,它接收了两个函数 mapStateToPropsmapDispatchToProps 以及一个组件 App,最终返回了一个增强版的组件。

class App extends React.Component {
}
connect(mapStateToProps, mapDispatchToProps)(App)

有了装饰器之后,connect 的写法可以变得更加优雅。

@connect(mapStateToProps, mapDispatchToProps)
class App extends React.Component {
}

3.4 类属性装饰器

类属性装饰器可以用在类的属性、方法、get/set 函数中,一般会接收三个参数:

  1. target:被修饰的类
  2. name:类成员的名字
  3. descriptor:属性描述符,对象会将这个参数传给 Object.defineProperty

使用类属性装饰器可以做到很多有意思的事情,比如最开始举的那个 readonly 的例子:

function readonly(target, name, descriptor) {
  descriptor.writable = false;
  return descriptor;
}
class Person {
    @readonly name = 'person'
}
const person = new Person();
person.name = 'tom'; 

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

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;
        }
    }
}
class Person {
    @time
    say() {
        console.log('hello')
    }
}
const person = new Person();
person.say();

在 react 知名的状态管理库 mobx 中,也通过装饰器来将类属性置为可观察属性,以此来实现响应式编程。

import {
    observable,
    action,
    autorun
} from 'mobx'

class Store {
    @observable count = 1;
    @action
    changeCount(count) {
        this.count = count;
    }
}

const store = new Store();
autorun(() => {
    console.log('count is ', store.count);
})
store.changeCount(10); // 修改 count 的值,会引起 autorun 中的函数自动执行。

3.5 装饰器组合

如果你想要使用多个装饰器,那么该怎么办呢?装饰器是可以叠加的,根据离被装饰类/属性的距离来依次执行。

class Person {
    @time
    @log
    say() {}
}

除此之外,在装饰器的提案中,还出现了一种组合了多种装饰器的装饰器例子。目前还没见到被使用。

通过使用 decorator 来声明一个组合装饰器 xyz,这个装饰器组合了多种装饰器。

decorator @xyz(arg, arg2 {
  @foo @bar(arg) @baz(arg2)
}
@xyz(1, 2) class C { }

和下面这种写法是一样的。

@foo @bar(1) @baz(2)
class C { }

4. 装饰器可以做哪些有意思的事情?

4.1 多重继承

在实现 JavaScript 多重继承的时候,可以使用 mixin 的方式,这里结合装饰器甚至还能更进一步简化 mixin 的使用。

mixin 方法将会接收一个父类列表,用其装饰目标类。我们理想中的用法应该是这样:

@mixin(Parent1, Parent2, Parent3)
class Child {}

和之前实现多重继承的时候实现原理一致,只需要拷贝父类的原型属性和实例属性就可以实现了。

这里创建了一个新的 Mixin 类,来将 mixinstargetClass 上面的所有属性都拷贝过去。

const mixin = (...mixins) => (targetClass) => {
  mixins = [targetClass, ...mixins];
  function copyProperties(target, source) {
    for (let key of Reflect.ownKeys(source)) {
      if (key !== 'constructor'
        && key !== 'prototype'
        && key !== 'name'
      ) {
        let desc = Object.getOwnPropertyDescriptor(source, key);
        Object.defineProperty(target, key, desc);
      }
    }
  }
  class Mixin {
    constructor(...args) {
      for (let mixin of mixins) {
        copyProperties(this, new mixin(...args)); // 拷贝实例属性
      }
    }
  }

  for (let mixin of mixins) {
    copyProperties(Mixin, mixin); // 拷贝静态属性
    copyProperties(Mixin.prototype, mixin.prototype); // 拷贝原型属性
  }
  return Mixin;
}

export default mixin

我们来测试一下这个 mixin 方法是否能够正常工作吧。

class Parent1 {
    p1() {
        console.log('this is parent1')
    }
}
class Parent2 {
    p2() {
        console.log('this is parent2')
    }
}
class Parent3 {
    p3() {
        console.log('this is parent3')
    }
}
@mixin(Parent1, Parent2, Parent3)
class Child {
    c1 = () => {
        console.log('this is child')
    }
}
const child = new Child();
console.log(child);

最终在浏览器中打印出来的 child 对象是这样的,证明了这个 mixin 是可以正常工作的。

注意:这里的 Child 类就是前面的 Mixin 类。

image.png-69.4kB

也许你会问,为什么还要多创建一个多余的 Mixin 类呢?为什么不能直接修改 targetClassconstructor 呢?前面不是讲过 Proxy 可以拦截 constructor 吗?

恭喜你,你已经想到了 Proxy 的一种使用场景。没错,这里用 Proxy 的确会更加优雅。

const mixin = (...mixins) => (targetClass) => {
    function copyProperties(target, source) {
        for (let key of Reflect.ownKeys(source)) {
          if ( key !== 'constructor'
            && key !== 'prototype'
            && key !== 'name'
          ) {
            let desc = Object.getOwnPropertyDescriptor(source, key);
            Object.defineProperty(target, key, desc);
          }
        }
      }
    
      for (let mixin of mixins) {
        copyProperties(targetClass, mixin); // 拷贝静态属性
        copyProperties(targetClass.prototype, mixin.prototype); // 拷贝原型属性
      }
      // 拦截 construct 方法,进行实例属性的拷贝
      return new Proxy(targetClass, {
        construct(target, args) {
          const obj = new target(...args);
          for (let mixin of mixins) {
              copyProperties(obj, new mixin()); // 拷贝实例属性
          }
          return obj;
        }
      });
}

4.2 防抖和节流

以往我们在频繁触发的场景下,为了优化性能,经常会使用到节流函数。下面以 React 组件绑定滚动事件为例子:

class App extends React.Component {
    componentDidMount() {   
        this.handleScroll = _.throttle(this.scroll, 500);
        window.addEveneListener('scroll', this.handleScroll);
    }
    componentWillUnmount() {
        window.removeEveneListener('scroll', this.handleScroll);
    }
    scroll() {}
}

在组件中绑定事件需要注意应当在组件销毁的时候进行解绑。而由于节流函数返回了一个新的匿名函数,所以为了之后能够有效解绑,不得不将这个匿名函数存起来,以便于之后使用。

但是在有了装饰器之后,我们就不必在每个绑定事件的地方都手动设置 throttle 方法,只需要在 scroll 函数添加一个 throttle 的装饰器就行了。

const throttle = (time) => {
    let prev = new Date();
    return (target, name, descriptor) => {
        const func = descriptor.value;
        if (typeof func === 'function') {
            descriptor.value = function(...args) {
		        const now = new Date();
		        if (now - prev > wait) {
			        fn.apply(this, args);
			        prev = new Date();
		        }
            }
        }
    }
}

使用起来比原来要简洁很多。

class App extends React.Component {
    componentDidMount() {
        window.addEveneListener('scroll', this.scroll);
    }
    componentWillUnmount() {
        window.removeEveneListener('scroll', this.scroll);
    }
    @throttle(50)
    scroll() {}
}

而实现防抖(debounce)函数装饰器和节流函数类似,这里也不再多说。

const debounce = (time) => {
    let timer;
    return (target, name, descriptor) => {
        const func = descriptor.value;
        if (typeof func === 'function') {
            descriptor.value = function(...args) {
                if(timer) clearTimeout(timer)
                timer = setTimeout(()=> {
                    fn.apply(this, args)
                }, wait)
            }
        }
    }
}

如果对节流和防抖函数比较感兴趣,那么可以去阅读一下这篇文章:函数节流与函数防抖

4.3 数据格式验证

通过类属性装饰器来对类的属性进行类型的校验。

const validate = (type) => (target, name) => {
    if (typeof target[name] !== type) {
        throw new Error(`attribute ${name} must be ${type} type`)
    }
}
class Form {
    @validate('string')
    static name = 111 // Error: attribute name must be ${type} type
}

如果你觉得对属性一个个手动去校验太过麻烦,也可以通过编写校验规则,来对整个类进行校验。

image_1e3c1khlm169r8ei7j4s25qjfm.png-44.7kB

const rules = {
    name: 'string',
    password: 'string',
    age: 'number'
}
const validator = rules => targetClass => {
    return new Proxy(targetClass, {
        construct(target, args) {
            const obj = new target(...args);
            for (let [name, type] of Object.entries(rules)) {
                if (typeof obj[name] !== type) {
                    throw new Error(`${name} must be ${type}`)
                }
            }
            return obj;
        }
    })
}

@validator(rules)
class Person {
    name = 'tom'
    password = '123'
    age = '21'
}
const person = new Person();

4.4 core-decorators.js

core-decorators 是一个封装了常用装饰器的 JS 库,它归纳了下面这些装饰器(只列举了部分)。

  1. autobind:自动绑定 this,告别箭头函数和 bind
  2. readonly:将类属性设置为只读
  3. override:检查子类的方法是否正确覆盖了父类的同名方法
  4. debounce:防抖函数
  5. throttle:节流函数
  6. enumerable:让一个类方法变得可枚举
  7. nonenumerable:让一个类属性变得不可枚举
  8. time:打印函数执行耗时
  9. mixin:将多个对象混入类(和我们上面的 mixin 不太一样)

5. 总结

装饰器虽然还属于不稳定的语法,但在很多框架中都已经广泛使用,例如 Angular、Nestjs 等等,和 Java 中的注解用法非常相似。

装饰器在 TypeScript 中结合反射后还有一些更高级的应用,之后会进行深入讲解。 PS:关注我的关注公众号「前端小馆」,不定期分享原创知识。

推荐阅读:

  1. 装饰器 —— 阮一峰
  2. JS 装饰器(Decorator)场景实战
  3. 探索JavaScript中的装饰器模式
  4. 王者荣耀之「装饰者模式」