TypeScript 5.7:一大波新特性来袭!

科技   2024-11-17 21:05   福建  

最近 TypeScript 5.7 发布了 RC 版本,其中包括了一大波新特性和优化措施,下面我们一起来学习下。

未初始化变量检查

在 TypeScript 中,对于未初始化的变量,长期以来编译器已经可以捕获到一些问题了,特别是在所有前置分支中变量尚未初始化的情况下。

例如,对于下面这段代码:

let result: number;
if (someCondition()) {
    result = doSomeWork();
else {
    let temporaryWork = doSomeWork();
    temporaryWork *= 2;
    // 忘记给 result 赋值了
}

console.log(result); // error: Variable 'result' is used before being assigned.

在这段代码中,由于 result 在所有可能的路径中并没有被保证初始化,因此编译器会报错,这一点 TypeScript 已经支持了很长时间。

但是,在有些情况下,分析就不那么准确了。例如,当变量在一个单独的函数中被访问时,类型系统无法确定该函数何时会被调用,往往会乐观地认为变量已经被初始化。

以下是这样的例子:

function foo({
    let result: number;
    if (someCondition()) {
        result = doSomeWork();
    } else {
        let temporaryWork = doSomeWork();
        temporaryWork *= 2;
        // 忘记给 result 赋值了
    }

    printResult();

    function printResult({
        console.log(result); // 这里不会报错
    }
}

在上述代码中,虽然 printResult 函数内部使用了 result 变量,但由于类型系统无法判断 printResult 何时会被调用,它会默认认为 result 已经被初始化,而不会报错。

但是,在 TypeScript 5.7 中,即使变量可能已经被初始化,类型系统也会在变量完全未初始化的情况下报告错误。来看以下例子:

function foo({
    let result: number;
    
    // 执行操作,但忘记给 result 赋值

    function printResult({
        console.log(result); // error: Variable 'result' is used before being assigned.
    }
}

在这段代码中,由于 result 在任何执行路径中都没有被赋值,类型系统会明确报错,指出未被初始化的变量使用了,这样可以帮助开发者更早发现程序中的潜在问题。

相对路径重写

现在有许多工具和运行时支持直接运行 TypeScript 代码,这意味着它们不需要生成输出 JavaScript 文件的构建步骤。例如,ts-nodetsxDenoBun 都支持直接运行 .ts 文件。最近,Node.js 也在探讨这种支持,例如通过 --experimental-transform-types--experimental-strip-types 选项。这样做非常方便,因为它让我们可以更快速地迭代,而无需担心重新运行构建任务。

但是,当使用这些模式时,有一些复杂性需要注意。为了最大限度地兼容所有这些工具,直接导入的 TypeScript 文件必须在运行时使用适当的 TypeScript 扩展名。例如,要导入一个名为 foo.ts 的文件,我们需要在 Node.js 的新实验性支持中这样写:

// main.ts

import * as foo from "./foo.ts"// <- 这里需要 foo.ts,而不是 foo.js

通常情况下,如果我们这样写,TypeScript 会报错,因为它期望我们导入的是输出文件。由于有些工具允许 .ts 导入,TypeScript 早已通过一个名为 --allowImportingTsExtensions 的选项支持这种导入方式。这种做法虽然可行,但如果我们需要真正将这些 .ts 文件生成 .js 文件呢?这对于那些需要分发 .js 文件的库作者来说是一个需求。但在此之前,TypeScript 一直避免重写任何路径。

为了支持这种情况,我们新增了一个编译选项 --rewriteRelativeImportExtensions。当导入路径是相对路径(以 ./../ 开头),以 TypeScript 扩展名(.ts.tsx.mts.cts)结尾,并且是非声明文件时,编译器会将路径重写为相应的 JavaScript 扩展名(.js.jsx.mjs.cjs)。

// 在使用 --rewriteRelativeImportExtensions 选项时...

// 这些路径会被重写。
import * as foo from "./foo.ts";
import * as bar from "../someFolder/bar.mts";

// 这些路径不会以任何方式被重写。
import * as a from "./foo";
import * as b from "some-package/file.ts";
import * as c from "@some-scope/some-package/file.ts";
import * as d from "#/file.ts";
import * as e from "./file.js";

以上的特性让我们可以编写 TypeScript 代码 "原地" 运行,并在准备好后将其编译为 JavaScript。

需要注意的是,TypeScript 通常避免重写路径。原因有很多,但最明显的一个原因是动态导入。如果开发者写了如下代码,处理导入接收的路径就不是那么简单的事了。实际上,不可能在任何依赖项中重写 import 的行为。

function getPath({
    if (Math.random() < 0.17) {
        return "./foo.ts";
    } else {
        return "./foo.js";
    }
}

let myImport = await import(getPath());

还有一个问题是(如上所述)只有相对路径会被重写,并且它们是“天真地”被重写。这意味着任何依赖 TypeScript 的 baseUrlpaths 的路径将不会被重写:

// tsconfig.json

{
    "compilerOptions": {
        "module""nodenext",
        // ...
        "paths": {
            "@/*": ["./src/*"]
        }
    }
}
// 不会被转换,不会生效。
import * as utilities from "@/utilities.ts";

同样,任何可能通过 package.jsonexportsimports 字段解析的路径也不会被重写:

// package.json
{
    "name""my-package",
    "imports": {
        "#root/*""./dist/*"
    }
}
// 不会被转换,不会生效。
import * as utilities from "#root/utilities.ts";

因此,如果你一直在使用带有多个包相互引用的工作空间式布局,可能需要使用带有作用范围的条件导出,以使其生效:

// my-package/package.json

{
    "name""my-package",
    "exports": {
        ".": {
            "@my-package/development""./src/index.ts",
            "import""./lib/index.js"
        },
        "./*": {
            "@my-package/development""./src/*.ts",
            "import""./lib/*.js"
        }
    }
}

每次你想要导入 .ts 文件时,可以使用 node --conditions=@my-package/development 运行。

注意我们为条件使用的作用域 @my-package/development。这是为了避免与可能也使用 development 条件的依赖项发生冲突而做出的权宜之计。如果每个包都在他们的包里导出 development,那么解析过程可能会尝试解析 .ts 文件,这不一定能成功。

支持 --target es2024 和 --lib es2024

TypeScript 5.7 现在支持 --target es2024,这意味着用户可以将目标运行时定为 ECMAScript 2024。这一特性主要是为了启用新的 --lib es2024 选项,该选项包含了许多新特性,例如 SharedArrayBufferArrayBufferObject.groupByMap.groupByPromise.withResolvers 等等。此外,Atomics.waitAsync 也从 --lib es2022 移动到了 --lib es2024

需要注意的是,由于 SharedArrayBuffer 和 ArrayBuffer 的变化,它们现在有些不同了。为了弥合这个差距并且保留底层的缓冲区类型,所有类型化数组(例如 Uint8Array 等)现在也变成了泛型。

interface Uint8Array<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike> {
    // ...
}

每个类型化数组现在包含一个名为 TArrayBuffer 的类型参数,虽然这个类型参数有一个默认的类型参数,这样我们仍然可以使用 Int32Array 而不必显式地写成 Int32Array<ArrayBufferLike>

上层配置文件搜索

当使用 TSServer(如 Visual Studio 或 VS Code)在编辑器中加载 TypeScript 文件时,编辑器会尝试找到 "拥有" 该文件的相关 tsconfig.json 文件。为此,它会从正在编辑的文件所在的目录开始向上遍历目录树,寻找任何名为 tsconfig.json 的文件。

以前,这个搜索会在找到第一个 tsconfig.json 文件时停止;但是,想象一下如下的项目结构:

project/
├── src/
│ ├── foo.ts
│ ├── foo-test.ts
│ ├── tsconfig.json
│ └── tsconfig.test.json
└── tsconfig.json

在这个例子中,src/tsconfig.json 是项目的主要配置文件,而 src/tsconfig.test.json 是用于运行测试的配置文件。

// src/tsconfig.json
{
    "compilerOptions": {
        "outDir""../dist"
    },
    "exclude": ["**/*.test.ts"]
}
// src/tsconfig.test.json
{
    "compilerOptions": {
        "outDir""../dist/test"
    },
    "include": ["**/*.test.ts"],
    "references": [
        { "path""./tsconfig.json" }
    ]
}
// tsconfig.json
{
    "files": [],
    "references": [
        { "path""./src/tsconfig.json" },
        { "path""./src/tsconfig.test.json" }
    ]
}

问题在于,当编辑 foo-test.ts 时,编辑器会找到 project/src/tsconfig.json 作为 "所有" 配置文件,但这并不是我们想要的。如果搜索在这里停止,这可能不是理想的结果。以前,唯一的解决办法是将 src/tsconfig.json 重命名为 src/tsconfig.src.json,这样所有文件都会找到顶层的 tsconfig.json,它引用了所有可能的项目。

project/
├── src/
│ ├── foo.ts
│ ├── foo-test.ts
│ ├── tsconfig.src.json
│ └── tsconfig.test.json
└── tsconfig.json

为了不让开发者必须这样做,TypeScript 5.7 现在会继续向上遍历目录树,在编辑器场景中找到其他合适的 tsconfig.json 文件。这样可以为项目的组织和配置文件的结构提供更多的灵活性。

在编辑器中更快地检查复杂项目的归属

想象一下,我们有一个大型代码库,结构如下:

packages
├── graphics/
│ ├── tsconfig.json
│ └── src/
│ └── ...
├── sound/
│ ├── tsconfig.json
│ └── src/
│ └── ...
├── networking/
│ ├── tsconfig.json
│ └── src/
│ └── ...
├── input/
│ ├── tsconfig.json
│ └── src/
│ └── ...
└── app/
├── tsconfig.json
├── some-script.js
└── src/
└── ...

每个 packages 目录中的文件夹都是一个独立的 TypeScript 项目,app 目录是主项目,依赖于所有其他项目。

// app/tsconfig.json
{
    "compilerOptions": {
        // ...
    },
    "include": ["src"],
    "references": [
        { "path""../graphics/tsconfig.json" },
        { "path""../sound/tsconfig.json" },
        { "path""../networking/tsconfig.json" },
        { "path""../input/tsconfig.json" }
    ]
}

注意我们在 app 目录中有一个文件 some-script.js。当我们在编辑器中打开 some-script.js 时,TypeScript 语言服务(也处理 JavaScript 文件的编辑器体验!)需要确定该文件属于哪个项目,以便应用正确的设置。

在这种情况下,最近的 tsconfig.json 并未包含 some-script.js,但 TypeScript 将继续询问:“app/tsconfig.json 引用的某个项目是否包含 some-script.js?”为了做到这一点,以前 TypeScript 会逐一加载每个项目,并在找到包含 some-script.js 的项目时停止。即使 some-script.js 不包含在根文件集中,TypeScript 仍会解析项目中的所有文件,因为根文件集中的某些文件可能间接引用 some-script.js

在长时间的实践中,我们发现这种行为会在大型代码库中导致极端且不可预测的行为。开发人员会打开偶然的脚本文件,但发现整个代码库都被加载了,等待时间异常漫长。

幸运的是,任何可以被另一个(非工作区)项目引用的项目必须启用一个名为 composite 的标志,该标志强制要求所有输入源文件必须在创建项目时已知。因此,当探测复合项目时,TypeScript 5.7 只会检查文件是否属于该项目的根文件集。这应能避免常见的最坏情况行为的发生。

--module nodenext 模式下的 JSON 模块导入验证

--module nodenext 模式下从 .json 文件导入时,TypeScript 现在会强制执行某些规则,以防止运行时错误。

例如,导入 JSON 文件时,必须包含 type: "json" 的导入属性:

import myConfig from "./myConfig.json";
//                   ~~~~~~~~~~~~~~~~~
// ❌ error: Importing a JSON file into an ECMAScript module requires a 'type: "json"' import attribute when 'module' is set to 'NodeNext'.

import myConfig from "./myConfig.json" with { type"json" };
//                                          ^^^^^^^^^^^^^^^^
// ✅ 这样是正确的,因为我们提供了 `type: "json"`

除此之外,TypeScript 不会生成 "具名" 导出,JSON 导入的内容只能通过默认导出来访问。

// ✅ 这样是可以的:
import myConfigA from "./myConfig.json" with { type"json" };
let version = myConfigA.version;

///////////

import * as myConfigB from "./myConfig.json" with { type"json" };

// ❌ 这样是不行的:
let version = myConfig.version;

// ✅ 这样是可以的:
let version = myConfig.default.version;

支持 Node.js 中的 V8 编译缓存

Node.js 22 支持一个新 API module.enableCompileCache()。这个 API 允许运行时重用第一次运行工具后的解析和编译工作的一部分。

TypeScript 5.7 现在利用了这个 API,使其能够更快地开始做有用的工作。在我们自己的一些测试中,我们发现运行 tsc --version 的速度提升了约 2.5 倍。

Benchmark 1: node ./built/local/_tsc.js --version (*without* caching)
Time (mean ± σ): 122.2 ms ± 1.5 ms [User: 101.7 ms, System: 13.0 ms]
Range (min … max): 119.3 ms … 132.3 ms 200 runs

Benchmark 2: node ./built/local/tsc.js --version (*with* caching)
Time (mean ± σ): 48.4 ms ± 1.0 ms [User: 34.0 ms, System: 11.1 ms]
Range (min … max): 45.7 ms … 52.8 ms 200 runs

Summary
node ./built/local/tsc.js --version ran
2.52 ± 0.06 times faster than node ./built/local/_tsc.js --version

TypedArrays 现在是 ArrayBufferLike 的泛型

在 ECMAScript 2024 中,SharedArrayBufferArrayBuffer 的类型略有不同。为了弥合这一差距并保留底层的缓冲类型,所有类型化数组(如 Uint8Array 等)现在也是泛型。

interface Uint8Array<TArrayBuffer extends ArrayBufferLike = ArrayBufferLike> {
    // ...
}

每个类型化数组现在都包含一个名为 TArrayBuffer 的类型参数,尽管该类型参数有一个默认类型参数,因此用户仍然可以直接使用 Int32Array,而不必显式地写成 Int32Array<ArrayBufferLike>

从非字面量方法名创建类中的索引签名

TypeScript 现在对于使用非字面量计算属性名声明的方法在类中具有更一致的行为。例如:

declare const symbolMethodName: symbol;

export class A {
    [symbolMethodName]() { return 1 };
}

以前,TypeScript 会将这个类视为如下:

export class A {
}

换句话说,从类型系统的角度来看,[symbolMethodName] 对类 A 没有任何影响。

TypeScript 5.7 现在更有意义地看待方法 [symbolMethodName]() ,并生成一个索引签名。因此,上面的代码现在解释为如下代码:

export class A {
    [x: symbol]: () => number;
}

这为对象字面量中的属性和方法提供了一致的行为。

在返回 nullundefined 的函数上增加隐式 any 错误

当一个函数表达式被上下文类型化为返回泛型类型的签名时,如果没有启用 strictNullChecks,TypeScript 现在会根据 noImplicitAny 适当地提供隐式 any 错误。

declare var p: Promise<number>;
const p2 = p.catch(() => null);
//                 ~~~~~~~~~~
// error TS7011: Function expression, which lacks return-type annotation, implicitly has an 'any' return type.

这意味着如果一个函数返回 nullundefined,但未明确指定返回类型,则会触发错误。这样可以确保代码更加严格和健壮,减少因不明确的类型导致的潜在问题。

最后

参考:https://devblogs.microsoft.com/typescript/announcing-typescript-5-7-rc/

全栈修仙之路
专注分享 TS、Vue3、前端架构和源码解析等技术干货。
 最新文章