TypeScript 4.0 新特性介绍 - 元组类型与短路赋值解析
可变参数元组类型
想象一下在 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 子句变量的类型指定为 unknown
。unknown
比 any
更安全,因为它提醒我们需要在操作值之前进行某种类型检查。
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
模式(隐含 incremental
和 noEmitOnError
)。
使用 --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.json
的 dependencies
(和 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
运算符时,操作数现在必须是 any
、unknown
、never
或是可选的(即类型中包含 undefined
)。否则,使用 delete
运算符会报错。
interface Thing {
prop: string;
}
function f(x: Thing) {
delete x.prop;
}
使用 TypeScript 的节点工厂已弃用
如今,TypeScript 提供了一组用于生成 AST 节点的“工厂”函数;然而,TypeScript 4.0 提供了一个新的节点工厂 API。因此,对于 TypeScript 4.0,我们决定弃用这些旧函数,以支持新的函数。
更多建议: