TS装饰器指北
装饰器是 JS
stage-2
的一个提案,并作为 TS 的实验特性存在。如果你有使用过 Spring 的经验,相信你一定对其中强大的注解能力印象深刻,借助装饰器强大元编程能力也可以在做到类似的功能。比如
Nestjs
就是基于装饰器特性构建的。本文就来聊聊 TS 装饰器(和 JS 装饰器不是一回事~~)。
装饰器模式
在正式聊装饰器之前,先说说装饰器模式,装饰器模式的特点是: 在不改变对象自身结构的前提下,向对象添加新的功能 。
举个 ,一个人,可以在冬天的时穿羽绒服,也可以在下雨天套上雨衣。所有这些外在的服装并没有改变人的本质,但是它们却拓展了人的基本抗性。
class Person {
intro() {
console.log("我是一个帅逼");
new Person().intro(); // 我是一个帅逼
// 获取原本的行为
const origin1 = Person.prototype.intro;
// 添加羽绒服
Person.prototype.intro = function () {
origin1.call(this);
console.log("我穿了一件羽绒服");
new Person().intro(); // 我是一个帅逼,我穿了一件羽绒服
// 获取原本的行为,这里的行为已经是被装饰过的
const origin2 = Person.prototype.intro;
// 添加雨衣
Person.prototype.intro = function () {
origin2.call(this);
console.log("我穿了一件雨衣");
new Person().intro(); // 我是一个帅逼,我穿了一件羽绒服,我穿了一件羽雨衣
这个过程就像是俄罗斯套娃一样,我们没有改变原本的功能,而是为它包装了一层新的功能。
而 TS 中的装饰器达到的效果和装饰器模式不能说一模一样,只能说完全相同 。只是以一种更加优雅的方式实现而已。
function wearDownCoat(
target: any,
key: string,
descriptor: PropertyDescriptor
const origin = descriptor.value;
descriptor.value = function () {
origin.call(this);
console.log("我穿了一件羽绒服");
function wearRainCoat(
target: any,
key: string,
descriptor: PropertyDescriptor
const origin = descriptor.value;
descriptor.value = function () {
origin.call(this);
console.log("我穿了一件雨衣");
class Person {
// 这就是装饰器,很简洁有木有
@wearRainCoat
@wearDownCoat
intro() {
console.log("我是一个帅逼");
new Person().intro(); // 我是一个帅逼,我穿了一件羽绒服,我穿了一件羽雨衣
装饰器
TS 中装饰器使用
@expression
这种模式,装饰器本质上就是
函数
。装饰器可以作用于: 1. 类声明 2. 方法 3. 访问器(
getter/setter
) 4. 属性 5. 方法参数
开启装饰器特性,需要在
tsconfig.json
中开启
experimentalDecorators
:
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true
}
先来快速认识一下这 5 种装饰器:
// 类装饰器
@classDecorator
class Person {
// 属性装饰器
@propertyDecorator
name: string;
// 方法装饰器
@methodDecorator
intro(
// 方法参数装饰器
@parameterDecorator words: string
// 访问器装饰器
@accessDecorator
get Name() {}
// 此时的 Person 已经是被装饰器增强过的了
const p=new Person()
装饰器只在解释执行时应用一次,比如上面的例子中,在完成 Person 的声明后,就已经应用了装饰器,之后的所有实例化都是增强过的 Person。
五大巨头
类装饰器
类装饰器可用于继承现有的类,或者为现有类添加属性和方法。其类型声明如下:
type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
- 参数
-
target
:类的构造函数 - 返回值:如果类装饰器返回了一个非空的值,那么该值将用来替代原本的类
举个 ,为
Person
类添加
run
方法:
type Constructor = new (...args: any[]) => Object;
function addRun(target: Constructor) {
// 返回一个继承 Person 的子类
return class extends target {
run() {
console.log("我在狂奔");
// 或者直接修改其 prototype,也能实现同样的效果
// target.prototype.run = function () {
// console.log("我在狂奔");
// };
@addRun
class Person {}
new Person().run(); // 我在狂奔
看起来似乎很棒,但是(总有那么一个但是)。TS 无法为装饰器提供类型保护,这是一个
已知的 bug,已经提了好几年了
,它无法感知我们对
Person
做了何种修改,因此在调用
run
方法时会报错:
毕竟装饰器其实一个很动态的特性,似乎和 TS 的类型系统原则相违背。为了解决这种报错,可以简单的直接加上
@ts-ignore
就行,毕竟
更合理一点的方法是额外提供一个类声明用于提供类型信息(还是有点奇奇怪怪 ):
declare class Decorator {
run(): void;
@addRun
class Person extends Decorator {}
new Person().run();
方法装饰器
方法装饰器可用于修改方法原本的实现。其类型声明如下:
declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;
- 参数
-
target
:修饰静态方法时,是类的构造方法;否则是类的原型(prototype
) -
propertyKey
: 方法名 -
descriptor
:方法的描述对象 - 返回值:如果方法装饰器返回了一个非空的值,那么该值将用来替代方法原本的描述对象
举个 ,为
Person
类的
run
方法添加输出其运行时间的功能:
function addLog(target: any, key: string, descriptor: PropertyDescriptor) {
const origin = descriptor.value;
descriptor.value = function () {
console.time("run");
origin.call(this);
console.timeEnd("run");
// 或者返回一个新的 descriptor
// return {
// ...descriptor,
// value: function () {
// console.time("run");
// origin.call(this);
// console.timeEnd("run");
// },
// };
class Person {
@addLog
run() {
console.log("我在狂奔");
new Person().run(); // run: 0.063ms
访问器装饰器
访问器装饰器其实本质上来说和方法装饰器几乎一样。唯一的区别就是描述对象不同。
注意,
TS 不允许为一个属性的 getter 和 setter 同时设置装饰器
。其实也很好理解,访问器装饰器的描述对象本来就是同时包含了
setter
和
getter
,如果同时设置,势必会引起冲突。
function capitalizeName(
target: any,
key: string,
descriptor: PropertyDescriptor
// descriptor 同时包含 set 和 get 方法
const set = descriptor.set;
descriptor.set = function (name: string) {
set.call(this, name.toUpperCase());
class Person {
private name: string = "";
get Name() {
return this.name;
@capitalizeName
set Name(name: string) {
this.name = name;
const p = new Person();
p.Name = "lower case";
console.log(p.Name); // LOWER CASE
属性装饰器
属性装饰器对比之前稍有不同。之前的构造方法和方法,访问器都是在类声明后就确定了,而属性需要等到类被实例化后才能拿到具体的结果,因此多用于收集信息。其类型声明如下:
type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;
- 参数
-
target
:修饰静态方法时,是类的构造方法;否则是类的原型(prototype
) -
propertyKey
: 方法名 - 返回值:忽略返回结果
提问题:为什么属性装饰器没有
descriptor
参数呢?
答案前面已经提到了, 属性需要等到类被实例化后才能拿到具体的结果 ,而装饰器实在类声明完成后就应用了,此时属性都还不存在呢,哪里来的描述对象呢?
单独使用属性装饰器意义不大,多用于和其他装饰器打配合。属性装饰器负责收集信息,其他装饰器使用这些信息。
参数装饰器
参数装饰器与属性装饰器类似,单独使用做的事情很有限,主要也是用来收集信息。和属性装饰器这个哥们属于是难兄难弟。其类型声明如下:
type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;
- 参数
-
target
:修饰静态方法时,是类的构造方法;否则是类的原型(prototype
) -
propertyKey
: 方法名 -
parameterIndex
:该参数在方法中入参中所在的位置 - 返回值:忽略返回结果
后续有例子讲解属性装饰器和参数装饰器,这里不再展开。
简单总结一下 5 种装饰器:
| 装饰器类型 | 参数 | 返回值 | | :------------- | :----------------------------------------------------------- | :----------------------------------------------------------- | | 类装饰器 | 1.
target
: 类的构造函数 | 如果类装饰器返回了一个非空的值,那么该值将用来替代原本的类 | | 方法装饰器 | 1.
target
: 修饰静态方法时是类构造函数;否则是类原型(
prototype
)
2.
propertyKey
: 方法名
3.
descriptor
:方法的描述对象 | 如果方法装饰器返回了一个非空的值,那么该值将用来替代方法原本的描述对象 | | 访问器装饰器 | 1.
target
: 修饰静态方法时是类构造函数;否则是类原型(
prototype
)
2.
propertyKey
: 方法名
3.
descriptor
:方法的描述对象 | 如果方法装饰器返回了一个非空的值,那么该值将用来替代方法原本的描述对象 | | 属性装饰器 | 1.
target
:修饰静态方法时是类构造函数;否则是类原型(
prototype
)
2.
propertyKey
: 方法名 | 忽略返回结果 | | 方法参数装饰器 | 1.
target
:修饰静态方法时是类构造函数;否则是类原型(
prototype
)
2.
propertyKey
: 方法名
3.
parameterIndex
:该参数在方法入参中所在的位置 | 忽略返回结果 |
装饰器工厂
有些装饰器的功能可能只有细微不同,就像文章开头提到的羽绒服和雨衣的例子一样。这个时候写多个装饰器不符合 DRY 原则 ,那么可以借助装饰器工厂简化代码。装饰器工厂本质也是函数,它会返回装饰器表达式供装饰器运行时调用。简而言之, 装饰器工长就是返回装饰器表达式的函数 ,又有点套娃的感觉了。
// 这就是装饰器工厂
function wearSomething(clothes: string) {
return function (target: any, key: string, descriptor: PropertyDescriptor) {
const origin = descriptor.value;
descriptor.value = function () {
origin.call(this);
console.log(`我穿了一件${clothes}`);
class Person {
@wearSomething("雨衣")
@wearSomething("羽绒服")
intro() {
console.log("我是一个帅逼");
new Person().intro(); // 我是一个帅逼,我是一个帅逼,我穿了一件羽雨衣
装饰器执行顺序
我们可以对同一属性应用多个装饰器,他们的顺序是:
- 先从外层到内层求值装饰器(如果是函数工厂的话)
- 应用装饰器时,是从内层到外层
function fn(str: string) {
console.log("求值装饰器:", str);
return function () {
console.log("应用装饰器:", str);
function decorator() {
console.log("应用其他装饰器");
class T {
@fn("外层")
@decorator
@fn("内层")
method() {}
}
代码将会输出:
求值装饰器: 外层
求值装饰器: 内层
应用装饰器: 内层
应用其他装饰器
应用装饰器: 外层
对于不同的类型的装饰器的顺序也有明确的规定:
-
首先,根据书写先后,顺序执行实例成员(即
prototype
)上的所有装饰器。对于同一方法来说,一定是先 应用 参数装饰器,再 应用 方法装饰器(参数装饰器 -> 方法 / 访问器 / 属性 装饰器) - 执行静态成员上的所有装饰器,顺序与上一条一致(参数装饰器 -> 方法 / 访问器 / 属性 装饰器)
- 执行构造方法上的所有装饰器(参数装饰器 -> 类装饰器)
function fn(str: string) {
console.log("求值装饰器:", str);
return function () {
console.log("应用装饰器:", str);
@fn("类装饰器")
class T {
constructor(@fn("类参数装饰器") foo: any) {}
@fn("静态属性装饰器")
static a: any;
@fn("属性装饰器")
b: any;
@fn("方法装饰器")
methodA(@fn("方法参数装饰器") foo: any) {}
@fn("静态方法装饰器")
static methodB(@fn("静态方法参数装饰器") foo: any) {}
@fn("访问器装饰器")
set C(@fn("访问器参数装饰器") foo: any) {}
@fn("静态访问器装饰器")
static set D(@fn("静态访问器参数装饰器") foo: any) {}
}
代码将会输出:
求值装饰器: 属性装饰器
应用装饰器: 属性装饰器
求值装饰器: 方法装饰器
求值装饰器: 方法参数装饰器
应用装饰器: 方法参数装饰器
应用装饰器: 方法装饰器
求值装饰器: 访问器装饰器
求值装饰器: 访问器参数装饰器
应用装饰器: 访问器参数装饰器
应用装饰器: 访问器装饰器
求值装饰器: 静态属性装饰器
应用装饰器: 静态属性装饰器
求值装饰器: 静态方法装饰器
求值装饰器: 静态方法参数装饰器
应用装饰器: 静态方法参数装饰器
应用装饰器: 静态方法装饰器
求值装饰器: 静态访问器装饰器
求值装饰器: 静态访问器参数装饰器
应用装饰器: 静态访问器参数装饰器
应用装饰器: 静态访问器装饰器
求值装饰器: 类装饰器
求值装饰器: 类参数装饰器
应用装饰器: 类参数装饰器
应用装饰器: 类装饰器
关门上源码
我们已经从表现层面讲解了装饰器的使用方法以及执行顺序。可能有点抽象,没关系,现在我们从源码层面来看看装饰器到底做了什么,从本质上了解装饰器。
上面的执行顺序示例代码将被编译成(可以跳转到 Playground 查看):
"use strict";
var __decorate =
(this && this.__decorate) || // 通过 IIFE 声明 decorate 函数
function (decorators, target, key, desc) {
// 参数个数 < 3,说明是类装饰器
// 参数个数 = 3,说明是属性装饰器
// 参数个数 > 3,说明是方法/参数/访问器装饰器
var c = arguments.length,
c < 3
? target
: desc === null // desc 为空,说明是方法/参数/访问器装饰器
? (desc = Object.getOwnPropertyDescriptor(target, key))
: desc, // 在应用类装饰器时,r 是构造方法;在应用属性装饰器时,是 void 0;否则是对应方法/属性的描述对象
// 如果当前环境支持 ES6 的 Reflect 特性,直接使用,否则使用 polyfill 实现相同的功能
if (typeof Reflect === "object" && typeof Reflect.decorate === "function")
r = Reflect.decorate(decorators, target, key, desc);
for (
var i = decorators.length - 1;
i >= 0;
i-- // 从内层到外层依次应用装饰器
if ((d = decorators[i]))
// 下面这一段赋值操作是精髓,建议多看几遍
(c < 3
? d(r) // 应用类装饰器
: c > 3
? d(target, key, r) // 应用方法/参数/访问器装饰器
: d(target, key)) || // 应用属性装饰器
// 很关键,如果装饰器没有返回值,则使用原值,保证不会被错误赋值为 undefined
// 注意:原值不代表不能被更新
// 比如:即使方法/访问器没有返回值,只要它们改动了描述对象,即 r,这个 r 也是被更新过的 r, 因为 r 是引用类型
// 但是对于参数装饰器来说,拿不到描述对象,所以 r 没法被改变
// 如果方法/访问器装饰器有返回结果,将其作为新的描述对象应用到 target
return c > 3 && r && Object.defineProperty(target, key, r), r;
var __param =
(this && this.__param) ||
function (paramIndex, decorator) {
// 参数装饰器其实就是应用闭包,拿到其 index,返回返回新的方法作为装饰器
// 这个方法与方法装饰器相比,不包含描述对象参数,且没有返回值
return function (target, key) {
decorator(target, key, paramIndex);
function fn(str) {
console.log("求值装饰器:", str);
return function () {
console.log("应用装饰器:", str);
var T = /** @class */ (function () {
// 类声明
function T(foo) {}
T.prototype.methodA = function (foo) {};
T.methodB = function (foo) {};
Object.defineProperty(T.prototype, "C", {
set: function (foo) {},
enumerable: false,
configurable: true,
Object.defineProperty(T, "D", {
set: function (foo) {},
enumerable: false,
configurable: true,
/* ---------- 类声明完成后,立即应用装饰器(装饰器执行一次的原因所在),之后实例化的对象基于装饰过的类 --------- */
// 首先是实例成员
// 所有的装饰器会按照 外层到内层的顺序 被组装成为一个数组
// 装饰器工厂会在组装时完成求值操作
__decorate([fn("属性装饰器")], T.prototype, "b", void 0);
__decorate(
[fn("方法装饰器"), __param(0, fn("方法参数装饰器"))],
T.prototype,
"methodA",
__decorate(
[fn("访问器装饰器"), __param(0, fn("访问器参数装饰器"))],
T.prototype,
"C",
// 然后是静态成员
__decorate([fn("静态属性装饰器")], T, "a", void 0);
__decorate(
[fn("静态方法装饰器"), __param(0, fn("静态方法参数装饰器"))],
"methodB",
__decorate(
[fn("静态访问器装饰器"), __param(0, fn("静态访问器参数装饰器"))],
"D",
// 最后是类构造方法,使用其返回结果作为新的构造方法
T = __decorate([fn("类装饰器"), __param(0, fn("类参数装饰器"))], T);
return T;
})();
结合注释,相信聪明的你,一定没问题,skr!
小试牛刀:方法参数类型校验
方法参数类型校验代码地址: https:// github.com/wjgogogo/ts- decorator/tree/master/param-validator
TS 为代码提供了编译时的类型检查,我们希望更进一步,在运行时也添加类型检查的能力。要实现这种能力,单一的装饰器就不够用了,需要多种装饰器配合使用。
大致的思路如下:
- 使用参数装饰器(终于等到你)标记需要做类型检查的参数
- 使用方法装饰器增强原方法的功能,在运行原方法前先进行类型检查
- 待一切正常后,运行原有方法
代码如下:
type Validator = (value: unknown) => boolean;
// map 用于收集不同方法参数校验器
const validatorMap = new Map<string, Validator[]>();
function applyValidator(validator: Validator) {
return function (target: any, key: string, idx: number) {
let validators: Validator[];
// 获取当前方法已存在的校验器
if (validatorMap.has(key)) {
validators = validatorMap.get(key);
} else {
// 如果不存在,则新增一个校验器配置到 map 中
validators = [];
validatorMap.set(key, validators);
// 将新的检验器加入到数组中,数组第几项就对应第几个参数的校验器
// 出于简化目的,假设每一个参数最多只能有一个校验器
validators[idx] = validator;
function validate(target: any, key: string, descriptor: PropertyDescriptor) {
const origin = descriptor.value;
descriptor.value = function (...args: unknown[]) {
// 如果该方法不需要校验,则直接运行原方法
if (!validatorMap.has(key)) {
return origin.apply(this, args);
const validators = validatorMap.get(key);
//先对方法的每一个参数进行校验,遇到不符合规则的情况,直接报错
validators.forEach((validator, idx) => {
if (!validate) {
return;
if (!validator(args[idx])) {
throw new TypeError(`Type validate failed for ${args[idx]}`);
// 所有校验通过后再运行原方法
return origin.apply(this, args);
const isString = applyValidator((x) => typeof x === "string");
const isNumber = applyValidator((x) => typeof x === "number");
class Person {
@validate
saySomething(@isString a: any, @isNumber b: any) {
console.log("a: ", a, "b: ", b);
new Person().saySomething("str", 12); // a: str b: 12
new Person().saySomething(12); // Type validate failed for 12
new Person().saySomething("str", "other str"); // Type validate failed for other str
通过上面的代码,就实现了一个简单的方法运行时参数类型验证的功能。
上面代码只存在一个类,所以
validatorMap
只需要保存
Person
的方法名和参数校验器即可。但实际情况会复杂很多,可能会有多个类,每个方法的参数可能会有多个校验器,存储这些信息的处理也会复杂很多。这个时候,我们就需要借助三方库助力开发。
reflect-metadata
Reflect Metadata 是 ES7 的一个提案,它主要用来在声明的时候添加和读取元数据。你可以查看 官网文档 获取详细的 API 说明。
reflect-metadata
内部也是以Map
的数据结构存储元数据。最核心的一点是,不仅要存储元数据,还要存储这个元数据所作用在类或者类的方法、属性。理解了这一点,就理解了reflect-metadata
当借助
reflect-metadata
后,上面的代码就可以进一步简化:
import "reflect-metadata";
function validate(target: any, key: string, descriptor: PropertyDescriptor) {
const origin = descriptor.value;
descriptor.value = function (...args: unknown[]) {
// 获取目标元数组
const validators = Reflect.getMetadata("validators", target, key);
if (!validators) {
return origin.apply(this, args);
validators.forEach((validator, idx) => {
if (!validate) {
return;
if (!validator(args[idx])) {
throw new TypeError(`Type validate failed for ${args[idx]}`);
// 所有校验通过后再运行原方法
return origin.apply(this, args);
const isString = (x: unknown) => typeof x === "string";
const isNumber = (x: unknown) => typeof x === "number";
class Person {
@validate
// Reflect.metadata 方法可以很方便的用于定义各种类型装饰器
@Reflect.metadata("validators", [isString, isNumber])
saySomething(a: any, b: any) {
console.log("a: ", a, "b: ", b);
}
通过
reflect-metadata
,我们就不再需要关心状态的存储,在使用
@Reflect.metadata
时,它会自动将校验器以
key-value
形式存储起来。其中:
-
第一个参数代表该元数据的
key
值,也是后续检索的依据 -
第一个参数代表该元数据的
value
值,即存储的信息 -
Reflect 在应用装饰器时,会关联当前的类(
target
)和方法 (propertyKey
)。因此在Reflect.getMetadata
,除了传key
值外,总是需要传入所查找的类以及其方法名或者属性名
但
reflect-metadata
也不完美,它的最多只能关联到当前的类和方法,比如对于参数装饰器,它能关联其
target
和
propertyKey
,标识它的
key-value
元数据是针对哪个类的哪个方法。但是没法关联参数装饰器的
index
信息,因此如果沿用参数装饰器的形式:
class Person {
@validate
// Reflect.metadata 方法可以很方便的用于定义各种类型装饰器
// @Reflect.metadata("validators", [isString, isNumber])
saySomething(
@Reflect.metadata("validators", isString) a: any,
@Reflect.metadata("validators", isNumber) b: any
console.log("a: ", a, "b: ", b);
}
最后
Reflect.getMetadata
时只能拿到
isNumber
的校验器,因为对于同一个类的同一个方法,不能有同名的
key
存在,会采用覆盖原则(毕竟用的是
Map
)。
折中方法是将index
信息以属性的形式存储到value
中,如同后续实战代码一样。
严格来说,元数据和 TS 并没有关系,但是 TS 在 1.5+ 的版本已经支持元数据,可以通过设置
emitDecoratorMetadata
开启此功能:
{
"compilerOptions": {
"target": "ES5",
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
}
你信不信,开启后,代码还可以进一步简化。
import "reflect-metadata";
function validate(target: any, key: string, descriptor: PropertyDescriptor) {
const origin = descriptor.value;
descriptor.value = function (...args: unknown[]) {
// 获取目标元数组
// 通过内置的 design:paramtypes 即可拿到参数的类型
const paramTypes = Reflect.getMetadata("design:paramtypes", target, key);
if (!paramTypes) {
return origin.apply(this, args);
paramTypes.forEach((paramType, idx) => {
!(args[idx].constructor === paramType || args[idx] instanceof paramType)
throw new TypeError(`Type validate failed for ${args[idx]}`);
// 所有校验通过后再运行原方法
return origin.apply(this, args);
class Person {
@validate
saySomething(a: string, b: number) {
console.log("a: ", a, "b: ", b);
}
我们甚至不需要手动添加校验器的方法装饰器,为什么能做到这一点呢?来看看此时代码的编译结果:
// metadata 提供的装饰器工厂
var __metadata =
(this && this.__metadata) ||
function (k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function")
return Reflect.metadata(k, v);
var Person = /** @class */ (function () {
function Person() {}
Person.prototype.saySomething = function (a, b) {
console.log("a: ", a, "b: ", b);
__decorate(
validate,
// 关键所在
// 在编译时,会自动添加以下三种类型装饰器
__metadata("design:type", Function),
__metadata("design:paramtypes", [String, Number]),
__metadata("design:returntype", void 0),
Person.prototype,
"saySomething",
return Person;
})();
TS 会自动添加三个类型装饰器到属性上,这三种类型分别是:
-
design:type
: 装饰器所应有的属性的类型 -
design:paramtypes
: 方法的参数的类型(只在方法装饰器时存在) -
design:returntype
: 方法的返回的类型(只在方法装饰器时存在)
装饰器拿到的类型都是构造函数。规则是:
| 原始类型 | 转换后类型 | | --------------- | ------------------------------------------------------------ | | number | Number | | string | String | | boolean | Boolean | | void/null/never | undefined | | array/tuple | Array | | class | 类工造函数 | | enum | 如果是数字妹枚举,则是 Number,如果是字符串枚举,则是 String,否则是 Object | | fucntion | Function | | 其他 | Object |
到现在为止,关于 TS 装饰器的所有知识点已经讲完了,我们试着上点难度,做两个有趣的实战练习。
实战 1:Route 配置自动注入
Route 配置自动注入代码地址: https:// github.com/wjgogogo/ts- decorator/tree/master/router-injection
koa
是 nodejs 开发中比较易用的服务端框架。实战 1 就来实现一个 Spring(或者说是 Nest)风格的路由自动注入功能:
整体思路如下:
- 使用各种装饰器收集信息
-
controller
装饰器收集路由前缀 -
method
装饰器收集路由信息和回调方法 -
param
装饰器收集哪些参数需要被映射为请求中的对应的参数信息 -
body
装饰器收集哪些参数需要被映射为post
请求中的body
信息 - 定义路由类
- 加载所有路由类,获取元数据信息,根据元数据生成路由配置
-
为
koa
实例添加路由配置
首先实现一系列的装饰器:
// 装饰器类型
export enum DecoratorKey {
Controller = "controller",
Method = "method",
Param = "param",
Body = "body",
// 请求类型
export enum MethodType {
Get = "get",
Post = "post",
// 请求装饰器元数据类型
export interface MethodMetadata {
method: MethodType;
route: string;
fn: Function;
// 请求参数装饰器元数据类型
export interface ParamMetadata {
idx: number;
key: string;
// controller 只用于收集路由前缀
export const controller = (prefix: string) => (target: any) => {
Reflect.defineMetadata(DecoratorKey.Controller, prefix, target);
// method 工厂用于收集路由方法,路径,和回调方法
export const method =
(method: string) =>
(route: string) =>
(target: any, key: string, descriptor: PropertyDescriptor) => {
Reflect.defineMetadata(
DecoratorKey.Method,
method,
route,
fn: descriptor.value,
target,
// 通过工厂生成 get 和 post 装饰器工厂
export const get = method(MethodType.Get);
export const post = method(MethodType.Post);
// 请求参数装饰器用于收集所有参数映射信息
export const param =
(paramKey: string) => (target: any, key: string, idx: number) => {
// 所有参数信息用数组存储,因为一个方法中可以使用多个参数映射
const params = Reflect.getMetadata(DecoratorKey.Param, target, key) ?? [];
params.push({
key: paramKey,
Reflect.defineMetadata(DecoratorKey.Param, params, target, key);
// body 参数装饰器用于收集所有参数映射信息
export const body = (target: any, key: string, idx: number) => {
// 因为 body 信息一般赋值给一个参数就可以了,所有存储一下是第几个参数即可
Reflect.defineMetadata(DecoratorKey.Body, idx, target, key);
};
接下来定义路由类配置信息:
@controller("/users")
export class User {
@get("/")
getUsers(ctx: Context) {
ctx.body = "get all users";
@get("/:id/:name")
getUserById(
@param("id") id: string,
@param("name") name: string,
ctx: Context
ctx.body = `get user by id: ${id}, ${name}`;
@post("/:id")
updateUserById(@param("id") id: string, @body body: any, ctx: Context) {
ctx.body = `update user by id: ${id}, ${JSON.stringify(body)}`;
}
然后加载路由配置,按照
约定大于配置
的原则,路由类通常放在统一的文件夹中,比如
controller
文件夹,程序读取并执行其中的所有文件,拿到配置信息然后进行路由组装。
import Router from "@koa/router";
import Application from "koa";
// 从简原则,我们这里通过手动 import,而不是通过读取文件的方法,其效果一致
import * as Controllers from "./controller";
import { DecoratorKey, MethodMetadata, ParamMetadata } from "./decorator";
// app 代表 koa 实例
export function loadRoutes(app: Application) {
// 遍历所有的 controller
Object.keys(Controllers).forEach((name) => {
// 每一个 controller 代表一组独立的路由配置
const router = new Router();
const Controller = Controllers[name];
// 获取当前类装饰器的 prefix 原数据
const prefix = Reflect.getMetadata(DecoratorKey.Controller, Controller);
// 新建 router 实例用于配置路由
router.prefix(prefix);
const Prototype = Controller.prototype;
// 遍历类中的所有方法,获取其中的配置元数据
Object.keys(Prototype).forEach((key) => {
const config: MethodMetadata = Reflect.getMetadata(
DecoratorKey.Method,
Prototype,
// 分别获取请求参数和 body 信息
const params = Reflect.getMetadata(DecoratorKey.Param, Prototype, key);
const bodyIdx = Reflect.getMetadata(DecoratorKey.Body, Prototype, key);
// 配置路由信息
router[config.method](config.route, (ctx, next) => {
// 处理参数映射,别忘了最后将 ctx 和 next 传入
config.fn(...handleArgs(ctx, params, bodyIdx), ctx, next);
app.use(router.routes());
function handleArgs(ctx, params: ParamMetadata[] = [], bodyIdx?: number) {
const args = [];
params.forEach(({ idx, key }) => {
// 请求的参数均在 params 对象上,将其映射到对应的参数位置上
args[idx] = ctx.params[key];
if (bodyIdx) {
// 当使用 bodyparser 后,请求的 body 信息在 request.body 上
args[bodyIdx] = ctx.request.body;
return args;
}
最后,实例化
koa
实例,载入中间件和路由信息即可:
// 别忘了引入 reflect-metadata
import "reflect-metadata";
import Koa from "koa";
import body from "koa-bodyparser";
import { loadRoutes } from "./loadRoutes";
const app = new Koa();
app.use(body());
loadRoutes(app);
app.listen(3000);
铛铛,完事儿~
实战 2:迷你 Mobx 实现响应式更新
Route 配置自动注入代码地址: https:// github.com/wjgogogo/ts- decorator/tree/master/toy-mobx
在前端热门的框架中,大多采用了响应式更新的方式,比如 Mobx ,Mobx 自身也有 基于装饰器的使用方法 ,建议先看看官方文档。实战 2 就来实现一个简易的迷你 Mobx 框架实现(面条代码警告 )。
先看看实现的效果:
import { autoRun, computed, observable, observer } from "./observer";
// 响应式的类
@observer
class Order {
id = 0;
// 需要响应变化的数据
@observable
price = 0;
@observable
count = 0;
// 类似于衍生数据
@computed
get amount() {
return this.price * this.count;
const order = new Order();
// 通过 autoRun 收集回调方法和数据的依赖关系
autoRun(function idFn() {
console.log("id:", order.id);
autoRun(function priceFn() {
console.log("price:", order.price);
autoRun(function princeAndCountFn() {
console.log(
`price(${order.price}) x count(${order.count}): ${
order.price * order.count
autoRun(function amountFn() {
console.log("amount: ", order.amount);
// 希望在所有 observable 属性值发生改变时,自动运行依赖该数据的回调方法
order.id = 12323; // 不是响应式属性,啥也不会发生
setTimeout(() => {
order.price = 10; // 运行 priceFn, priceAndCountFn, 以及 amountFn
}, 1000);
setTimeout(() => {
order.count = 100; // 运行 priceAndCountFn, 以及 amountFn
}, 1000);
说一说整体思路:
- 修改所有 observable 属性的描述对象,将它变为访问器
-
在
autoRun
执行回调时,调用如果使用了order
的某个响应属性,就会走到getter
访问器中,此时除了返回数据外,还需要做依赖收集,将刚回调存起来 -
在修改了响应属性的值后,就会走到其
setter
访问器中,此时除了更新数据外,依次将收集的回调方法一一调用 - 在每次执行回调前,别忘了先将回调和响应函数之前的关联取消掉,否则在每次执行回调后,就会产生越来越多的依赖关系
没错,其实就是 观察者模式 。
import "reflect-metadata";
enum DecoratorType {
Observable = "observable",
Computed = "computed",
interface ComputedMetadata {
key: string;
fn: Function;
// Effect 类型
interface Effect {
execute: Function;
deps: Set<Set<Effect>>;
let runningEffect: Effect = null;
function subscribe(observer: Set<Effect>, effect: Effect) {
observer.add(effect); // 收集 effect 回调信息
effect.deps.add(observer); // effect 也需要和 observer 建立联系,用于后续解绑操作
function injectObservableKeys(obj, keys: string[] = []) {
keys.forEach((key) => {
let value = obj[key];
let subscribes = new Set<Effect>();
Object.defineProperty(obj, key, {
get() {
// 依赖收集
subscribe(subscribes, runningComputed || runningEffect);
return value;
set(updated) {
value = updated;
// 复制一份新的依赖队列遍历,千万不要在原对象上遍历,因为在执行回调时,又会绑定新的依赖项,造成无限循环
[...subscribes].forEach((effect) => effect.execute());
// observer
export function observer(target): any {
// 拿到所有的 observable 属性名
const observableKeys: string[] = Reflect.getMetadata(
DecoratorType.Observable,
target.prototype
// 返回一个新的类
return class extends target {
constructor() {
super(); // 调用 super 方法完成属性初始化
injectObservableKeys(this, observableKeys); // 处理其中的 observable keys
// observable 用于收集所有响应式属性
export const observable = (target, key) => {
const keys = Reflect.getMetadata(DecoratorType.Observable, target) ?? [];
keys.push(key);
Reflect.defineMetadata(DecoratorType.Observable, keys, target);
function cleanup(effect: Effect) {
effect.deps.forEach((dep) => {
dep.delete(effect);
effect.deps.clear();
export function autoRun(fn: Function) {
const execute = () => {
// 双向解绑
cleanup(effect);
// 设置当前 effect 变量,在执行回调时,响应属性才知道当前运行的是哪个 effect
runningEffect = effect;
try {
fn();
} finally {
runningEffect = null;
// 每一个 effect 需要包含一个需要在依赖属性变化执行的回调,以及它所依赖的属性的 subscribe 几何
const effect: Effect = {
execute,
deps: new Set(),
// 先执行依次,建立依赖关系
execute();
}
暂时先忽略
computed
装饰器相关代码,在代码执行后,其依赖关系如图:
后续在响应属性更新后,就会执行相应的依赖回调。
再来看看
computed
方法,
computed
属性相当于一个中间层,一边对接回调方法,一边对接响应属性,在响应属性改变后,执行
computed
的属性的响应方法(类似于响应数据的
setter
方法),然后再执行所有回调方法:
因此,
computed
其实和
effect
类似。加入
computed
后,完整代码如下:
import "reflect-metadata";
enum DecoratorType {
Observable = "observable",
Computed = "computed",
interface ComputedMetadata {
key: string;
fn: Function;
// Effect 类型
interface Effect {
execute: Function;
deps: Set<Set<Effect>>;
let runningEffect: Effect = null;
let runningComputed: Effect = null;
function subscribe(observer: Set<Effect>, effect: Effect) {
observer.add(effect); // 收集 effect 回调信息
effect.deps.add(observer); // effect 也需要和 observer 建立联系,用于后续解绑操作
function injectObservableKeys(obj, keys: string[] = []) {
keys.forEach((key) => {
let value = obj[key];
let subscribes = new Set<Effect>();
Object.defineProperty(obj, key, {
get() {
// 依赖收集,runningComputed 优先
subscribe(subscribes, runningComputed || runningEffect);
return value;
set(updated) {
value = updated;
// 复制一份新的依赖队列遍历,千万不要在原对象上遍历,因为在执行回调时,又会绑定新的依赖项,造成无限循环
[...subscribes].forEach((effect) => effect.execute());
function injectComputedKeys(obj, keys: ComputedMetadata[] = []) {
keys.forEach((computed) => {
let subscribes = new Set<Effect>();
const executeComputedGetter = () => {
cleanup(effect);
// 用另个标识标记 computed effect
runningComputed = effect;
try {
return computed.fn.call(obj);
} finally {
runningComputed = null;
// computed 的 execute 就是让其依赖回调都执行一遍
const execute = () => {
[...subscribes].forEach((effect) => effect.execute());
const effect: Effect = {
execute,
deps: new Set(),
Object.defineProperty(obj, computed.key, {
get() {
subscribe(subscribes, runningEffect);
return executeComputedGetter();
// observer
export function observer(target): any {
// 拿到所有的 observable 属性名
const observableKeys: string[] = Reflect.getMetadata(
DecoratorType.Observable,
target.prototype
const computedKeys: ComputedMetadata[] = Reflect.getMetadata(
DecoratorType.Computed,
target.prototype
// 返回一个新的类
return class extends target {
constructor() {
super(); // 调用 super 方法完成属性初始化
injectObservableKeys(this, observableKeys); // 处理其中的 observable keys
injectComputedKeys(this, computedKeys);
// observable 用于收集所有响应式属性
export const observable = (target, key) => {
const keys = Reflect.getMetadata(DecoratorType.Observable, target) ?? [];
keys.push(key);
Reflect.defineMetadata(DecoratorType.Observable, keys, target);
export const computed = (target, key, descriptor) => {
const keys = Reflect.getMetadata(DecoratorType.Computed, target) ?? [];
// 假定所有 computed 都是 getter
keys.push({ key, fn: descriptor.get });
Reflect.defineMetadata(DecoratorType.Computed, keys, target);
function cleanup(effect: Effect) {
effect.deps.forEach((dep) => {
dep.delete(effect);
effect.deps.clear();
export function autoRun(fn: Function) {
const execute = () => {
// 双向解绑
cleanup(effect);
// 设置当前 effect 变量
runningEffect = effect;
try {
fn();
} finally {
runningEffect = null;
// 每一个 effect 需要包含一个需要在依赖属性变化执行的回调,以及它所依赖的属性的 subscribe 几何
const effect: Effect = {