最近在用 TypeScript 开发一款在 Deno 环境下运行的 Web 服务框架,其中大量用到了装饰器。
这是一小段框架的测试代码。
import { Controller } from "../../Server.ts";
import { Get, Post, Query } from "../../Router.ts";
interface User {
id: string;
name: string;
}
let users: User[] = [
{
id: "1",
name: "小张",
},
{
id: "2",
name: "小李",
},
];
@Controller("user")
export class UserController {
@Get("find")
findById(@Query("id") id: string): User | undefined {
return users.find((user) => user.id === id);
}
}
用过 Java 的朋友一定会感觉很熟悉,一股很浓的 Spring 风不是吗?
用过 nodejs 的朋友应该也会闻到一股 Nestjs 的清香吧?
事实上就是这样,我目前在做的这个框架就是在 Spring 上面寻找的灵感。其实也谈不上什么灵感,可能只是单纯喜欢这种风格的服务端代码。
在此之前,我用过很多服务端框架,比如 Java 的 Spring 系列、Python 的 Django、Go 的 Gin、nodejs 的 express、koa、nestjs、nextjs 等等,还有很多叫不上名字的,总之数不胜数,但现在仍然觉得在企业级的服务端开发领域,Spring 是设计最完善的,也是最易用的。
这个感受并不是我突然感慨或是因为正在使用 Spring,其实我近两年基本没怎么好好写过 Java 代码,连 web 服务端代码也很少有写。
上面那段代码的风格与 JavaScript 做的 web 框架是不太一样的。
类比其他 JavaScript 的 Web 框架,比如个人认为很棒的 koa,代码是这种风格的。
const router = require("koa-router")();
router.get("/xx", async (ctx, next) => {
ctx.body = "hello";
});
module.exports = router;
这种风格也挺好。
两种风格的代码最大的区别其实就是面向对象风格和函数式风格的差异,与实现的功能无关。
koa 也有 TypeScript 版本,也支持上面那种面向对象风格的代码,只是用的人很少。
如果你用了 TypeScript,基本上已经确定你的代码风格很大程度上都可能是面向对象风格的,很少会有人使用 TypeScript 编写函数式风格的代码,相比之下,JavaScript 可能更擅长一些。
去年我曾经有一段时间在用 Ramda 和 TypeScript 写过一个组件库,但编码过程体验比较糟糕。
一般来说,对函数式有追求的人,二元论都比较严重,写出来的代码都很简洁。但容易偏激,甚至于简洁胜过一切,包括性能和可读性。TypeScript 的特点是稳定,对应的,代码会很繁琐。当然可以通过配置把 ts 的检测都关掉,以此来写出简短的代码,但这和直接用 JavaScript 又有什么区别呢?
所以我认为 TypeScript 不适合写函数式风格的代码。
好了,言归正传。
JavaScript 能够写装饰器吗?
答案是现在还不能。
装饰器这个提案在 17 年 4 月 30 日就已经提出了,然而漫长的三年过后,目前仍然处在第二阶段。
正式通过提案,不知道要等到什么年月。其实我想,这种语言发展方式,能够在一定程度上给 JavaScript 延年益寿。
虽然官方标准不支持,但是早就已经有大量开源项目都在通过转译器来使用这一特性了。基于 TypeScript 的项目都支持这个特性。
TypeScript 默认是不支持这种写法的,需要在 tsconfig.json 中将"experimentalDecorators"
设置为
true。
装饰器的本质就是一个高阶函数,来自于装饰器模式。很多语言里都有这种东西。
装饰器即然是 JavaScript 中的概念,那么它的触发时机也将是在 JavaScript 的预编译阶段,而非 TypeScript 的编译阶段。
参数的区分
如果对装饰器在参数形式的层面进行细分的话,可以分为两种。
一种是不带参数的,一种是带参数的。
装饰器 Decorator
不带参数的就是标准的装饰器。
下面这个例子中,@mewing 可以劫持 Cat 这个类,并将 Cat 的构造函数传入 mewing 函数中,然后在 mewing 函数中执行某些操作。
@mewing
class Cat {
constructor() {
console.log("喵!");
}
}
function mewing(target: any) {
console.log("喵!");
}
使用 nodejs 运行以上代码,会在控制台打印 「喵!」,即使不进行 new 的操作。
也就是上面解释的,装饰器的逻辑会在 JavaScript 的预编译阶段执行。
因为装饰器是在预编译阶段执行,所以无法干扰后续创建的实例。但是可以在装饰器中创建类的实例,可这种做法没有什么意义。
带参装饰器:装饰器工厂
带参数的装饰器可以传递一些元数据到装饰器中,比如控制猫叫的次数。
@mewing(4)
class Cat {
constructor() {
console.log("喵!");
}
}
function mewing(num: number) {
return (target: any) => {
for (let i = 0; i < num; i++) {
console.log("喵!");
}
};
}
这样就会听到四声猫叫。
带参装饰器和无参装饰器的区别并不明显,唯一的区别就是带参装饰器会在外层包裹一个函数,这个外层函数就是装饰工厂函数,而内层函数就是真正的装饰器。
类型的区分
在类型层面上划分,装饰器又分 5 种类型。
分别是类声明装饰器(ClassDecorator)、属性装饰器(PropertyDecorator)、访问器装饰器(AccessorDecorators)、方法装饰器(MethodDecorator)和参数装饰器(ParameterDecorator)。
下面是这 5 种类型的声明,其中访问器装饰器和属性装饰器共享 PropertyDecorator 类型。
declare type ClassDecorator = <TFunction extends Function>(
target: TFunction
) => TFunction | void;
declare type PropertyDecorator = (
target: Object,
propertyKey: string | symbol
) => void;
declare type MethodDecorator = <T>(
target: Object,
propertyKey: string | symbol,
descriptor: TypedPropertyDescriptor<T>
) => TypedPropertyDescriptor<T> | void;
declare type ParameterDecorator = (
target: Object,
propertyKey: string | symbol,
parameterIndex: number
) => void;
类声明装饰器 ClassDecorator
类声明装饰器可以直接修改类的定义。
它接收一个参数,该参数是原类的构造函数,并可以选择是否返回一个类。如果返回了一个类,就可以把原来的那个类替换掉。
@mewing(2)
class Cat {
constructor() {
console.log("喵!");
}
}
function mewing(num: number) {
return (target: any) => {
return class Dog {
constructor() {
for (let i = 0; i < num; i++) console.log("汪!");
}
};
};
}
new Cat();
上面的代码运行结果是「汪!」而不是「喵!」,也就是说,表面上还是一个可爱的小喵咪,但实际上已经变成了汪星人。
需要注意的是,原来的类有多少方法和属性,新返回的替换类就需要实现和创建与之前数量相同的方法和属性。
你也可以不返回任何值,而是只对它的原型进行修改,比如扩展它的方法。
@mewing(0)
class Cat {
constructor() {
console.log("喵!");
}
}
function mewing(num: number) {
return (target: any) => {
target.prototype.cute = () => {
console.log("🐱");
};
};
}
new Cat().cute();
注意不可以在类装饰器中直接修改 target,因为此时已经完成了类的定义,只能在这里替换掉原有定义或者扩展原型。
方法装饰器 MethodDecorator
方法装饰器只能应用于 class 的方法上。
与类装饰器不同的是,它多了一个 descriptor 参数。这个参数会在构建目标大于等于 es5 版本时有效。
如果构建目标是 es3 版本,那么该参数为 undefined。实际上现在几乎已经没有人会去构建 es3 版本的 JavaScript 代码了。
descriptor 的作用就是设置该条属性的描述,你可以直接修改上面的属性,或者返回一个新的对象替换掉默认的描述。
let defaultDescriptor = {
value: [Function],
writable: true,
enumerable: true,
configurable: true,
};
下面是一个来自魔改官方 Demo 的 Demo。
class Cat {
constructor() {
console.log("喵!");
}
@enumerable(true)
cute() {
console.log("🐱");
}
}
function enumerable(value: boolean) {
return function (
target: any,
propertyKey: string,
descriptor: TypedPropertyDescriptor<any>
) {
descriptor.enumerable = value;
};
}
for (let i in new Cat()) {
console.log(i);
}
你会发现 cute 方法变成了不可枚举的状态。
方法装饰器可以修改对应的方法,也可以新增加方法。比如:
class Cat {
constructor() {
console.log("喵!");
}
@wawa
cute() {
console.log("🐱");
}
}
function wawa(
target: any,
propertyKey: string,
descriptor: TypedPropertyDescriptor<any>
) {
descriptor.value = () => console.log("wawa");
target.dudu = () => console.log("dudu");
}
new Cat().cute();
new Cat().dudu();
属性装饰器 PropertyDecorator && 访问器装饰器 AccessorDecorators
属性装饰器和访问器装饰器非常相似,就放在一起讲了。
实际上属性装饰器就是类的构造成员装饰器。
比如可以设置默认值。
class Cat {
@rename name: string | undefined;
}
function rename(target: any, propertyKey: string) {
target[propertyKey] = "小花";
}
console.log(new Cat().name);
当然也可以做一些更奇怪的事情,比如给这条属性和访问器设置属性描述。
class Cat {
private _age: number = 1;
@initName name: string | undefined;
@watch
get age() {
return this._age;
}
set age(v: number) {
this._age = v;
}
}
function initName(target: any, propertyKey: string) {
target[propertyKey] = "我叫阿呆";
}
function watch(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
let { set, get } = descriptor;
descriptor.set = function (v: number) {
console.log(`你在设置新的 ${propertyKey}, 新的值为: ${v}。`);
set?.call(target, v);
};
descriptor.get = get?.bind(target);
}
let cat = new Cat();
cat.age = 2;
console.log(`name: ${cat.name} age: ${cat.age}`);
访问器装饰器实际上和属性装饰器作用很类似。
但是由于装饰器不能访问到实例对象,所以访问者装饰器就显得有些鸡肋。
如果你要修改 descriptor 上的任意一个属性,那么其他属性都需要重新设置。如果仍然想使用原来类中设置的属性,那么需要将 this 绑定到 target 上。虽然说 target 就是类的构造函数,但是经过装饰器装饰之后,一切都会变成新的。如果设置了多个访问者装饰器,那么在多个访问者装饰器中,是共享同一个 target 和 descriptor 的。
比如再添加一个@watch2 装饰器,将其中绑定 get 的逻辑移到@watch2 中,预编译结束后,@watch 中设置的 set 并没有被覆盖掉。
class Cat {
private _age: number = 1;
@initName name: string | undefined;
@watch
@watch2
get age() {
return this._age;
}
set age(v: number) {
this._age = v;
}
}
function initName(target: any, propertyKey: string) {
target[propertyKey] = "我叫阿呆";
}
function watch(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
let { set } = descriptor;
descriptor.set = function (v: number) {
console.log(`你在设置新的 ${propertyKey}, 新的值为: ${v}。`);
set?.call(target, v);
};
}
function watch2(
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
let { get } = descriptor;
descriptor.get = get?.bind(target);
}
let cat = new Cat();
cat.age = 2;
console.log(`name: ${cat.name} age: ${cat.age}`);
参数装饰器 ParameterDecorator
参数装饰器有 3 个参数,分别是类的构造、方法名和参数的下标。
class Cat {
constructor() {
console.log("喵!");
}
cute(@emoji emoji: string) {
console.log(emoji);
}
}
function emoji(
target: any,
propertyKey: string | symbol,
parameterIndex: number
) {
let params = [];
params[parameterIndex] = "🐈";
target[propertyKey] = target[propertyKey].bind(target, ...params);
}
new Cat().cute();
参数装饰器无法改变它所属函数的定义,所以上面的代码是不符合预期的。
但它能够增加其他方法,比如像下面这样:
class Cat {
constructor() {
console.log("喵!");
}
cute(@emoji emoji: string) {
console.log(emoji);
}
}
function emoji(
target: any,
propertyKey: string | symbol,
parameterIndex: number
) {
let params = [];
params[parameterIndex] = "🐈";
target.cute2 = target[propertyKey].bind(target, ...params);
}
new Cat().cute2();
当然也是有办法修改它所属方法定义的,只不过非常麻烦。可以把对应的数据挂在到 target 上,再通过方法装饰器从 target 中取出来,最终在方法装饰器中完成方法的重新定义。
装饰器执行顺序
同一位置有多个装饰器时的执行顺序
以管道的形式调用,自顶向下调用每个装饰工厂方法,再自内向外调用装饰器方法。整个过程可类比洋葱模型。
@a
@b
c
会变成类似于下面这样。
a(b(c));
参数装饰器的执行顺序
从最后一个参数开始执行。
class Cat {
constructor() {
console.log("喵!");
}
cute(@emoji emoji: string, @voice voice: string) {
console.log(emoji, voice);
}
}
function emoji(
target: any,
propertyKey: string | symbol,
parameterIndex: number
) {
console.log("emoji call");
}
function voice(
target: any,
propertyKey: string | symbol,
parameterIndex: number
) {
console.log("voice call");
}
// voice call
// emoji call
不同类型的装饰器执行顺序
除了类装饰器和参数装饰器以外,其他装饰器按照出现顺序顺序执行。
在方法装饰器中,如果碰到参数装饰器,按照参数装饰器的顺序执行。
最后执行的永远是类装饰器。
下面用 50 行代码搞清楚执行顺序。
@clazz()
class CallSequence {
@property() property: undefined;
@method()
mtehod(@parameter1() p: any, @parameter2() p2: any) {}
private _a = 1;
@accessor()
get a() {
return this._a;
}
}
function clazz() {
console.log("ClassDecorator before");
return (target: any) => {
console.log("ClassDecorator after");
};
}
function method() {
console.log("MethodDecorator before");
return (t: any, k: any, p: any) => {
console.log("MethodDecorator after");
};
}
function property() {
console.log("PropertyDecorator before");
return (t: any, k: any) => {
console.log("PropertyDecorator after");
};
}
function accessor() {
console.log("AccessorDecorators before");
return (t: any, k: any) => {
console.log("AccessorDecorators after");
};
}
function parameter1() {
console.log("ParameterDecorator1 before");
return (t: any, k: any, i: any) => {
console.log("ParameterDecorator1 after");
};
}
function parameter2() {
console.log("ParameterDecorator2 before");
return (t: any, k: any, i: any) => {
console.log("ParameterDecorator2 after");
};
}
打印结果:
PropertyDecorator before
PropertyDecorator after
MethodDecorator before
ParameterDecorator1 before
ParameterDecorator2 before
ParameterDecorator2 after
ParameterDecorator1 after
MethodDecorator after
AccessorDecorators before
AccessorDecorators after
ClassDecorator before
ClassDecorator after
用途
装饰器的目的就是为了更好的进行元编程。关于元编程,还有另外一个库,reflect-metadata,该库诞生于 2015 年 3 月 12 日,距今已经 5 年有余,然而却连提议都没有。因为它需要等待装饰器的提案通过才能提议。
不过得益于当前转换编译器的强大,如果你需要,可以随心所欲的使用它们。
你可以利用它们做任何想做的事情,比如打印日志,参数注入等等,也可以拿去做框架。
因为装饰器无法访问到类的实例,所以有时会给人一种掣肘的感觉,实际上这不是装饰器设计的初衷。
装饰器的初衷是在类被创建前,改变类的定义。
除此以外的其他目的,都不是装饰器的初衷,但装饰器也并没有做出限制。
具体能够将它应用到什么程度,还要看个人发挥了。
上面介绍的特性、概念以及用法仅适用于当前阶段(2020 年 6 月 9 日凌晨,Stage 2 阶段),后续仍然有改动的可能,具体变化需要持续关注 ts39。