随着JavaScript的装饰器提案进入到stage3阶段,TypeScript的实现也紧随其后,在5.0 Beta发布了相应的实现,在5.2中又实现了装饰器元数据( decorator metadata)。
这篇文章《TypeScript 5.0 将支持全新的装饰器写法!》提到了装饰器的历史,这里我摘录几个重要节点:
- 2014-04-10:Yehuda Katz 和 Ron Buckton 合作向 TC39 提出装饰器提案。该提案进入 stage0 阶段。
- 2015-03-24:decorator 提案进入 stage1 阶段。
- 2015-07-20:TypeScript 1.5 发布并支持 stage1 阶段的 decorators 写法,也就是我们目前最常用的 --experimentalDecorators。
- 2016-07-28:decorators 提案进入了 stage2 阶段。(由于这个提案有严重的性能问题,所以后续并没有被广泛使用)
- 2022-03-28:Chris Garrett 加入提案后帮助它进入了 stage3 阶段,并将装饰器 metadata 的能力单独抽离到另一个 stage2 阶段的提案。
- 2023-01-26:TypeScript 5.0 beta 版本发布,支持 stage3 阶段的装饰器写法。
从这些时间节点看出,装饰器长达五六年时间没有变化了,也一直没有提升成标准,突然在22年有了新的提案,前端界真是永远在折腾的路上啊。
作为内置了TypeScript的Deno,在1.32版本起已经在使用TypeScript 5了,但是并不能使用新版装饰器,于是有人提了issue询问:
按照他的理解,之前Deno没有支持新版装饰器,原因可能在SWC上,但现在新版SWC已经支持了,想问是不是可以使用了。
由于我的oak_nest框架仿(抄)的NestJS API,大量使用了装饰器,很怕随着Deno的升级不能使用了,所以比较关心这个问题。
V8与装饰器
7月19日,有人回复说在等V8引擎来实现Stage 3的装饰器。
当时看到这个回答有些懵圈,Deno不是使用SWC或者TSC编译的TS吗?怎么又跟V8扯上关系了?
看过我之前文章的朋友都知道,Deno本质上并非TypeScript的运行时,而是将TypeScript编译成JavaScript,由V8引擎解释运行。而TypeScript的编译原本是使用其提供的TSC编译器,但TSC是用JS实现的,速度远远比不上用Rust开发的SWC(Babel也是同样的原因被辗压,在追求构建性能的场景下被替代),所以在运行代码时,如果需要类型检查(命令中添加--check
),则使用TSC,这时会检查与类型相关的错误;否则使用SWC编译器实现快速的转译。早前Deno是默认开启类型检查的,后来某个版本起禁掉了。
从这个意义上讲,Deno直接用SWC和TSC将TypeScript代码编译为JS,是完全没有问题的。为什么还要等待V8实现呢?
如果V8底层实现了Stage 3 装饰器,唯一的优势是编译后的代码体积减少。但我认为对Deno而言意义不大,因为Deno与Bun不同的一点是,战略重心还是偏向后端或全栈,在前端工具链上没有建树,像Fresh框架仍依赖于esbuild,所以Deno使用转译方案也无所谓,毕竟在新版装饰器发布之前也都这么来的。
上周(2023年9月4日)Deno的维护者之一lucacasonato在这个issue下提出:
我建议我们通过转译在 1.37 版本中启用 TS / TSX / JSX 中的 JS 标准装饰器。在 V8 支持它们之前,它们不会在普通的 JS 中得到支持。
这将是一个破坏性的改变。现有的 Typescript "experimentalDecorators" 的用户,在 Deno <1.36 中工作的装饰器将在 Deno >=1.37 中停止工作。它们必须被替换为 JS 标准装饰器。
我认为这个破坏性的改变是可以接受的,因为实验性装饰器一直是一个实验性的功能,期望在 ES 装饰器发布后被弃用 / 删除。
如果对此提议提出了重大关切,我们也可以将此更改推迟为 Deno 2.0 的破坏性更改。
我对前一句『通过转译支持JS 标准装饰器』非常赞同,但停止支持experimentalDecorators
认为太危险。
为什么呢?
TypeScript 5发布后,网上介绍新特性的文章层出不穷,对装饰器的变化介绍也有不少,但一个重要细节罕有提到,或者没有重点介绍,那就是新版装饰器不支持参数装饰器。
参数装饰器
什么是参数装饰器?
对NestJS有所了解,或者看过我的《一起学Deno》的读者朋友应该熟悉,通常一个Controller的代码大概是这样的:
typescript
@UseGuards(AuthGuard, SSOGuard)
@Controller("/user")
export class UserController {
constructor(private readonly loggerService: LoggerService) {}
@readOnly
name: string;
@Post("addUsers")
@LogTime()
addUsers(@Body() params: Params) {
const result = mockjs.mock({
"citys|100": [{ name: "@city", "value|1-100": 50, "type|0-2": 1 }],
});
return result;
}
}
所有@
开头的都是装饰器,放在class上(比如@Controller
)的是类装饰器,在方法上(如@Post
)的是方法装饰器,在属性上(如@readOnly
)的是属性装饰器,在参数前的(如@Body
)自然就是参数装饰器。
之前看某篇文章(忘了记录原地址了)说新版装饰器的写法是这样的:
typescript
function validate(context: { kind: "parameter" }) {
context.addInitializer(function (this: any) {
const value = this[context.parameterIndex];
if (typeof value !== "number") throw new Error("Invalid argument");
});
}
class Calculator {
add(@validate x: number, @validate y:number): number {
return x + y;
}
}
但事实上现阶段TypeScript并不支持。TypeScript5.0的博客中说:
这个新的装饰器提案不兼容------ emitDecoratorMetadata,并且它不允许装饰参数。未来的 ECMAScript 提案可能有助于弥补这一差距。
我在TypeScript 5.3发版计划下询问什么时候会支持ParameterDecorator
,jakebailey 回复我说:
请参阅 github.com/tc39/propos...,TypeScript 只是遵循规范,不会在参数装饰器不是 Stage 3 的情况下添加它。
于是我又去看了他推荐的这个TC39提案,它是JS的提案,文中也提到与TypeScript"实验性"装饰器的比较:
TypeScript 实验性装饰器与 Babel 遗留装饰器大致相似,因此该部分中的注释也适用。另外:
- 此建议不包括参数修饰器,但它们可能由未来的内置装饰器提供,请参阅 EXTENSIONS.md。
- TypeScript 装饰器在所有静态装饰器之前运行所有实例装饰器,而此提案中的评估顺序基于程序中的顺序,无论它们是静态的还是实例的。
参数装饰器用法是这样的:
typescript
function dec(_, context) {
assert(context.kind === "parameter");
return arg => arg - 1;
}
function f(@dec @{x: "y"} arg) { return arg * 2 ; }
f(5) // 8
f[Symbol.annotations].parameters[0].x // "y"
带有装饰器或注解参数的函数与装饰/注解函数类似处理:它们不会被提升,并且在执行它们的定义之前处于TDZ中。
参数装饰器细节:
- 第一个参数:未定义
- 第二个参数:一个上下文对象,只有 { kind: 'parameter' }
- 返回值:一个函数,它接受一个参数值并返回一个新的参数值。该函数使用调用包围函数时使用的this值调用。
这个例子可以被解析为:
typescript
let decInstance = dec(undefined, {kind: "parameter"});
function f(arg_) {
const arg = decInstance(arg_);
return arg * 2 ;
}
f[Symbol.annotations] = {}
f[Symbol.annotations].parameters = []
f[Symbol.annotations].parameters[0] = {x: "y"};
不得不说,@{x: "y"}
的写法真是一言难尽,与私有变量#
开头的用法一样让人欣赏无力。
所以,凡是用到ParameterDecorator
的库或框架,都只能等待ES标准的进展。
值得欣慰的是,TypeScript也考虑到这一点,所以暂时并不会去除experimentalDecorators
的配置项,两个版本可以同时存在。
元数据
TypeScript 5.2支持了装饰器元数据,这个也是个重大的变化。
什么是元数据?简单来说就是附加在目标位置(类、方法、属性、参数等)上的数据信息。
旧版
我们看下原来是怎么样的。 这是一个使用了format
装饰器的类:
typescript
class Greeter {
@format("Hello, %s")
greeting: string;
constructor(message: string) {
this.greeting = message;
}
greet() {
let formatString = getFormat(this, "greeting");
return formatString.replace("%s", this.greeting);
}
}
这是format
装饰器的代码:
typescript
import "reflect-metadata";
const formatMetadataKey = Symbol("format");
function format(formatString: string) {
return Reflect.metadata(formatMetadataKey, formatString);
}
function getFormat(target: any, propertyKey: string) {
return Reflect.getMetadata(formatMetadataKey, target, propertyKey);
}
旧版的核心是使用了reflect-metadata
这个npm包,它是对Reflect
的扩展,本质上就是一个全局的Map或WeakMap对象,提供几个方法可以存储与读取数据。
Deno中对应的是deno_reflect,我Fork自reflect_metadata,修改了一处顺序引发的Bug。
以上面那段代码为例:
typescript
@UseGuards(AuthGuard, SSOGuard)
@Controller("/user")
export class UserController {
constructor(private readonly loggerService: LoggerService) {}
@Post("addUsers")
@LogTime()
addUsers(@Body() params: Params) {
const result = mockjs.mock({
"citys|100": [{ name: "@city", "value|1-100": 50, "type|0-2": 1 }],
});
return result;
}
}
Deno将之编译后的代码为:
typescript
// deno-lint-ignore-file no-explicit-any
function _ts_decorate(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;
}
function _ts_metadata(k, v) {
if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
}
function _ts_param(paramIndex, decorator) {
return function(target, key) {
decorator(target, key, paramIndex);
};
}
export let UserController = class UserController {
loggerService;
constructor(loggerService){
this.loggerService = loggerService;
}
addUsers(params) {
//
}
};
_ts_decorate([
Post("addUsers"),
LogTime(),
_ts_param(0, Body()),
_ts_metadata("design:type", Function),
_ts_metadata("design:paramtypes", [
typeof Params === "undefined" ? Object : Params
])
], UserController.prototype, "addUsers", null);
UserController = _ts_decorate([
UseGuards(AuthGuard, SSOGuard),
Controller("/user"),
_ts_metadata("design:type", Function),
_ts_metadata("design:paramtypes", [
typeof LoggerService === "undefined" ? Object : LoggerService
])
], UserController);
NestJS的编译结果也差不多。这里记录了参数类型,这些类型可以用来作依赖注入、参数校验等。
新版
新版装饰器,是在上下文(context)中添加了一个metadata属性:
diff
type Decorator = (value: Input, context: {
kind: string;
name: string | symbol;
access: {
get?(): unknown;
set?(value: unknown): void;
};
isPrivate?: boolean;
isStatic?: boolean;
addInitializer?(initializer: () => void): void;
+ metadata?: Record<string | number | symbol, unknown>;
}) => Output | void;
这是TypeScript官方博客修改后能够运行的样例:
typescript
(Symbol.metadata as any) ??= Symbol("Symbol.metadata"); // 不是必需的
function setMetadata(
_target: any,
context: ClassMemberDecoratorContext,
) {
console.log(context.name);
context.metadata[context.name] = true;
}
class SomeClass {
@setMetadata
foo = 123;
@setMetadata
accessor bar = "hello!";
@setMetadata
baz() {}
}
const ourMetadata = SomeClass[Symbol.metadata];
console.log(JSON.stringify(ourMetadata), Symbol.metadata);
// { "bar": true, "baz": true, "foo": true }
有一点需要注意,Symbol.metadata大部分运行时(我用的Node.js 20.5.0)还没有,需要额外添加第一句。
博客里有介绍:
旧版的样例可以修改为:
javascript
(Symbol.metadata as any) ??= Symbol("Symbol.metadata");
const formatMetadataKey = Symbol("format");
function format(formatString: string) {
return (_target: any, context: ClassMemberDecoratorContext) => {
context.metadata[formatMetadataKey] = formatString;
};
}
function getFormat(target: any) {
return target[Symbol.metadata][formatMetadataKey];
}
class Greeter {
@format("Hello, %s")
greeting: string;
constructor(message: string) {
this.greeting = message;
}
greet() {
let formatString = getFormat(Greeter);
return formatString.replace("%s", this.greeting);
}
}
const greeter = new Greeter("world");
console.log(greeter.greet());
相较于旧版,优点也很明显,不需要额外的工具库,只是这个context,指的是class本身而非实例。详见proposal-decorator-metadata。
其它装饰器的迁移
如果你原来的库中并没有用到参数装饰器,那么就可以考虑迁移了。
类装饰器
比如原来一个作用在class上的类装饰器是这样的:
typescript
export const META_PATH_KEY = Symbol("meta:path");
export const Controller = (
path: string,
options?: AliasOptions,
): ClassDecorator => {
return (target) => {
Reflect.defineMetadata(META_PATH_KEY, path, target);
};
};
新版的签名换成:
typescript
type ClassDecorator = (
value: Function,
context: ClassDecoratorContext
) => Function | void;
/**
* 提供给类装饰器的上下文。
* @template Class 与此上下文相关联的被装饰类的类型。
*/
interface ClassDecoratorContext<
Class extends abstract new (...args: any) => any = abstract new (...args: any) => any,
> {
/** 被装饰元素的类型。 */
readonly kind: "class";
/** 被装饰类的名称。 */
readonly name: string | undefined;
addInitializer(initializer: (this: Class) => void): void;
readonly metadata: DecoratorMetadata;
}
改动也很简单:
typescript
(Symbol.metadata as any) ??= Symbol("Symbol.metadata");
export const META_PATH_KEY = Symbol("meta:path");
export const Controller = (
path: string,
) => {
return function (value: Function, context: ClassDecoratorContext) {
console.log("value", value); // [class UserController]
context.metadata[META_PATH_KEY] = path;
};
};
@Controller("/user")
class UserController {}
console.log(UserController[Symbol.metadata]![META_PATH_KEY]); // /user
类方法装饰器
使用元数据
这是一个旧版的Get方法装饰器:
typescript
export const META_METHOD_KEY = Symbol("meta:method");
export const META_PATH_KEY = Symbol("meta:path");
export enum Methods {
GET = "get",
POST = "post",
PUT = "put",
DELETE = "delete",
HEAD = "head",
PATCH = "patch",
OPTIONS = "options",
}
const createMappingDecorator =
(method: Methods) =>
(path: string): MethodDecorator => {
return (_target, _property, descriptor) => {
Reflect.defineMetadata(META_PATH_KEY, path, descriptor.value);
Reflect.defineMetadata(META_METHOD_KEY, method, descriptor.value);
};
};
export const Get = createMappingDecorator(Methods.GET);
新版类型签名:
typescript
type ClassMethodDecorator = (
value: Function,
context: ClassMethodDecoratorContext
) => Function | void;
/**
* 提供给类方法装饰器的上下文。
* @template This 类元素将被定义的类型。对于静态类元素,这将是构造函数的类型。对于非静态类元素,这将是实例的类型。
* @template Value 被装饰的类方法的类型。
*/
interface ClassMethodDecoratorContext<
This = unknown,
Value extends (this: This, ...args: any) => any = (this: This, ...args: any) => any,
> {
/** 被装饰的类元素的类型。 */
readonly kind: "method";
/** 被装饰的类元素的名称。 */
readonly name: string | symbol;
/** 表示类元素是静态 ( `true` ) 还是实例 ( `false` ) 元素的值。 */
readonly static: boolean;
/** 表示类元素是否具有私有名称的值。 */
readonly private: boolean;
/** 一个可以在运行时访问类元素当前值的对象。 */
readonly access: {
has(object: This): boolean;
get(object: This): Value;
};
addInitializer(initializer: (this: This) => void): void;
readonly metadata: DecoratorMetadata;
}
改版后:
typescript
(Symbol.metadata as any) ??= Symbol("Symbol.metadata");
export const META_PATH_KEY = Symbol("meta:path");
const createMappingDecorator = (method: Methods) => (path: string) => {
return function (value: Function, context: ClassMethodDecoratorContext) {
console.log("value", value); // [Function: user]
const map = (context.metadata[method] ??= {}) as Record<string, Function>;
map[path] = value;
};
};
export const Get = createMappingDecorator(Methods.GET);
export const Post = createMappingDecorator(Methods.POST);
class UserController {
@Get("/user")
user() {}
@Post("/user")
saveUser() {}
}
console.log(UserController[Symbol.metadata]);
// value [Function: user]
// value [Function: saveUser]
// [Object: null prototype] {
// get: { '/user': [Function: user] },
// post: { '/user': [Function: saveUser] }
// }
addInitializer初始化
再举一个this绑定的例子,这是用例:
typescript
class SomeClass {
foo = 123;
@bind
test() {
console.log(this.foo);
}
test2() {
console.log(this === undefined);
}
}
const cls = new SomeClass();
const test = cls.test;
test(); // 123
const test2 = cls.test2;
test2(); // true
旧版:
typescript
function bound(): MethodDecorator {
return (target, _property, descriptor) => {
// deno-lint-ignore ban-types
const fn = descriptor.value as Function;
if (fn) {
descriptor.value = fn.bind(target);
}
};
}
export const bind = bound();
新版:
typescript
export function bind(value: Function, context: ClassMethodDecoratorContext) {
if (context.private) {
throw new TypeError("Not supported on private methods.");
}
context.addInitializer(function () {
(this as any)[context.name] = value.bind(this);
});
}
属性装饰器
属性装饰器与方法装饰器类似,没什么好说的。
typescript
type ClassFieldDecorator = (
value: unknown,
context: ClassFieldDecoratorContext
) => Function | void;
/**
* 提供给类字段装饰器的上下文。
* @template This 类元素将被定义的类型。对于静态类元素,这将是构造函数的类型。对于非静态类元素,这将是实例的类型。
* @template Value 被装饰的类字段的类型。
*/
interface ClassFieldDecoratorContext<
This = unknown,
Value = unknown,
> {
/** 被装饰的类元素的类型。 */
readonly kind: "field";
/** 被装饰的类元素的名称。 */
readonly name: string | symbol;
/** 表示类元素是静态 ( `true` ) 还是实例 ( `false` ) 元素的值。 */
readonly static: boolean;
/** 表示类元素是否具有私有名称的值。 */
readonly private: boolean;
/** 一个可以在运行时访问类元素当前值的对象。 */
readonly access: {
/**
* 确定对象是否具有与被装饰元素相同名称的属性。
*/
has(object: This): boolean;
/**
* 获取提供对象上字段的值。
*/
get(object: This): Value;
/**
* 设置提供对象上字段的值。
*/
set(object: This, value: Value): void;
};
/**
* 添加一个回调函数,在运行静态初始化器之前(当装饰一个`static`元素时),或在运行实例初始化器之前(当装饰一个非`static`元素时)调用。
*/
addInitializer(initializer: (this: This) => void): void;
readonly metadata: DecoratorMetadata;
}
这里举个数据库Schema常用的例子,包含记录属性元数据和格式化年龄。这次我们先说新版:
typescript
(Symbol.metadata as any) ??= Symbol("Symbol.metadata");
function FormatAge(value: unknown, context: ClassFieldDecoratorContext) {
return (initialValue: number) => 18 + (initialValue ?? 0);
}
interface IProps {
required?: boolean;
}
function Prop(props?: IProps) {
return (value: unknown, context: ClassFieldDecoratorContext) => {
context.metadata[context.name] = props;
};
}
export class User {
@Prop({
required: true,
})
userId: string;
@Prop()
@FormatAge
age: number;
}
const user = new User();
console.log(user.age); // 18
console.log(User[Symbol.metadata]);
// [Object: null prototype] { userId: { required: true }, age: undefined }
注意下这里的age,它的初始值会被FormatAge
装饰器格式化。
但在旧版里,就无法做到这点:
typescript
interface IProps {
required?: boolean;
}
const PROP_META_KEY = Symbol("design:prop");
function Prop(props?: IProps) {
return (target: any, propertyKey: string) => {
Reflect.defineMetadata(PROP_META_KEY, props, target, propertyKey);
};
}
function FormatAge(
target: any,
propertyKey: string,
) {
console.log(
target,
propertyKey,
Reflect.getMetadata("design:type", target, propertyKey),
); // {} age [Function: Number]
}
export class User {
@Prop({ required: true })
userId: string;
@FormatAge
@Prop()
age: number;
}
const user = new User();
console.info(user.age);
console.log(Reflect.getMetadata(PROP_META_KEY, user, "userId")); // { required: true }
console.log(Reflect.getMetadata(PROP_META_KEY, user, "age")); // undefined
不过有一点非常重要,它可以通过design:type
获取到属性字段对应的类型(本例为[Function: Number]
),底层库可以用来做校验。这也是新版缺失了design:type
、design:paramtypes
和 design:returntype
这些注入的类型后必须面对的问题。
总结
TypeScript 5起,装饰器有了重大变更,它不向下兼容,与原来元数据的处理方式也截然不同,最重要的一点是目前尚不支持参数装饰器,它需要等待ES标准进入Stage 3阶段后才会实现。
值得庆幸的是,在可预见的未来里,TypeScript一时半会不会放弃旧版装饰器,二者会同时存在相当一段时间。但这样一来,很容易出现这样一个尴尬的场景,某个工程使用装饰器只能二选一,因为旧版装饰器和新版的区别在于experimentalDecorators
这个配置项的开启与否,如果引用的某个包已经使用了新版装饰器,但是另一个包用的是旧版,那是无法工作的,除非引用的是编译后的代码。这对Node.js生态链影响不大,因为它只运行编译后的JS,而原生支持TS的Deno和Bun则要头疼了。
最后,如果你要使用新版装饰器,Babel需要使用插件babel-plugin-proposal-decorators。新版装饰器还有更多有趣的玩法,推荐看这篇文章javascript decorators。