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

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

image_1e41h0m6hs19r6dcqvam1cog1t.png-414.6kB

1. 前言

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

code.png-98.4kB

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

class Person {
    @readonly count = 0;
}
复制代码

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

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

2. 装饰器模式

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

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

2.1 一个英雄联盟的例子

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

image_1e41h3e4e62nnd0hv0f02q5136.png-242.1kB

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

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 Chinese
复制代码

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

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')
    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 中结合反射后还有一些更高级的应用,下篇文章会进行深刻讲解。

推荐阅读:

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

❤️ 看完三件事

若是你以为这篇内容对你挺有启发,我想邀请你帮我三个小忙:

  1. 点赞,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)
  2. 关注公众号「前端小馆」,或者加我微信号「testygy」,不按期分享原创知识。
  3. 也看看其它文章

相关文章
相关标签/搜索