TypeScript 5.0 更新了什么?
TypeScript 5.0 更新了什么?
最近,TypeScript 5.0 终于发布 Release 版本了,增加了许多有用的新特性。旨在使TypeScript更小、更简单、更快速。
Here’s a quick list of what’s new in TypeScript 5.0!
装饰器 / Decorators
装饰器是即将推出的 ECMAScript 功能,它允许我们以可重用的方式自定义类及其成员。
让我们考虑以下代码:
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
const p = new Person("Ron");
p.greet();
这里的 greate
方法很简单,但是如果这个方法做一些异步的、递归的、或者有副作用的等等。我们尝试使用console.log
来帮助我们debug
。
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
greet() {
console.log("LOG: Entering method.");
console.log(`Hello, my name is ${this.name}.`);
console.log("LOG: Exiting method.")
}
}
如果我们有一种办法可以很方便的在每一个方法都加上类似的代码,那就会很方便的帮助我们开发。这就是装饰器
的用武之地。
我们可以编写一个loggedMethod
函数:
function loggedMethod(originalMethod: any, _context: any) {
function replacementMethod(this: any, ...args: any[]) {
console.log("LOG: Entering method.")
const result = originalMethod.call(this, ...args);
console.log("LOG: Exiting method.")
return result;
}
return replacementMethod;
}
这里接收一个originalMethod
的参数,并且返回了一个函数replacementMethod
,执行过程如下:
- 打印:LOG: Entering method.
- 将
this
参数和它的所有参数传递给原始方法 - 打印:LOG: Exiting method.
- 返回原始结果的执行方法
现在我们可以使用loggedMethod
去装饰 greet
方法:
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@loggedMethod
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
const p = new Person("Ron");
p.greet();
// Output:
//
// LOG: Entering method.
// Hello, my name is Ron.
// LOG: Exiting method.
我们只需要在greet
上面使用 loggedMethod
——注意这里的写法是@loggedMethod
。这样,它会被原始方法和 context
对象调用。因为 loggedMethod
返回了一个新函数,该函数替换了 greet
的原始定义。
loggedMethod 的第二个参数被称为“ context
对象”,它包含一些关于如何声明装饰方法的有用信息——比如它是 #private
成员还是静态成员,或者方法的名称是什么。 下面来重写 loggedMethod 以利用它并打印出被修饰的方法的名称。
function loggedMethod(originalMethod: any, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);
function replacementMethod(this: any, ...args: any[]) {
console.log(`LOG: Entering method '${methodName}'.`)
const result = originalMethod.call(this, ...args);
console.log(`LOG: Exiting method '${methodName}'.`)
return result;
}
return replacementMethod;
}
TypeScript 提供了一个名为 ClassMethodDecoratorContext
的类型,它对方法装饰器采用的 context
对象进行建模。除了元数据之外,方法的 context
对象还有一个有用的函数:addInitializer
。 这是一种挂接到构造函数开头的方法(如果使用静态方法,则挂接到类本身的初始化)。
举个例子,在JavaScript中,经常会写如下的模式:
class Person {
name: string;
constructor(name: string) {
this.name = name;
this.greet = this.greet.bind(this);
}
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
或者,greet
可以声明为初始化为箭头函数的属性。
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
greet = () => {
console.log(`Hello, my name is ${this.name}.`);
};
}
编写这段代码是为了确保在greet
作为独立函数调用或作为回调函数传递时不会重新绑定。
const greet = new Person("Ray").greet;
greet();
可以编写一个装饰器,使用addInitializer
在构造函数中为我们调用 bind
。
function bound(originalMethod: any, context: ClassMethodDecoratorContext) {
const methodName = context.name;
if (context.private) {
throw new Error(`'bound' cannot decorate private properties like ${methodName as string}.`);
}
context.addInitializer(function () {
this[methodName] = this[methodName].bind(this);
});
}
bound
不会返回任何内容,所以当它装饰一个方法时,它会保留原来的方法。相反,它会在其他字段初始化之前添加逻辑。
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@bound
@loggedMethod
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
const p = new Person("Ray");
const greet = p.greet;
greet();
注意,我们使用了两个装饰器:@bound
和@loggedMethod
。这些装饰是以“相反的顺序”运行的。也就是说,@loggedMethod
修饰了原始方法greet
, @bound
修饰了@loggedMethod
的结果。在这个例子中,这没有关系——但如果装饰器有副作用或期望某种顺序,则可能有关系。
可以将这些装饰器放在同一行:
@bound @loggedMethod greet() {
console.log(`Hello, my name is ${this.name}.`);
}
我们甚至可以创建返回装饰器函数的函数。这使得我们可以对最终的装饰器进行一些自定义。如果我们愿意,我们可以让loggedMethod
返回一个装饰器,并自定义它记录消息的方式。
function loggedMethod(headMessage = "LOG:") {
return function actualDecorator(originalMethod: any, context: ClassMethodDecoratorContext) {
const methodName = String(context.name);
function replacementMethod(this: any, ...args: any[]) {
console.log(`${headMessage} Entering method '${methodName}'.`)
const result = originalMethod.call(this, ...args);
console.log(`${headMessage} Exiting method '${methodName}'.`)
return result;
}
return replacementMethod;
}
}
如果这样做,必须在使用loggedMethod
作为装饰器之前调用它。然后,可以传入任何字符串作为记录到控制台的消息的前缀。
class Person {
name: string;
constructor(name: string) {
this.name = name;
}
@loggedMethod("")
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
const p = new Person("Ron");
p.greet();
// Output:
//
// Entering method 'greet'.
// Hello, my name is Ron.
// Exiting method 'greet'.
装饰器可不仅仅用于方法,还可以用于属性/字段、getter
、setter
和自动访问器。甚至类本身也可以装饰成子类化和注册。
上面的loggedMethod
和bound
装饰器示例写的很简单,并省略了大量关于类型的细节。实际上,编写装饰器可能相当复杂。例如,上面的loggedMethod
类型良好的版本可能看起来像这样:
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);
function replacementMethod(this: This, ...args: Args): Return {
console.log(`LOG: Entering method '${methodName}'.`)
const result = target.call(this, ...args);
console.log(`LOG: Exiting method '${methodName}'.`)
return result;
}
return replacementMethod;
}
我们必须使用this
、Args
和return
类型参数分别建模this、参数和原始方法的返回类型。
具体定义装饰器函数的复杂程度取决于想要保证什么。需要记住,装饰器的使用次数将超过它们的编写次数,所以类型良好的版本通常是更好的——但显然与可读性有一个权衡,所以请尽量保持简单。
const 类型参数 / const Type Parameters
当推断一个对象的类型时,TypeScript通常会选择一个通用类型。例如,在本例中,names
的推断类型是string[]
:
type HasNames = { readonly names: string[] };
function getNamesExactly<T extends HasNames>(arg: T): T["names"] {
return arg.names;
}
// Inferred type: string[]
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"]});
通常这样做的目的是实现突变。然而,根据getnames
确切的作用以及它的使用方式,通常情况下需要更具体的类型。到目前为止,通常不得不在某些地方添加const
,以实现所需的推断:
// The type we wanted:
// readonly ["Alice", "Bob", "Eve"]
// The type we got:
// string[]
const names1 = getNamesExactly({ names: ["Alice", "Bob", "Eve"]});
// Correctly gets what we wanted:
// readonly ["Alice", "Bob", "Eve"]
const names2 = getNamesExactly({ names: ["Alice", "Bob", "Eve"]} as const);
这写起来会很麻烦,也很容易忘记。在 TypeScript 5.0 中,可以在类型参数声明中添加const
修饰符,从而使类const
推断成为默认值:
type HasNames = { names: readonly string[] };
function getNamesExactly<const T extends HasNames>(arg: T): T["names"] {
// ^^^^^
return arg.names;
}
// Inferred type: readonly ["Alice", "Bob", "Eve"]
// Note: Didn't need to write 'as const' here
const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] });
注意,const
修饰符并不排斥可变值,也不需要不可变约束。使用可变类型约束可能会得到意外的结果。例如:
declare function fnBad<const T extends string[]>(args: T): void;
// 'T' is still 'string[]' since 'readonly ["a", "b", "c"]' is not assignable to 'string[]'
fnBad(["a", "b" ,"c"]);
这里,T的推断候选值是readonly ["a", "b", "c"]
,而readonly
数组不能用于需要可变数组的地方。在这种情况下,推理回退到约束,数组被视为string[]
,调用仍然成功进行。
更好的定义应该使用readonly string[]
:
declare function fnGood<const T extends readonly string[]>(args: T): void;
// T is readonly ["a", "b", "c"]
fnGood(["a", "b" ,"c"]);
同样,要记住,const
修饰符只影响在调用中编写的对象、数组和基本类型表达式的推断,所以不会(或不能)用const
修饰的参数将看不到任何行为的变化:
declare function fnGood<const T extends readonly string[]>(args: T): void;
const arr = ["a", "b" ,"c"];
// 'T' is still 'string[]'-- the 'const' modifier has no effect here
fnGood(arr);
extends 支持多配置文件 / Supporting Multiple Configuration Files in extends
当管理多个项目时,通常每个项目的 tsconfig.json
文件都会继承于基础配置。这就是为什么TypeScript支持extends
字段,用于从compilerOptions
中复制字段。
// packages/front-end/src/tsconfig.json
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "../lib",
// ...
}
}
但是,在某些情况下,可能希望从多个配置文件进行扩展。例如,想象一下使用一个TypeScript 基本配置文件到 npm。如果想让所有的项目也使用npm中@tsconfig/strictest
包中的选项,那么有一个简单的解决方案:将tsconfig.base.json
扩展到@tsconfig/strictest
:
// tsconfig.base.json
{
"extends": "@tsconfig/strictest/tsconfig.json",
"compilerOptions": {
// ...
}
}
这在一定程度上是有效的。 如果有任何项目不想使用 @tsconfig/strictest
,就必须手动禁用这些选项,或者创建一个不从 @tsconfig/strictest
扩展的单独版本的 tsconfig.base.json
。
为了提供更多的灵活性,Typescript 5.0 允许extends
字段接收多个项。例如,在这个配置文件中:
{
"extends": ["a", "b", "c"],
"compilerOptions": {
// ...
}
}
这样写有点像直接扩展 c,其中 c 扩展 b,b 扩展 a。 如果任何字段“冲突”,则后一个项生效。
所以在下面的例子中,strictNullChecks
和 noImplicitAny
都会在最终的 tsconfig.json
中启用。
// tsconfig1.json
{
"compilerOptions": {
"strictNullChecks": true
}
}
// tsconfig2.json
{
"compilerOptions": {
"noImplicitAny": true
}
}
// tsconfig.json
{
"extends": ["./tsconfig1.json", "./tsconfig2.json"],
"files": ["./index.ts"]
}
可以用下面的方式重写最上面的例子:
// packages/front-end/src/tsconfig.json
{
"extends": ["@tsconfig/strictest/tsconfig.json", "../../../tsconfig.base.json"],
"compilerOptions": {
"outDir": "../lib",
// ...
}
}
所有枚举都是联合枚举 / All enums Are Union enums
当 TypeScript 最初引入枚举时,它只不过是一组具有相同类型的数值常量:
enum E {
Foo = 10,
Bar = 20,
}
E.Foo 和 E.Bar 唯一的特别之处在于它们可以分配给任何期望类型 E 的东西。除此之外,它们只是数字。
function takeValue(e: E) {}
takeValue(E.Foo); // works
takeValue(123); // error!
直到 TypeScript 2.0 引入了枚举字面量类型,它赋予每个枚举成员自己的类型,并将枚举本身转换为每个成员类型的联合。它还允许我们只引用枚举类型的一个子集,并缩小这些类型。
// Color is like a union of Red | Orange | Yellow | Green | Blue | Violet
enum Color {
Red, Orange, Yellow, Green, Blue, /* Indigo */, Violet
}
// Each enum member has its own type that we can refer to!
type PrimaryColor = Color.Red | Color.Green | Color.Blue;
function isPrimaryColor(c: Color): c is PrimaryColor {
// Narrowing literal types can catch bugs.
// TypeScript will error here because
// we'll end up comparing 'Color.Red' to 'Color.Green'.
// We meant to use ||, but accidentally wrote &&.
return c === Color.Red && c === Color.Green && c === Color.Blue;
}
给每个枚举成员指定自己的类型有一个问题,即这些类型在某种程度上与成员的实际值相关联。在某些情况下,这个值是不可能计算出来的——例如,枚举成员可以通过函数调用进行初始化。
enum E {
Blah = Math.random()
}
每当TypeScript遇到这些问题时,它都会悄无声息地退出并使用旧的枚举策略。这意味着要放弃并集和字面量类型的所有优点。
TypeScript 5.0 通过为每个计算成员创建唯一的类型,设法将所有枚举转换为联合枚举。这意味着现在可以缩小所有枚举的范围,并将其成员作为类型引用。
https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/