详解 TS 中的装饰器 | 青训营笔记

465 阅读6分钟

这是我参与「第四届青训营 」笔记创作活动的的第6天,总结一下 TypeScript 中装饰器的相关内容,作为之前前端设计模式的补充。

一、引言

官网上有关装饰器的介绍是这样的:

随着 TypeScript 和 ES6 里引入了类,在一些场景下我们需要额外的特性来支持标注或修改类及其成员。 装饰器(Decorators)为我们在类的声明及成员上通过元编程语法添加标注提供了一种方式。

正如衣服穿在人身上可以在不改变人的身体构造的情况下起到保暖和美化的作用一样,装饰器也能在不改变原有代码结构的基础上实现对代码功能的扩展

和 Python 一样,TypeScript 提供的装饰器语法实际上是基于函数闭包的一种语法糖,例如在 Python 中:

def decorator(fn):
    def wrapper(*args, **kwargs):
        print("start...")
        fn(*args, **kwargs)
        print("end...")
    return wrapper

@decorator
def func():
    print("run...")

func()
# start...
# run...
# end...

上面的代码可以理解成:

func = decorator(func)
func()

此时func函数作为参数被传入到decorator函数中并返回一个闭包函数并取代原有的func,这样便实现了在原有函数执行的上下文中进行任意的操作。

TypeScript 中的装饰器也是差不多的道理,但是相比于 Python 只能装饰函数,TypeScript 中的装饰器能装饰类、方法、访问器、属性、参数,自然也会更为复杂。我们可以看一下官网上给的一个例子:

function classDecorator<T extends { new (...args: any[]): {} }>(constructor: T) {
	return class extends constructor {
		newProperty = "new property";
		hello = "override";
	};
}

@classDecorator
class Greeter {
	property = "property";
	hello: string;
	constructor(m: string) {
		this.hello = m;
	}
}

console.log(new Greeter("world"));

这是一个使用类装饰器向类中添加新的属性和修改原有的属性值的例子。使用命令tsc --target ES5 --experimentalDecorators(或者配置tsconfig.json文件)便可以得到编译完成的 JS 代码,可以看出 TS 中装饰器的实现本质上也是通过闭包实现的。

var __decorate =
    (this && this.__decorate) ||
    function (decorators, target, key, desc) {
        var c = arguments.length,
            r = c < 3 ? target : desc === null ? (desc = Object.getOwnPropertyDescriptor(target, key)) : desc,
            d;
        if (typeof Reflect === "object" && typeof Reflect.decorate === "function")
            r = Reflect.decorate(decorators, target, key, desc);
        else
            for (var i = decorators.length - 1; i >= 0; i--)
                if ((d = decorators[i]))
                    r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
        return c > 3 && r && Object.defineProperty(target, key, r), r;
    };

Greeter = __decorate([classDecorator], Greeter);

下面我将结合具体的实例分别介绍 TypeScript 中的这五种装饰器。

二、详细介绍

1. 类装饰器

我们将用装饰器的形式实现将一个类变为单例模式。

首先我们定义一个Request类,它包含一个cache属性用于缓存和一个request方法用于模拟网络请求。然后运行main函数我们可以看出,现在的Request类还不是单例,每个实例化对象都有着自己的cache

class Request {
    cache: Record<string, string>

    constructor() {
        this.cache = {}
    }

    async request(url: string) {
        let resp: string
        if (url in this.cache) {
            resp = this.cache[url]
        } else {
            resp = await new Promise<string>((resolve) => {
                setTimeout(() => {
                    resolve(`done: ${url}`)
                }, 2000)
            })
            this.cache[url] = resp
        }
        console.log(resp)
    }
}

async function main() {
    const startTime1 = Date.now()
    await new Request().request("/test")
    const endTime1 = Date.now()
    console.log(`第一次耗时:${endTime1 - startTime1}`)

    const startTime2 = Date.now()
    await new Request().request("/test")
    const endTime2 = Date.now()
    console.log(`第二次耗时:${endTime2 - startTime2}`)
}

image.png

接下来我们开始编写装饰器函数:

function singleton<T extends new(...args: any[]) => object>(target: T) {
    return class extends target {
        static instance: object

        static getInstance() {
            if (!this.instance) {
                this.instance = new target()
            }
            return this.instance
        }
    }
}

类装饰器的参数只有一个,就是被装饰类的构造函数,我们通过返回一个匿名类继承自传入的构造函数,便实现了对被装饰类功能的扩展。

修改main函数,通过调用getInstance方法获取到Request类的实例(由于装饰器是将新的方法动态添加中被装饰类中去的,编译器无法感知这个过程,所以会报错,只能通过添加注释// @ts-ignore的方式来忽略错误)。经过测试我们可以发现第二次调用request方法时成功从单例的缓存池中获取到了结果。

image.png

除此之外还可以通过target.prototype获取到被装饰类的属性或方法以及对其进行修改

2. 方法装饰器

现在我们有这样一个Person类,它能实现输出字符串"Hello!"的功能。

class Person {
    name: string

    constructor(name: string) {
        this.name = name
    }

    hello() {
        console.log("Hello!")
    }
}

let person = new Person("vii")
person.hello()

如果我们需要对这个功能进行扩展,需要它能在输出"Hello"字符串之后输出自己的名字,那么我们可以用装饰器来实现。首先定义一个装饰器函数:

function sayMyName(target: Person, propertyKey: string, descriptor: PropertyDescriptor) {
    const hello = descriptor.value
    descriptor.value = function (this: Person) {
        hello.call(this)
        console.log(`My name is ${this.name}.`)
    }
}

然后只需在hello方法上使用该装饰器便能实现对原有方法功能的扩展。

@sayMyName
hello() {
    console.log("Hello!")
}

看看运行的结果:

image.png

下面我们来看一下装饰器函数具体是怎么实现的:

首先sayMyName函数有三个参数,分别为Person类的原型对象Person.prototype被装饰方法的名称以及方法的描述对象

注意:当装饰的方法为静态方法时,第一个参数获取到的是类的构造函数

我们输出这三个参数看一下。

image.png

可以看到Person类的原型对象中只有一个constructor构造函数和一个我们定义的hello函数(看下面编译出来的 JS 代码就知道为什么了),因此我们无法通过其访问到name这个实例属性,同时经过尝试发现通过原型对象修改hello函数也是无效的(我也不知道为什么,先记住,之后再慢慢研究...)。

var Person = /** @class */ (function () {
    function Person(name) {
        this.name = name;
    }
    Person.prototype.hello = function () {
        console.log("Hello!");
    };
    __decorate([
        sayMyName
    ], Person.prototype, "hello", null);
    return Person;
}());

但是我们可以通过第三个参数来获取到被装饰的方法本身并修改它,描述对象中的value属性便是hello这个方法本身。同时我们还可以在重新定义的函数中通过this访问到实例对象中的属性。

但是需要注意一点:我们需要在函数参数列表的最前面显式定义this的类型,否则编译器会报错,但是这个参数实际上不会影响到函数传参的过程。

具体可以参考官网上关于this参数的介绍:函数 · TypeScript中文网 · TypeScript——JavaScript的超集 (tslang.cn)

另外,当代码输出目标的版本大于 ES5 时,装饰器函数的返回值将将被当做被装饰方法的属性描述符。因此下面这种方法也能达到我们的目的。

function sayMyName(target: Person, propertyKey: string, descriptor: PropertyDescriptor) {
    return {
        ...descriptor,
        value(this: Person) {
            descriptor.value.call(this)
            console.log(`My name is ${this.name}.`)
        },
    }
}

注意:当代码输出目标的版本小于 ES5 时,装饰器函数参数中的属性描述符将会是undefined,同时函数的返回值也会被忽略。

3. 访问器装饰器

访问器装饰器的用法与函数装饰器基本一致,唯一的不同点在于,如果需要在装饰器函数中获取到被装饰的访问器,需要通过属性描述对象和getset属性获取,而不是方法装饰器中的value属性。

4. 属性装饰器

不同于前面的几种装饰器都是在定义类的时候就完成了“装饰”的过程,属性装饰器需要等到类实例化之后才能获取到被装饰属性。

我们来看一下下面这个例子:

class Message {
    content: string

    constructor(content: string) {
        this.content = content
    }

    send() {
        console.log(this.content)
    }
}

new Message("Hello!").send()

如果我们需要对content属性进行格式化操作,按照前面例子的思路,我们可能会想到要这样做:

function format(formatString: string) {
    return function (target: any, propertyKey: string) {
        console.log(target[propertyKey])
        target[propertyKey] = formatString.replace("%s", target[propertyKey])
    }
}

...
@format("msg: %s")
content: string

但是正如之前所说,我们无法在属性装饰器函数中通过参数传入的原型对象获取到content这个实例属性,即target[propertyKey]只能获取到undefined,因此这样的方式是行不通的(但是为原型对象添加属性是可行的)。

因此在平时单独使用属性装饰器的意义并不大,通常是使用reflect-metadata这个库来为其它装饰器提供元数据(这部分内容在后面会详细介绍)。

5. 参数装饰器

参数装饰器函数中传入的参数为三个:前两个依然为类的原型对象和属性名称,但是第三个参数有所不同,表示的是参数在参数列表中的位置(parameterIndex: number).

和属性装饰器类似,单独使用参数装饰器的意义不大,据官网所说,参数装饰器只能用来监视一个方法的参数是否被传入

6. 装饰器的执行顺序

当对同一个声明应用多个装饰器时,将从上到下依次对装饰器表达式进行求值,前一装饰器的求值结果将作为下一个装饰器的装饰对象。

当对不同声明应用装饰器时,将按照以下顺序进行求值:

  1. 参数装饰器,然后依次是方法装饰器,访问器装饰器,或属性装饰器应用到每个实例成员
  2. 参数装饰器,然后依次是方法装饰器,访问器装饰器,或属性装饰器应用到每个静态成员
  3. 参数装饰器应用到构造函数
  4. 类装饰器应用到类

7. 使用元数据

MetaData:也称元数据,元数据是用来描述数据的数据。借助于reflect-metadata这个库,我们能实现为类或者类的属性添加元数据以达到类似依赖注入的目的。

如何使用reflect-metadata库:装饰器 · TypeScript中文网 · TypeScript——JavaScript的超集 (tslang.cn)

reflect-metadata的仓库:rbuckton/reflect-metadata: Prototype for a Metadata Reflection API for ECMAScript (github.com)

下面我们来看一个通过装饰器和元数据实现字段校验的例子。

首先我们定义一个接口Validator来对校验器的功能进行抽象,并且实现两个类:PhoneValidatorPasswordValidator,分别负责对手机号和密码两个字段做格式校验。

interface Validator {
    msg: string // 若校验不通过,提供错误信息
    validate: (value: any) => boolean
}

class PhoneValidator implements Validator {
    msg = "请输入11位有效手机号"

    validate(value: string) {
        return /^1((3\d)|(4[5-7|9])|(5[0-3|5-9])|(6[5-7])|(7[0-8])|(8\d)|(9[189]))\d{8}$/.test(value)
    }
}

class PasswordValidator implements Validator {
    msg = "密码应由8-16位英文或数字字符组成"

    validate(value: string) {
        return /^[\w\d]{8,16}$/.test(value)
    }
}

然后我们需要定义参数装饰器用于指定参数字段对应的校验器,这里我们使用装饰器工厂的方式,即根据传入的参数返回对应的装饰器。

type Rules = "phone" | "password"

const validatorMap: Record<Rules, Validator> = {
    "phone": new PhoneValidator(),
    "password": new PasswordValidator(),
}

const metadataKey = Symbol("validator")

function field(type: FieldType) {
    const validator = validatorMap[type]
    return function (target: any, propertyKey: string, paramIndex: number) {
        const validators: (Validator[] | undefined)[] = Reflect.getMetadata(metadataKey, target, propertyKey) ?? []
        validators[paramIndex] ??= []
        validators[paramIndex]!.push(validator)
        Reflect.defineMetadata(metadataKey, validators, target, propertyKey)
    }
}

接下来我们就需要定义一个方法装饰器来开启对参数字段的校验。

class ValidationFailedError extends Error {
}

function withValidation(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    return {
        ...descriptor,
        value(...args: any[]) {
            if (Reflect.hasMetadata(metadataKey, target, propertyKey)) {
                const validators: (Validator[] | undefined)[] = Reflect.getMetadata(metadataKey, target, propertyKey)
                args.forEach((arg, index) => { // 逐字段校验
                    validators[index]?.forEach((validator) => { // 一个参数可以绑定多个校验器
                        if (!validator.validate(arg)) {
                            throw new ValidationFailedError(validator.msg)
                        }
                    })
                })
            }
            return descriptor.value() // 校验通过之后才执行原来的方法
        },
    }
}

最后将装饰器加到需要开启校验的方法以及对应的参数上就可以了,我们来测试一下结果。

class User {
    private phone = "15500000001"
    private password = "12345678"

    @withValidation
    login(@field("phone") phone: string, @field("password") password: string) {
        if (phone === this.phone && password === this.password) {
            console.log("登录成功")
        }
    }
}

const user = new User()
user.login("1550000000", "12345678") // 手机号只有10位
user.login("15500000001", "1234567") // 密码只有8位
user.login("15500000001", "12345678") // 手机号和密码都符合要求

image.png

image.png

image.png

可以看到运行结果是符合我们预期的。

三、个人总结

以上就是我总结的关于 TS 中装饰器的所有内容了~

之前写 Python 的时候使用装饰器也比较多,真心觉得这是一种十分优雅的写代码的方式。在看到 TS 中也有装饰器的语法而且更加强大之后就写了这篇总结。所以这既是自己学习的一个过程也是希望这篇文章能帮助到大家。

还有一部分内容是关于如何使用装饰器实现依赖注入等功能,可能后续有空了会进行补充~

四、引用参考

装饰器 · TypeScript中文网 · TypeScript——JavaScript的超集 (tslang.cn)

TS装饰器介绍_getTheCheeseOfGod的博客-CSDN博客_ts装饰器

TS装饰器指北 - 知乎 (zhihu.com)

es7之Reflect Metadata_街边吃垃圾的博客-CSDN博客_reflect-metadata