读懂 JS 装饰器

1. 前言

知道装饰器还是在 Mobx 中见过类似的语法,对某个类或属性或方法进行包装修饰,是一种与类(class)相关的语法,用来注释或修改类和类方法,是实现面向切面编程(AOP)的一种重要模式。

import { Controller, Get, Post } from '@nestjs/common';
@Controller('cats');
export class CatsController {
    @Post();
    create(): string {
        return 'this action adds a new cat';
    }
    @Get();
    findAll(): string {
        return 'this action returns all cats';
    }
}

下面是一个使用装饰器修饰属性的简单例子,@readonly 可以将 count 属性设置为只读,通过此类方式,装饰器可以大大提高代码的简洁性和可读性。

class Person {
    @readonly count = 0;
}

由于目前所有浏览器暂未支持装饰器语法,如果要看到运行效果,可以通过去 babel 官网进行验证。

2. 装饰器模式

经典的装饰器模式是一种结构型设计模式,它允许向一个现有的对象中添加新的功能,同时又保证不改变它的结构,是对现有类的一个包装修饰。

通常来说,在代码设计中,应该遵循 「多用组合,少用继承」的原则。通过装饰器模式可以动态地给一个对象添加额外的职责。就增加新的功能而言,装饰器模式比生成子类更加灵活,简单。

2.1 一个英雄的例子

在游戏中设计一个特定的英雄的类。

class Hero {
    attack() {}
}
class SpecialHero extends Hero {
    attack() {
        console.log('斩钢闪');
    }
}

当另外一个英雄具有上述英雄的一些技能(属性或方法)时,就需要第二次继承,此时需要继承 SpecialHero。

class FirstHero extends SpecialHero {}

如果有第二个英雄、第三个英雄时,就要继承四次 SpecialHero 类,岂不是有多少个英雄就要继承多少次 SpecialHero 类。

Alt text

class SecondHero extends SpecialHero {}
class ThirdHero extends SpecialHero {}
...

可以换一种思路来思考这个问题,把英雄身上的 Skills 当做英雄身上的衣服。在不同的季节就换上不同的衣服,到了冬天,甚至会叠加多件衣服。当 Skills 都没有了,相当于把这件衣服脱了下来。

衣服对人来说起到修饰作用,Skills 对于英雄来说也只是增强效果。想到这里,你是不是有思路了呢?没错,可以创建 Skills 类,传入英雄后获得一个新的增强后的英雄类。

class FirstHero extends Skills {
    construtor(hero) {
        this.hero = hero;
    }
    extraDamage() {}
    attack() {
        return this.hero.attack() + this.extraDamage();
    }
}
class SecondHero extends Skills {
    construtor(hero) {
        this.hero = hero;
    }
    // 技能 (减10%)
    skillDecrese() {
        return this.hero.skillDecrese() * 0.9;
    }
}
class ThirdHero extends Skills {
    construtor(hero) {
        this.hero = hero;
    }
    // 回城速度缩短一半
    backSpeed() {
        return this.hero.backSpeed() * 0.5;
    }
}

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

const specialhero = new specialHero(); // 特殊的英雄
const firsthero   = new FirstHero(specialhero); // 第一个英雄
const secondhero  = new FirstHero(specialhero); // 第二个英雄
const thirdhero   = new FirstHero(specialhero); // 第三个英雄

3. ES7 装饰器

decorator (装饰器)是 ES7 中的一个提案,目前处于 stage-2 阶段。装饰器与函数组合(compose)以及高阶函数很相似,使用 @ 符号作为标识符,放置在被装饰的代码前面。在 Python 语言中,早就已经有了非常成熟的装饰器方案,下面就来看看 Python 中的一个装饰器的例子。

3.1 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})     

Alt text

auth 装饰器是通过检查 cookie 来判断用户是否登录的。 auth 函数是一个高阶函数,它接受了一个 func 函数作为参数,返回了一个新的 inner 函数。在 inner 函数中进行 cookie 的检查,由此来判断跳回登录页面还是继续执行 func 函数。在所有需要权限验证的函数上,都可以使用这个 auth 装饰器,简洁明了且无侵入。

3.2 JavaScript中的装饰器

JavaScript 中的装饰器和 Python 中的装饰器类似,依赖于 Object.defineProperty,一般是用来装饰类、类属性、类方法。使用装饰器可以做到不直接修改代码,就实现某些功能,做到真正的面向切面编程。这在一定程度上和 Proxy 很相似,但使用起来比 Proxy 会更加简洁。

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 Studnent('English');
student1.speak(); // "I can speak Chinese"
student2.speak(); // "I can speak English"

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

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

如果你经常编写 react-redux 的代码,会经常遇到需要将 store 中的数据映射到组件的情况。 其中 connect 就是一个高阶组件,它接收两个函数 mapStateToProps 和 mapDispatchToProps 以及一个组件 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). decriptor:属性描述符,对象会将这个参数传给 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 { observale, action, autorun } from 'mobx'
class Store {
    @observale 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) @bar(2)
class C {}

4.装饰器可以用来干啥

4.1 多重继承

通过 mixin 可以实现多重继承,如果使用装饰器可以进步一简化 mixin 的使用。 mixin 方法将会接收一个父类列表,通过它来装饰目标类。我们理想中的用法应该是这样:

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

这里只需要拷贝父类的原型属性和实例属性就可以实现多重继承。这里创建了一个新的 Mixin 类,来将 mixintargetClass 上面的所有属性都拷贝过去。

const mixin = (...mixins) => (targetClass) => {
    mixins = [targetClass, ...mixins];

    function copyProperties(target, source) {
        for (let key of Reflect.ownKeys(source)) {
            if (key !== 'construtor' && 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);

Alt text

最终在浏览器中打印出来的 child 对象是这样的,证明这个 mixin 是可以正常工作的,注意这里的 Child 就是前面的 Mixin类。

也许你会问,为什么要创建一个多余的 Mixin类呢?为什么不直接修改 targetClass 的 constructor 呢?

原因是 Proxy 会拦截 constructor。 这里是 Proxy 的一种使用场景,使用 Proxy 会更优雅。

const mixin = (...mixins) => (targetClass) => {
    function copyProperties(target, source) {
        for (let key of Reflect.ownKeys(source)) {
            if (key !== "construtor" && key !== "name" && key !== "prototype") {
                let desc = Object.getOwnProperyDescriptor(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 组件绑定滚动事件为例:

const App extends React.Component {
    componentDidMount() {
        this.handleScroll = _.throttle(this.srcoll, 500);
        window.addEventListener('srcoll', this.handleScroll)
    }
    componentWillUnmount() {
        window.removeEventListener('srcoll', this.handleScroll)
    }
    srcoll() {}
}

在组件中绑定事件需要注意在组件销毁时进行解绑。而由于节流函数返回了一个新的匿名函数,所以为了之后能够有效解绑,不得不将这个匿名函数存起来,以便之后使用。但是有了装饰器之后,我们就不必在每个绑定事件的地方都手动设置 throttle 方法,只需要在 scroll 函数添加 一个 throttle 的装饰器就行了。

const throttle = (wait) => {
    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.addEventListener('srcoll',this.srcoll);
    }
    componentWillUnmount() {
        window.removeEventListener('srcoll',this.srcoll);
    }
    @throttle(50)
    srcoll() {}
}

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

const debounce = (wait) => {
    let timer;
    return (target, name, descriptor) => {
        const func = descriptor.value;
        if (typeof func === 'function') {
            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 string type
    }
}

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

Alt text

const rules = {
    name: 'string',
    password: 'string',
    age: 'number'
}
const validator = rules => targetClass => {
    return new Proxy(target, {
        construct(target, args) {
            const obj = new target(...args);
            for (let [name, type] of Object.entries(rules)) {
                if (typeof rules[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(); // Error: 
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.总结

JavaScript 装饰器是高级 JavaScript 编程语法中的一个较难的知识点,刚开始会理解不透彻,但随着在实际场景以及实际项目中开始使用,会是代码简洁许多,这里推荐尝试 React 的状态管理工具 Mobx 库,因为里面有多种使用装饰器的实际场景和案例。