TypeScript 4.0 新特性介绍 - 元组类型与短路赋值解析

2025-03-25 14:50 更新

可变参数元组类型

想象一下在 JavaScript 中有一个叫 concat 的函数,它接受两个数组或元组类型并将它们连接起来创建一个新数组。

function concat(arr1, arr2) {
    return [...arr1, ...arr2];
}

还有一个 tail 函数,它接受一个数组或元组并返回除第一个元素外的所有元素。

function tail(arg) {
    const [_, ...result] = arg;
    return result;
}

在 TypeScript 中如何为这些函数添加类型呢?对于 concat,在旧版本的语言中我们只能尝试写一些重载。

function concat(arr1: [], arr2: []): [];
function concat<A>(arr1: [A], arr2: []): [A];
function concat<A, B>(arr1: [A, B], arr2: []): [A, B];
function concat<A, B, C>(arr1: [A, B, C], arr2: []): [A, B, C];
function concat<A, B, C, D>(arr1: [A, B, C, D], arr2: []): [A, B, C, D];
function concat<A, B, C, D, E>(arr1: [A, B, C, D, E], arr2: []): [A, B, C, D, E];
function concat<A, B, C, D, E, F>(arr1: [A, B, C, D, E, F], arr2: []): [A, B, C, D, E, F];

这仅仅是当第二个数组为空时的七种重载。如果 arr2 有一个参数,还需要添加更多重载。

TypeScript 4.0 引入了两个基本更改以及推断改进,使这些类型成为可能。

第一个更改是元组类型语法中的扩展现在可以是泛型。这意味着我们可以在不知道实际类型的情况下,表示对元组和数组的高阶操作。当在这些元组类型中实例化泛型扩展时,它们可以生成其他数组和元组类型。

第二个更改是元组中的剩余元素可以出现在任何位置,而不仅仅是末尾!

通过结合这两种行为,我们可以为 concat 编写一个单一的、类型良好的签名:

type Arr = readonly any[];
function concat<T extends Arr, U extends Arr>(arr1: T, arr2: U): [...T, ...U] {
    return [...arr1, ...arr2];
}

虽然这个签名仍然有点长,但它只是一个不需要重复的签名,并且在所有数组和元组上都能提供可预测的行为。


带标签的元组元素

改进元组类型和参数列表的体验很重要,因为它可以让我们在常见的 JavaScript 习惯用法周围获得强类型验证。使用元组类型作为剩余参数就是一个关键点。

例如,以下使用元组类型作为剩余参数的函数:

function foo(...args: [string, number]): void {
    // ...
}

对于任何调用 foo 的人来说,应该与以下函数没有区别:

function foo(arg0: string, arg1: number): void {
    // ...
}

在第一个例子中,我们没有为第一个和第二个元素提供参数名。虽然这对类型检查没有影响,但缺少标签的元组位置可能使它们更难使用,更难传达我们的意图。

这就是为什么在 TypeScript 4.0 中,元组类型现在可以提供标签。

type Range = [start: number, end: number];

为了加深参数列表和元组类型之间的联系,剩余元素和可选元素的语法镜像了参数列表的语法。

type Foo = [first: number, second?: string, ...rest: any[]];

使用带标签的元组时,需要注意一些规则。例如,当为元组元素添加标签时,元组中的其他元素也必须有标签。

标签不会要求我们在解构时以不同的方式命名变量。它们仅用于文档和工具。


从构造函数推断类属性

当启用 noImplicitAny 时,TypeScript 4.0 现在可以使用控制流分析来确定类中属性的类型。

class Square {
    area;
    sideLength;
    constructor(sideLength: number) {
        this.sideLength = sideLength;
        this.area = sideLength ** 2;
    }
}

在构造函数并非所有路径都分配给实例成员的情况下,属性可能被视为 undefined

class Square {
    sideLength;
    constructor(sideLength: number) {
        if (Math.random()) {
            this.sideLength = sideLength;
        }
    }
    get area() {
        return this.sideLength ** 2;
    }
}

在你更了解情况时(例如,你有一个 initialize 方法),仍然需要显式的类型注解以及在 strictPropertyInitialization 下的确定性赋值断言。

class Square {
    sideLength!: number;
    constructor(sideLength: number) {
        this.initialize(sideLength);
    }
    initialize(sideLength: number) {
        this.sideLength = sideLength;
    }
    get area() {
        return this.sideLength ** 2;
    }
}


短路赋值运算符

JavaScript 和许多其他语言支持一组称为复合赋值运算符的运算符。复合赋值运算符对两个参数应用运算符,然后将结果赋值给左侧。你可能见过这些:

// 加法
a += b;
// 减法
a -= b;
// 乘法
a *= b;
// 除法
a /= b;
// 指数运算
a **= b;
// 左移位
a <<= b;

直到最近,还有三个显著的例外:逻辑与(&&)、逻辑或(||)和 nullish 合并(??)。

这就是为什么 TypeScript 4.0 支持 ECMAScript 的新功能,添加了三个新的赋值运算符:&&=||=??=

这些运算符非常适合替代用户可能编写的代码,例如:

a = a && b;
a = a || b;
a = a ?? b;

或者类似的 if 块:

if (!a) {
    a = b;
}

还有一些我们见过的(或者我们自己写过的)懒加载值的模式,只有在需要时才会初始化。

let values: string[];
(values ?? (values = [])).push("hello");
// 之后
(values ??= []).push("hello");

在罕见情况下,如果你使用带有副作用的 getter 或 setter,需要注意这些运算符只在必要时执行赋值。从这个意义上说,不仅运算符的右侧被“短路”,赋值本身也被短路。

const obj = {
    get prop() {
        console.log("getter has run");
        return Math.random() < 0.5;
    },
    set prop(_val: boolean) {
        console.log("setter has run");
    }
};
function foo() {
    console.log("right side evaluated");
    return true;
}
console.log("This one always runs the setter");
obj.prop = obj.prop || foo();
console.log("This one *sometimes* runs the setter");
obj.prop ||= foo();


在 catch 子句绑定中使用 unknown 类型

从 TypeScript 诞生之初,catch 子句变量一直被类型化为 any。这意味着 TypeScript 允许你对它们做任何事情。

try {
    // 做一些工作
} catch (x) {
    // x 的类型是 'any' - 玩得开心!
    console.log(x.message);
    console.log(x.toUpperCase());
    x++;
    x.yadda.yadda.yadda();
}

如果我们试图在错误处理代码中防止更多错误发生,这可能会带来一些不良行为!因为这些变量默认具有 any 类型,它们缺乏任何类型安全性,可能会在无效操作上出错。

这就是为什么 TypeScript 4.0 现在允许你将 catch 子句变量的类型指定为 unknownunknownany 更安全,因为它提醒我们需要在操作值之前进行某种类型检查。

try {
    // ...
} catch (e: unknown) {
    // 不能访问 unknown 类型的值
    console.log(e.toUpperCase());
    if (typeof e === "string") {
        // 我们已经将 'e' 窄化为 'string' 类型
        console.log(e.toUpperCase());
    }
}

虽然 catch 变量的类型不会默认改变,但我们可能会考虑在未来添加一个新的 strict 模式标志,以便用户可以选择这种行为。同时,应该可以编写一个 lint 规则来强制 catch 变量具有显式的 : any: unknown 注解。


自定义 JSX 工厂

在使用 JSX 时,片段是一种允许我们返回多个子元素的 JSX 元素类型。当我们首次在 TypeScript 中实现片段时,我们对其他库如何利用它们没有很好的想法。如今,大多数鼓励使用 JSX 并支持片段的库都有类似的 API 形状。

在 TypeScript 4.0 中,用户可以通过新的 jsxFragmentFactory 选项自定义片段工厂。

例如,以下 tsconfig.json 文件告诉 TypeScript 以与 React 兼容的方式转换 JSX,但将每个工厂调用切换为 h 而不是 React.createElement,并使用 Fragment 而不是 React.Fragment

{
    "": {
        "": "esnext",
        "": "commonjs",
        "": "react",
        "": "h",
        "": "Fragment"
    }
}

在需要在每个文件中使用不同的 JSX 工厂的情况下,可以利用新的 /** @jsxFrag */ 注释 pragma。例如,以下代码:

// 注意:这些注释 pragma 需要以 JSDoc 风格的多行语法编写才能生效
/** @jsx h */
/** @jsxFrag Fragment */
import { h, Fragment } from "preact";
export const Header = (
    <>
        <h1>Welcome</h1>
    </>
);

将被转换为以下输出 JavaScript:

import React from 'react';
export const Header = React.createElement(
    React.Fragment,
    null,
    React.createElement("h1", null, "Welcome")
);


构建模式下使用 --noEmitOnError 的速度提升

以前,在 incremental 模式下使用 noEmitOnError 标志编译一个有错误的程序会非常慢。这是因为上次编译的信息不会基于 noEmitOnError 标志缓存在 .tsbuildinfo 文件中。

TypeScript 4.0 改变了这一点,在这些场景中提供了巨大的速度提升,并且反过来改进了 --build 模式(隐含 incrementalnoEmitOnError)。


使用 --incremental 和 --noEmit

TypeScript 4.0 允许我们在使用 noEmit 标志的同时仍然利用增量编译。这以前是不允许的,因为 incremental 需要生成 .tsbuildinfo 文件;然而,启用更快增量构建的用例对于所有用户来说都足够重要。


编辑器改进

TypeScript 编译器不仅为大多数主要编辑器中的 TypeScript 本身提供编辑体验,还为 Visual Studio 系列编辑器中的 JavaScript 体验提供支持。因此,我们的很多工作都集中在改进编辑器场景上——这是你作为开发人员花费大部分时间的地方。

在编辑器中使用新的 TypeScript/JavaScript 功能会因编辑器而异,但

  • Visual Studio Code 支持选择不同版本的 TypeScript。或者,可以使用 JavaScript/TypeScript 夜ly版本扩展来保持最新(这通常非常稳定)。
  • Visual Studio 2017/2019 有 SDK 安装程序和 MSBuild 安装。
  • Sublime Text 3 支持选择不同版本的 TypeScript

你可以查看支持 TypeScript 的编辑器列表,了解更多你最喜欢的编辑器是否支持使用新版本。


转换为可选链式调用

可选链式调用是一个最近受到很多喜爱的功能。这就是为什么 TypeScript 4.0 带来了一个新的重构,将常见的模式转换为利用可选链式调用和 nullish 合并!


@deprecated 支持

TypeScript 的编辑支持现在可以识别声明是否被 /** @deprecated */ JSDoc 注释标记。这些信息会在完成功能列表中显示,并作为编辑器可以特别处理的建议诊断。在像 VS Code 这样的编辑器中,已弃用的值通常会以删除线样式显示。


启动时的部分语义模式

我们听到了很多用户在大型项目中启动时间过长的反馈。罪魁祸首通常是称为程序构建的过程。这是从初始根文件集开始,解析它们,查找它们的依赖项,解析这些依赖项,查找这些依赖项的依赖项,依此类推的过程。项目越大,你等待基本编辑操作(如转到定义或快速信息)的时间就越长。

这就是为什么我们一直在为编辑器开发一种新的模式,在完整语言服务体验加载之前提供部分体验。核心思想是编辑器可以运行一个轻量级的部分服务器,只查看编辑器当前打开的文件。

很难确切地说你会看到什么样的改进,但根据经验,以前在 Visual Studio Code 代码库上,TypeScript 完全响应需要 20 秒到一分钟。相比之下,我们的新部分语义模式似乎将延迟缩短到只需几秒钟。

目前,只有 Visual Studio Code 支持这种模式,并且在 Visual Studio Code Insiders 中有一些用户体验改进即将推出。我们认识到这种体验在用户体验和功能上可能仍有改进空间,我们有一系列改进想法。我们希望获得更多关于你认为可能有用的反馈。


更智能的自动导入

自动导入是一个让编码变得更加容易的出色功能,但每当自动导入似乎无法正常工作时,都会让用户感到困惑。我们从用户那里听到的一个具体问题是,自动导入在 TypeScript 编写的依赖项上无法正常工作,直到他们在项目中的其他地方至少显式导入了一次。

为什么自动导入对 @types 包有效,但对自带类型的包无效?事实证明,自动导入只对你的项目已经包含的包有效。因为 TypeScript 有一些奇怪的默认行为,会自动将 node_modules/@types 中的包添加到你的项目中,所以这些包会被自动导入。另一方面,其他包被排除在外,因为遍历所有 node_modules 包可能会非常昂贵。

所有这些都导致了一个很糟糕的初始体验,当你试图自动导入一个刚刚安装但尚未使用过的包时。

TypeScript 4.0 现在在编辑器场景中做了一些额外的工作,以包含你在 package.jsondependencies(和 peerDependencies)字段中列出的包。这些包中的信息仅用于改进自动导入,不会改变类型检查等其他任何内容。这允许我们为所有带有类型的依赖项提供自动导入,而无需承担完整 node_modules 搜索的成本。

在罕见情况下,当你的 package.json 列出了超过十个尚未导入的带类型依赖项时,此功能会自动禁用,以防止项目加载缓慢。要强制启用此功能或完全禁用它,你应该能够配置你的编辑器。对于 Visual Studio Code,这是“包含包 JSON 自动导入”(或 typescript.preferences.includePackageJsonAutoImports)设置。


我们的新网站

TypeScript 网站最近已经从头重写并推出!

我们已经写了一些关于新网站的内容,所以你可以在那里了解更多;但值得一提的是,我们仍然希望听到你的想法!如果你有任何问题、评论或建议,可以在网站的问题跟踪器上提交。


破坏性更改

lib.d.ts 更改

我们的 lib.d.ts 声明已更改——最具体来说,DOM 的类型已更改。最显著的更改可能是移除了 document.origin,它仅在旧版本的 IE 和 Safari 中有效。MDN 建议迁移到 self.origin


属性覆盖访问器(反之亦然)是一个错误

以前,只有在使用 useDefineForClassFields 时,属性覆盖访问器或访问器覆盖属性才会报错;然而,TypeScript 现在在派生类中声明会覆盖基类中的 getter 或 setter 的属性时始终会报错。

class Base {
    get foo() {
        return 100;
    }
    set foo(value) {
        // ...
    }
}
class Derived extends Base {
    foo = 10;
}
class Base {
    prop = 10;
}
class Derived extends Base {
    get prop() {
        return 100;
    }
}


delete 的操作数必须是可选的

strictNullChecks 中使用 delete 运算符时,操作数现在必须是 anyunknownnever 或是可选的(即类型中包含 undefined)。否则,使用 delete 运算符会报错。

interface Thing {
    prop: string;
}
function f(x: Thing) {
    delete x.prop;
}


使用 TypeScript 的节点工厂已弃用

如今,TypeScript 提供了一组用于生成 AST 节点的“工厂”函数;然而,TypeScript 4.0 提供了一个新的节点工厂 API。因此,对于 TypeScript 4.0,我们决定弃用这些旧函数,以支持新的函数。


以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号