Class Method Parameter Decorators S1
中文标题:类方法参数装饰器
提案速览
它旨在支持依赖注入、ORM 实体构造、FFI 编组、HTTP 路由和参数验证等用例。该提案定义了参数装饰器的语法、文法规则和语义,包括一个包含参数名称、索引和所属函数等信息的上下文对象。它还概述了早期错误、装饰器求值和应用顺序,并提供了来自实际 TypeScript 库的示例。
Note
以下 README 来自上游仓库,其中的阶段或状态标注可能滞后;当前信息以提案概览为准。
类方法和构造函数参数的 ECMAScript 装饰器
本提案增加了对类构造函数和类方法参数上使用装饰器的支持。
状态
阶段: 1
提案发起人: Ron Buckton (@rbuckton)
上次提交: 2023年3月
更多信息请参见 TC39 提案流程。
作者
概述与动机
装饰器是 ECMAScript 的一种元编程能力,允许你用函数引用注释一个声明,该函数引用将随声明的定义特征一起被调用,并可能替换或增强该声明。当前的 Stage 3 装饰器提案允许在 class 声明和表达式以及其方法、getter、setter 和字段上使用装饰器。
参数装饰器将此元编程能力扩展到类构造函数和类方法的参数,旨在支持多种用例,包括:
- 基于构造函数参数的依赖注入 (DI)。
- 与私有字段耦合的对象关系映射 (ORM) 实体构造。
- 外国函数接口 (FFI) 的方法参数编组。
- 参数的元数据。
- 路由:将 HTTP 请求头/查询字符串参数/请求体字段等绑定到参数。
- 参数验证:禁止
null/undefined,范围验证,正则表达式字符串验证等。
这些场景中的许多都来源于 TypeScript 中传统装饰器的现有用途,以及 Java 和 C# 等语言的类似功能。例如,VS Code 大量使用构造函数参数装饰器进行依赖注入。
参数装饰器允许你轻松地注释方法或构造函数参数,以赋予特定的元数据或更改行为:
class UserManager {
createUser(@NotEmpty username, @NotEmpty password, @ValidateEmail emailAddress, @MinValue(0) age) { }
}
虽然使用常规的方法装饰器也能实现同样的效果,但你实际上只能通过参数的序号位置来关联这种装饰。随着参数的添加、删除或重新排序,它们会随着时间的推移变得很难维护,而且当你必须仅凭序号位置来直观地关联装饰器和参数时,阅读和审查也会困难得多:
class UserManager {
@param(3, ValidateEmail)
@param(2, MinValue(0))
@param(1, NotEmpty)
@param(0, NotEmpty)
-- createUser(username, password, age, emailAddress) { ... }
++ createUser(username, password, emailAddress, age) { ... }
++ // 糟糕,忘了修复 @param 装饰器的顺序...
}
方法装饰器也不会为参数提供有用的上下文,例如其名称,而像 HTTP 路由器中的 @FromForm 参数可以利用这些上下文:
// 没有参数装饰器:
class BookApi {
@Route("/book/:isbn/review", { method: "post", form: true })
@param(0, FromUri({ name: "isbn" })) // 需要在声明名称和参数列表中重复...
@param(1, FromForm({ name: "subject" }))
@param(2, FromForm({ name: "description" }))
@param(3, FromForm({ name: "score" }))
postReview(isbn, subject, description, score) { ... }
}
// 使用参数装饰器:
class BookApi {
// 如果名称匹配,则无需重复...
@Route("/book/:isbn/review", { method: "post", form: true })
postReview(@FromUri isbn, @FromForm subject, @FromForm description, @FromForm score) { ... }
}
参数装饰器还可能通过提供一种观察并可能替换传入参数的机制,为数据验证和转换提供有用的途径,类似于字段装饰器允许你观察并可能替换初始化器:
// 参数验证(观察参数而不修改它):
function NotEmpty(target, context) {
if (context.kind !== "parameter") throw new TypeError();
return function (arg) {
if (typeof arg !== "string" || arg.length !== 0) {
throw new TypeEror(`Argument '${context.name}' expects a non-empty string`);
}
return arg
}
}
// FFI 编组(将输入参数从外部类型编组为原生类型):
/** 将长度前缀的 BSTR 的 FFI 指针转换为 `string` */
function BStr(target, context) {
return function (arg) {
if (arg instanceof ffi.Pointer) {
return arg.asBStr();
}
return arg;
}
}
/** 将双字节空终止的 unicode 字符串的 FFI 指针转换为 `string` */
function LPWStr(target, context) {
return function (arg) {
if (arg instanceof ffi.Pointer) {
return arg.asLPWStr();
}
return arg;
}
}
先前实现
语法
参数装饰器使用与类和方法的装饰器相同的语法,除了它们可以放置在类 constructor 或类方法声明的参数之前:
// 构造函数参数装饰器
class CustomizationService {
constructor(
@inject("StorageService") storageService,
@inject("UserProfileService") userProfileService
) {
...
}
}
// 方法参数装饰器:
class BookApi {
@Route("/book/:isbn", { method: "get" })
getBook(@FromUri isbn) { ... }
@Route("/book/:isbn/review", { method: "post", form: true })
postReview(@FromUri isbn, @FromForm subject, @FromForm description, @FromForm score) { ... }
}
// setter 参数:
class User {
...
get username() { return this.#username; }
set username(@NotEmpty value) { this.#username = value; }
}
对象字面量方法和 setter,或函数声明和表达式上的参数装饰器,不在本提案的范围内。但是,我们打算本提案的设计允许将来在语言中采用函数装饰器后扩展到该空间。
语法规则
以下是拟议语法的粗略轮廓,不应解释为最终语法。如果本提案被采用,语法可能会根据反馈而改变。
FunctionRestParameter[Yield, Await] :
-- BindingRestElement[?Yield, ?Await]
++ DecoratorList[?Yield, ?Await]? BindingRestElement[?Yield, ?Await]
FormalParameter[Yield, Await] :
-- BindingElement[?Yield, ?Await]
++ DecoratorList[?Yield, ?Await]? BindingElement[?Yield, ?Await]
语义
早期错误
除了上述拟议语法外,如果 DecoratorList 出现在 FunctionDeclaration,FunctionExpression,GeneratorDeclaration,GeneratorExpression,AsyncFunctionDeclaration,AsyncFunctionExpression,AsyncGeneratorDeclaration,AsyncGeneratorExpression,ArrowFunction 或 AsyncArrowFunction 的 FormalParameters 中,那将是一个早期错误。如果 DecoratorList 出现在 MethodDefinition 的 FormalParameters 中,并且该 MethodDefinition 直接包含在 ObjectLiteralExpression 中,也是一个早期错误。
装饰器表达式求值
参数装饰器将按文档顺序进行求值,与任何其他装饰器一样。参数装饰器不能访问方法体内的局部作用域,因为它们是静态求值的,与可能位于其所属方法或类上的任何装饰器同时求值。
例如,给定源代码
@A
@B
class Cls {
@C
@D
method(@E @F param1, @G @H param2) { }
}
装饰器表达式将按以下顺序求值:A,B,C,D,E,F,G,H
装饰器应用顺序
参数装饰器将应用于其所属方法上的任何装饰器之前。给定参数的参数装饰器独立于后续参数上的装饰器应用。在单个参数的装饰器中,这些装饰器按逆序应用,与语言中其他装饰器求值方式一致。
例如,给定源代码
@A
@B
class Cls {
@C
@D
method(@E @F param1, @G @H param2) { }
}
装饰器将按以下顺序应用:
param1 的 F,E
param2 的 H,G
method 的 D,C
class Cls 的 B,A
参数装饰器的结构
参数装饰器通常预期有两个参数:target 和 context。然而,与字段装饰器非常相似,target 参数始终为 undefined,因为参数本身不是 JavaScript 中的具体化对象。
参数装饰器的 context 将包含关于参数的有用信息:
type ParameterDecoratorContext = {
kind: "parameter";
name: string | undefined;
index: number;
rest: boolean;
function: {
kind: "class" | "method" | "setter";
name: string | symbol | undefined;
static?: boolean;
private?: boolean;
};
metadata: object;
addInitializer(initializer: () => void): void;
}
kind — 指示被装饰的元素的种类。
name — 如果参数是有名称的,则为字符串,如果参数是绑定模式,则为 undefined。
index — 参数在参数列表中的序号位置,这对于构造函数参数注入等场景(如依赖注入)是必需的。
rest — 指示参数是否为 ... 剩余元素。
function — 包含关于参数所属函数的有限信息,这在为 metadata 属性赋值时区分成员是必要的,将来也用于区分类方法参数和函数参数,因为这可能影响元数据的分配方式。
metadata — 与装饰器元数据提案一致,你将能够向类附加元数据。
addInitializer — 这允许你附加一个额外的静态或实例初始化器,就像你可以为所属方法上的装饰器所做的那样。
参数装饰器可以返回 undefined 或函数值。如果返回了函数,它将在所属函数调用期间绑定该参数时被调用。其行为类似于从字段装饰器返回的函数:
class C {
method(@A param1) {
...
}
}
大致等价于
var _param1_init
class C {
static {
_param1_init = A(undefined, { kind: "parameter", name: "param1", index: 0, /*...*/ });
}
method(param1) {
if (_param1_init !== undefined) param1 = _param1_init.call(this, param1);
...
}
}
示例
ECMAScript
依赖注入 (DI)
依赖注入系统通常使用构造函数参数注入来在请求组件实例时满足依赖。这允许这样的组件执行额外的初始化逻辑并设置私有字段:
customizationService.js
import { inject } from "di-framework"
class CustomizationService {
#storageService;
#authorizationService;
constructor(
@inject("StorageService") storageService,
@inject("AuthorizationService") authorizationService
) {
storageService.ensurePerUserStorage();
this.#storageService = storageService;
this.#authorizationService = authorizationService;
}
setTheme(userId, theme) {
if (!this.#authorizationService.currentUserHasPermission(userId, ["CHANGE_PROFILE"])) {
throw new Error()
}
this.#storageServce.writeProperty(`${userId}/profile/theme`, theme);
}
}
组合允许你轻松地将具有许多不同部分的复杂应用程序拼接在一起,并定制每环境的依赖:
main.js
import { Container } from "di-framework"
import { FileSystemStorageService } from "./fileSystemStorageService.js"
import { CloudStorageService } from "./cloudStorageService.js"
import { AuthorizationService } from "./authorizationService.js"
import { CustomizationService } from "./customizationService.js"
import { HttpService } from "./httpService.js"
import { Application } from "./app.js"
export function main(useCloudStorage) {
const container = new Container()
container.set("StorageService", useCloudStorage ? CloudStorageService : FileSystemStorageService)
container.set("AuthorizationService", AuthorizationService)
container.set("CustomizationService", CustomizationService)
...
const customizationService = container.get("CustomizationService")
customizationService.setTheme(userId, theme)
}
构造函数参数注入的优点之一是,通过使用依赖项的模拟或伪实现,可以相当容易地隔离测试组件或服务:
customizationService.tests.js
import { CustomizationService } from "./customizationService.js"
describe("CustomizationService tests", () => {
it("throws when access invalid", () => {
const fakeStorageService = { ensurePerUserStorage() {} };
const fakeAuthorizationService = { currentUserHasPermission: (userId, permissions) => false };
const customizationService = new CustomizationService(fakeStorageService, fakeAuthorizationService);
expect(() => customizationService.setTheme(1234, "dark")).toThrow();
})
});
对象关系映射 (ORM)
许多 ORM 系统利用用户定义的类来建模实体:
@Entity()
export class User {
@Field({ type: "string" })
id;
@Field({ type: "string" })
email;
@Field({ type: "byte(16)" })
passwordHash;
@Field({ type: "string" })
fullName;
constructor(id, passwordHash, email, fullName) {
this.id = id;
this.passwordHash = passwordHas;
this.email = email;
this.fullName = fullName;
}
}
然而,它们通常通过忽略构造函数并使用 Object.create() 来实现。这使得当实体可能具有私有类元素时,重新水合实体变得困难:
@Entity()
export class User {
@Field({ type: "string" })
id;
@Field({ type: "string" })
email;
@Field({ type: "byte(16)" })
#passwordHash; // 不能使用 Object.create
@Field({ type: "string" })
fullName;
constructor(id, password, email, fullName) {
this.id = id;
this.passwordHash = password;
this.email = email;
this.fullName = fullName;
}
}
如果实体具有私有字段,ORM 将无法在不调用构造函数的情况下重新水合它,并且需要一种机制来将数据库记录字段与关联的参数关联起来。如今,ORM 通常通过传入包含记录键/值映射的对象来处理这个问题,但这通常会导致需要重载构造函数以同时处理 ORM 构造和为用户易用而设计的构造函数。
为了简化这一点,构造函数参数可用于向 ORM 系统指示应为哪些参数提供哪些字段,以及顺序:
@Entity({ constructable: true })
export class User {
@Field({ type: "string" })
id;
@Field({ type: "string" })
email;
@Field({ type: "byte(16)" })
#passwordHash; // 不能使用 Object.create
@Field({ type: "string" })
fullName;
@Field({ type: "timestamp" })
#createdOn;
constructor(
@Field() id,
@Field({ name: "passwordHash" }) password,
@Field() email,
@Field() fullName,
@Field() createdOn = new Date()
) {
this.id = id;
this.passwordHash = password;
this.email = email;
this.fullName = fullName;
this.#createdOn = createdOn;
}
}
外国函数接口
像 ffi-napi 这样的包可用于从 NodeJS 调用原生代码。将 ECMAScript 回调传递给原生代码需要将参数和返回值编组为原生格式以及从原生格式编组回来:
// 到原生库的接口
let libname = ffi.Library('./libname', {
'setCallback': ['void', ['pointer']]
});
// 从原生库回到 js 的回调
let callback = ffi.Callback('void', ['int', 'string'],
function(id, name) {
console.log("id: ", id);
console.log("name: ", name);
});
libname.setCallback(callback);
使用参数装饰器,我们可以轻松地在参数本身上注释编组行为:
class MyClass {
@MarshalReturnAs("void")
static callback(
@MarshalAs("int") id,
@MarshalAs("string") name
) {
console.log("id: ", id);
console.log("name: ", name);
}
static {
let libname = ffi.Library('./libname', {
'setCallback': ['void', ['pointer']]
});
libname.setCallback(this.callback);
}
}
HTTP 路由
类方法是 REST Web 服务中 Web API 路由的绝佳对应物。方法参数装饰器可以促进路由参数、查询字符串值、POST 请求体和表单字段映射到参数的方式:
export class BookApi {
// 示例:
// GET /books
// GET /books?p=2
// GET /books?p=3&ps=25
@Get("/books")
getBooks(@FromQuery("p") page = 1, @FromQuery("ps") pageSize = 10) {
...
}
// 示例:
// GET /book/123-4567890123
@Get("/book/:isbn")
getBook(@FromRoute isbn) {
...
}
@Post("/book/:isbn/review", { form: true })
postReviewForm(@FromRoute isbn, @FromSession user, @FromForm subject, @FromForm description, @FromForm score) { }
@Post("/book/:isbn/review", { json: true })
postReviewJson(@FromRoute isbn, @FromSession user, @FromBody { subject, description, score }) { }
}
参数验证
参数验证器允许你简洁地验证构造函数和方法输入:
export class UserManager {
createUser(
@NotEmpty() username,
@NotEmpty() password,
@EmailValidator() email,
@NotEmpty() fullName,
@MinValue(13) age
) {
...
}
}
const mgr = new UserManager();
mgr.createUser("", "", "invalid#email", "", 0) // 抛出异常
TypeScript 中的实际示例
GitHub 上有数千个实例的构造函数和方法参数装饰器。以下是一些来自主流库的示例。
NestJS
NestJS 使用构造函数参数装饰器进行依赖注入、路由和消息参数绑定:
packages/common/pipes/parse-int.pipe.ts
import { Injectable } from '../decorators/core/injectable.decorator';
import { Optional } from '../decorators/core/optional.decorator';
...
@Injectable()
export class ParseIntPipe implements PipeTransform<string> {
protected exceptionFactory: (error: string) => any;
constructor(@Optional() options?: ParseIntPipeOptions) {
...
}
}
integration/websockets/src/app.gateway.ts
import {
MessageBody,
SubscribeMessage,
WebSocketGateway,
} from '@nestjs/websockets';
@WebSocketGateway(8080)
export class ApplicationGateway {
@SubscribeMessage('push')
onPush(@MessageBody() data) {
return {
event: 'pop',
data,
};
}
}
integration/repl/src/users/users.controller.ts
...
@Controller('users')
export class UsersController {
...
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOne(+id);
}
@Patch(':id')
update(@Param('id') id: string, @Body() updateUserDto: UpdateUserDto) {
return this.usersService.update(+id, updateUserDto);
}
...
}
以及其他 181 个...
Angular
Angular 使用构造函数参数装饰器进行依赖注入,以及 HTML 元素属性绑定:
main/packages/platform-browser/src/browser.ts (依赖注入)
...
@NgModule({
providers: [
...BROWSER_MODULE_PROVIDERS, //
...TESTABILITY_PROVIDERS
],
exports: [CommonModule, ApplicationModule],
})
export class BrowserModule {
constructor(@Optional() @SkipSelf() @Inject(BROWSER_MODULE_PROVIDERS_MARKER)
providersAlreadyPresent: boolean|null) {
...
}
...
}
main/packages/examples/core/ts/metadata/metadata.ts (属性绑定)
...
@Directive({selector: 'input'})
class InputAttrDirective {
constructor(@Attribute('type') type: string) {
// type in this example would be 'text'
}
}
...
以及其他 203 个...
用于 Angular 的 PrimeNG
PrimeNG 使用构造函数参数装饰器作为 Angular 依赖注入系统的一部分:
src/app/components/tree/tree.ts
...
export class UITreeNode implements OnInit {
...
constructor(@Inject(forwardRef(() => Tree)) tree) {
...
}
...
}
src/app/components/messages/messages.ts
...
export class Messages implements AfterContentInit, OnDestroy {
...
constructor(@Optional() public messageService: MessageService, public el: ElementRef, public cd: ChangeDetectorRef) {}
...
}
...
以及其他 8 个...
相关提案
待办事项
以下是推进 TC39 提案流程每个阶段的高级任务列表:
Stage 1 进入标准
Stage 2 进入标准
Stage 3 进入标准
Stage 4 进入标准