装饰器 Decorators
装饰器是即将到来的 ECMAScript 特性,它允许我们定制可重用的类以及类成员。
考虑如下的代码:
tsclass 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();
这里的 greet 很简单,但我们假设它很复杂 - 例如包含异步的逻辑,是递归的,具有副作用等。
不管你把它想像成多么混乱复杂,现在我们想插入一些 console.log 语句来调试 greet。
tsclass 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:
tsfunction 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)作为参数并返回一个函数:
- 打印
"Entering…"消息 - 将
this值以及所有的参数传递给原方法 - 打印
"Exiting..."消息,并且 - 返回原方法的返回值。
现在可以使用 loggedMethod 来装饰 greet 方法:
tsclass Person {name: string;constructor(name: string) {this.name = name;}@loggedMethodgreet() {console.log(`Hello, my name is ${this.name}.`);}}const p = new Person('Ron');p.greet();// 输出://// LOG: Entering method.// Hello, my name is Ron.// LOG: Exiting method.
我们刚刚在 greet 上使用了 loggedMethod 装饰器 - 注意一下写法 @loggedMethod。
这样做之后,loggedMethod 被调用时会传入被装饰的目标 target 以及一个上下文对象 context object 作为参数。
因为 loggedMethod 返回了一个新函数,因此这个新函数会替换掉 greet 的原始定义。
在 loggedMethod 的定义中带有第二个参数。
它就是上下文对象 context object,包含了一些有关于装饰器声明细节的有用信息 -
例如是否为 #private 成员,或者 static,或者方法的名称。
让我们重写 loggedMethod 来使用这些信息,并且打印出被装饰的方法的名字。
tsfunction 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 的类型用于描述装饰器方法接收的上下文对象。
除了元数据外,上下文对象中还提供了一个有用的函数 addInitializer。
它提供了一种方式来 hook 到构造函数的起始位置。
例如在 JavaScript 中,下面的情形很常见:
tsclass 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 可以被声明为使用箭头函数初始化的属性。
tsclass Person {name: string;constructor(name: string) {this.name = name;}greet = () => {console.log(`Hello, my name is ${this.name}.`);};}
这类代码的目的是确保 this 值不会被重新绑定,当 greet 被独立地调用或者在用作回调函数时。
tsconst greet = new Person('Ron').greet;// 我们不希望下面的调用失败greet();
我们可以定义一个装饰器来利用 addInitializer 在构造函数里调用 bind。
tsfunction 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 没有返回值 - 因此当它装饰一个方法时,不会影响原先的方法。
但是,它会在字段被初始化前添加一些逻辑。
tsclass Person {name: string;constructor(name: string) {this.name = name;}@bound@loggedMethodgreet() {console.log(`Hello, my name is ${this.name}.`);}}const p = new Person('Ron');const greet = p.greet;// Works!greet();
我们将两个装饰器叠在了一起 - @bound 和 @loggedMethod。
这些装饰器以“相反的”顺序执行。
也就是说,@loggedMethod 装饰原始方法 greet,
@bound 装饰的是 @loggedMethod 的结果。
此例中,这不太重要 - 但如果你的装饰器带有副作用或者期望特定的顺序,那就不一样了。
值得注意的是:如果你在乎代码样式,也可以将装饰器放在同一行上。
ts@bound @loggedMethod greet() {console.log(`Hello, my name is ${this.name}.`);}
可能不太明显的一点是,你甚至可以定义一个返回装饰器函数的函数。
这样我们可以在一定程序上定制最终的装饰器。
我们可以让 loggedMethod 返回一个装饰器并且定制如何打印消息。
tsfunction 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 装饰器之前需要先调用它。
接下来就可以传入任意字符串作为打印消息的前缀。
tsclass 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)以及自动存取器。 甚至,类本身也可以被装饰,用于处理子类化和注册。
想深入了解装饰器,可以阅读 Axel Rauschmayer 的文章。
更多详情请参考 PR。
与旧的实验性的装饰器的差异
如果你有一定的 TypeScript 经验,你会发现 TypeScript 多年前就已经支持了“实验性的”装饰器特性。
虽然实验性的装饰器非常地好用,但是它实现的是旧版本的装饰器规范,并且总是需要启用 --experimentalDecorators 编译器选项。
若没有启用它并且使用了装饰器,TypeScript 会报错。
在未来的一段时间内,--experimentalDecorators 依然会存在;
然而,如果不使用该标记,在新代码中装饰器语法也是合法的。
在 --experimentalDecorators 之外,它们的类型检查和代码生成方式也不同。
类型检查和代码生成规则存在巨大差异,以至于虽然装饰器可以被定义为同时支持新、旧装饰器的行为,但任何现有的装饰器函数都不太可能这样做。
新的装饰器提案与 --emitDecoratorMetadata 的实现不兼容,并且不支持在参数上使用装饰器。
未来的 ECMAScript 提案可能会弥补这个差距。
最后要注意的是:除了可以在 export 关键字之前使用装饰器,还可以在 export 或者 export default 之后使用。
但是不允许混合使用两种风格。
ts// allowed@registerexport default class Foo {// ...}// also allowedexport default@registerclass Bar {// ...}// error - before *and* after is not allowed@before@afterexport class Bar {// ...}
编写强类型的装饰器
上面的例子 loggedMethod 和 bound 是故意写的简单并且忽略了大量和类型有关的细节。
为装饰器添加类型可能会很复杂。
例如,强类型的 loggedMethod 可能像下面这样:
tsfunction 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、形式参数和返回值添加类型,上面使用了类型参数 This,Args 以及 Return。
装饰器函数到底有多复杂取决于你要确保什么。
但要记住,装饰器被使用的次数远多于被编写的次数,因此强类型的版本是通常希望得到的 -
但我们需要在可读性之间做出取舍,因此要尽量保持简洁。
未来会有更多关于如何编写装饰器的文档 - 但是这篇文章详细介绍了装饰器的工作方式。
const 类型参数
在推断对象类型时,TypeScript 通常会选择一个通用类型。
例如,下例中 names 的推断类型为 string[]:
tstype 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'] });
这样做的目的通常是为了允许后面可以进行修改。
然而,根据 getNamesExactly 的具体功能和预期使用方式,通常情况下需要更加具体的类型。
直到现在,API 作者们通常不得不在一些位置上添加 as const 来达到预期的类型推断目的:
ts// 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 形式的类型推断成为默认行为:
tstype 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' hereconst names = getNamesExactly({ names: ['Alice', 'Bob', 'Eve'] });
注意,const 修饰符不会拒绝可修改的值,并且不需要不可变约束。
使用可变类型约束可能会产生令人惊讶的结果。
tsdeclare 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[]:
tsdeclare function fnGood<const T extends readonly string[]>(args: T): void;// T is readonly ["a", "b", "c"]fnGood(['a', 'b', 'c']);
要注意 const 修饰符只影响在函数调用中直接写出的对象、数组和基本表达式的类型推断,
因此,那些无法(或不会)使用 as const 进行修饰的参数在行为上不会有任何变化:
tsdeclare 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 herefnGood(arr);
extends 支持多个配置文件
在管理多个项目时,拥有一个“基础”配置文件,其他 tsconfig.json 文件可以继承它,这会非常有帮助。
这就是为什么 TypeScript 支持使用 extends 字段来从 compilerOptions 中复制字段的原因。
json// packages/front-end/src/tsconfig.json{"extends": "../../../tsconfig.base.json","compilerOptions": {"outDir": "../lib"// ...}}
然而,有时您可能想要从多个配置文件中进行继承。
例如,假设您正在使用一个在 npm 上发布的 TypeScript 基础配置文件。
如果您希望自己所有的项目也使用 npm 上的 @tsconfig/strictest 包中的选项,那么有一个简单的解决方案:让 tsconfig.base.json 从 @tsconfig/strictest 进行扩展:
json// tsconfig.base.json{"extends": "@tsconfig/strictest/tsconfig.json","compilerOptions": {// ...}}
这在某种程度上是有效的。
如果您的某些工程不想使用 @tsconfig/strictest,那么必须手动禁用这些选项,或者创建一个不继承于 @tsconfig/strictest 的 tsconfig.base.json。
为了提高灵活性,TypeScript 5.0 允许 extends 字段指定多个值。
例如,有如下的配置文件:
json{"extends": ["a", "b", "c"],"compilerOptions": {// ...}}
这样写就如同是直接继承 c,而 c 继承于 b,b 继承于 a。
如果出现冲突,后来者会被采纳。
在下面的例子中,在最终的 tsconfig.json 中 strictNullChecks 和 noImplicitAny 会被启用。
json// tsconfig1.json{"compilerOptions": {"strictNullChecks": true}}// tsconfig2.json{"compilerOptions": {"noImplicitAny": true}}// tsconfig.json{"extends": ["./tsconfig1.json", "./tsconfig2.json"],"files": ["./index.ts"]}
另一个例子,我们可以这样改写最初的示例:
json// packages/front-end/src/tsconfig.json{"extends": ["@tsconfig/strictest/tsconfig.json","../../../tsconfig.base.json"],"compilerOptions": {"outDir": "../lib"// ...}}
更多详情请参考:PR。
所有的 enum 均为联合 enum
在最初 TypeScript 引入枚举类型时,它们只不过是一组同类型的数值常量。
tsenum E {Foo = 10,Bar = 20,}
E.Foo 和 E.Bar 唯一特殊的地方在于它们可以赋值给任何期望类型为 E 的地方。
除此之外,它们基本上等同于 number 类型。
tsfunction takeValue(e: E) {}takeValue(E.Foo); // workstakeValue(123); // error!
直到 TypeScript 2.0 引入了枚举字面量类型,枚举才变得更为特殊。 枚举字面量类型为每个枚举成员提供了其自己的类型,并将枚举本身转换为每个成员类型的联合类型。 它们还允许我们仅引用枚举中的一部分类型,并细化掉那些类型。
ts// Color is like a union of Red | Orange | Yellow | Green | Blue | Violetenum 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;}
为每个枚举成员提供其自己的类型的一个问题是,这些类型在某种程度上与成员的实际值相关联。 在某些情况下,无法计算该值 - 例如,枚举成员可能由函数调用初始化。
tsenum E {Blah = Math.random(),}
每当 TypeScript 遇到这些问题时,它会悄悄地退而使用旧的枚举策略。 这意味着放弃所有联合类型和字面量类型的优势。
TypeScript 5.0 通过为每个计算成员创建唯一类型,成功将所有枚举转换为联合枚举。 这意味着现在所有枚举都可以被细化,并且每个枚举成员都有其自己的类型。
更多详情请参考 PR
--moduleResolution bundler
TypeScript 4.7 支持将 --module 和 --moduleResolution 选项设置为 node16 和 nodenext。
这些选项的目的是更好地模拟 Node.js 中 ECMAScript 模块的精确查找规则;
然而,这种模式存在许多其他工具实际上并不强制执行的限制。
例如,在 Node.js 的 ECMAScript 模块中,任何相对导入都需要包含文件扩展名。
ts// entry.mjsimport * as utils from './utils'; // wrong - we need to include the file extension.import * as utils from './utils.mjs'; // works
对于 Node.js 和浏览器来说,这样做有一些原因 - 它可以加快文件查找速度,并且对于简单的文件服务器效果更好。
但是对于许多使用打包工具的开发人员来说,node16 / nodenext 设置很麻烦,
因为打包工具中没有这么多限制。
在某些方面,node 解析模式对于任何使用打包工具的人来说是更好的。
但在某些方面,原始的 node 解析模式已经过时了。
大多数现代打包工具在 Node.js 中使用 ECMAScript 模块和 CommonJS 查找规则的融合。
例如,像在 CommonJS 中一样,无扩展名的导入也可以正常工作,但是在查找包的导出条件时,它们将首选像在 ECMAScript 文件中一样的 import 条件。
为了模拟打包工具的工作方式,TypeScript 现在引入了一种新策略:--moduleResolution bundler。
json{"compilerOptions": {"target": "esnext","moduleResolution": "bundler"}}
如果你使用如 Vite, esbuild, swc, Webpack, parcel 等现代打包工具,它们实现了混合的查找策略,新的 bundler 选项是更好的选择。
另一方面,如果您正在编写一个要发布到 npm 的代码库,那么使用 bundler 选项可能会隐藏影响未使用打包工具用户的兼容性问题。
因此,在这些情况下,使用 node16 或 nodenext 解析选项可能是更好的选择。
更多详情请参考 PR
定制化解析的标记
JavaScript 工具现在可以模拟“混合”解析规则,就像我们上面描述的 bundler 模式一样。
由于工具的支持可能有所不同,因此 TypeScript 5.0 提供了启用或禁用一些功能的方法,这些功能可能无法与您的配置一起使用。
allowImportingTsExtensions
--allowImportingTsExtensions 允许 TypeScript 文件导入使用了 TypeScript 特定扩展名的文件,例如 .ts, .mts, .tsx。
此标记仅在启用了 --noEmit 或 --emitDeclarationOnly 时允许使用,
因为这些导入路径无法在运行时的 JavaScript 输出文件中被解析。
这里的期望是,您的解析器(例如打包工具、运行时或其他工具)将保证这些在 .ts 文件之间的导入可以工作。
resolvePackageJsonExports
--resolvePackageJsonExports 强制 TypeScript 使用 package.json 里的 exports 字段,如果它尝试读取 node_modules 里的某个包。
当 --moduleResolution 为 node16, nodenext 和 bundler 时,该选项的默认值为 true。
resolvePackageJsonImports
--resolvePackageJsonImports 强制 TypeScript 使用 package.json 里的 imports 字段,当它查找以 # 开头的文件时,且该文件的父目录中包含 package.json 文件。
当 --moduleResolution 为 node16, nodenext 和 bundler 时,该选项的默认值为 true。
allowArbitraryExtensions
在 TypeScript 5.0 中,当导入路径不是以已知的 JavaScript 或 TypeScript 文件扩展名结尾时,编译器将查找该路径的声明文件,形式为 {文件基础名称}.d.{扩展名}.ts。
例如,如果您在打包项目中使用 CSS 加载器,您可能需要编写(或生成)如下的声明文件:
css/* app.css */.cookie-banner {display: none;}
ts// app.d.css.tsdeclare const css: {cookieBanner: string;};export default css;
tsx// App.tsximport styles from './app.css';styles.cookieBanner; // string
默认情况下,该导入将引发错误,告诉您 TypeScript 不支持此文件类型,您的运行时可能不支持导入它。
但是,如果您已经配置了运行时或打包工具来处理它,您可以使用新的 --allowArbitraryExtensions 编译器选项来抑制错误。
需要注意的是,历史上通常可以通过添加名为 app.css.d.ts 而不是 app.d.css.ts 的声明文件来实现类似的效果 - 但是,这只在 Node.js 中 CommonJS 的 require 解析规则下可以工作。
严格来说,前者被解析为名为 app.css.js 的 JavaScript 文件的声明文件。
由于 Node 中的 ESM 需要使用包含扩展名的相对文件导入,因此在 --moduleResolution 为 node16 或 nodenext 时,TypeScript 会在示例的 ESM 文件中报错。
customConditions
--customConditions 接受额外的条件列表,当 TypeScript 从 package.json 的exports或 imports 字段解析时,这些条件应该成功。
这些条件会被添加到解析器默认使用的任何现有条件中。
例如,有如下的配置:
json{"compilerOptions": {"target": "es2022","moduleResolution": "bundler","customConditions": ["my-condition"]}}
每当 package.json 里引用了 exports 或 imports 字段时,TypeScript 都会考虑名为 my-condition 的条件。
所以当从具有如下 package.json 的包中导入时:
json{// ..."exports": {".": {"my-condition": "./foo.mjs","node": "./bar.mjs","import": "./baz.mjs","require": "./biz.mjs"}}}
TypeScript 会尝试查找 foo.mjs 文件。
该字段仅在 --moduleResolution 为 node16, nodenext 和 bundler 时有效。
—verbatimModuleSyntax
在默认情况下,TypeScript 会执行导入省略。 大体上来讲,如果有如下代码:
tsimport { Car } from './car';export function drive(car: Car) {// ...}
TypeScript 能够检测到导入语句仅用于导入类型,因此会删除导入语句。 最终生成的 JavaScript 代码如下:
jsexport function drive(car) {// ...}
大多数情况下这是没问题的,因为如果 Car 不是从 ./car 导出的值,我们将会得到一个运行时错误。
但在一些特殊情况下,它增加了一层复杂性。
例如,不存在像 import "./car"; 这样的语句 - 这个导入语句会被完全删除。
这对于有副作用的模块来讲是有区别的。
TypeScript 的 JavaScript 代码生成策略还有其它一些复杂性 - 导入省略不仅只是由导入语句的使用方式决定 - 它还取决于值的声明方式。 因此,如下的代码的处理方式不总是那么明显:
tsexport { Car } from './car';
这段代码是应该保留还是删除?
如果 Car 是使用 class 声明的,那么在生成的 JavaScript 代码中会被保留。
但是如果 Car 是使用类型别名或 interface 声明的,那么在生成的 JavaScript 代码中会被省略。
尽管 TypeScript 可以根据多个文件来综合判断如何生成代码,但不是所有的编译器都能够做到。
导入和导出语句中的 type 修饰符能够起到一点作用。
我们可以使用 type 修饰符明确声明导入和导出是否仅用于类型分析,并且可以在生成的 JavaScript 文件中完全删除。
ts// This statement can be dropped entirely in JS outputimport type * as car from './car';// The named import/export 'Car' can be dropped in JS outputimport { type Car } from './car';export { type Car } from './car';
type 修饰符本身并不是特别管用 - 默认情况下,导入省略仍会删除导入语句,
并且不强制要求您区分类型导入和普通导入以及导出。
因此,TypeScript 提供了 --importsNotUsedAsValues 来确保您使用类型修饰符,
--preserveValueImports 来防止某些模块消除行为,
以及 --isolatedModules 来确保您的 TypeScript 代码在不同编译器中都能正常运行。
不幸的是,理解这三个标志的细节很困难,并且仍然存在一些意外行为的边缘情况。
TypeScript 5.0 提供了一个新的 --verbatimModuleSyntax 来简化这个情况。
规则很简单 - 所有不带 type 修饰符的导入导出语句会被保留。
任何带有 type 修饰符的导入导出语句会被删除。
ts// Erased away entirely.import type { A } from 'a';// Rewritten to 'import { b } from "bcd";'import { b, type c, type d } from 'bcd';// Rewritten to 'import {} from "xyz";'import { type xyz } from 'xyz';
使用这个新的选项,实现了所见即所得。
但是,这在涉及模块互操作性时会有一些影响。
在这个标志下,当您的设置或文件扩展名暗示了不同的模块系统时,ECMAScript 的导入和导出不会被重写为 require 调用。
相反,您会收到一个错误。
如果您需要生成使用 require 和 module.exports 的代码,您需要使用早于 ES2015 的 TypeScript 的模块语法:
tsimport foo = require('foo');// ==>const foo = require('foo');
tsfunction foo() {}function bar() {}function baz() {}export = {foo,bar,baz,};// ==>function foo() {}function bar() {}function baz() {}module.exports = {foo,bar,baz,};
虽然这是一种限制,但它确实有助于使一些问题更加明显。
例如,在 --module node16 下很容易忘记在 package.json 中设置 type 字段。
结果是开发人员会开始编写 CommonJS 模块而不是 ES 模块,但却没有意识到这一点,从而导致查找规则和 JavaScript 输出出现意外的结果。
这个新的标志确保您有意识地使用文件类型,因为语法是刻意不同的。
因为 --verbatimModuleSyntax 相比于 --importsNotUsedAsValues 和 --preserveValueImports 提供了更加一致的行为,推荐使用前者,后两个标记将被弃用。
支持 export type *
在 TypeScript 3.8 引入类型导入时,该语法不支持在 export * from "module" 或 export * as ns from "module" 重新导出上使用。
TypeScript 5.0 添加了对两者的支持:
ts// models/vehicles.tsexport class Spaceship {// ...}// models/index.tsexport type * as vehicles from './vehicles';// main.tsimport { vehicles } from './models';function takeASpaceship(s: vehicles.Spaceship) {// ok - `vehicles` only used in a type position}function makeASpaceship() {return new vehicles.Spaceship();// ^^^^^^^^// 'vehicles' cannot be used as a value because it was exported using 'export type'.}
更多详情请参考 PR。
支持 JSDoc 中的 @satisfies
TypeScript 4.9 支持 satisfies 运算符。
它确保了表达式的类型是兼容的,且不影响类型自身。
例如,有如下代码:
tsinterface 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 知道 myConfigSettings.extends 声明为数组 - 因为 satisfies 会验证对象的类型。
因此,如果我们想在 extends 上进行映射操作,那是可以的。
tsdeclare function resolveConfig(configPath: string): CompilerOptions;let inheritedConfigs = myConfigSettings.extends.map(resolveConfig);
这对 TypeScript 用户来讲是有用处的,但是许多人使用 TypeScript 来对带有 JSDoc 的 JavaScript 代码进行类型检查。
因此,TypeScript 5.0 支持了新的 JSDoc 标签 @satisfies 来做相同的事。
/** @satisfies */ 能够检查出类型不匹配:
ts// @ts-check/*** @typedef CompilerOptions* @prop {boolean} [strict]* @prop {string} [outDir]*//*** @satisfies {CompilerOptions}*/let myCompilerOptions = {outdir: '../lib',// ~~~~~~ oops! we meant outDir};
但它会保留表达式的原始类型,允许我们稍后使用值的更详细的类型。
ts// @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 */ 也可以在行内的括号表达式上使用。
可以像下面这样定义 myConfigSettings:
tslet myConfigSettings = /** @satisfies {ConfigSettings} */ {compilerOptions: {strict: true,outDir: '../lib',},extends: ['@tsconfig/strictest/tsconfig.json', '../../../tsconfig.base.json'],};
为什么?当你更深入地研究其他代码时,比如函数调用,它通常更有意义。
tscompileCode(/** @satisfies {ConfigSettings} */ {// ...});
更多详情请参考 PR。 感谢作者 Oleksandr Tarasiuk。
支持 JSDoc 中的 @overload
在 TypeScript 中,你可以为一个函数指定多个重载。 使用重载能够描述一个函数可以使用不同的参数进行调用,也可能会返回不同的结果。 它们可以限制调用方如何调用函数,并细化他们将得到的结果。
ts// Our overloads:function printValue(str: string): void;function printValue(num: number, maxFractionDigits?: number): void;// Our implementation: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 的第一个参数可以为 string 或 number 类型。
如果接收的是 number 类型,那么它还接收第二个参数决定打印的小数位数。
TypeScript 5.0 支持在 JSDoc 里使用 @overload 来声明重载。
每一个 JSDoc @overload 标记都表示一个不同的函数重载。
js// @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 都能够提示函数调用是否正确。
js// all allowedprintValue('hello!');printValue(123.45);printValue(123.45, 2);printValue('hello!', 123); // error!
更多详情请参考 PR,感谢 Tomasz Lenarcik。
在 --build 模式下使用有关文件生成的选项
TypeScript 现在允许在 --build 模式下使用如下选项:
--declaration--emitDeclarationOnly--declarationMap--sourceMap--inlineSourceMap
这使得在构建过程中定制某些部分变得更加容易,特别是在你可能会有不同的开发和生产构建时。
例如,一个库的开发构建可能不需要生成声明文件,但是生产构建则需要。 一个项目可以将生成声明文件配置为默认关闭,并使用如下方式构建:
shtsc --build -p ./my-project-dir
开发完毕后,在“生产环境”构建时使用 --declaration 选项:
shtsc --build -p ./my-project-dir --declaration
更多详情请参考 PR。
编辑器导入语句排序时不区分大小写
在 Visual Studio 和 VS Code 等编辑器中,TypeScript 可以帮助组织和排序导入和导出语句。 不过,通常情况下,对于何时将列表“排序”,可能会有不同的解释。
例如,下面的导入列表是否已排序?
tsimport { Toggle, freeze, toBoolean } from './utils';
令人惊讶的是,答案可能是“这要看情况”。
如果我们不考虑大小写敏感性,那么这个列表显然是没有排序的。
字母f排在t和T之前。
但在大多数编程语言中,排序默认是比较字符串的字节值。 JavaScript 比较字符串的方式意味着 “Toggle” 总是排在 “freeze” 之前,因为根据 ASCII 字符编码,大写字母排在小写字母之前。 所以从这个角度来看,导入列表是已排序的。
以前,TypeScript 认为导入列表已排序,因为它进行了基本的大小写敏感排序。 这可能让开发人员感到沮丧,因为他们更喜欢不区分大小写的排序方式,或者使用像 ESLint 这样的工具默认需要不区分大小写的排序方式。
现在,TypeScript 默认会检测大小写敏感性。 这意味着 TypeScript 和类似 ESLint 的工具通常不会因为如何最好地排序导入而“互相冲突”。
我们的团队还在尝试更多的排序策略,你可以在这里了解更多。 这些选项可能最终可以由编辑器进行配置。 目前,它们仍然不稳定和实验性的,你可以通过在 JSON 选项中使用 typescript.unstable 条目来选择它们。 下面是你可以尝试的所有选项(设置为它们的默认值):
json{"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 现在会检测被检查的值是否具有字面量类型。
如果是,它将提供一个补全选项,可以为每个未覆盖的情况构建骨架代码。
更多详情请参考 PR。
速度,内存以及代码包尺寸优化
TypeScript 5.0 在我们的代码结构、数据结构和算法实现方面进行了许多强大的变化。 这些变化的意义在于,整个体验都应该更快 —— 不仅仅是运行 TypeScript,甚至包括安装 TypeScript。
以下是我们相对于 TypeScript 4.9 能够获得的一些有趣的速度和大小优势。
| Scenario | Time or Size Relative to TS 4.9 |
|---|---|
| material-ui build time | 90% |
| TypeScript Compiler startup time | 89% |
| Playwright build time | 88% |
| TypeScript Compiler self-build time | 87% |
| Outlook Web build time | 82% |
| VS Code build time | 80% |
| typescript npm Package Size | 59% |


怎么做到的呢?我们将在未来的博客文章中详细介绍一些值得注意的改进。 但我们不会让你等到那篇博客文章。
首先,我们最近将 TypeScript 从命名空间迁移到了模块,这使我们能够利用现代构建工具来执行像作用域提升这样的优化。 使用这些工具,重新审视我们的打包策略,并删除一些已过时的代码,使 TypeScript 4.9 的 63.8 MB 包大小减少了约 26.4 MB。 这也通过直接函数调用为我们带来了显著的加速。 我们在这里撰写了关于我们迁移到模块的详细介绍。
TypeScript 还在编译器内部对象类型上增加了更多的一致性,并且也减少了一些这些对象类型上存储的数据。 这减少了多态操作,同时平衡了由于使我们的对象结构更加一致而带来的内存使用增加。
我们还在将信息序列化为字符串时执行了一些缓存。 类型显示,它可能在错误报告、声明生成、代码补全等情况下使用,是非常昂贵的操作。 TypeScript 现在对一些常用的机制进行缓存,以便在这些操作之间重复使用。
我们进行了一个值得注意的改变,改善了我们的解析器,即在某些情况下,利用 var 来避免在闭包中使用 let 和 const 的成本。 这提高了一些解析性能。
总的来说,我们预计大多数代码库应该会从 TypeScript 5.0 中看到速度的提升,并且一直能够保持 10% 到 20% 之间的优势。 当然,这将取决于硬件和代码库的特性,但我们鼓励你今天就在你的代码库上尝试它!
更多详情: