likes
comments
collection
share

TypeScript 5+装饰器变更的影响

作者站长头像
站长
· 阅读数 9

随着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询问: TypeScript 5+装饰器变更的影响

按照他的理解,之前Deno没有支持新版装饰器,原因可能在SWC上,但现在新版SWC已经支持了,想问是不是可以使用了。

由于我的oak_nest框架仿(抄)的NestJS API,大量使用了装饰器,很怕随着Deno的升级不能使用了,所以比较关心这个问题。

V8与装饰器

7月19日,有人回复说在等V8引擎来实现Stage 3的装饰器。

TypeScript 5+装饰器变更的影响 当时看到这个回答有些懵圈,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的代码大概是这样的:

@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)自然就是参数装饰器。

之前看某篇文章(忘了记录原地址了)说新版装饰器的写法是这样的:

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发版计划下询问什么时候会支持ParameterDecoratorjakebailey 回复我说:

请参阅 github.com/tc39/propos…,TypeScript 只是遵循规范,不会在参数装饰器不是 Stage 3 的情况下添加它。

于是我又去看了他推荐的这个TC39提案,它是JS的提案,文中也提到与TypeScript“实验性”装饰器的比较:

TypeScript 实验性装饰器与 Babel 遗留装饰器大致相似,因此该部分中的注释也适用。另外:

  • 此建议不包括参数修饰器,但它们可能由未来的内置装饰器提供,请参阅 EXTENSIONS.md
  • 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值调用。

这个例子可以被解析为:

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装饰器的类:

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装饰器的代码:

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。

以上面那段代码为例:

@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将之编译后的代码为:

// 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属性:

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官方博客修改后能够运行的样例:

(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)还没有,需要额外添加第一句。

博客里有介绍: TypeScript 5+装饰器变更的影响

旧版的样例可以修改为:

(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上的类装饰器是这样的:

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);
  };
};

新版的签名换成:

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;
}

改动也很简单:

(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方法装饰器:

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);

新版类型签名:

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;
}

改版后:

(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绑定的例子,这是用例:

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

旧版:

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();

新版:

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);
  });
}

属性装饰器

属性装饰器与方法装饰器类似,没什么好说的。

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常用的例子,包含记录属性元数据和格式化年龄。这次我们先说新版:

(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装饰器格式化。

但在旧版里,就无法做到这点:

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:typedesign:paramtypes design:returntype这些注入的类型后必须面对的问题。

总结

TypeScript 5起,装饰器有了重大变更,它不向下兼容,与原来元数据的处理方式也截然不同,最重要的一点是目前尚不支持参数装饰器,它需要等待ES标准进入Stage 3阶段后才会实现。

值得庆幸的是,在可预见的未来里,TypeScript一时半会不会放弃旧版装饰器,二者会同时存在相当一段时间。但这样一来,很容易出现这样一个尴尬的场景,某个工程使用装饰器只能二选一,因为旧版装饰器和新版的区别在于experimentalDecorators这个配置项的开启与否,如果引用的某个包已经使用了新版装饰器,但是另一个包用的是旧版,那是无法工作的,除非引用的是编译后的代码。这对Node.js生态链影响不大,因为它只运行编译后的JS,而原生支持TS的Deno和Bun则要头疼了。

最后,如果你要使用新版装饰器,Babel需要使用插件babel-plugin-proposal-decorators。新版装饰器还有更多有趣的玩法,推荐看这篇文章javascript decorators