2023 年 3 月 17 日,TypeScript 5.0 正式发布!此版本带来了许多新功能,旨在使 TypeScript 更小、更简单、更快。TypeScript 5.0 实现了新的装饰器标准、更好地支持 Node 和打构建工具中的 ESM 项目的功能、库作者控制泛型推导的新方法、扩展了 JSDoc 功能、简化了配置,并进行了许多其他改进。
可以通过以下 npm 命令开始使用 TypeScript 5.0:
npm install -D typescript 复制代码
以下是 TypeScript 5.0 的主要更新:
全新装饰器
const
类型参数
extends
支持多配置文件
所有枚举都是联合枚举
--moduleResolution
bundler
自定义解析标志
--verbatimModuleSyntax
支持 export type *
JSDoc 支持 @satisfies
JSDoc 支持 @overload
编辑器中不区分大小写的导入排序
完善 switch/case
优化速度、内存和包大小
其他重大更改和弃用
装饰器是即将推出的 ECMAScript 特性,它允许我们以可重用的方式自定义类及其成员。
考虑以下代码:
class Person { name: string; constructor(name: string) { this.name = name; } greet() { console.log(`Hello, my name is ${this.name}.`); } } const p = new Person("Ray"); p.greet(); 复制代码
这里的 greet
方法很简单,在实际中它内部可能会跟复杂,比如需要执行异步逻辑,或者进行递归,亦或是有副作用等。那就可能需要使用 console.log
来调试 greet
:
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; } 复制代码
这里用了很多 any,可以暂时忽略,这样可以让例子尽可能得简单。
这里,loggedMethod
需要传入一个参数(originalMethod
) 并返回一个函数。执行过程如下:
打印: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("Ray"); p.greet(); 复制代码
输出如下:
LOG: Entering method. Hello, my name is Ray. 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("Ray"); p.greet(); 复制代码
输出结果如下:
Entering method 'greet'. Hello, my name is Ray. 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、参数和原始方法的返回类型。
具体定义装饰器函数的复杂程度取决于想要保证什么。需要记住,装饰器的使用次数将超过它们的编写次数,所以类型良好的版本通常是更好的——但显然与可读性有一个权衡,所以请尽量保持简单。
当推断一个对象的类型时,TypeScript通常会选择一个通用类型。例如,在本例中,names
的推断类型是string[]
:
type HasNames = { readonly names: string[] }; function getNamesExactly<T extends HasNames>(arg: T): T["names"] { return arg.names; } // names 的推断类型为 string[] const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"]}); 复制代码
通常这样做的目的是实现突变。然而,根据getnames
确切的作用以及它的使用方式,通常情况下需要更具体的类型。到目前为止,通常不得不在某些地方添加const
,以实现所需的推断:
// 我们想要的类型: readonly ["Alice", "Bob", "Eve"] // 我们得到的类型: string[] const names1 = getNamesExactly({ names: ["Alice", "Bob", "Eve"]}); // 得到想要的类型: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; } // 推断类型:readonly ["Alice", "Bob", "Eve"] // 注意,这里不需要再写 as const const names = getNamesExactly({ names: ["Alice", "Bob", "Eve"] }); 复制代码
注意,const
修饰符并不排斥可变值,也不需要不可变约束。使用可变类型约束可能会得到意外的结果。例如:
declare function fnBad<const T extends string[]>(args: T): void; // T仍然是string[],因为readonly ["a", "b", "c"]不能赋值给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 是 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 仍然是 string[],const 修饰符没有作用 fnGood(arr); 复制代码
当管理多个项目时,通常每个项目的 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", // ... } } 复制代码
当 TypeScript 最初引入枚举时,它只不过是一组具有相同类型的数值常量:
enum E { Foo = 10, Bar = 20, } 复制代码
E.Foo 和 E.Bar 唯一的特别之处在于它们可以分配给任何期望类型 E 的东西。除此之外,它们只是数字。
function takeValue(e: E) {} takeValue(E.Foo); // ✅ takeValue(123); // ❌ 复制代码
直到 TypeScript 2.0 引入了枚举字面量类型,它赋予每个枚举成员自己的类型,并将枚举本身转换为每个成员类型的联合。它还允许我们只引用枚举类型的一个子集,并缩小这些类型。
// Color就像是一个联合:Red | Orange | Yellow | Green | Blue | Violet enum Color { Red, Orange, Yellow, Green, Blue, /* Indigo */, Violet } // 每个枚举成员都有自己的类型,可以引用 type PrimaryColor = Color.Red | Color.Green | Color.Blue; function isPrimaryColor(c: Color): c is PrimaryColor { // 缩小字面量类型可以捕获bug // TypeScript在这里会报错,因为 // 最终会比较 Color.Red 和 Color.Green。 // 本想使用||,但不小心写了&& return c === Color.Red && c === Color.Green && c === Color.Blue; } 复制代码
给每个枚举成员指定自己的类型有一个问题,即这些类型在某种程度上与成员的实际值相关联。在某些情况下,这个值是不可能计算出来的——例如,枚举成员可以通过函数调用进行初始化。
enum E { Blah = Math.random() } 复制代码
每当TypeScript遇到这些问题时,它都会悄无声息地退出并使用旧的枚举策略。这意味着要放弃并集和字面量类型的所有优点。
TypeScript 5.0 通过为每个计算成员创建唯一的类型,设法将所有枚举转换为联合枚举。这意味着现在可以缩小所有枚举的范围,并将其成员作为类型引用。
TypeScript 4.7 为 --module
和 --moduleResolution
设置引入了 node16 和 nodenext 选项。这些选项的目的是更好地模拟 Node.js 中 ECMAScript 模块的精确查找规则; 然而,这种模式有许多其他工具没有真正执行的限制。
例如,在 Node.js 的 ECMAScript 模块中,任何相对导入都需要包含文件扩展名。
// entry.mjs import * as utils from "./utils"; // ❌ - 需要包括文件扩展名。 import * as utils from "./utils.mjs"; // ✅ 复制代码
在Node.js和浏览器中这样做是有原因的——它使文件查找更快,并且更适合原始文件服务器。但对于许多使用打包工具的开发人员来说,node16/nodenext 的设置很麻烦,因为打包工具没有这些限制中的大部分。在某些方面,node解析模式更适合使用打包工具的人。
但在某些方面,原有的 node 解析模式已经过时了。 大多数现代打包工具在 Node.js 中使用 ECMAScript 模块和 CommonJS 查找规则的融合。
为了模拟打包工具是如何工作的,TypeScript 5.0 引入了一个新策略:--moduleResolution bundler
{ "compilerOptions": { "target": "esnext", "moduleResolution": "bundler" } } 复制代码
如果正在使用现代打包工具,如 Vite、esbuild、swc、Webpack、Parcel 或其他实现混合查找策略的打包工具,那么新的 bundler
选项应该非常适合你。
另一方面,如果正在编写一个打算在 npm 上发布的库,使用bundler
选项可以隐藏不使用bundler
的用户可能出现的兼容性问题。因此,在这些情况下,使用node16
或nodenext
解析选项可能是更好的方法。
JavaScript 工具现在可以模拟“混合”解析规则,就像上面描述的打包工具模式一样。 由于工具的支持可能略有不同,TypeScript 5.0 提供了启用或禁用一些功能的方法。
--allowImportingTsExtensions
允许 TypeScript 文件使用特定于 TypeScript 的扩展名(如 .ts
、.mts
或 .tsx
)相互导入。
仅当启用 --noEmit
或 --emitDeclarationOnly
时才允许使用此标志,因为这些导入路径在运行时无法在 JavaScript 输出文件中解析。 这里的期望是解析器(例如打包工具、运行时或其他工具)将使 .ts
文件之间的这些导入正常工作。
--resolvePackageJsonExports
强制 TypeScript 在从 node_modules
中的包中读取时查询 package.json
文件的 exports
字段。
--resolvePackageJsonImports
强制 TypeScript 在从其祖先目录包含 package.json
的文件执行以 #
开头的查找时查询 package.json
文件的 imports
字段。
在 --moduleResolution
的 node16
、nodenext
和 bundler
选项下,此选项默认为 true。
在 TypeScript 5.0 中,当导入路径以不是已知 JavaScript 或 TypeScript 文件扩展名的扩展名结尾时,编译器将以 {file basename}.d.{extension}
的形式查找该路径的声明文件。例如,如果在打包项目中使用 CSS loader,可能希望为这些样式表编写(或生成)声明文件:
/* app.css */ .cookie-banner { display: none; } 复制代码
// app.d.css.ts declare const css: { cookieBanner: string; }; export default css; 复制代码
// App.tsx import styles from "./app.css"; styles.cookieBanner; // string 复制代码
默认情况下,这个导入将引发一个错误,让你知道TypeScript不理解这个文件类型,你的运行时可能不支持导入它。但是,如果已经配置了运行时或打包工具来处理它,则可以使用新--allowArbitraryExtensions
编译器选项来抑制错误。
注意,可以通过添加一个名为 app.css.d.ts
而不是 app.d.css.ts
的声明文件通常可以实现类似的效果。然而,这只是通过 Node 对 CommonJS 的 require 解析规则实现的。严格来说,前者被解释为一个名为 app.css.js
的 JavaScript 文件的声明文件。 因为相关文件导入需要在 Node 的 ESM 支持中包含扩展名,所以在我们的例子中,TypeScript 会在 --moduleResolution
node16 或 nodenext 下的 ESM 文件中出错。
--customConditions
获取当 TypeScript 从 package.json
的 [exports] 或 (nodejs.org/api/package…) 或 imports
字段解析时应该成功的附加的条件列表。这些条件将添加到解析器默认使用的现有条件中。
例如,当此字段在 tsconfig.json 中设置为:
{ "compilerOptions": { "target": "es2022", "moduleResolution": "bundler", "customConditions": ["my-condition"] } } 复制代码
任何时候在 package.json 中引用 exports 或 imports 字段时,TypeScript 都会考虑名为 my-condition 的条件。
因此,当从具有以下 package.json 的包中导入时:
{ // ... "exports": { ".": { "my-condition": "./foo.mjs", "node": "./bar.mjs", "import": "./baz.mjs", "require": "./biz.mjs" } } } 复制代码
TypeScript 将尝试查找与foo.mjs
对应的文件。这个字段只有在 node16、nodenext 和--modulerresolution
为 bundler 时才有效。
默认情况下,TypeScript 会执行一些称为导入省略的操作。如果这样写:
import { Car } from "./car"; export function drive(car: Car) { // ... } 复制代码
TypeScript 检测到只对类型使用导入并完全删除导入。输出 JavaScript 可能是这样的:
export function drive(car) { // ... } 复制代码
大多数时候这很好,因为如果 Car
不是从 ./car
导出的值,将得到一个运行时错误。但对于某些边界情况,它确实增加了一层复杂性。例如,没有像 import "./car"
这样的语句,即完全放弃了 import
,这实际上对有无副作用的模块产生影响。
TypeScript 的 JavaScript emit 策略也有另外几层复杂性——省略导入并不总是由如何使用 import 驱动的,它通常还会参考值的声明方式。所以并不总是很清楚是否像下面这样的代码:
export { Car } from "./car"; 复制代码
如果 Car 是用类之类的东西声明的,那么它可以保存在生成的 JavaScript 文件中。 但是,如果 Car 仅声明为类型别名或接口,则 JavaScript 文件不应导出 Car。
虽然 TypeScript 可能能够根据来自跨文件的信息做出这些发出决策,但并非每个编译器都可以。
imports 和 exports 的类型修饰符在这些情况下会有帮助。我们可以明确指定import
或export
仅用于类型分析,并且可以在JavaScript文件中使用类型修饰符完全删除。
// 这条语句可以在JS输出中完全删除 import type * as car from "./car"; // 在JS输出中可以删除命名的import/export Car import { type Car } from "./car"; export { type Car } from "./car"; 复制代码
类型修饰符本身并不是很有用——默认情况下,模块省略仍然会删除导入,并且没有强制区分类型和普通导入和导出。 因此 TypeScript 有标志 --importsNotUsedAsValues
以确保使用 type
修饰符,--preserveValueImports
以防止某些模块省略行为,以及 --isolatedModules
以确保 TypeScript 代码适用于不同的编译器。 不幸的是,很难理解这 3 个标志的细节,并且仍然存在一些具有意外行为的边界情况。
TypeScript 5.0 引入了一个名为 --verbatimModuleSyntax
的新选项来简化这种情况。规则要简单得多,任何没有 type
修饰符的导入或导出都会被保留。任何使用 type
修饰符的内容都会被完全删除。
// 完全被删除 import type { A } from "a"; // 重写为 'import { b } from "bcd";' import { b, type c, type d } from "bcd"; // 重写为 'import {} from "xyz";' import { type xyz } from "xyz"; 复制代码
有了这个新选项,所见即所得。不过,当涉及到模块互操作时,这确实有一些影响。 在此标志下,当设置或文件扩展名暗示不同的模块系统时,ECMAScript 导入和导出不会被重写为 require
调用。相反,会得到一个错误。 如果需要生成使用 require
和 module.exports
的代码,则必须使用早于 ES2015 的 TypeScript 模块语法: 虽然这是一个限制,但它确实有助于使一些问题更加明显。 例如,忘记在 --module node16 下的 package.json
中设置 type
字段是很常见的。 因此,开发人员会在没有意识到的情况下开始编写 CommonJS 模块而不是 ES 模块,从而给出意外的查找规则和 JavaScript 输出。 这个新标志确保有意使用正在使用的文件类型,因为语法是有意不同的。
因为 --verbatimModuleSyntax
提供了比 --importsNotUsedAsValues
和 --preserveValueImports
更一致的作用,所以这两个现有标志被弃用了。
当 TypeScript 3.8 引入仅类型导入时,新语法不允许在 export * from "module" 或 export * as ns from "module" 重新导出时使用。 TypeScript 5.0 添加了对这两种形式的支持:
// models/vehicles.ts export class Spaceship { // ... } // models/index.ts export type * as vehicles from "./vehicles"; // main.ts import { vehicles } from "./models"; function takeASpaceship(s: vehicles.Spaceship) { // ✅ } function makeASpaceship() { return new vehicles.Spaceship(); // ^^^^^^^^ // vehicles 不能用作值,因为它是使用“export type”导出的。 } 复制代码
@satisfies
TypeScript 4.9 引入了 satisfies
操作符。它确保表达式的类型是兼容的,而不影响类型本身。以下面的代码为例:
interface CompilerOptions { strict?: boolean; outDir?: string; // ... } interface ConfigSettings { compilerOptions?: CompilerOptions; extends?: string | string[]; // ... } let myConfigSettings = { compilerOptions: { strict: true, outDir: "../lib", // ... }, extends: [ "@tsconfig/strictest/tsconfig.json", "../../../tsconfig.base.json" ], } satisfies ConfigSettings; 复制代码
这里,TypeScript 知道 myCompilerOptions.extends
是用数组声明的,因为虽然 satisfies
验证了对象的类型,但它并没有直接将其更改为 CompilerOptions
而丢失信息。所以如果想映射到 extends
上,是可以的。
declare function resolveConfig(configPath: string): CompilerOptions; let inheritedConfigs = myConfigSettings.extends.map(resolveConfig); 复制代码
这对 TypeScript 用户很有帮助,但是很多人使用 TypeScript 来使用 JSDoc 注释对 JavaScript 代码进行类型检查。 这就是为什么 TypeScript 5.0 支持一个名为 @satisfies
的新 JSDoc 标签,它做的事情完全一样。
/** @satisfies */
可以捕获类型不匹配:
// @ts-check /** * @typedef CompilerOptions * @prop {boolean} [strict] * @prop {string} [outDir] */ /** * @satisfies {CompilerOptions} */ let myCompilerOptions = { outdir: "../lib", // ~~~~~~ oops! we meant outDir }; 复制代码
但它会保留表达式的原始类型,允许稍后在代码中更精确地使用值。
// @ts-check /** * @typedef CompilerOptions * @prop {boolean} [strict] * @prop {string} [outDir] */ /** * @typedef ConfigSettings * @prop {CompilerOptions} [compilerOptions] * @prop {string | string[]} [extends] */ /** * @satisfies {ConfigSettings} */ let myConfigSettings = { compilerOptions: { strict: true, outDir: "../lib", }, extends: [ "@tsconfig/strictest/tsconfig.json", "../../../tsconfig.base.json" ], }; let inheritedConfigs = myConfigSettings.extends.map(resolveConfig); 复制代码
/** @satisfies */
也可以内嵌在任何带括号的表达式上。 可以这样写 myCompilerOptions
:
let myConfigSettings = /** @satisfies {ConfigSettings} */ ({ compilerOptions: { strict: true, outDir: "../lib", }, extends: [ "@tsconfig/strictest/tsconfig.json", "../../../tsconfig.base.json" ], }); 复制代码
这可能在函数调用时更有意义:
compileCode(/** @satisfies {CompilerOptions} */ ({ // ... })); 复制代码
@overload
在 TypeScript 中,可以为函数指定重载。 重载提供了一种方式,用不同的参数调用一个函数,并返回不同的结果。它可以限制调用者实际使用函数的方式,并优化将返回的结果。
// 重载: function printValue(str: string): void; function printValue(num: number, maxFractionDigits?: number): void; // 实现: function printValue(value: string | number, maximumFractionDigits?: number) { if (typeof value === "number") { const formatter = Intl.NumberFormat("en-US", { maximumFractionDigits, }); value = formatter.format(value); } console.log(value); } 复制代码
这里,printValue 将字符串或数字作为第一个参数。如果它需要一个数字,它可以使用第二个参数来确定可以打印多少个小数位。
TypeScript 5.0 现在允许 JSDoc 使用新的 @overload
标签声明重载。 每个带有 @overload
标签的 JSDoc 注释都被视为以下函数声明的不同重载。
// @ts-check /** * @overload * @param {string} value * @return {void} */ /** * @overload * @param {number} value * @param {number} [maximumFractionDigits] * @return {void} */ /** * @param {string | number} value * @param {number} [maximumFractionDigits] */ function printValue(value, maximumFractionDigits) { if (typeof value === "number") { const formatter = Intl.NumberFormat("en-US", { maximumFractionDigits, }); value = formatter.format(value); } console.log(value); } 复制代码
现在,无论是在 TypeScript 还是 JavaScript 文件中编写,TypeScript 都可以让我们知道是否错误地调用了函数。
printValue("hello!"); printValue(123.45); printValue(123.45, 2); printValue("hello!", 123); // ❌ 复制代码
在 Visual Studio 和 VS Code 等编辑器中,TypeScript 支持组织和排序导入和导出的体验。 但是,对于列表何时“排序”,通常会有不同的解释。
例如,下面的导入列表是否排序?
import { Toggle, freeze, toBoolean, } from "./utils"; 复制代码
答案可能是“视情况而定”。 如果不关心区分大小写,那么这个列表显然没有排序。 字母 f 出现在 t 和 T 之前。
但在大多数编程语言中,排序默认是比较字符串的字节值。JavaScript 比较字符串的方式意味着“Toggle”总是在“freeze”之前,因为根据 ASCII 字符编码,大写字母在小写字母之前。 所以从这个角度来看,导入列表是已排序的。
TypeScript 之前认为导入列表是已排序的,因为它会做基本的区分大小写的排序。 对于喜欢不区分大小写排序的开发人员,或者使用像 ESLint 这样默认需要不区分大小写排序的工具的开发人员来说,这可能是一个阻碍。
TypeScript 现在默认检测大小写。这意味着 TypeScript 和 ESLint 等工具通常不会就如何最好地对导入进行排序而相互“斗争”。
这些选项最终可能由编辑器配置。目前,它们仍然不稳定且处于试验阶段,现在可以通过在 JSON 选项中使用 typescript.unstable
在 VS Code 中选择加入它们。 以下是可以尝试的所有选项(设置为默认值):
{ "typescript.unstable": { // Should sorting be case-sensitive? Can be: // - true // - false // - "auto" (auto-detect) "organizeImportsIgnoreCase": "auto", // Should sorting be "ordinal" and use code points or consider Unicode rules? Can be: // - "ordinal" // - "unicode" "organizeImportsCollation": "ordinal", // Under `"organizeImportsCollation": "unicode"`, // what is the current locale? Can be: // - [any other locale code] // - "auto" (use the editor's locale) "organizeImportsLocale": "en", // Under `"organizeImportsCollation": "unicode"`, // should upper-case letters or lower-case letters come first? Can be: // - false (locale-specific) // - "upper" // - "lower" "organizeImportsCaseFirst": false, // Under `"organizeImportsCollation": "unicode"`, // do runs of numbers get compared numerically (i.e. "a1" < "a2" < "a100")? Can be: // - true // - false "organizeImportsNumericCollation": true, // Under `"organizeImportsCollation": "unicode"`, // do letters with accent marks/diacritics get sorted distinctly // from their "base" letter (i.e. is é different from e)? Can be // - true // - false "organizeImportsAccentCollation": true }, "javascript.unstable": { // same options valid here... }, } 复制代码
switch/case
在编写 switch 语句时,TypeScript 现在会检测被检查的值何时具有字面量类型。以提供更便利的代码快捷输入:
TypeScript 5.0 在代码结构、数据结构和算法实现中包含许多强大的变化。这些都意味着整个体验应该更快——不仅仅是运行 TypeScript,甚至安装它。
以下是相对于 TypeScript 4.9 在速度和大小方面的优势:
场景 | 时间或大小相对于 TS 4.9 |
---|---|
material-ui 构建时间 | 90% |
TypeScript 编译器启动时间 | 89% |
Playwright 构建时间 | 88% |
TypeScript 编译器自构建时间 | 87% |
Outlook Web 构建时间 | 82% |
VS Code 构建时间 | 80% |
TypeScript npm 包大小 | 59% |
图表形式: TypeScript 包大小变化: 那为什么会有如此大的提升呢?部分优化细节如下:
首先,将 TypeScript 从命名空间迁移到模块,这样就能够利用现代构建工具来执行优化。重新审视了打包策略并删除一些已弃用的代码,已将 TypeScript 4.9 的 63.8 MB 包大小减少了约 26.4 MB。还通过直接函数调用带来了显著的速度提升。
在将信息序列化为字符串时,执行了一些缓存。 类型显示可能作为错误报告、声明触发、代码补全等的一部分发生,最终可能会相当昂贵。TypeScript 现在缓存了一些常用的机制以在这些操作中重用。
总的来说,预计大多数代码库应该会看到 TypeScript 5.0 的速度提升,并且始终能够重现 10% 到 20% 之间的提升。当然,这将取决于硬件和代码库特性。
TypeScript 现在的 target
是 ECMAScript 2018。TypeScript 软件包还将预期的最低引擎版本设置为 12.20。对于 Node.js 用户来说,这意味着 TypeScript 5.0 需要至少Node.js 12.20 或更高版本才能运行。
更改 DOM 类型的生成方式可能会对现有代码产生影响。注意,某些属性已从数字转换为数字字面量类型,并且用于剪切、复制和粘贴事件处理的属性和方法已跨接口移动。
在 TypeScript 5.0 中, 转向了模块,删除了一些不必要的接口,并进行了一些正确性改进。
如果编写的代码可能导致隐式字符串到数字的强制转换,TypeScript 中的某些操作现在会进行警告:
function func(ns: number | string) { return ns * 4; // 错误,可能存在隐式强制转换 } 复制代码
在 5.0 中,这也将应用于关系运算符 >、<、<= 和 >=:
function func(ns: number | string) { return ns > 4; } 复制代码
如果需要这样做,可以使用+
显式地将操作数转换为数字:
function func(ns: number | string) { return +ns > 4; // OK } 复制代码
在 TypeScript 5.0 中,弃用了以下设置和设置值:
--target: ES3
--out
--noImplicitUseStrict
--keyofStringsOnly
--suppressExcessPropertyErrors
--suppressImplicitAnyIndexErrors
--noStrictGenericChecks
--charset
--importsNotUsedAsValues
--preserveValueImports
在 TypeScript 5.5 之前,这些配置将继续被允许使用,届时它们将被完全删除,但是,如果正在使用这些设置,将收到警告。 在 TypeScript 5.0 以及未来版本 5.1、5.2、5.3 和 5.4 中,可以指定 "ignoreDeprecations": "5.0"
以消除这些警告。 很快会发布一个 4.9 补丁,允许指定 ignoreDeprecations
以实现更平滑的升级。除了弃用之外,还更改了一些设置以更好地改进 TypeScript 中的跨平台行为。
--newLine
,控制 JavaScript 文件中发出的行结束符,如果没有指定,过去是根据当前操作系统推断的。我们认为构建应该尽可能确定,Windows 记事本现在支持换行符,所以新的默认设置是 LF。 旧的特定于操作系统的推理行为不再可用。
--forceConsistentCasingInFileNames
,它确保项目中对相同文件名的所有引用都在大小写中达成一致,现在默认为 true
。 这有助于捕获在不区分大小写的文件系统上编写的代码的差异问题。