TypeScript 3.8

仅类型导入与导出

大多数用户可能永远不需要考虑这个特性;不过,如果你在 isolatedModules、TypeScript 的 transpileModule API 或 Babel 下遇到过问题,那么这个特性可能与你相关。

TypeScript 3.8 为仅类型导入和导出新增了一种语法。

ts
import type { SomeThing } from "./some-module.js";
export type { SomeThing };

import type 只导入用于类型标注和声明的声明。它总是会被完全擦除,因此在运行时不会留下任何痕迹。类似地,export type 只提供一个可用于类型上下文的导出,也会从 TypeScript 的输出中被擦除。

需要注意的是,类在运行时是一个值,在设计时是一个类型,其用法是上下文敏感的。使用 import type 导入类时,你无法做诸如继承它之类的事情。

ts
import type { Component } from "react";
interface ButtonProps {
// ...
}
class Button extends Component<ButtonProps> {
// ~~~~~~~~~
// error! 'Component' only refers to a type, but is being used as a value here.
// ...
}

如果你以前用过 Flow,那么语法相当相似。区别之一是,我们添加了一些限制,以避免出现可能产生歧义的代码。

ts
// Is only 'Foo' a type? Or every declaration in the import?
// We just give an error because it's not clear.
import type Foo, { Bar, Baz } from "some-module";
// ~~~~~~~~~~~~~~~~~~~~~~
// error! A type-only import can specify a default import or named bindings, but not both.

配合 import type,TypeScript 3.8 还新增了一个编译器标志,用于控制那些在运行时不会被使用的导入的行为:importsNotUsedAsValues。这个标志接受 3 个不同的值:

  • remove:这是当前丢弃这些导入的行为。它将继续作为默认值,并且是一项非破坏性变更。
  • preserve:这会保留所有值从未被使用的导入。这可能导致导入/副作用被保留。
  • error:这会保留所有导入(与 preserve 选项相同),但当某个值导入仅被用作类型时会报错。如果你希望确保没有值被意外导入,同时仍让副作用导入保持显式,这个选项可能很有用。

关于该特性的更多信息,你可以查看这个拉取请求,以及相关更改,后者放宽了 import type 声明中的导入可被使用的位置。

ECMAScript 私有字段

TypeScript 3.8 带来了对 ECMAScript 私有字段的支持,它是第 3 阶段的类字段提案的一部分。

ts
class Person {
#name: string;
constructor(name: string) {
this.#name = name;
}
greet() {
console.log(`Hello, my name is ${this.#name}!`);
}
}
let jeremy = new Person("Jeremy Bearimy");
jeremy.#name;
// ~~~~~
// Property '#name' is not accessible outside class 'Person'
// because it has a private identifier.

与常规属性(即便是用 private 修饰符声明的属性)不同,私有字段有一些需要记住的规则。其中一些是:

  • 私有字段以 # 字符开头。有时我们称之为私有名称。
  • 每个私有字段名称的作用域都唯一限定在它所属的类中。
  • 像 public 或 private 这样的 TypeScript 可访问性修饰符不能用于私有字段。
  • 在所属类之外无法访问甚至无法检测到私有字段——即使是 JS 用户也不行!有时我们称之为硬隐私。

除了“硬”隐私之外,私有字段的另一个好处就是我们刚才提到的唯一性。例如,常规属性声明很容易在子类中被覆盖。

ts
class C {
foo = 10;
cHelper() {
return this.foo;
}
}
class D extends C {
foo = 20;
dHelper() {
return this.foo;
}
}
let instance = new D();
// 'this.foo' refers to the same property on each instance.
console.log(instance.cHelper()); // prints '20'
console.log(instance.dHelper()); // prints '20'

使用私有字段,你永远不必担心这一点,因为每个字段名称对其所属的类都是唯一的。

ts
class C {
#foo = 10;
cHelper() {
return this.#foo;
}
}
class D extends C {
#foo = 20;
dHelper() {
return this.#foo;
}
}
let instance = new D();
// 'this.#foo' refers to a different field within each class.
console.log(instance.cHelper()); // prints '10'
console.log(instance.dHelper()); // prints '20'

另一点值得注意的是,在任何其他类型上访问私有字段都会导致 TypeError!

ts
class Square {
#sideLength: number;
constructor(sideLength: number) {
this.#sideLength = sideLength;
}
equals(other: any) {
return this.#sideLength === other.#sideLength;
}
}
const a = new Square(100);
const b = { sideLength: 100 };
// Boom!
// TypeError: attempted to get private field on non-instance
// This fails because 'b' is not an instance of 'Square'.
console.log(a.equals(b));

最后,对于任何普通 .js 文件的用户,私有字段总是必须先声明再赋值。

js
class C {
// No declaration for '#foo'
// :(
constructor(foo: number) {
// SyntaxError!
// '#foo' needs to be declared before writing to it.
this.#foo = foo;
}
}

JavaScript 一直允许用户访问未声明的属性,而 TypeScript 一直要求类属性必须有声明。对于私有字段,无论我们处理的是 .js 还是 .ts 文件,都始终需要声明。

js
class C {
/** @type {number} */
#foo;
constructor(foo: number) {
// This works.
this.#foo = foo;
}
}

关于实现的更多信息,你可以查看最初的拉取请求

我应该用哪一种?

我们已经收到许多关于作为 TypeScript 用户应该使用哪种私有方式的问题:最常见的是,“我应该使用 private 关键字,还是 ECMAScript 的井号(#)私有字段?”这取决于具体情况!

就属性而言,TypeScript 的 private 修饰符会被完全擦除——这意味着在运行时,它的行为完全像一个普通属性,无法分辨它是用 private 修饰符声明的。使用 private 关键字时,私有性只在编译时/设计时被强制执行,对 JavaScript 使用者来说完全基于约定。

ts
class C {
private foo = 10;
}
// This is an error at compile time,
// but when TypeScript outputs .js files,
// it'll run fine and print '10'.
console.log(new C().foo); // prints '10'
// ~~~
// error! Property 'foo' is private and only accessible within class 'C'.
// TypeScript allows this at compile-time
// as a "work-around" to avoid the error.
console.log(new C()["foo"]); // prints '10'

好处在于,这种“软隐私”可以帮助你的使用者临时绕过无法访问某些 API 的限制,而且它在任何运行时中都有效。

另一方面,ECMAScript 的 # 私有成员在类之外完全无法访问。

ts
class C {
#foo = 10;
}
console.log(new C().#foo); // SyntaxError
// ~~~~
// TypeScript reports an error *and*
// this won't work at runtime!
console.log(new C()["#foo"]); // prints undefined
// ~~~~~~~~~~~~~~~
// TypeScript reports an error under 'noImplicitAny',
// and this prints 'undefined'.

这种硬隐私对于严格确保任何人都无法使用你的任何内部实现非常有用。如果你是库作者,删除或重命名私有字段永远不应导致破坏性变更。

正如我们提到的,另一个好处是使用 ECMAScript 的 # 私有成员时子类化可以更容易,因为它们确实是私有的。使用 ECMAScript # 私有字段时,任何子类都不必担心字段命名冲突。而对于 TypeScript 的 private 属性声明,用户仍然必须小心,不要践踏超类中声明的属性。

还有一点需要考虑的是你希望代码在哪里运行。目前,TypeScript 只有在目标为 ECMAScript 2015(ES6)或更高版本时才能支持此特性。这是因为我们的降级实现使用 WeakMap 来强制实现私有性,而 WeakMap 无法以不造成内存泄漏的方式进行 polyfill。相比之下,TypeScript 用 private 声明的属性适用于所有目标——甚至 ECMAScript 3!

最后一个考虑因素可能是速度:private 属性与其他任何属性没有区别,因此无论目标运行时是什么,访问它们都和其他属性访问一样快。相比之下,由于 # 私有字段是使用 WeakMap 降级实现的,使用它们可能更慢。虽然一些运行时可能会优化其 # 私有字段的实际实现,甚至拥有快速的 WeakMap 实现,但并非所有运行时都是如此。

export * as ns 语法

经常会有这样的情况:用一个单一的入口点把另一个模块的所有成员作为单个成员暴露出来。

ts
import * as utilities from "./utilities.js";
export { utilities };

这种做法如此常见,以至于 ECMAScript 2020 最近新增了一种语法来支持这一模式!

ts
export * as utilities from "./utilities.js";

这对 JavaScript 来说是一项很好的体验提升,TypeScript 3.8 实现了这种语法。当你的模块目标早于 es2020 时,TypeScript 会生成类似第一个代码片段的内容。

顶层 await

TypeScript 3.8 支持了一项便捷的、即将到来的 ECMAScript 特性,称为“顶层 await”。

JavaScript 用户常常为了使用 await 而引入一个 async 函数,然后在定义它之后立即调用该函数。

js
async function main() {
const response = await fetch("...");
const greeting = await response.text();
console.log(greeting);
}
main().catch((e) => console.error(e));

这是因为在以前的 JavaScript 中(以及大多数具有类似特性的其他语言中),await 只允许在 async 函数体内使用。而有了顶层 await,我们可以在模块的顶层使用 await。

ts
const response = await fetch("...");
const greeting = await response.text();
console.log(greeting);
// Make sure we're a module
export {};

注意这里有一个微妙之处:顶层 await 只在模块的顶层有效,而只有当 TypeScript 找到 import 或 export 时,文件才会被视为模块。在一些基础场景中,你可能需要写一句 export {} 作为样板代码来确保这一点。

顶层 await 可能无法在你目前预期的所有环境中工作。目前,只有当 target 编译器选项为 es2017 或更高,且 module 为 esnext 或 system 时,才能使用顶层 await。若干环境和打包器的支持可能有限,或需要启用实验性支持。

关于我们实现的更多信息,你可以查看最初的 pull request。

target 和 module 支持 es2020

TypeScript 3.8 支持将 es2020 作为 module 和 target 的选项。这会保留较新的 ECMAScript 2020 特性,如可选链、空值合并、export * as ns 和动态 import(...) 语法。这也意味着 bigint 字面量现在在 esnext 之下有了稳定的 target。

JSDoc 属性修饰符

TypeScript 3.8 通过开启 allowJs 标志来支持 JavaScript 文件,并且通过 checkJs 选项或在 .js 文件顶部添加 // @ts-check 注释,还支持对这些 JavaScript 文件进行类型检查。

由于 JavaScript 文件没有专门的类型检查语法,TypeScript 借助 JSDoc。TypeScript 3.8 理解几个用于属性的新 JSDoc 标签。

首先是可访问性修饰符:@public、@private 和 @protected。这些标签的作用分别与 TypeScript 中的 public、private 和 protected 完全一样。

js
// @ts-check
class Foo {
constructor() {
/** @private */
this.stuff = 100;
}
printStuff() {
console.log(this.stuff);
}
}
new Foo().stuff;
// ~~~~~
// error! Property 'stuff' is private and only accessible within class 'Foo'.
  • @public 始终是隐含的,可以省略,表示属性可以从任何地方访问。
  • @private 表示属性只能在包含它的类中使用。
  • @protected 表示属性只能在包含它的类及其所有派生子类中使用,但不能在包含该类的不同实例上使用。

接下来,我们还添加了 @readonly 修饰符,确保属性只在初始化时被写入。

js
// @ts-check
class Foo {
constructor() {
/** @readonly */
this.stuff = 100;
}
writeToStuff() {
this.stuff = 200;
// ~~~~~
// Cannot assign to 'stuff' because it is a read-only property.
}
}
new Foo().stuff++;
// ~~~~~
// Cannot assign to 'stuff' because it is a read-only property.

在 Linux 上更好地监视目录与 watchOptions

TypeScript 3.8 采用了新的目录监视策略,这对高效地捕获 node_modules 中的变化至关重要。

先介绍一点背景:在 Linux 等操作系统上,TypeScript 会在 node_modules 及其许多子目录上安装目录监视器(而非文件监视器),以检测依赖项的变化。这是因为可用文件监视器的数量往往被 node_modules 中的文件数量所压倒,而需要跟踪的目录要少得多。

旧版本的 TypeScript 会立即在文件夹上安装目录监视器,在启动时这没问题;然而在 npm install 期间,node_modules 内会发生大量活动,这可能会让 TypeScript 不堪重负,常常使编辑器会话慢如蜗牛。为防止这种情况,TypeScript 3.8 会在安装目录监视器前稍作等待,给这些高度易变的目录一些稳定下来的时间。

由于每个项目可能在不同策略下表现更好,而这种新方式也可能不适合你的工作流,TypeScript 3.8 在 tsconfig.json 和 jsconfig.json 中引入了新的 watchOptions 字段,让用户可以告诉编译器/语言服务应使用哪些监视策略来跟踪文件和目录。

{
// Some typical compiler options
"": "es2020",
"": "node"
// ...
},
// NEW: Options for file/directory watching
"watchOptions": {
// Use native file system events for files and directories
"": "useFsEvents",
"": "useFsEvents",
// Poll files for updates more frequently
// when they're updated a lot.
"": "dynamicPriority"
}
}

watchOptions 包含 4 个可配置的新选项:

  • watchFile:单个文件如何被监视的策略。可以设置为

    • fixedPollingInterval:以固定间隔每秒检查每个文件数次。
    • priorityPollingInterval:每秒检查每个文件数次,但会使用启发式方法,对某些类型的文件检查频率更低。
    • dynamicPriorityPolling:使用动态队列,修改频率较低的文件会被更少地检查。
    • useFsEvents(默认值):尝试使用操作系统/文件系统的原生事件来检测文件变化。
    • useFsEventsOnParentDirectory:尝试使用操作系统/文件系统的原生事件来监听文件所在目录的变化。这可以使用更少的文件监视器,但可能不够准确。
  • watchDirectory:在缺少递归文件监视功能的系统上监视整个目录树的策略。可以设置为:

    • fixedPollingInterval:以固定间隔每秒检查每个目录数次。
    • dynamicPriorityPolling:使用动态队列,修改频率较低的目录会被更少地检查。
    • useFsEvents(默认值):尝试使用操作系统/文件系统的原生事件来检测目录变化。
  • fallbackPolling:使用文件系统事件时,此选项指定当系统原生文件监视器用尽和/或不支持原生文件监视器时所使用的轮询策略。可以设置为

    • fixedPollingInterval:(见上文。)
    • priorityPollingInterval:(见上文。)
    • dynamicPriorityPolling:(见上文。)
    • synchronousWatchDirectory:禁用对目录的延迟监视。当可能同时发生大量文件变化时(例如运行 npm install 导致 node_modules 变化),延迟监视很有用,但在一些较少见的场景中,你可能想用此标志禁用它。

关于这些更改的更多信息,请前往 GitHub 查看 pull request了解更多。

“快速而宽松”的增量检查

TypeScript 3.8 引入了一个新的编译器选项 assumeChangesOnlyAffectDirectDependencies。启用此选项后,TypeScript 将避免重新检查/重建所有真正可能受影响的文件,而只重新检查/重建已更改的文件以及直接导入它们的文件。

例如,考虑 fileD.ts 导入 fileC.ts,fileC.ts 导入 fileB.ts,fileB.ts 导入 fileA.ts,如下所示:

fileA.ts <- fileB.ts <- fileC.ts <- fileD.ts

在 --watch 模式下,fileA.ts 的改动通常意味着 TypeScript 至少需要重新检查 fileB.ts、fileC.ts 和 fileD.ts。而在 assumeChangesOnlyAffectDirectDependencies 下,fileA.ts 的改动意味着只需重新检查 fileA.ts 和 fileB.ts。

在像 Visual Studio Code 这样的代码库中,对于某些文件的改动,这让重建时间从约 14 秒缩短到约 1 秒。虽然我们并不建议所有代码库都采用该选项,但如果你拥有极其庞大的代码库,并且愿意把整个项目的错误推迟到之后再处理(例如通过 tsconfig.fullbuild.json 做一次专门的构建,或者在 CI 中进行构建),那么你可能会对它感兴趣。

如需了解更多细节,可以查看原始拉取请求。

TypeScript 文档是一个开源项目。帮助我们改进这些页面,请发送 Pull Request ❤

最后更新:2026 年 10 月 23 日