TypeScript 5.5 RC(候选版本)发布公告

原文:devblogs.microsoft.com/typescript/...

作者:Daniel Rosenwasser 2024年6月6日

译者注:为避免冗余,文内各小节下删除了相关链接,有需要的同学请看原文


今天,我们很高兴地宣布 TypeScript 5.5 候选版本的发布。

要开始使用这个候选版本,你可以通过 NuGet 获取,或者使用以下命令通过 npm 获取:

css 复制代码
npm install -D typescript@rc

Beta 版后的新增功能

自 Beta 版发布以来,我们做了一些更改,我们想特别提及一下。

首先,我们添加了对 ECMAScript 新 Set 方法的支持。此外,我们对 TypeScript 的新正则表达式检查的行为进行了调整,使其稍微宽松一些,但对于仅在 ECMAScript 的附录 B 中允许的可疑转义仍会报错。

我们还增加并记录了更多性能优化:特别是在 transpileModule 中跳过检查以及 TypeScript 过滤上下文类型的优化。这些优化在许多常见场景下可以显著加快构建和迭代时间。

推断类型谓词

这一部分由 Dan Vanderkam 撰写,他在 TypeScript 5.5 中实现了这一功能。感谢 Dan!

TypeScript 的控制流分析在跟踪变量类型随代码流动变化方面做得非常好:

typescript 复制代码
interface Bird { 
  commonName: string; 
  scientificName: string; 
  sing(): void; 
} 
// Maps国家名称到国鸟
// 不是所有国家都有官方鸟(看看你,加拿大!)
declare const nationalBirds: Map<string, Bird>; 

function makeNationalBirdCall(country: string) { 
  const bird = nationalBirds.get(country); // bird 的声明类型是 Bird | undefined
  if (bird) { 
    bird.sing();  // 在 if 语句中 bird 的类型是 Bird 
  } else { 
    // 在这里 bird 的类型是 undefined。
  } 
}

通过让你处理 undefined 情况,TypeScript 推动你编写更健壮的代码。

过去,这种类型的细化更难应用于数组。在 TypeScript 的所有以前版本中,这将是一个错误:

javascript 复制代码
function makeBirdCalls(countries: string[]) { 
    // birds: (Bird | undefined)[]
    const birds = countries 
        .map(country => nationalBirds.get(country)) 
        .filter(bird => bird !== undefined); 
    for (const bird of birds) { 
        bird.sing();  // 错误:'bird' 可能是 'undefined'。
    } 
}

这个代码是完全正确的:我们已经过滤掉了所有的 undefined 值。但TypeScript 无法理解这一点。

在 TypeScript 5.5 中,类型检查器对这段代码没有问题:

javascript 复制代码
function makeBirdCalls(countries: string[]) { 
    // birds: Bird[]
    const birds = countries 
        .map(country => nationalBirds.get(country)) 
        .filter(bird => bird !== undefined); 
    for (const bird of birds) { 
        bird.sing(); // 没问题!
    } 
}

注意 birds 的更精确类型。

这是因为 TypeScript 现在为 filter 函数推断出一个类型谓词。通过将其提取到独立函数中,可以更清楚地看到其运作原理:

javascript 复制代码
// function isBirdReal(bird: Bird | undefined): bird is Bird
function isBirdReal(bird: Bird | undefined) { 
    return bird !== undefined; 
}

bird is Bird 是类型谓词。它意味着,如果函数返回 true,那么它就是 Bird(如果函数返回 false,那么它就是 undefined)。Array.prototype.filter 的类型声明了解类型谓词,因此最终结果是你获得了更精确的类型,并且代码通过了类型检查。

译者注:这个新的特性刚好解决了我在上一篇文章中的问题。

如果满足以下条件,TypeScript 会推断一个函数返回类型谓词:

  1. 函数没有显式的返回类型或类型谓词注解。
  2. 函数只有一个 return 语句,没有隐式返回。
  3. 函数不改变其参数。
  4. 函数返回与参数细化相关的布尔表达式。

通常情况下,这与你的预期一致。以下是一些推断类型谓词的更多示例:

typescript 复制代码
// const isNumber: (x: unknown) => x is number
const isNumber = (x: unknown) => typeof x === 'number'; 
// const isNonNullish: <T>(x: T) => x is NonNullable<T>
const isNonNullish = <T,>(x: T) => x != null;

以前,TypeScript 只会推断这些函数返回 boolean。现在它会推断带有类型谓词的签名,如 x is number 或 x is NonNullable<T>。

类型谓词具有"当且仅当"语义。如果一个函数返回 x is T,那么这意味着:

  1. 如果函数返回 true,那么 x 的类型是 T。
  2. 如果函数返回 false,那么 x 的类型不是 T。

如果你期望推断出类型谓词但没有推断出,那么你可能违反了第二条规则。这通常出现在"真值"检查中:

typescript 复制代码
function getClassroomAverage(students: string[], allScores: Map<string, number>) { 
    const studentScores = students 
        .map(student => allScores.get(student)) 
        .filter(score => !!score); 
    return studentScores.reduce((a, b) => a + b) / studentScores.length; 
    // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
    // 错误:对象可能是 'undefined'。
}

TypeScript 没有为 score => !!score 推断类型谓词,这是正确的:如果返回 true,则 score 是 number。但如果返回 false,则 score 可能是 undefined 或 number(具体来说是 0)。这是一个实际的错误:如果任何学生在考试中得零分,那么过滤掉他们的分数会导致平均分偏高。平均分以下的学生会更少,更多的学生会感到沮丧!

与第一个例子一样,最好明确过滤掉 undefined 值:

typescript 复制代码
function getClassroomAverage(students: string[], allScores: Map<string, number>) { 
    const studentScores = students 
        .map(student => allScores.get(student)) 
        .filter(score => score !== undefined); 
    return studentScores.reduce((a, b) => a + b) / studentScores.length; 
    // 没问题!
}

对于对象类型的真值检查,将推断类型谓词,因为没有歧义。记住,函数必须返回 boolean 才能成为推断类型谓词的候选:x => !!x 可能会推断类型谓词,但 x => x 绝对不会。

显式类型谓词继续如以前一样工作。TypeScript 不会检查它是否会推断出相同的类型谓词。显式类型谓词(is)并不比类型断言(as)更安全。

如果 TypeScript 现在推断出比你想要的更精确的类型,这个功能可能会破坏现有代码。例如:

typescript 复制代码
// 以前,nums: (number | null)[]
// 现在,nums: number[]
const nums = [1, 2, 3, null, 5].filter(x => x !== null); 
nums.push(null); 
// 在 TS 5.4 中没问题,在 TS 5.5 中错误

解决方法是使用显式类型注解告诉 TypeScript 你想要的类型:

typescript 复制代码
const nums: (number | null)[] = [1, 2, 3, null, 5].filter(x => x !== null);
nums.push(null);  // 在所有版本中都正常

常量索引访问的控制流细化

TypeScript现在能够在obj和key实际上是常量的情况下,细化obj[key]形式的表达式。

typescript 复制代码
function f1(obj: Record<string, unknown>, key: string) {
    if (typeof obj[key] === "string") {
        // 现在可以,以前会报错
        obj[key].toUpperCase();
    }
}

在上面的例子中,obj 和 key 都从未被改变过,因此 TypeScript 可以在 typeof 检查之后将 obj[key] 的类型缩小到 string。

JSDoc 中的类型导入

目前,如果你想要在 JavaScript 文件中仅为类型检查导入某些内容,这是很麻烦的。JavaScript 开发者无法简单地导入一个名为 SomeType 的类型,如果在运行时它不存在的话。

typescript 复制代码
// ./some-module.d.ts
export interface SomeType {
  // ...
}

// ./index.js
import { SomeType } from "./some-module";
// ❌ 运行时错误!
/**
 * @param {SomeType} myValue
 */
function doSomething(myValue) {
  // ...
}

SomeType 在运行时不存在,因此导入将失败。开发者可以改用命名空间导入。

javascript 复制代码
import * as someModule from "./some-module";
/**
 * @param {someModule.SomeType} myValue
 */
function doSomething(myValue) {
    // ...
}

但是 ./some-module 仍然会在运行时被导入,这可能也不是期望的行为。

为了避免这种情况,开发者通常不得不在 JSDoc 注释中使用 import(...) 类型。

javascript 复制代码
/**
 * @param {import("./some-module").SomeType} myValue
 */
function doSomething(myValue) {
    // ...
}

如果你想要在多个地方重用相同的类型,你可以使用 typedef 避免重复导入。

javascript 复制代码
/**
 * @typedef {import("./some-module").SomeType} SomeType
 */
/**
 * @param {SomeType} myValue
 */
function doSomething(myValue) {
    // ...
}

这对于局部使用 SomeType 是有帮助的,但对于许多导入来说有点啰嗦。

因此,TypeScript 现在支持一个新的 @import 注释标签,其语法与 ECMAScript 导入相同。

javascript 复制代码
/** @import { SomeType } from "some-module" */
/**
 * @param {SomeType} myValue
 */
function doSomething(myValue) {
    // ...
}

在这里,我们使用了具名导入。我们也可以将导入写成命名空间导入的形式。

javascript 复制代码
/** @import * as someModule from "some-module" */
/**
 * @param {someModule.SomeType} myValue
 */
function doSomething(myValue) {
    // ...
}

由于这些只是 JSDoc 注释,它们根本不影响运行时行为。

我们要特别感谢 Oleksandr Tarasiuk 提供了这个改变!

正则表达式语法检查

到目前为止,TypeScript 通常会跳过代码中的大多数正则表达式。这是因为正则表达式在技术上具有可扩展的语法,而 TypeScript 从未努力将正则表达式编译成较早版本的 JavaScript。不过,这意味着很多常见问题会在正则表达式中被忽略,它们要么会在运行时变成错误,要么悄悄地失败。

但是,TypeScript 现在可以对正则表达式进行基本的语法检查了!

javascript 复制代码
let myRegex = /@robot(\s+(please|immediately)))? do some task/;
// ~
// 错误!
// 意外的 ')'. 你是想用反斜杠转义它吗?

这是一个简单的例子,但这种检查可以捕获很多常见的错误。事实上,TypeScript 的检查稍微超出了语法检查。例如,TypeScript 现在可以捕获不存在的反向引用。

javascript 复制代码
let myRegex = /@typedef \{import\((.+)\)\.([a-zA-Z_]+)\} \3/u;
// ~
// 错误!
// 这个反向引用指向不存在的组。
// 这个正则表达式中只有 2 个捕获组。

相同的情况也适用于命名捕获组。

javascript 复制代码
let myRegex = /@typedef \{import\((?<importPath>.+)\)\.(?<importedEntity>[a-zA-Z_]+)\} \k<namedImport>/;
// ~~~~~~~~~~~
// 错误!
// 在这个正则表达式中没有叫 'namedImport' 的捕获组。

当你的目标 ECMAScript 版本较早时,TypeScript 的检查也会意识到某些 RegExp 特性是否被使用。例如,如果我们在 ES5 目标中使用像上面的命名捕获组,我们会得到一个错误。

javascript 复制代码
let myRegex = /@typedef \{import\((?<importPath>.+)\)\.(?<importedEntity>[a-zA-Z_]+)\} \k<importedEntity>/;
// ~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~
// 错误!
// 命名捕获组只有在目标为 'ES2018' 或更高版本时可用。

对于某些正则表达式标志,情况也是如此。

请注意,TypeScript 的正则表达式支持仅限于正则表达式字面量。如果尝试使用字符串字面量调用 new RegExp,TypeScript 将不会检查提供的字符串。

我们要感谢 GitHub 用户 graphemecluster,他与我们一起反复迭代,将此功能纳入了 TypeScript。

支持新的 ECMAScript Set 方法

TypeScript 5.5 声明了 ECMAScript Set 类型的新提议方法。其中一些方法,如 union、 intersection、 difference 和 symmetricDifference,接另一个 Set 并将一个新的 Set 作为结果返回。而另一些方法,如 isSubsetOf、 isSupersetOf 和 isDisjointFrom,接另一个 Set 并返回一个布尔值。这些方法均不会改变原始的 Set。

以下是如何使用这些方法以及它们的行为的一个快速示例:

typescript 复制代码
let fruits = new Set(["apples", "bananas", "pears", "oranges"]);
let applesAndBananas = new Set(["apples", "bananas"]);
let applesAndOranges = new Set(["apples", "oranges"]);
let oranges = new Set(["oranges"]);
let emptySet = new Set();

////
// union
////

// Set(4) {'apples', 'bananas', 'pears', 'oranges'}
console.log(fruits.union(oranges));

// Set(3) {'apples', 'bananas', 'oranges'}
console.log(applesAndBananas.union(oranges));

////
// intersection
////

// Set(2) {'apples', 'bananas'}
console.log(fruits.intersection(applesAndBananas));

// Set(0) {}
console.log(applesAndBananas.intersection(oranges));

// Set(1) {'apples'}
console.log(applesAndBananas.intersection(applesAndOranges));

////
// difference
////

// Set(3) {'apples', 'bananas', 'pears'}
console.log(fruits.difference(oranges));

// Set(2) {'pears', 'oranges'}
console.log(fruits.difference(applesAndBananas));

// Set(1) {'bananas'}
console.log(applesAndBananas.difference(applesAndOranges));

////
// symmetricDifference
////

// Set(2) {'bananas', 'oranges'}
console.log(applesAndBananas.symmetricDifference(applesAndOranges)); // no apples

////
// isDisjointFrom
////

// true
console.log(applesAndBananas.isDisjointFrom(oranges));

// false
console.log(applesAndBananas.isDisjointFrom(applesAndOranges));

// true
console.log(fruits.isDisjointFrom(emptySet));

// true
console.log(emptySet.isDisjointFrom(emptySet));

////
// isSubsetOf
////

// true
console.log(applesAndBananas.isSubsetOf(fruits));

// false
console.log(fruits.isSubsetOf(applesAndBananas));

// false
console.log(applesAndBananas.isSubsetOf(oranges));

// true
console.log(fruits.isSubsetOf(fruits));

// true
console.log(emptySet.isSubsetOf(fruits));

////
// isSupersetOf
////

// true
console.log(fruits.isSupersetOf(applesAndBananas));

// false
console.log(applesAndBananas.isSupersetOf(fruits));

// false
console.log(applesAndBananas.isSupersetOf(oranges));

// true
console.log(fruits.isSupersetOf(fruits));

// false
console.log(emptySet.isSupersetOf(fruits));

我们要感谢 Kevin Gibbons,他不仅提出了 ECMAScript 中的该功能,还为 TypeScript 中的 Set、ReadonlySet 和 ReadonlySetLike 提供了声明!

独立声明

这一部分是由 Rob Palmer 共同撰写的,他支持独立声明的设计。

声明文件(即 .d.ts 文件)描述了 TypeScript 对现有库和模块的形状。这种轻量级描述包括库的类型签名,但不包括诸如函数体之类的实现细节。它们被发布,以便 TypeScript 可以有效地检查你对库的使用,而无需分析库本身。虽然可以手动编写声明文件,但如果你正在编写类型化的代码,最安全、最简单的方法是让 TypeScript 根据源文件自动生成它们,使用 --declaration 参数。

TypeScript 编译器及其 API 一直负责生成声明文件;但是,有一些用例可能需要使用其他工具,或者传统的构建过程不够灵活。

用例:更快的声明文件生成工具

设想你想创建一个更快的工具来生成声明文件,也许作为发布服务或新捆绑器的一部分。虽然有一个蓬勃发展的生态系统来将 TypeScript 转换为 JavaScript 的工具,但将 TypeScript 转换为声明文件的工具却并不存在。原因是 TypeScript 的类型推断允许我们编写无需显式声明类型的代码,这意味着声明文件的生成可能比较复杂。

让我们考虑一个简单的例子,一个函数将两个导入的变量相加。

typescript 复制代码
// util.ts
export let one = "1";
export let two = "2";

// add.ts
import { one, two } from "./util";
export function add() { 
  return one + two; 
}

即使我们只想生成 add.d.ts 文件,TypeScript 也需要进入另一个导入的文件(util.ts)中,推断出 one 和 two 的类型为字符串,然后计算出两个字符串的+运算符将导致一个字符串返回类型。

typescript 复制代码
// add.d.ts
export declare function add(): string;

虽然这种推断对开发人员体验很重要,但意味着希望生成声明文件的工具需要复制类型检查器的部分内容,包括推断和解析模块规范器以跟踪导入。

使用情况:并行声明发出和并行检查

假设你有一个包含许多项目的monorepo和一个希望能够帮助你更快检查代码的多核 CPU。如果我们可以通过在不同的核心上运行每个项目来同时检查所有这些项目,那将是多么美妙。

不幸的是,我们没有自由来并行执行所有工作。原因是我们必须按依赖顺序构建这些项目,因为每个项目都在对其依赖项的声明文件进行检查。

因此,我们必须首先构建依赖项以生成声明文件。TypeScript 的项目引用功能的工作方式相同,按"拓扑"依赖顺序构建项目集。

例如,如果我们有两个名为 backend 和 frontend 的项目,并且它们都依赖于一个名为 core 的项目,那么在 core被构建并生成其声明文件之前,TypeScript 无法开始对 frontend 或 backend 进行类型检查。

在上面的图表中,你可以看到我们存在一个瓶颈。虽然我们可以并行构建前端和后端,但需要先等待核心构建完成,然后才能开始。我们如何改进呢?

嗯,如果一个快速的工具可以并行生成核心的所有声明文件,那么 TypeScript 就可以立即并行检查核心、前端和后端的类型。

解决方案:显式类型!

这两种用例中的共同要求是,我们需要一个跨文件类型检查器来生成声明文件。这对于工具社区来说是一个很大的要求。

更复杂的例子是,如果我们想要以下代码的声明文件:

typescript 复制代码
import { add } from "./add";

const x = add();

export function foo() {
    return x;
}

我们需要为 foo 生成一个签名。这需要查看 foo 的实现。foo 只返回 x,因此获取 x 的类型需要查看 add 的实现。但这可能需要查看 add 的依赖项的实现,依此类推。我们看到生成声明文件需要大量逻辑来确定不一定局限于当前文件的不同位置的类型。

尽管如此,对于寻求快速迭代时间和完全并行构建的开发人员来说,还有另一种思考这个问题的方法。

声明文件只需要模块的公共 API 类型 ------ 换句话说,导出内容的类型。如果有争议地,开发人员愿意显式地写出他们导出的内容的类型,工具可以生成声明文件,而无需查看模块的实现 ------ 也无需重新实现完整的类型检查器。

这就是新的 --isolatedDeclarations 选项的用武之地。--isolatedDeclarations 在没有类型检查器的情况下无法可靠地转换模块时报告错误。简而言之,如果你有一个对其出口没有足够注释的文件,它使得 TypeScript 报告错误。这意味着在上面的示例中,我们将看到以下错误:

typescript 复制代码
export function foo() {
//              ~~~
// error! Function must have an explicit
// return type annotation with --isolatedDeclarations.
    return x;
}

为什么错误是可取的? 因为这意味着 TypeScript 可以:

  1. 告诉我们是否其他工具在生成声明文件时会有问题
  2. 提供一个快速修复来帮助添加这些缺失的注释。

不过,这种模式并不要求在任何地方都有注释。对于局部变量,这些可以被忽略,因为它们不会影响公共 API。例如,以下代码不会产生错误:

typescript 复制代码
import { add } from "./add";

const x = add("1", "2"); // 对 'x' 没有错误,因为它没有被导出

export function foo(): string {
  return x;
}

此外,还有一些表达式的类型是trivial(平凡)的计算。

typescript 复制代码
// 对 'x' 没有错误。
// 计算类型为 'number' 很trivial
export let x = 10;

// 对 'y' 没有错误。 
// 我们可以从返回表达式获得类型。
export function y() {
  return 20;
}

// 对 'z' 没有错误。
// 类型断言清楚地表明了类型。
export function z() {
  return Math.max(x, y()) as number;
}

使用 isolatedDeclarations

isolatedDeclarations 要求同时设置声明或复合标志。

请注意,isolatedDeclarations 不会改变 TypeScript 如何执行输出 ------ 它只会改变错误报告的方式。 重要的是,与 isolatedModules 类似,在 TypeScript 中启用该功能不会立即带来这里讨论的潜在好处。所以请耐心等待,期待未来在这一领域的发展。 考虑到工具作者,我们也应该认识到,目前并非 TypeScript 的所有声明输出都可以被其他想要使用它作为指南的工具轻松复制。这是我们正在努力改善的事情。

除此之外,隔离声明仍然是一个新特性,我们正在努力改善体验。某些场景,如在类和对象文字中使用计算属性声明,在 isolatedDeclarations 下尚不受支持。请继续关注此空间,并随时向我们提供反馈。

我们还认为值得指出的是,isolatedDeclarations 应该因地制宜地采用。使用 isolatedDeclarations 会丢失一些开发人员的方便性,因此如果你的设置没有利用前面提到的两个场景,它可能不是正确的选择。对于其他人来说,isolatedDeclarations 的工作已经揭示了许多优化机会,可以解锁不同的并行构建策略。同时,如果你愿意进行权衡,我们相信 isolatedDeclarations 一旦外部工具可用,就可以成为加速构建过程的强大工具。

贡献者

isolatedDeclarations 的工作一直是 TypeScript 团队与 Bloomberg 和 Google 的基础设施和工具团队之间的长期合作努力。像 Google 的 Hana Joo 实现了隔离声明错误的快速修复(稍后将详细介绍),以及 Ashley Claymore、Jan Kühle、Lisa Velden、Rob Palmer 和 Thomas Chetwin 等人已经参与了几个月的讨论、规范和实现。 但我们认为特别值得一提的是 Bloomberg 的 Titian Cernicova-Dragomir 所提供的大量工作。Titian 在推动 isolatedDeclarations 的实施方面发挥了关键作用,并在此之前就一直是 TypeScript 项目的贡献者。

尽管该功能涉及许多更改,但你可以在此处查看 Isolated Declarations 的核心工作。

配置文件的 ${configDir} 模板变量

在许多代码库中,重复使用一个共享的tsconfig.json 文件作为其他配置文件的"基础"是很常见的。这是通过在tsconfig.json 文件中使用 extends 字段来实现的。

json 复制代码
{
  "extends": "../../tsconfig.base.json",
  "compilerOptions": {
    "outDir": "./dist"
  }
}

这方面的一个问题是,tsconfig.json 文件中的所有路径都是相对于文件本身的位置而言的。 这意味着,如果你有一个共享的 tsconfig.base.json 文件被多个项目使用,相对路径通常在衍生项目中是没有用的。

例如,想象一下下面的tsconfig.base.json:

json 复制代码
{
  "compilerOptions": {
    "typeRoots": [
      "./node_modules/@types",
      "./custom-types"
    ],
    "outDir": "dist"
  }
}

如果作者的意图是每个扩展此文件的 tsconfig.json 应该相对于衍生的 tsconfig.json 输出到一个dist目录,并且有一个相对于衍生 tsconfig.json 的 custom-types 目录,那么这是不会奏效的。 typeRoots 路径将相对于共享的 tsconfig.base.json 文件的位置,而不是扩展它的项目。每个扩展这个共享文件的项目都需要声明自己的 outDir 和 typeRoots,内容完全相同。 这可能会令人沮丧,并且很难在项目之间保持同步,虽然上面的示例使用的是 typeRoots,但这也是路径和其他选项的常见问题。

为了解决这个问题,TypeScript 5.5 引入了一个新的模板变量 ${configDir}。当${configDir} 写在 tsconfig.json 或 jsconfig.json 文件的某些路径字段中时,这个变量将被当前编译中配置文件所在的目录替换。这意味着上面的 tsconfig.base.json 可以重写为:

json 复制代码
{
  "compilerOptions": {
    "typeRoots": [
      "${configDir}/node_modules/@types",
      "${configDir}/custom-types"
    ],
    "outDir": "${configDir}/dist"
  }
}

现在,当一个项目扩展这个文件时,路径将相对于衍生的 tsconfig.json,而不是共享的 tsconfig.base.json 文件。这使得跨项目共享配置文件变得更加容易,并确保配置文件更加可移植。

如果你打算制作一个可扩展的 tsconfig.json 文件,请考虑是否应该使用 ${configDir} 而不是 ./ 。

从 package.json 依赖项中获取声明文件生成

之前,TypeScript 经常会发出如下错误消息:

bash 复制代码
The inferred type of "X" cannot be named without a reference to "Y". This is likely not portable. A type annotation is necessary.

这往往是由于 TypeScript 的声明文件生成发现了从未在程序中显式导入的文件内容。如果生成一个相对路径的导入可能会有风险。 但是,对于在 package.json 的 dependencies(或 peerDependencies 和 optionalDependencies)中有显式依赖项的代码库来说,在某些解析模式下生成这样的导入应该是安全的。 因此,在 TypeScript 5.5 中,我们在这种情况下更宽容,许多这种错误应该会消失。

编辑器和监视模式可靠性改进

TypeScript 已经添加了一些新功能或修复了现有的逻辑,使 --watch 模式和 TypeScript 的编辑器集成感觉更可靠。这应该有助于减少 TSServer/编辑器的重启。

正确刷新配置文件中的编辑器错误

TypeScript 可以为 tsconfig.json 文件生成错误;然而,这些错误实际上是从加载项目中生成的,编辑器通常不会直接请求 tsconfig.json 文件的这些错误。 虽然这听起来像是一个技术细节,但这意味着当 tsconfig.json 中的所有错误都被修复时,TypeScript 不会发出一组全新的空错误,用户将会看到过时的错误,除非他们重新加载编辑器。

TypeScript 5.5 现在有意发出一个事件来清除这些错误。

更好地处理删除后立即写入的情况

一些工具会选择删除文件,然后从头开始创建新文件,而不是覆盖文件。在运行 npm ci 时就是这种情况。

虽然这对这些工具来说可能是有效的,但对于 TypeScript 的编辑器场景来说可能会有问题,因为删除一个被监视的文件可能会释放它及其所有传递依赖项。在快速删除和创建文件时,TypeScript 可能会拆除整个项目,然后从头开始重建。

TypeScript 5.5 现在采取了更细致的方法,在捕捉到新的创建事件之前,保留被删除项目的一部分。这应该可以让像 npm ci 这样的操作与 TypeScript 配合得更好。

失败解析中跟踪符号链接

当 TypeScript 无法解析一个模块时,它仍然需要监视任何失败的查找路径,以防该模块稍后被添加。

之前这没有应用于符号链接目录,这可能会在单一存储库场景中导致可靠性问题,当一个项目中发生构建但另一个项目没有反馈时。

这个问题应该在 TypeScript 5.5 中得到修复,这意味着你不需要那么频繁地重启编辑器了。

项目引用有助于自动导入

自动导入不再需要在项目引用设置中至少有一个显式导入到依赖项目。相反,自动导入补全应该可以在你在 tsconfig.json 的 references 字段中列出的任何内容上正常工作。

性能和体积优化

语言服务和公共 API 中的单态对象

在 TypeScript 5.0 中,我们确保我们的 Node 和 Symbol 对象有一致的属性集和初始化顺序。这样做有助于减少不同操作中的多态性,使运行时能够更快地获取属性。

通过进行这一更改,我们在编译器中获得了令人印象深刻的速度提升;然而,大部分这些变更都是在我们数据结构的内部分配器上进行的。语言服务以及 TypeScript 的公共 API 使用不同的分配器来处理某些对象。这使得 TypeScript 编译器变得更加精简,因为仅供语言服务使用的数据永远不会出现在编译器中。

在 TypeScript 5.5 中,相同的单态化工作已经在语言服务和公共 API 上完成。这意味着你的编辑器体验以及使用 TypeScript API 的任何构建工具都会变得更快。

事实上,在我们的基准测试中,当使用公共 TypeScript API 的分配器时,我们看到了 5-8% 的构建时间加速,而语言服务操作则快了10-20% 。 虽然这确实意味着内存的增加,但我们认为这种权衡是值得的,并希望寻找方法来减少这种内存开销。现在一切应该感觉更加敏捷了。

单态化控制流节点

在 TypeScript 5.5 中,控制流图的节点已经单态化,使它们始终保持一致的形状。通过这样做,检查时间通常会减少约 1%。

我们的控制流图优化

在许多情况下,控制流分析会遍历不提供任何新信息的节点。我们观察到,在某些节点的前提条件(或"主导节点")中没有任何提前终止或影响的情况下,这些节点可以始终被跳过。

因此,TypeScript 现在构建其控制流图以利用这一点,链接到提供更多有趣控制流分析信息的早期节点。这产生了一个更扁平的控制流图,这可能更高效地进行遍历。这种优化带来了适度的收益,但在某些代码库上最多可达 2% 的构建时间减少。

跳过 transpileModule 和 transpileDeclaration 中的检查

TypeScript 的 transpileModule API 可用于将单个 TypeScript 文件的内容编译为 JavaScript。同样,transpileDeclaration API (见下文) 可用于为单个 TypeScript 文件生成声明文件。 这些 API 存在的一个问题是,TypeScript 内部会在发出输出之前对整个文件内容执行完整的类型检查。这是必需的,因为需要收集某些信息以供发射阶段使用。

在 TypeScript 5.5 中,我们找到了一种方法来避免执行完整的检查,只需在必要时懒惰地收集这些信息,并且 transpileModule 和 transpileDeclaration 默认情况下都启用了这个功能。 因此,与这些 API 集成的工具,如使用 transpileOnly 的 ts-loader 和 ts-jest,应该会看到明显的速度提升。在我们的测试中,使用 transpileModule 通常可以看到大约 2 倍的构建时间加速。

TypeScript 包大小减少

进一步利用我们在 5.0 中向模块的转变,我们通过让 tsserver.js 和 typingsInstaller.js 从一个共同的 API 库导入,而不是让它们各自生成独立的束,显著减小了 TypeScript 的整体包大小。

这使 TypeScript 的磁盘占用从 30.2 MB 减少到 20.4 MB,打包大小从 5.5 MB 减少到 3.7 MB!

声明文件发射中的Node重用

作为启用 isolatedDeclarations 工作的一部分,我们大幅提高了 TypeScript 在生成声明文件时直接复制输入源代码的频率。

例如,假设你编写了以下代码:

typescript 复制代码
export const strBool: string | boolean = "hello";
export const boolStr: boolean | string = "world";

请注意,联合类型是等价的,但联合的顺序不同。在发出声明文件时,TypeScript 有两种等价的输出可能性:

第一种是使用每种类型的一致的标准表示:

typescript 复制代码
export const strBool: string | boolean;
export const boolStr: string | boolean;

第二种是完全按照编写的类型注解重用:

typescript 复制代码
export const strBool: string | boolean;
export const boolStr: boolean | string;

第二种方法通常更可取,原因如下:

  • 许多等价的表示仍然编码了一些意图,最好在声明文件中保留下来
  • 生成类型的新表示可能有些昂贵,所以应该尽量避免
  • 用户编写的类型通常比生成的类型表示更短

在 5.5 版本中,我们大大提高了 TypeScript 能够正确识别需要完全按照输入文件中编写的方式打印类型的位置的数量。这些情况中的许多是不可见的性能改进 ------ TypeScript 会生成全新的语法节点(Node)并将其序列化为字符串。相反,TypeScript 现在可以直接在原始语法节点上进行操作,这更加便宜和更快。

缓存歧义联合的上下文类型

当 TypeScript 请求表达式的上下文类型,比如对象字面量时,通常会遇到一个联合类型。在这种情况下,TypeScript 会尝试根据已知属性和已知值(即辨别属性)来过滤联合成员。这项工作可能非常昂贵,尤其是如果最终得到包含许多属性的对象。 在 TypeScript 5.5 中,一旦计算缓存了很多内容,TypeScript就不需要在每个对象字面量的属性上重新计算了。进行这种优化将编译 TypeScript 编译器本身的时间缩短了 250 毫秒。

从 ECMAScript 模块更方便地使用 API

之前,如果你在 Node.js 中编写 ECMAScript 模块,从 typescript 包中无法进行命名导入。

typescript 复制代码
import { createSourceFile } from "typescript"; // ❌ error

import * as ts from "typescript";
ts.createSourceFile // ❌ undefined???

ts.default.createSourceFile // ✅ works - but ugh!

这是因为 cjs-module-lexer无法识别 TypeScript 生成的 CommonJS 代码的模式。这个问题已经得到修复,用户现在可以在 Node.js 的 ECMAScript 模块中从 TypeScript npm 包使用命名导入了。

typescript 复制代码
import { createSourceFile } from "typescript"; // ✅ works now!

import * as ts from "typescript";
ts.createSourceFile // ✅ works now!

transpileDeclaration API

TypeScript 的 API 公开了一个名为 transpileModule 的函数。它旨在简化编译单个 TypeScript 代码文件的过程。由于它没有访问整个程序的权限,因此如果代码违反了 isolatedModules 选项下的任何错误,它可能无法生成正确的输出。

在 TypeScript 5.5 中,我们添加了一个新的类似 API 叫做 transpileDeclaration。这个 API 类似于 transpileModule,但它专门设计用于根据某些输入源文本生成单个声明文件。与 transpileModule 一样,它无法访问整个程序,也有类似的警告:它只有在输入代码不存在新的 isolatedDeclarations 选项下的错误时,才能生成准确的声明文件。

如果需要,这个函数可以用来在 isolatedDeclarations 模式下对所有文件的声明输出进行并行处理。请注意,尽管你可能会在 transpileDeclaration 中经历一些 transpileModule 的性能开销,但我们正在努力进一步优化这一点。

值得注意的行为变更

本节重点介绍一组值得注意的变更,这些变更应该作为任何升级的一部分得到认知和理解。有时它会强调弃用、删除和新的限制。它还可以包含从功能上来说是改进的错误修复,但也可能通过引入新的错误来影响现有的构建。

禁用 TypeScript 5.0 中弃用的功能

TypeScript 5.0 弃用了以下选项和行为:

  • charset
  • target: ES3
  • importsNotUsedAsValues
  • noImplicitUseStrict
  • noStrictGenericChecks
  • keyofStringsOnly
  • suppressExcessPropertyErrors
  • suppressImplicitAnyIndexErrors
  • out
  • preserveValueImports
  • project references 中的 prepend
  • implicitly OS-specific newLine

为了继续使用上述弃用的选项,使用 TypeScript 5.0 及更新版本的开发者不得不指定一个名为ignoreDeprecations且值为 "5.0" 的新选项。

在 TypeScript 5.5 中,这些选项不再有任何效果。为了帮助顺利的升级路径,你仍然可以在 tsconfig中指定它们,但在 TypeScript 6.0 中将会报错。另请参见概述我们弃用策略的 Flag Deprecation Plan。

lib.d.ts 变更

DOM 生成的类型可能会影响对你代码库的类型检查。更多信息请参见 TypeScript 5.5 的 DOM 更新。

尊重其它模块模式下的文件扩展名和 package.json

在 Node.js 实现对 ECMAScript 模块的支持之前,TypeScript 无法确定 node_modules 中找到的 .d.ts 文件代表的是以 CommonJS 还是 ECMAScript 模块编写的 JavaScript 文件。当 npm 几乎完全是 CommonJS 时,这并没有造成太多问题 ------ 如果存疑,TypeScript 可以假设一切都像 CommonJS 那样运行。 不幸的是,如果这种假设是错误的,它可能允许不安全的导入:

typescript 复制代码
// node_modules/dep/index.d.ts
export declare function doSomething(): void;

// index.ts
// 如果 "dep" 是 CommonJS 模块,则可以,但如果它是 ECMAScript 模块,则会失败 - 即使在打包器中也是如此
import dep from "dep";
dep.doSomething();

实际上,这种情况很少出现。但是在 Node.js 开始支持 ECMAScript 模块的这些年里,ESM 在 npm 上的份额已经增长。 幸运的是,Node.js 也引入了一种机制,可以帮助 TypeScript 确定一个文件是 ECMAScript 模块还是 CommonJS 模块:.mjs 和 .cjs 文件扩展名以及 package.json 中的 "type" 字段。

TypeScript 4.7 增加了对这些指示器的理解,以及编写 .mts 和 .cts 文件的支持。但是,TypeScript 只会在 --module node16 和 --module nodenext 下读取这些指示器,所以上面不安全的导入对于使用 --module esnext 和 --moduleResolution bundler 的人来说仍然是个问题。

为了解决这个问题,TypeScript 5.5 在所有模块模式下都读取和存储由文件扩展名和 package.json "type" 编码的模块格式信息,并使用它来解决上例中的歧义(除了 amd、umd 和 system 模式)。

尊重这种格式信息的一个次要影响是,格式特定的 TypeScript 文件扩展名(.mts 和 .cts)或你自己项目中显式设置的 package.json "type" 将会覆盖你设置的 --module 选项,如果它被设置为 commonjs 或 es2015 through esnext。之前,在技术上可以将 CommonJS 输出生成到 .mjs 文件中,反之亦然:

typescript 复制代码
// main.mts
export default "oops";

// $ tsc --module commonjs main.mts
// main.mjs
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = "oops";

现在,.mts 文件(或范围内的 package.json 带有 "type": "module" 的 .ts 文件)永远不会输出 CommonJS,.cts 文件(或范围内的 package.json 带有 "type": "commonjs" 的 .ts 文件)也永远不会输出 ESM。

装饰器解析更加严格

自从 TypeScript 最初引入对装饰器的支持以来,提案的指定语法已经变得更加严格。TypeScript 现在更加严格地限制允许的形式。虽然很罕见,但现有的装饰器可能需要加上括号来避免错误。

typescript 复制代码
class DecoratorProvider {
  decorate(...args: any[]) { }
}

class D extends DecoratorProvider {
  m() {
    class C {
      @super.decorate // ❌ error
      method1() { }

      @(super.decorate) // ✅ okay
      method2() { }
    }
  }
}

undefined 不再是可定义的类型名称

TypeScript 一直禁止使用与内置类型冲突的类型别名名称:

typescript 复制代码
// 非法
type null = any;
// 非法 
type number = any;
// 非法
type object = any;
// 非法
type any = any;

由于一个错误,这个逻辑并没有同样应用于内置类型 undefined。在 5.5 中,这现在被正确地识别为错误:

typescript 复制代码
// 现在也非法
type undefined = any;

对名为 undefined 的类型别名的裸引用实际上从来都不起作用。你可以定义它们,但不能使用它们作为未限定的类型名称。

typescript 复制代码
export type undefined = string;
export const m: undefined = "";
// ^
// 在 5.4 及更早版本中出错 - 甚至没有考虑局部定义的 'undefined'。

简化引用指令声明输出

在生成声明文件时,TypeScript 会在认为需要时合成一个引用指令。例如,所有 Node.js 模块都是以沉默方式声明的,因此不能仅通过模块解析来加载。像这样的文件:

typescript 复制代码
import path from "path";
export const myPath = path.parse(__filename);

将会输出一个声明文件:

typescript 复制代码
/// <reference types="node" />
import path from "path";
export declare const myPath: path.ParsedPath;

即使原始源代码中从未出现过引用指令。

类似地,TypeScript 也会删除它认为不需要包含在输出中的引用指令。例如,让我们假设我们有一个对 jest 的引用指令,但是这个引用指令在生成声明文件时并不必要。TypeScript 会简单地把它删除。所以在下面的例子中:

typescript 复制代码
/// <reference types="jest" />
import path from "path";
export const myPath = path.parse(__filename);

TypeScript 仍然会输出:

typescript 复制代码
/// <reference types="node" />
import path from "path";
export declare const myPath: path.ParsedPath;

在处理 isolatedDeclarations 的过程中,我们意识到这种逻辑对于任何试图在不进行类型检查或使用多个文件上下文的情况下实现声明输出的人来说都是不可行的。这种行为从用户的角度来看也很难理解;输出文件中是否出现引用指令似乎是不一致和难以预测的,除非你完全理解类型检查过程中发生的事情。为了防止在启用 isolatedDeclarations 时声明输出发生不同,我们知道我们的输出需要改变。

通过实验,我们发现 TypeScript 合成引用指令的几乎所有情况都只是为了导入 node 或 react。这些是下游用户已经通过 tsconfig.json "types" 或库导入引用了这些类型的情况,所以不再合成这些引用指令不太可能会破坏任何人。

值得注意的是,这已经是 lib.d.ts 的工作方式了;当一个模块导出一个 WeakMap 时,TypeScript 不会合成对 lib="es2015" 的引用,而是假设下游用户已经将其包括在他们的环境中。

对于由库作者编写的(而不是合成的)引用指令,进一步的实验表明,它们中的绝大多数都被删除了,从未出现在输出中。保留下来的大多数引用指令都是损坏的,可能并不打算被保留。

鉴于这些结果,我们决定在 TypeScript 5.5 中极大地简化声明输出中的引用指令。一致的策略将有助于库作者和使用者更好地控制他们的声明文件。

引用指令不再被合成。用户编写的引用指令不再被保留,除非用一个新的 preserve="true" 属性进行注解。具体来说,一个像这样的输入文件:

typescript 复制代码
/// <reference types="some-lib" preserve="true" />
/// <reference types="jest" />
import path from "path";
export const myPath = path.parse(__filename);

将会输出:

typescript 复制代码
/// <reference types="some-lib" preserve="true" />
import path from "path";
export declare const myPath: path.ParsedPath;

添加 preserve="true" 与较老版本的 TypeScript 向后兼容,因为未知属性会被忽略。

这一变化也提高了性能;在我们的基准测试中,在启用了声明输出的项目中,输出阶段提高了 1-4%。

下一步是什么?

目前,我们预计除了对编译器的关键错误修复和对语言服务的次要错误修复之外,TypeScript 5.5 将很少有其他变更。在未来几周内,我们将发布 TypeScript 5.5 的第一个稳定版本。如果你需要协调目标发布日期和更多信息,请密切关注我们的迭代计划。

否则,我们的主要重点是开发 TypeScript 5.6,我们将在未来几天内提供迭代计划(包括预定的发布日期)。此外,我们还让在 npm 上使用 TypeScript 的每夜构建版本变得很容易,并且在 Visual Studio Code 中也有一个扩展来使用这些每夜构建版本。

所以请试试 RC 或我们的每夜构建,告诉我们运行情况如何!

编码愉快!

------ Daniel Rosenwasser 和 TypeScript 团队

相关推荐
雪芽蓝域zzs18 分钟前
从零搭建【pnpm+Vue3+TS+Setup+VueRouter4+Pinia+Axios+ElementPlus (unplugin 按需)】仿若依后台
前端·vue.js
燕落南枝25 分钟前
HTML 列表标签详解:有序列表、无序列表与描述列表
前端·html
派小心.1 小时前
DAU和MAU怎么计算?先确定活跃事件、用户键和统计窗口
大数据·服务器·前端·人工智能·数据分析
代码猎人1 小时前
前端怎么配合后端完成bug修复
前端
叫我铁汁儿吧1 小时前
JS作用域
前端·面试
定栓1 小时前
Webpack迁到Vite第七节(四):全家桶移除 — 保留项、脚本与数据收尾
前端·webpack·node.js
VimorphTech1 小时前
React 任务轮询,为什么不能只写一个 setInterval?
javascript·typescript
代码猎人1 小时前
前端怎么优化首屏加载速度
前端
代码猎人1 小时前
前端怎么保障项目稳定上线与运行
前端
数据掘金1 小时前
小程序埋点方案的埋点文档怎么自动生成?
前端