Skip to content
赞助商
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待

TypeScript 4.7 ​

Node.js 中的 ECMAScript 模块支持 ​

在过去几年中,Node.js 一直在努力支持 ECMAScript 模块(ESM)。 这是一个非常困难的功能,因为 Node.js 生态系统构建在另一个称为 CommonJS(CJS)的模块系统之上。 两者之间的互操作性带来了巨大的挑战,需要协调许多新功能; 然而,Node.js 中对 ESM 的支持主要是在 Node.js 12 及更高版本中实现的。 在 TypeScript 4.5 左右,我们在 Node.js 中推出了仅限夜间版的 ESM 支持,以获取用户的反馈并让库作者为更广泛的支持做好准备。

TypeScript 4.7 通过两个新的 module 设置添加了此功能:node16 和 nodenext。

jsonc
{
    "compilerOptions": {
        "module": "node16",
    }
}

这些新模式带来了一些高级功能,我们将在此处探讨。

package.json 中的 type 和新扩展名 ​

Node.js 支持 package.json 中的一个新设置 叫做 type。 "type" 可以设置为 "module" 或 "commonjs"。

jsonc
{
    "name": "my-package",
    "type": "module",

    "//": "...",
    "dependencies": {
    }
}

此设置控制 .js 和 .d.ts 文件是被解释为 ES 模块还是 CommonJS 模块,未设置时默认为 CommonJS。 当一个文件被认为是 ES 模块时,与 CommonJS 相比会有一些不同的规则:

  • 可以使用 import/export 语句。
  • 可以使用顶层 await
  • 相对导入路径需要完整的扩展名(我们必须写 import "./foo.js" 而不是 import "./foo")。
  • 导入可能以不同于 node_modules 中依赖项的方式解析。
  • 某些类似全局的值(如 require 和 module)不能直接使用。
  • CommonJS 模块在某些特殊规则下被导入。

我们稍后会回到其中一些。

为了覆盖 TypeScript 在此系统中的工作方式,.ts 和 .tsx 文件现在以相同的方式工作。 当 TypeScript 找到一个 .ts、.tsx、.js 或 .jsx 文件时,它会向上查找 package.json 以查看该文件是否为 ES 模块,并使用它来确定:

  • 如何找到该文件导入的其他模块
  • 以及在生成输出时如何转换该文件

当一个 .ts 文件被编译为 ES 模块时,ECMAScript import/export 语句在 .js 输出中保持不变; 当它被编译为 CommonJS 模块时,它将产生与今天在 --module commonjs 下相同的输出。

这也意味着作为 ES 模块的 .ts 文件与作为 CJS 模块的 .ts 文件之间的路径解析方式不同。 例如,假设你今天有以下代码:

ts
// ./foo.ts
export function helper() {
    // ...
}

// ./bar.ts
import { helper } from "./foo"; // 仅在 CJS 中有效

helper();

这段代码在 CommonJS 模块中有效,但在 ES 模块中会失败,因为相对导入路径需要使用扩展名。 因此,它必须被重写为使用 foo.ts 输出的扩展名——所以 bar.ts 将不得不从 ./foo.js 导入。

ts
// ./bar.ts
import { helper } from "./foo.js"; // 在 ESM 和 CJS 中均有效

helper();

一开始这可能感觉有点麻烦,但 TypeScript 工具(如自动导入和路径补全)通常会自动为你完成。

另一件需要提及的事情是,这也适用于 .d.ts 文件。 当 TypeScript 在包中找到 .d.ts 文件时,它会根据包含的包进行解释。

新文件扩展名 ​

package.json 中的 type 字段很好,因为它允许我们继续使用 .ts 和 .js 文件扩展名,这很方便; 然而,有时你需要编写一个与 type 指定的不同的文件。 或者你可能只是更喜欢始终保持显式。

Node.js 支持两种扩展名来帮助解决这个问题:.mjs 和 .cjs。 .mjs 文件始终是 ES 模块,而 .cjs 文件始终是 CommonJS 模块,并且无法覆盖这些。

反过来,TypeScript 支持两种新的源文件扩展名:.mts 和 .cts。 当 TypeScript 将这些文件输出到 JavaScript 文件时,它将分别输出为 .mjs 和 .cjs。

此外,TypeScript 还支持两种新的声明文件扩展名:.d.mts 和 .d.cts。 当 TypeScript 为 .mts 和 .cts 生成声明文件时,它们对应的扩展名将是 .d.mts 和 .d.cts。

使用这些扩展名完全是可选的,但即使你选择不在主要工作流程中使用它们,它们也常常很有用。

CommonJS 互操作性 ​

Node.js 允许 ES 模块导入 CommonJS 模块,就好像它们是带有默认导出的 ES 模块一样。

ts
// ./foo.cts
export function helper() {
    console.log("hello world!");
}

// ./bar.mts
import foo from "./foo.cjs";

// 打印 "hello world!"
foo.helper();

在某些情况下,Node.js 还会从 CommonJS 模块合成命名导出,这可能更方便。 在这些情况下,ES 模块可以使用“命名空间风格”的导入(即 import * as foo from "..."),或命名导入(即 import { helper } from "...")。

ts
// ./foo.cts
export function helper() {
    console.log("hello world!");
}

// ./bar.mts
import { helper } from "./foo.cjs";

// 打印 "hello world!"
helper();

TypeScript 并不总能知道这些命名导出是否会被合成,但 TypeScript 在从明确是 CommonJS 模块的文件导入时会保持宽容并使用一些启发式方法。

关于互操作的一个 TypeScript 特定注意事项是以下语法:

ts
import foo = require("foo");

在 CommonJS 模块中,这简化为一个 require() 调用,而在 ES 模块中,这会导入 createRequire 来实现相同的目的。 这将使代码在浏览器等运行时(不支持 require())中的可移植性降低,但对于互操作性通常很有用。 反过来,你可以使用此语法将上面的示例编写如下:

ts
// ./foo.cts
export function helper() {
    console.log("hello world!");
}

// ./bar.mts
import foo = require("./foo.cjs");

foo.helper()

最后,值得注意的是,从 CJS 模块导入 ESM 文件的唯一方法是使用动态 import() 调用。 这可能会带来挑战,但这是今天 Node.js 中的行为。

你可以在此处阅读更多关于 Node.js 中 ESM/CommonJS 互操作的信息。

package.json 导出、导入和自引用 ​

Node.js 支持一个用于在 package.json 中定义入口点的新字段,称为 "exports"。 这个字段是定义 "main" 的更强大替代方案,可以控制包的哪些部分暴露给消费者。

以下是一个为 CommonJS 和 ESM 提供单独入口点的 package.json:

jsonc
// package.json
{
    "name": "my-package",
    "type": "module",
    "exports": {
        ".": {
            // ESM 中 `import "my-package"` 的入口点
            "import": "./esm/index.js",

            // CJS 中 `require("my-package")` 的入口点
            "require": "./commonjs/index.cjs",
        },
    },

    // 旧版本 Node.js 的 CJS 回退
    "main": "./commonjs/index.cjs",
}

这个功能有很多内容,你可以在 Node.js 文档中阅读更多信息。 这里我们将重点介绍 TypeScript 如何支持它。

使用 TypeScript 原始的 Node 支持,它会查找 "main" 字段,然后查找与该入口点对应的声明文件。 例如,如果 "main" 指向 ./lib/index.js,TypeScript 会查找名为 ./lib/index.d.ts 的文件。 包作者可以通过指定一个单独的 "types" 字段来覆盖(例如 "types": "./types/index.d.ts")。

新的支持与导入条件的工作方式类似。 默认情况下,TypeScript 叠加了相同的导入条件规则——如果你从 ES 模块编写 import,它将查找 import 字段,而如果从 CommonJS 模块,它将查看 require 字段。 如果找到它们,它将查找相应的声明文件。 如果你需要为类型声明指向不同的位置,可以添加一个 "types" 导入条件。

jsonc
// package.json
{
    "name": "my-package",
    "type": "module",
    "exports": {
        ".": {
            // ESM 中 `import "my-package"` 的入口点
            "import": {
                // TypeScript 将查找的位置。
                "types": "./types/esm/index.d.ts",

                // Node.js 将查找的位置。
                "default": "./esm/index.js"
            },
            // CJS 中 `require("my-package")` 的入口点
            "require": {
                // TypeScript 将查找的位置。
                "types": "./types/commonjs/index.d.cts",

                // Node.js 将查找的位置。
                "default": "./commonjs/index.cjs"
            },
        }
    },

    // 旧版本 TypeScript 的回退
    "types": "./types/index.d.ts",

    // 旧版本 Node.js 的 CJS 回退
    "main": "./commonjs/index.cjs"
}

"types" 条件在 "exports" 中应始终放在第一位。

重要的是要注意,CommonJS 入口点和 ES 模块入口点各自需要自己的声明文件,即使它们之间的内容相同。 每个声明文件根据其文件扩展名和 package.json 的 "type" 字段被解释为 CommonJS 模块或 ES 模块,并且此检测到的模块种类必须与 Node 将为相应 JavaScript 文件检测到的模块种类匹配,以使类型检查正确。 尝试使用单个 .d.ts 文件来为 ES 模块入口点和 CommonJS 入口点提供类型将导致 TypeScript 认为只有其中一个入口点存在,从而给包的消费者带来编译器错误。

TypeScript 还以类似的方式支持 package.json 的 "imports" 字段,通过查找与相应文件一起的声明文件,并支持包自引用自身。 这些功能通常设置起来不那么复杂,但也得到支持。

期待您的反馈! ​

在我们继续开发 TypeScript 4.7 的过程中,我们期望看到更多关于此功能的文档和完善。 支持这些新功能是一项雄心勃勃的任务,这就是为什么我们正在寻求早期反馈! 请尝试一下,并告诉我们它对你来说效果如何。

有关更多信息,你可以在此处查看实现 PR。

模块检测控制 ​

将模块引入 JavaScript 的一个问题是现有“脚本”代码和新模块代码之间的模糊性。 模块中的 JavaScript 代码运行略有不同,并且具有不同的作用域规则,因此工具必须决定每个文件的运行方式。 例如,Node.js 要求模块入口点写在 .mjs 中,或者附近有一个带有 "type": "module" 的 package.json。 每当 TypeScript 在文件中发现任何 import 或 export 语句时,它就会将该文件视为模块,否则将假定 .ts 或 .js 文件是在全局作用域上操作的脚本文件。

这与 Node.js 的行为不太匹配,因为 package.json 可以更改文件的格式,或者 --jsx 设置 react-jsx 会使任何 JSX 文件包含对 JSX 工厂的隐式导入。 这也与现代期望不匹配,因为大多数新的 TypeScript 代码都是考虑模块而编写的。

这就是为什么 TypeScript 4.7 引入了一个名为 moduleDetection 的新选项。 moduleDetection 可以采用 3 个值:"auto"(默认)、"legacy"(与 4.6 及更早版本相同的行为)和 "force"。

在 "auto" 模式下,TypeScript 不仅会查找 import 和 export 语句,还会检查

  • 在 --module nodenext/--module node16 下运行时,package.json 中的 "type" 字段是否设置为 "module",以及
  • 在 --jsx react-jsx 下运行时,当前文件是否为 JSX 文件

如果你希望每个文件都被视为模块,"force" 设置可确保每个非声明文件都被视为模块。 无论 module、moduleResolution 和 jsx 如何配置,都将如此。

同时,"legacy" 选项只是回到旧行为,仅通过寻找 import 和 export 语句来确定文件是否为模块。

你可以在拉取请求上阅读更多关于此更改的信息。

括号元素访问的控制流分析 ​

TypeScript 4.7 现在在索引键是字面量类型和唯一符号时收窄元素访问的类型。 例如,考虑以下代码:

ts
const key = Symbol();

const numberOrString = Math.random() < 0.5 ? 42 : "hello";

const obj = {
    [key]: numberOrString,
};

if (typeof obj[key] === "string") {
    let str = obj[key].toUpperCase();
}

以前,TypeScript 不会考虑对 obj[key] 的任何类型保护,并且不知道 obj[key] 实际上是一个 string。 相反,它会认为 obj[key] 仍然是 string | number,并且访问 toUpperCase() 会触发错误。

TypeScript 4.7 现在知道 obj[key] 是一个字符串。

这也意味着在 --strictPropertyInitialization 下,TypeScript 可以正确检查计算属性是否在构造函数体结束时初始化。

ts
// 'key' 的类型为 'unique symbol'
const key = Symbol();

class C {
    [key]: string;

    constructor(str: string) {
        // 哎呀,忘了设置 'this[key]'
    }

    screamString() {
        return this[key].toUpperCase();
    }
}

在 TypeScript 4.7 下,--strictPropertyInitialization 报告一个错误,告诉我们 [key] 属性在构造函数结束时没有被明确赋值。

我们要感谢 Oleksandr Tarasiuk 提供了此更改!

改进的对象和方法中的函数推断 ​

TypeScript 4.7 现在可以对对象和数组中的函数执行更细粒度的推断。 这允许这些函数的类型以从左到右的方式一致地流动,就像普通参数一样。

ts
declare function f<T>(arg: {
    produce: (n: string) => T,
    consume: (x: T) => void }
): void;

// 有效
f({
    produce: () => "hello",
    consume: x => x.toLowerCase()
});

// 有效
f({
    produce: (n: string) => n,
    consume: x => x.toLowerCase(),
});

// 以前是错误,现在有效。
f({
    produce: n => n,
    consume: x => x.toLowerCase(),
});

// 以前是错误,现在有效。
f({
    produce: function () { return "hello"; },
    consume: x => x.toLowerCase(),
});

// 以前是错误,现在有效。
f({
    produce() { return "hello" },
    consume: x => x.toLowerCase(),
});

在某些示例中,推断失败是因为知道其 produce 函数的类型会间接地请求 arg 的类型,然后才能为 T 找到好的类型。 TypeScript 现在收集可能有助于推断 T 类型的函数,并延迟从它们推断。

有关更多信息,你可以查看我们对推断过程的具体修改。

实例化表达式 ​

有时函数可能比我们想要的更通用。 例如,假设我们有一个 makeBox 函数。

ts
interface Box<T> {
    value: T;
}

function makeBox<T>(value: T) {
    return { value };
}

也许我们想创建一组更专门的函数来创建 Wrench 和 Hammer 的 Box。 今天要这样做,我们必须将 makeBox 包装在其他函数中,或者为 makeBox 的别名使用显式类型。

ts
function makeHammerBox(hammer: Hammer) {
    return makeBox(hammer);
}

// 或...

const makeWrenchBox: (wrench: Wrench) => Box<Wrench> = makeBox;

这些方法有效,但包装对 makeBox 的调用有点浪费,并且编写 makeWrenchBox 的完整签名可能变得笨拙。 理想情况下,我们希望能够在替换其签名中所有泛型的同时,简单地给 makeBox 起一个别名。

TypeScript 4.7 正是允许这样做的! 我们现在可以获取函数和构造函数,并直接为它们提供类型参数。

ts
const makeHammerBox = makeBox<Hammer>;
const makeWrenchBox = makeBox<Wrench>;

因此,我们可以专门化 makeBox 以接受更具体的类型并拒绝其他任何类型。

ts
const makeStringBox = makeBox<string>;

// TypeScript 正确地拒绝了这一点。
makeStringBox(42);

此逻辑也适用于构造函数,如 Array、Map 和 Set。

ts
// 具有类型 `new () => Map<string, Error>`
const ErrorMap = Map<string, Error>;

// 具有类型 `// Map<string, Error>`
const errorMap = new ErrorMap();

当函数或构造函数被赋予类型参数时,它将生成一个新类型,该类型保留所有具有兼容类型参数列表的签名,并将相应的类型参数替换为给定的类型参数。 任何其他签名都将被丢弃,因为 TypeScript 会假设它们不打算被使用。

有关此功能的更多信息,请查看拉取请求。

infer 类型变量上的 extends 约束 ​

条件类型有点像是高级用户的功能。 它们允许我们匹配和推断类型的形状,并基于它们做出决策。 例如,我们可以编写一个条件类型,如果元组类型是类似 string 的类型,则返回其第一个元素。

ts
type FirstIfString<T> =
    T extends [infer S, ...unknown[]]
        ? S extends string ? S : never
        : never;

 // string
type A = FirstIfString<[string, number, number]>;

// "hello"
type B = FirstIfString<["hello", number, number]>;

// "hello" | "world"
type C = FirstIfString<["hello" | "world", boolean]>;

// never
type D = FirstIfString<[boolean, number, string]>;

FirstIfString 匹配任何至少有一个元素的元组,并将第一个元素的类型作为 S 捕获。 然后它检查 S 是否与 string 兼容,如果是则返回该类型。

请注意,我们必须使用两个条件类型来编写这个。 我们可以将 FirstIfString 编写如下:

ts
type FirstIfString<T> =
    T extends [string, ...unknown[]]
        // 从 `T` 中获取第一个类型
        ? T[0]
        : never;

这有效,但它更“手动”,声明性稍差。 我们不是仅仅在类型上进行模式匹配并为第一个元素命名,而是必须用 T[0] 取出 T 的第 0 个元素。 如果我们处理的是比元组更复杂的类型,这可能会变得更棘手,因此 infer 可以简化事情。

使用嵌套条件类型来推断类型然后匹配该推断类型是很常见的。 为了避免第二层嵌套,TypeScript 4.7 现在允许你在任何 infer 类型上放置约束。

ts
type FirstIfString<T> =
    T extends [infer S extends string, ...unknown[]]
        ? S
        : never;

这样,当 TypeScript 匹配 S 时,它也确保 S 必须是 string。 如果 S 不是 string,它会走 false 路径,在这些情况下是 never。

有关更多详细信息,你可以在 GitHub 上阅读有关更改的信息。

类型参数的可选方差注解 ​

让我们看以下类型。

ts
interface Animal {
    animalStuff: any;
}

interface Dog extends Animal {
    dogStuff: any;
}

// ...

type Getter<T> = () => T;

type Setter<T> = (value: T) => void;

假设我们有两个不同的 Getter 实例。 确定任意两个不同的 Getter 是否可以相互替换完全取决于 T。 在判断 Getter<Dog> → Getter<Animal> 的赋值是否有效时,我们必须检查 Dog → Animal 是否有效。 因为 T 的每个类型都以相同的“方向”关联,我们说 Getter 类型在 T 上是协变的。 另一方面,检查 Setter<Dog> → Setter<Animal> 是否有效涉及检查 Animal → Dog 是否有效。 这种方向的“翻转”有点像数学中检查 −x < −y 与检查 y < x 相同。 当我们必须像这样翻转方向来比较 T 时,我们说 Setter 在 T 上是逆变的。

使用 TypeScript 4.7,我们现在能够显式地指定类型参数上的方差。

因此,现在如果我们想明确 Getter 在 T 上是协变的,我们可以给它一个 out 修饰符。

ts
type Getter<out T> = () => T;

类似地,如果我们还想明确 Setter 在 T 上是逆变的,我们可以给它一个 in 修饰符。

ts
type Setter<in T> = (value: T) => void;

这里使用 out 和 in 是因为类型参数的方差取决于它是用在输出还是输入位置。 与其考虑方差,不如只考虑 T 是否用在输出和输入位置。

也有同时使用 in 和 out 的情况。

ts
interface State<in out T> {
    get: () => T;
    set: (value: T) => void;
}

当 T 同时用在输出和输入位置时,它变得不变。 两个不同的 State<T> 不能互换,除非它们的 T 相同。 换句话说,State<Dog> 和 State<Animal> 不能相互替换。

现在从技术上讲,在纯结构类型系统中,类型参数及其方差并不真正重要——你只需将类型插入每个类型参数的位置,并检查每个匹配成员在结构上是否兼容。 那么,如果 TypeScript 使用结构类型系统,我们为什么对类型参数的方差感兴趣? 我们为什么想要注释它们?

一个原因是,对于读者来说,能够一眼看到类型参数的使用方式可能很有用。 对于更复杂的类型,可能很难判断一个类型是用于读取、写入还是两者兼有。 如果我们忘记说明该类型参数的使用方式,TypeScript 也会帮助我们。 例如,如果我们忘记在 State 上同时指定 in 和 out,我们会得到一个错误。

ts
interface State<out T> {
    //          ~~~~~
    // 错误!
    // 类型 'State<sub-T>' 不能赋值给类型 'State<super-T>',如方差注释所暗示。
    //   属性 'set' 的类型不兼容。
    //     类型 '(value: sub-T) => void' 不能赋值给类型 '(value: super-T) => void'。
    //       参数 'value' 和 'value' 的类型不兼容。
    //         类型 'super-T' 不能赋值给类型 'sub-T'。
    get: () => T;
    set: (value: T) => void;
}

另一个原因是精度和速度! TypeScript 已经尝试将类型参数的方差推断作为一种优化。 通过这样做,它可以在合理的时间内对较大的结构类型进行类型检查。 提前计算方差使类型检查器能够跳过更深的比较,而只需比较类型参数,这比一遍又一遍地比较类型的完整结构快得多。 但通常存在这种情况,这种计算仍然相当昂贵,并且计算可能会发现无法准确解析的循环,这意味着类型的方差没有明确的答案。

ts
type Foo<T> = {
    x: T;
    f: Bar<T>;
}

type Bar<U> = (x: Baz<U[]>) => void;

type Baz<V> = {
    value: Foo<V[]>;
}

declare let foo1: Foo<unknown>;
declare let foo2: Foo<string>;

foo1 = foo2;  // 应该是错误,但不是 ❌
foo2 = foo1;  // 错误 - 正确 ✅

提供显式注解可以加速这些循环处的类型检查并提供更好的准确性。 例如,在上面的例子中将 T 标记为不变有助于阻止有问题的赋值。

diff
- type Foo<T> = {
+ type Foo<in out T> = {
      x: T;
      f: Bar<T>;
  }

我们不一定建议为每个类型参数注释其方差; 例如,使方差比必要的更严格是可能的(但不推荐),所以如果它实际上是协变、逆变甚至独立,TypeScript 不会阻止你将某物标记为不变。 因此,如果你选择添加显式方差标记,我们鼓励你深思熟虑并精确地使用它们。

但如果你正在处理深度递归类型,尤其是如果你是库作者,你可能有兴趣使用这些注释来让你的用户受益。 这些注释可以在精度和类型检查速度方面带来好处,甚至可以影响他们的代码编辑体验。 确定方差计算何时是类型检查时间的瓶颈可以通过实验进行,并使用像我们的 analyze-trace 工具来确定。

有关此功能的更多详细信息,你可以阅读拉取请求。

使用 moduleSuffixes 自定义解析 ​

TypeScript 4.7 现在支持 moduleSuffixes 选项来自定义模块说明符的查找方式。

jsonc
{
    "compilerOptions": {
        "moduleSuffixes": [".ios", ".native", ""]
    }
}

给定上述配置,如下所示的导入...

ts
import * as foo from "./foo";

将尝试查看相对文件 ./foo.ios.ts、./foo.native.ts,最后是 ./foo.ts。

此功能对于 React Native 项目很有用,其中每个目标平台可以使用具有不同 moduleSuffixes 的单独 tsconfig.json。

moduleSuffixes 选项由 Adam Foxman 贡献!

resolution-mode ​

使用 Node 的 ECMAScript 解析,包含文件的模式和使用的语法决定了导入的解析方式; 但是,从 ECMAScript 模块引用 CommonJS 模块的类型,反之亦然,将很有用。

TypeScript 现在允许 /// <reference types="..." /> 指令。

ts
/// <reference types="pkg" resolution-mode="require" />

// 或

/// <reference types="pkg" resolution-mode="import" />

此外,在 TypeScript 的夜间版本中,import type 可以指定导入断言来实现类似的功能。

ts
// 就像使用 `require()` 导入一样解析 `pkg`
import type { TypeFromRequire } from "pkg" assert {
    "resolution-mode": "require"
};

// 就像使用 `import` 导入一样解析 `pkg`
import type { TypeFromImport } from "pkg" assert {
    "resolution-mode": "import"
};

export interface MergedType extends TypeFromRequire, TypeFromImport {}

这些导入断言也可以在 import() 类型上使用。

ts
export type TypeFromRequire =
    import("pkg", { assert: { "resolution-mode": "require" } }).TypeFromRequire;

export type TypeFromImport =
    import("pkg", { assert: { "resolution-mode": "import" } }).TypeFromImport;

export interface MergedType extends TypeFromRequire, TypeFromImport {}

import type 和 import() 语法仅在 TypeScript 的夜间构建中支持 resolution-mode。 你可能会收到如下错误:

Resolution mode assertions are unstable. Use nightly TypeScript to silence this error. Try updating with 'npm install -D typescript@next'.

如果你确实在 TypeScript 的夜间版本中使用此功能,请考虑就此问题提供反馈。

你可以查看相应的更改对于引用指令和对于类型导入断言。

跳转到源定义 ​

TypeScript 4.7 包含对一个名为跳转到源定义的新实验性编辑器命令的支持。 它类似于跳转到定义,但从不返回声明文件内的结果。 相反,它尝试查找相应的实现文件(如 .js 或 .ts 文件),并在那里找到定义——即使这些文件通常被 .d.ts 文件遮蔽。

当你需要查看从库导入的函数的实现,而不是其在 .d.ts 文件中的类型声明时,这通常非常有用。

在 yargs 包的使用上使用“跳转到源定义”命令,编辑器跳转到 yargs 中的 index.cjs 文件。

你可以在最新版本的 Visual Studio Code 中尝试这个新命令。 但请注意,此功能仍处于预览阶段,并且有一些已知限制。 在某些情况下,TypeScript 使用启发式方法来猜测哪个 .js 文件对应于定义的给定结果,因此这些结果可能不准确。 Visual Studio Code 也尚未指示结果是否为猜测,但这是我们正在合作的事情。

你可以在我们的专用反馈问题上留下关于该功能的反馈、阅读已知限制或了解更多信息。

分组感知的组织导入 ​

TypeScript 为 JavaScript 和 TypeScript 提供了组织导入编辑器功能。 不幸的是,它可能有点生硬,并且通常只会天真地对导入语句进行排序。

例如,如果你在以下文件上运行组织导入...

ts
// 本地代码
import * as bbb from "./bbb";
import * as ccc from "./ccc";
import * as aaa from "./aaa";

// 内置模块
import * as path from "path";
import * as child_process from "child_process"
import * as fs from "fs";

// 一些代码...

你会得到类似以下的内容

ts
// 本地代码
import * as child_process from "child_process";
import * as fs from "fs";
// 内置模块
import * as path from "path";
import * as aaa from "./aaa";
import * as bbb from "./bbb";
import * as ccc from "./ccc";


// 一些代码...

这...不理想。 当然,我们的导入是按路径排序的,并且我们的注释和换行符被保留,但并不是我们期望的方式。 很多时候,如果我们以特定方式对导入进行分组,那么我们希望保持这种分组。

TypeScript 4.7 以分组感知的方式执行组织导入。 在上面的代码上运行它看起来更像你期望的那样:

ts
// 本地代码
import * as aaa from "./aaa";
import * as bbb from "./bbb";
import * as ccc from "./ccc";

// 内置模块
import * as child_process from "child_process";
import * as fs from "fs";
import * as path from "path";

// 一些代码...

我们要感谢 Minh Quy 提供了此功能。

对象方法代码片段补全 ​

TypeScript 现在为对象字面量方法提供代码片段补全。 当在对象中补全成员时,TypeScript 将仅为方法名称提供一个典型的补全条目,以及为完整的方法定义提供一个单独的补全条目!

从对象补全一个完整的方法签名

有关更多详细信息,请参阅实现拉取请求。

破坏性更改 ​

lib.d.ts 更新 ​

虽然 TypeScript 努力避免重大破坏,但即使内置库中的微小更改也可能导致问题。 我们预计 DOM 和 lib.d.ts 更新不会导致重大破坏,但可能会有一些小问题。

JSX 中更严格的展开检查 ​

在 JSX 中编写 ...spread 时,TypeScript 现在强制执行更严格的检查,即给定类型实际上是一个对象。 因此,具有 unknown 和 never 类型的值(以及更罕见的,仅仅是 null 和 undefined)不能再展开到 JSX 元素中。

所以对于以下示例:

tsx
import * as React from "react";

interface Props {
    stuff?: string;
}

function MyComponent(props: unknown) {
    return <div {...props} />;
}

你现在将收到如下错误:

Spread types may only be created from object types.

这使得此行为与对象字面量中的展开更加一致。

有关更多详细信息,请查看 GitHub 上的更改。

模板字符串表达式的更严格检查 ​

当在模板字符串中使用 symbol 值时,它将在 JavaScript 中触发运行时错误。

js
let str = `hello ${Symbol()}`;
// TypeError: Cannot convert a Symbol value to a string

因此,TypeScript 也会发出错误; 然而,TypeScript 现在还检查以某种方式约束为 symbol 的泛型值是否在模板字符串中使用。

ts
function logKey<S extends string | symbol>(key: S): S {
    // 现在是错误。
    console.log(`${key} is the key`);
    return key;
}

function get<T, K extends keyof T>(obj: T, key: K) {
    // 现在是错误。
    console.log(`Grabbing property '${key}'.`);
    return obj[key];
}

TypeScript 现在将发出以下错误:

Implicit conversion of a 'symbol' to a 'string' will fail at runtime. Consider wrapping this expression in 'String(...)'.

在某些情况下,你可以通过将表达式包装在对 String 的调用中来解决此问题,正如错误消息所建议的那样。

ts
function logKey<S extends string | symbol>(key: S): S {
    // 不再是错误。
    console.log(`${String(key)} is the key`);
    return key;
}

在其他情况下,此错误过于迂腐,并且在 keyof 时你可能根本不在乎是否允许 symbol 键。 在这种情况下,你可以切换到 string & keyof ...:

ts
function get<T, K extends string & keyof T>(obj: T, key: K) {
    // 不再是错误。
    console.log(`Grabbing property '${key}'.`);
    return obj[key];
}

有关更多信息,你可以查看实现拉取请求。

LanguageServiceHost 上的 readFile 方法不再是可选的 ​

如果你正在创建 LanguageService 实例,那么提供的 LanguageServiceHost 将需要提供 readFile 方法。 此更改对于支持新的 moduleDetection 编译器选项是必要的。

你可以在此处阅读有关更改的更多信息。

readonly 元组具有 readonly 的 length 属性 ​

readonly 元组现在将其 length 属性视为 readonly。 对于固定长度的元组,这几乎从未被观察到,但对于具有尾随可选和剩余元素类型的元组来说,这是一个可以被观察到的疏忽。

因此,以下代码现在将失败:

ts
function overwriteLength(tuple: readonly [string, string, string]) {
    // 现在报错。
    tuple.length = 7;
}

你可以在此处阅读有关此更改的更多信息。