Ts5.0 装饰器新语法
ts5.0 发布了一些新特性,没有在官网的 handbook 上找到学习资料,在油管上找了一些视频来学习,讲得还挺不错, 下面主要来入门下 Method Decorator 的用法。
1. 环境准备
ts 版本修改
- 自己下载 ts5 版本,然后在 vscode 右下角选择 ts 版本为 workspace5.0.2
- 或者直接用 vscode 插件 JavaScript and TypeScript Nightly 里的最新版本
- 用 ts 官网的 playground 也行
全局下载 ts-node
这个插件可以直接运行 typescript 文件
提示:老装饰器语法的选项 experimentalDecorators
一定要关掉,否则走的老版本。
2. 装饰器介绍
装饰器语法是: @decoratorName
, 主要作用就是在运行时通过包装一个新的函数,给被装饰的函数增加一些新的功能。
decoratorName
的格式是: (target: any, context: any) => any
target
是被装饰的目标对象context
是上下文信息对象,这个比较厉害,可以通过kind
(被装饰的对象类型),name
(被装饰的对象名字),private
(是否是私有的),static
(是否是静态的),access
(访问器对象) 属性拿到被装饰对象的各种信息,还可以通过addInitializer
在类被实例化或定义的时候注入额外的启动方法。
可以使用 高阶函数 和 Object.defineProperty
来模拟生成, 所以兼容性很 ok。
3. 实现一个类方法装饰器
// demo.ts
// This 是实例类型
// Args 是参数类型
// Return 是返回值类型,可推断
// 装饰器返回的函数的类型在这三部分必须和原先函数上的保持一致,这样才能正确替换
function loggedMethod<This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<
This,
(this: This, ...args: Args) => Return
>
) {
const methodName = String(context.name);
const replaceMethod = function (this: This, ...args: Args) {
console.log(`LOG: Entering method ${methodName}`);
const result = target.call(this, ...args);
console.log(`LOG: Exiting method ${methodName}`);
return result;
};
return replaceMethod;
}
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@loggedMethod
intro(guest: string) {
console.log(`Hello ${guest}, my name is ${this.name}`);
}
}
const p = new Person('jack');
p.intro('tom');
运行之后得到:
LOG: Entering method intro
Hello tom, my name is jack
LOG: Exiting method intro
可以看到,类方法 intro
被装饰成功了,它在原本的功能上新增了函数启动和退出的打印逻辑,这在实际应用中可以改造成入参校验逻辑 和 副作用清理逻辑。
4. 实现装饰器 Creator
如果装饰器的逻辑依赖一些动态的值,就可以实现一个装饰器 creator 来满足,它其实还是一个包装的函数工厂,可以根据传入的参数来返回特定功能的装饰器函数。
比如:在前面的功能基础上,让装饰器支持打印不同的日志类型
+ function log(level: 'INFO' | 'WARN' | 'ERROR') {
return function loggedMethod<This, Args extends any[], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<
This,
(this: This, ...args: Args) => Return
>
) {
const methodName = String(context.name);
const replaceMethod = function (this: This, ...args: Args) {
+ console.log(`${level}: Entering method ${methodName}`);
const result = target.call(this, ...args);
+ console.log(`${level}: Exiting method ${methodName}`);
return result;
};
return replaceMethod;
};
+ }
调用的时候这样:
@log('INFO')
intro(guest: string) {
console.log(`Hello ${guest}, my name is ${this.name}`);
}
得到的结果是:
INFO: Entering method intro
Hello tom, my name is jack
INFO: Exiting method intro
再比如:实现一个限制入参长度的装饰器 creator
function limit(count: number) {
return function <This, Args extends [arg: any[], ...rest: any[]], Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<
This,
(this: This, ...args: Args) => Return
>
) {
const methodName = String(context.name);
const replaceMethod = function (this: This, ...args: Args) {
if (args[0].length > count) {
throw new Error(`${methodName}, cannot call with more than ${count} items`)
}
return target.call(this, ...args);
};
return replaceMethod;
}
}
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
// 只能向最多 2 个人介绍
@limit(2)
introGroup(guests: string[]) {
guests.forEach((g) => {
this.intro(g);
});
}
@log('INFO')
intro(guest: string) {
console.log(`Hello ${guest}, my name is ${this.name}`);
}
}
const p = new Person('jack');
p.introGroup(['tom', 'ben', 'chris']);
运行后的结果是:
throw new Error(`${methodName}, cannot call with more than ${count} items`)
^
Error: introGroup, cannot call with more than 2 items
像这样再拓展下去,其实可以实现非常多有用的装饰器。
5. 装饰器抽象工厂
继续拓展,如果现在需要制作一系列的负责校验的装饰器,比如:参数类型校验,参数长度校验,返回结果校验… 那么这一类的装饰器就会有很多的公共逻辑,可以继续抽象出一个专门产出校验类装饰器creator 的工厂。
// 抽象工厂函数
function makeGuardDecorator<T, A extends any[]>(
fn: (t: T, ...args: A) => boolean,
errMessage: string
) {
return function (t: T) {
return function <This, Args extends A, Return>(
target: (this: This, ...args: Args) => Return,
context: ClassMethodDecoratorContext<
This,
(this: This, ...args: Args) => Return
>
) {
const methodName = String(context.name);
const replaceMethod = function (this: This, ...args: Args) {
if (fn(t, ...args)) {
return target.call(this, ...args);
}
// 如果校验失败
throw new Error(`${methodName}: ${errMessage}, arg: ${t}`);
};
return replaceMethod;
};
};
}
// 生成装饰器 creator
const limit = makeGuardDecorator(
(count: number, arg: any[], ...rest: any[]) => {
return arg.length <= count;
},
`too many element in array`
);
const startsWith = makeGuardDecorator(
(letter: string, arg: string, ...rest: any[]) => {
return arg.charAt(0).toLowerCase() === letter.toLowerCase();
},
`starts with wrong letter`
);
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@limit(2)
introGroup(guests: string[]) {
guests.forEach((g) => {
this.intro(g);
});
}
@startsWith('T')
intro(guest: string) {
console.log(`Hello ${guest}, my name is ${this.name}`);
}
}
const p = new Person('jack');
p.introGroup(['tom', 'ben', 'chris']);
运行后的结果是:
Hello tom, my name is jack
throw new Error(`${methodName}: ${errMessage}, arg: ${t}`);
^
Error: intro: starts with wrong letter, arg: T
可以看到,通过这种方式我们让装饰器功能更强大的同时,还聚合了很多公共的功能,这在编写某个具体功能的装饰器时,反而简单了,不需要写那么多的泛型和传导。
6. 多个装饰器
可以把多个装饰器组合起来形成更强大的多层级装饰器。
比如:我们希望 intro 方法具有打印进入和退出日志功能的同时,还能校验参数的首字符是否符合要求,就可以这样:
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@limit(3)
introGroup(guests: string[]) {
guests.forEach((g) => {
this.intro(g);
});
}
// 多个装饰器的装饰顺序:从下到上 (一层一层被包装和替换,下面在编译后的实现中可以看到)
// 多个装饰器的调用顺序:从上到下
@log('WARN')
@startsWith('T')
intro(guest: string) {
console.log(`Hello ${guest}, my name is ${this.name}`);
}
}
运行的结果是:
# ts-node .\demo5.ts
WARN: Entering method intro
Hello tom, my name is jack
WARN: Exiting method intro
WARN: Entering method intro
throw new Error(`${methodName}: ${errMessage}, arg: ${t}`);
^
Error: intro: starts with wrong letter, arg: T
可以看到,由于 @log('WARN')
在 @startsWith('T')
的前面,所以会即使 startsWith
判断失败,也会在之前先调用一次 log
,但不是完整的调用,因为在代码上 target.call(this, args)
会报错然后退出程序。
如果把两个顺序换下,结果就不会多打印一次 WARN: Entering method intro
。
7.编译后的装饰器
把上面的例子复制到 ts playGround 中, 可以看到编译后的实现代码, 从中可以得到一些有用的信息:
- 装饰器的存储是一个闭包的形式,它是和属性是绑定在一起
- 在类完成定义的时候,装饰器就已经完成了初始化调用,后续对类方法的调用会直接使用替换好的函数
let Person = (() => {
var _a;
let _instanceExtraInitializers = [];
// 使用闭包存储装饰器
let _introGroup_decorators;
let _intro_decorators;
return _a = class Person {
constructor(name) {
// 执行 context 上添加的启动函数
this.name = (__runInitializers(this, _instanceExtraInitializers), void 0);
this.name = name;
}
introGroup(guests) {
guests.forEach((g) => {
this.intro(g);
});
}
intro(guest) {
console.log(`Hello ${guest}, my name is ${this.name}`);
}
},
(() => {
// 装饰器使用 _propertyName_decorators 来命名,可以看成是一个栈结构(出口向下)
_introGroup_decorators = [limit(3)];
_intro_decorators = [log('WARN'), startsWith('T')];
// 对指定的装饰对象应用装饰器
__esDecorate(_a, null, _introGroup_decorators, { kind: "method", name: "introGroup", static: false, private: false, access: { has: obj => "introGroup" in obj, get: obj => obj.introGroup } }, null, _instanceExtraInitializers);
__esDecorate(_a, null, _intro_decorators, { kind: "method", name: "intro", static: false, private: false, access: { has: obj => "intro" in obj, get: obj => obj.intro } }, null, _instanceExtraInitializers);
})(),
_a;
})();
- 装饰器应用是通过修改方法对应的
descriptor
并通过Object.defineProperty
完成替换的 - 从下面代码也可以看到替换顺序是从下到上的(栈顶向下),所以在前面会看到:多个装饰器的时候,上层的装饰器执行权更高。
var __esDecorate =
(this && this.__esDecorate) ||
function (
ctor,
descriptorIn,
decorators,
contextIn,
initializers,
extraInitializers
) {
function accept(f) {
if (f !== void 0 && typeof f !== 'function')
throw new TypeError('Function expected');
return f;
}
var kind = contextIn.kind,
// accessor descriptor key 或者 data descriptor key
key = kind === 'getter' ? 'get' : kind === 'setter' ? 'set' : 'value';
// 静态的装饰 target 是类本身,否则是类的原型
var target =
!descriptorIn && ctor
? contextIn['static']
? ctor
: ctor.prototype
: null;
// 取出被装饰器属性对应的属性描述符
var descriptor =
descriptorIn ||
(target ? Object.getOwnPropertyDescriptor(target, contextIn.name) : {});
var _,
done = false;
// 从栈顶依次取出每个装饰器函数, 注册Initializer -> 调用函数 --> 替换原来的值
for (var i = decorators.length - 1; i >= 0; i--) {
var context = {};
for (var p in contextIn) context[p] = p === 'access' ? {} : contextIn[p];
for (var p in contextIn.access) context.access[p] = contextIn.access[p];
context.addInitializer = function (f) {
if (done)
throw new TypeError(
'Cannot add initializers after decoration has completed'
);
extraInitializers.push(accept(f || null));
};
// 调用装饰器函数,获取 replaceMethod
var result = (0, decorators[i])(
kind === 'accessor'
? { get: descriptor.get, set: descriptor.set }
: descriptor[key],
context
);
if (kind === 'accessor') {
if (result === void 0) continue;
if (result === null || typeof result !== 'object')
throw new TypeError('Object expected');
if ((_ = accept(result.get))) descriptor.get = _;
if ((_ = accept(result.set))) descriptor.set = _;
if ((_ = accept(result.init))) initializers.push(_);
} else if ((_ = accept(result))) {
if (kind === 'field') initializers.push(_);
else descriptor[key] = _; // 修改属性描述符对应的 value 值
}
}
// 使用 Object.defineProperty 完成替换
// 如果是多个装饰器会被覆盖,但是逻辑上是层层包装的
if (target) Object.defineProperty(target, contextIn.name, descriptor);
done = true;
};
结束
至此装饰器总算是不再那么神秘了。项目中如果遇到一些主逻辑以外的通用功能,使用上装饰器可以很方便的进行逻辑复用,并且让代码聚焦在主业务逻辑的处理上。