彻底掌握 TypeScript——IDE 超能力

0 阅读18分钟

TypeScript 的工作方式并不会因为你使用的是哪一款 IDE 而改变,但在本书中,我们默认你使用的是 VS Code。当你打开 VS Code 时,TypeScript 服务器就会在后台启动。只要你打开着 TypeScript 文件,它就会一直处于活动状态。本章将介绍一些由 TypeScript 服务器驱动、在 VS Code 中非常强大的功能。

自动补全

如果要说一个我们离不开的 TypeScript 功能,那一定是自动补全(autocomplete) 。TypeScript 知道你应用中每一个东西的类型,因此它能够在你输入时给出建议,大幅提升你的开发效率。

在下面这个例子中,只要在 audioElement 后面输入一个 p,就会弹出所有以 p 开头的属性:

const audioElement = document.createElement("audio");

audioElement.p; // 会提示 play、pause、part 等

这个功能在探索你并不熟悉的 API 时尤其强大,比如这里的 HTMLAudioElement API

手动触发自动补全

你经常会希望手动触发自动补全。在 VS Code 中,可以使用 CTRL + 空格 快捷键来列出当前输入位置的可用建议。比如,当你想给某个元素添加事件监听器时,你会看到可用事件的列表:

document.addEventListener(
  "", // 在这里触发自动补全
);

如果把光标放在引号里面并按下 CTRL + 空格,就会显示可监听的事件列表:

DOMContentLoaded
abort
animationcancel
...

如果你想把列表缩小到自己更关心的事件,可以先输入 drag,再按 CTRL + 空格,这样就只会显示相关事件:

drag
dragend
dragenter
dragleave
...

自动补全是编写 TypeScript 代码时不可或缺的工具,而在 VS Code 中,它默认就是可用的。

练习 2-1:自动补全

下面有一段可以触发自动补全的代码示例:

const acceptsObj = (obj: {foo: string; bar: number; baz: boolean}) => {};

acceptsObj({
  // 在这里自动补全!
});

练习使用自动补全快捷键,在调用 acceptsObj 时把对象补全出来。

参见 https://totalts.link/essentials-2-1/

解答

当你在对象内部按下 CTRL + 空格 时,会看到基于 {foo: string; bar: number; baz: boolean} 类型推导出来的可选属性列表:

bar;
baz;
foo;

随着你逐个选择属性,自动补全列表也会更新,只显示尚未填写的剩余属性。

TypeScript 的错误检查

TypeScript 最广为人知的能力之一,就是它的错误检查。它有一套规则,用来确保你的代码做的事情,和你以为它在做的事情一致。你每次对文件做出修改时,TypeScript 服务器都会重新检查你的代码。

如果服务器发现错误,就会通知 VS Code 在有问题的代码下方画出一条红色波浪线。把鼠标悬停在带下划线的代码上,就能看到错误信息。一旦你修改了代码,TypeScript 服务器会再次检查;如果错误已经修复,红色波浪线也会消失。你可以把它想象成一位老师站在你身后,在你打字时用红笔给你的作业做标注。

下面我们更深入地看看这些错误。

运行时错误

有时,TypeScript 会提醒你某些代码在运行时肯定会失败。例如下面这段代码:

const a = null;

a.toString(); // a 下方会出现红色波浪线

TypeScript 会告诉你 a 有问题。把鼠标悬停在上面,会看到下面的错误信息:

'a' is possibly 'null'.

这条信息告诉你问题出在哪里,但不一定直接告诉你问题本身是什么。在这里,你需要停下来想一想:为什么不能在 null 上调用 toString()?因为如果你真这么做了,运行时就会抛出错误:

Uncaught TypeError: Cannot read properties of null (reading 'toString').

也就是说,TypeScript 在你根本不需要运行代码的情况下,就提前告诉你这里可能出错——这非常方便。

非运行时错误

并不是 TypeScript 警告你的所有问题,都会真的在运行时报错。来看下面这个例子,我们试图从一个空对象上读取属性:

const obj = {};

const result = obj.foo; // foo 下方会出现红色波浪线

TypeScript 会在 foo 下方画出红色波浪线。但仔细想想,这段代码在运行时其实并不会报错。我们只是访问了一个对象中不存在的属性 foo。这不会抛异常,只会让 result 的值变成 undefined

TypeScript 居然会对一个不会导致运行时报错的地方发出警告,这看起来可能有点奇怪,但实际上这是件好事。因为如果 TypeScript 不对这种情况给出警告,那就相当于默认你可以随时从任何对象上访问任何属性。放到整个应用中,这会积累出非常多的 bug。

因此,最好把 TypeScript 的规则理解为一种带有立场的规范(opinionated rules) 。它们是一组有帮助的提示,会让你的应用整体上更加安全。

警告出现的位置

TypeScript 会尽量把警告放在尽可能接近问题源头的位置。来看下面这个例子:

type Album = {
  artist: string;
  title: string;
  year: number;
};

const album: Album = {
  artsist: "Television", // artsist 下方会出现红色波浪线
  title: "Marquee Moon",
  year: 1977,
};

这里我们定义了一个 Album 类型,它是一个包含三个属性的对象。接着,我们用 const album: Album 声明 album 必须符合这个类型。即使你现在还不完全理解这里的语法,也没关系,后面都会讲到。

你能看出问题吗?在创建 album 时,artist 被误拼成了 artsist。当你把鼠标悬停在 artsist 上方时,会看到如下错误信息:

Type '{artsist: string; title: string; year: number;}' is not assignable to type 'Album'.

这是因为我们已经声明了 album 必须是 Album 类型,但实际写的时候把 artist 拼错了。TypeScript 在告诉你:这里写错了,而且它甚至还能提示你正确的拼写。

多行错误信息

有时,TypeScript 给出的错误位置并不会那么直观。看下面这个例子,我们有一个叫做 logUserJobTitle 的函数,它会打印用户的职位名称:

const logUserJobTitle = (user: {
  job: {
    title: string;
  };
}) => {
  console.log(user.job.title);
};

关键点在于:logUserJobTitle 接收一个 user 对象,其中有一个 job 属性,而 job 又有一个 title 属性。

现在,我们来调用这个函数,但传入的 job.title 是一个数字,而不是字符串:

const exampleUser = {
  job: {
    title: 123,
  },
};

logUserJobTitle(exampleUser); // exampleUser 下方会出现红色波浪线

你可能会觉得,TypeScript 应该在 exampleUser 对象内部的 title 上报错。但实际上,它把错误标在了整个 exampleUser 变量上。

把鼠标悬停在 exampleUser 上,会看到下面这段错误信息:

Argument of type '{job: {title: number;};}' is not assignable to
parameter of type '{job: {title: string;};}'.
  The types of 'job.title' are incompatible between these types.
    Type 'number' is not assignable to type 'string'.

这段报错有好几行,看起来可能会有点吓人。面对这种多行错误,一个实用经验是:从最底下一行开始看

Type 'number' is not assignable to type 'string'.

这句话告诉你:某个本该是字符串的位置,传进去了一个数字。这就是问题的根源。

再往上一行看:

The types of 'job.title' are incompatible between these types.

这说明问题出在 job 对象里的 title 属性上。看到这里,其实你已经不需要最上面那一长行总结性信息了。处理 TypeScript 多行报错时,从下往上读通常是个很有帮助的策略。

查看变量和声明的信息

你可以悬停查看的,不只是错误信息。实际上,只要你把鼠标停在某个变量或声明上,VS Code 都会显示与之相关的信息。

在下面这个例子里,你可以悬停在 thing 上,看到它的类型是 number

let thing = 123;

// 悬停在 thing 上会显示:

let thing: number;

这种悬停查看的能力,对更复杂的例子同样有效。这里,otherObject 展开了 otherThing 的属性,同时又新增了 thing

let otherThing = {
  name: "Alice",
};

const otherObject = {
  ...otherThing,
  thing,
};

otherObject.thing;

悬停在 otherObject 上,VS Code 会计算并显示它的所有属性:

// 悬停在 otherObject 上会显示:

const otherObject: {
  thing: number;
  name: string;
};

不同的位置,悬停显示的信息也不同。比如,如果你悬停在 otherObject.thing 中的 .thing 上,看到的会是 thing 这个属性的类型:

(property) thing: number

要尽快习惯在代码库里“到处悬停查看变量和声明”,因为这是理解代码行为的一种极好方式。

练习 2-2:悬停查看函数调用

在下面这段代码中,我们想用 document.getElementById 获取一个 ID 为 12 的元素,但 TypeScript 报错了:

let element = document.getElementById(12); // 12 下方会出现红色波浪线

悬停查看,能怎样帮助你判断 document.getElementById 实际需要什么类型的参数?再加一道附加题:element 的类型是什么?

参见 https://totalts.link/essentials-2-2/

解答

首先,你可以直接悬停在红色波浪线对应的错误上。在这里,把鼠标停在 12 上,会看到下面的错误信息:

Argument of type 'number' is not assignable to parameter of type 'string'.

你也可以悬停在 getElementById 函数名本身上,查看它的签名:

(method) Document.getElementById(elementId: string): HTMLElement | null

getElementById 这个例子里,你可以看到它长得像一个函数签名:它接收一个类型为 stringelementId 参数,并返回 HTMLElement | null。后面我们会详细讲这个语法,但它本质上表示:返回值要么是一个 HTMLElement,要么是 null

注意,这段信息只有在你悬停在 getElementById 上时才会出现。如果你悬停的是 document 本身,就看不到 getElementById 的签名信息,因此悬停时一定要尽量精确。

这就告诉你,修复这个错误的方法是把参数改成字符串:

let element = document.getElementById("12");

你也已经知道,element 的类型应该就是 document.getElementById 的返回值类型;把鼠标悬停在 element 上,就能确认这一点:

// 悬停在 element 上会显示:

const element: HTMLElement | null;

在不同的位置悬停,会揭示不同的信息。因此,在编写 TypeScript 时,我们几乎会不停地“到处悬停”,以便更准确地理解代码在做什么。

JSDoc 注释

JSDoc 是一种为代码中的类型和函数添加文档说明的语法。它可以让 VS Code 在悬停弹出的信息框中显示额外的说明内容。这个功能在团队协作中尤其有用。

下面是一个为 logValues 函数编写文档的例子:

/**
 * Logs the values of an object to the console.
 *
 * @param obj - The object to log.
 *
 * @example
 * ```ts
 * logValues({a: 1, b: 2});
 * // Output:
 * // a: 1
 * // b: 2
 * ```
 */

const logValues = (obj: any) => {
  for (const key in obj) {
    console.log(`${key}: ${obj[key]}`);
  }
};

其中,@param 标签用于描述函数参数;@example 标签用于给出函数的使用示例,并且可以配合 Markdown 语法来书写。

JSDoc 注释还支持很多其他标签。完整列表可以在 JSDoc 官方文档中查看:https://jsdoc.app

无论你是在开发一个库、与团队协作,还是在维护自己的个人项目,添加 JSDoc 注释都是一种非常实用的方式,可以帮助你传达代码的目的和用法。

练习 2-3:为悬停提示添加文档说明

下面有一个简单函数,用来把两个数字相加:

const myFunction = (a: number, b: number) => {
  return a + b;
};

要理解这个函数的作用,你必须直接去读代码。请给这个函数添加一些文档说明,这样当你悬停在它上面时,就能直接看到它是做什么的。

参见 https://totalts.link/essentials-2-3/

解答

在这个例子里,你可以说明这个函数是“将两个数字相加”。同时,也可以使用 @example 标签给出一个用法示例:

/**
 * Adds two numbers together.
 * @example
 * myFunction(1, 2);
 * // Will return 3
 */

const myFunction = (a: number, b: number) => {
  return a + b;
};

这样一来,每次你把鼠标悬停在这个函数上时,都会看到函数签名、注释内容,以及 @example 之后带完整语法高亮的示例代码:

// 悬停在 myFunction 上会显示:

const myFunction: (a: number, b: number) => number

Adds two numbers together.

@example

myFunction(1, 2);

// Will return 3

虽然这个例子很简单(你当然也可以直接把函数取个更好的名字),但在真实项目中,JSDoc 往往是记录代码的重要工具。

使用“转到定义”和“查找引用”进行导航

TypeScript 服务器还提供了跳转到变量或声明定义位置的能力。在 VS Code 中,这个 Go to Definition(转到定义) 的快捷操作,在 macOS 上是 COMMAND + 单击,在 Windows 上可以使用 CTRL + 右键,或者在当前光标位置按 F12。你也可以在任意平台上右键,然后从上下文菜单中选择 Go to Definition。为了简洁起见,下面我们统一使用 macOS 快捷方式来描述。

当你跳转到定义位置之后,再次执行 COMMAND + 单击,就可以看到该变量或声明被使用的所有位置。这就是 Go to References(查找引用) 。在大型代码库中导航时,这个功能尤其有用。

你还可以使用这个快捷方式查看内置类型和库的类型定义。比如,在使用 getElementById 方法时,如果你对 document 执行 COMMAND + 单击,就会跳转到 document 的类型定义。这是一个非常棒的功能,能帮助你理解内置类型和类库是如何工作的。

Rename Symbol

在某些情况下,你可能希望在整个代码库中重命名一个变量。比如,某个数据库列名从 id 改成了 entityId。这时,简单的查找替换并不好用,因为 id 在很多地方都可能有不同含义。TypeScript 提供的 Rename Symbol(重命名符号) 功能,可以通过一次操作安全地完成这种重构。来看下面的例子:

const filterUsersById = (id: string) => {
  return users.filter((user) => user.id === id);
};

右键点击 filterUsersById 函数的 id 参数,选择 Rename Symbol。(你也可以直接按 F2。)随后会弹出一个面板,让你输入新的名称。输入 userIdToFilterBy,然后按回车。VS Code 足够智能,能够识别出你想重命名的只是这个函数参数,而不是 user.id 这个对象属性:

const filterUsersById = (userIdToFilterBy: string) => {
  return users.filter((user) => user.id === userIdToFilterBy);
};

Rename Symbol 是一个非常适合做代码重构的工具,而且它还支持跨多个文件一起重命名。

自动导入

大型 JavaScript 应用通常由大量模块组成,手动从其他文件中导入内容会非常繁琐。幸运的是,TypeScript 支持自动导入

当你开始输入某个想要导入的变量名时,在按下 CTRL + 空格 后,TypeScript 会显示建议列表。只要从列表中选中一个变量,TypeScript 就会自动在文件顶部添加对应的 import 语句。

不过,在变量名中间位置使用自动补全时要稍微小心一点,因为它有可能会不小心改动整行剩余内容。为了避免这种问题,最好确保光标位于名称末尾之后,再按 CTRL + 空格

Quick Fixes

VS Code 还提供了一个 Quick Fix(快速修复) 功能,可以用来执行一些快速重构脚本。现在,我们先用它来一次性导入多个缺失的依赖。

要打开 Quick Fixes 菜单,在 macOS 上按 COMMAND + .(句点) ,在 Windows 上按 CTRL + .(句点) 。如果你在一行引用了尚未导入的值的代码上使用这个快捷方式,就会看到一个弹窗:

const triangle = new Triangle(); // Triangle 下方会出现红色波浪线

Quick Fixes 菜单中,其中一个选项是 Add All Missing Imports。选择它之后,所有缺失的导入都会被自动添加到文件顶部:

import {Triangle} from "./shapes";

const triangle = new Triangle();

Quick Fixes 菜单里提供了很多重构选项,它是了解 TypeScript 能力的一个很好的入口。(我们会在练习 2-4 中再次回到这个菜单。)

重启 VS Code 的 TypeScript 服务器

前面你已经看到,TypeScript 在 VS Code 中能带来很多很酷的能力。不过,运行一个语言服务器并不是件简单的事。TypeScript 服务器有时会进入不正常状态,导致工作异常。比如配置文件发生变化,或者你在操作一个特别大的代码库时,就可能遇到这种情况。有时候,本来应该出现的错误不再显示了;有时候,类型信息也会莫名其妙地混乱。

如果你发现了一些奇怪的行为,通常值得尝试一下重启 TypeScript 服务器。具体方法是:在 macOS 上按 COMMAND + SHIFT + P,在 Windows 上按 CTRL + SHIFT + P 打开 VS Code 的命令面板(Command Palette),然后搜索 Restart TS Server。几秒钟之后,服务器通常就会重新正常工作,错误提示也会恢复正常。

在 JavaScript 中工作

如果你本来就是 JavaScript 用户,可能已经注意到:上面讲到的很多功能,其实即使不使用 TypeScript,在 JavaScript 文件里也已经可用了。自动补全、整理导入、自动导入、悬停查看,这些功能在 JavaScript 中同样存在。为什么会这样?

答案还是:因为 TypeScript。TypeScript 的 IDE 服务器不仅会运行在 TypeScript 文件上,也会运行在 JavaScript 文件上。这意味着,TypeScript 的部分高级 IDE 能力,其实在 JavaScript 中也能使用。

不过,也有一些功能在 JavaScript 中并不会默认启用。最典型的就是 IDE 内部的错误提示。因为没有类型注解,TypeScript 对你的代码结构并没有足够的把握,也就无法给出精确的警告。

注意
实际上,TypeScript 支持通过 JSDoc 注释为 .js 文件补充类型信息,只不过这套能力默认并没有完全启用。后面你会学到如何配置它。

TypeScript 之所以这样做,是为了让 VS Code 用户在 JavaScript 开发中也能获得更好的体验。哪怕只提供 TypeScript 能力的一部分,也总比完全没有要好。换句话说,对于 JavaScript 用户而言,迁移到 TypeScript 应该是一件非常自然、非常熟悉的事:IDE 体验还是那个体验,只不过变得更强了。

练习 2-4:Quick Fix 重构

现在回到前面提到过的 VS Code Quick Fixes 菜单。下面这个例子中,有一个函数内部定义了一个 randomPercentage 变量,它通过调用 Math.random() 并把结果格式化成固定小数位来生成一个随机百分比:

const func = () => {
  // 把这段逻辑重构成独立函数
  const randomPercentage = `${(Math.random() * 100).toFixed(2)}%`;

  console.log(randomPercentage);
};

这里的目标是:把生成随机百分比的逻辑提取成一个单独的函数。

选中一个变量、一行代码,或者一整段代码,然后按下 COMMAND + . 打开 Quick Fixes 菜单。根据你打开菜单时光标所在的位置不同,你会看到几种不同的代码修改选项。可以多试试不同位置,观察它们对这个示例函数会产生什么影响。

参见 https://totalts.link/essentials-2-4/

解答

Quick Fixes 菜单会根据你打开时光标所在的位置,显示不同的重构选项。

内联变量

如果你把目标放在 randomPercentage 上,可以选择 Inline Variable,这会删除这个变量,并直接把它的值内联到 console.log 中:

const func = () => {
  console.log(`${(Math.random() * 100).toFixed(2)}%`);
};

提取常量

如果你只选中较小的一部分代码,比如 Math.random() * 100,就会出现 Extract Constant in Enclosing Scope 选项。选择它后,会创建一个新的局部变量,并提示你为它命名。保存并运行代码格式化工具后,结果会变得很整洁:

const func = () => {
  const randomTimes100 = Math.random() * 100;
  const randomPercentage = `${randomTimes100.toFixed(2)}%`;
  console.log(randomPercentage);
};

类似地,Extract to Constant in Module Scope 会把新常量提取到模块级作用域中:

const randomTimes100 = Math.random() * 100;

const func = () => {
  const randomPercentage = `${randomTimes100.toFixed(2)}%`;
  console.log(randomPercentage);
};

提取函数

如果你选中整个“随机百分比生成逻辑”,还会看到另外一些提取选项。比如 Extract to Function in Module Scope,它和提取常量类似,但生成的是一个函数:

const func = () => {
  const randomPercentage = getRandomPercentage();
  console.log(randomPercentage);
};

function getRandomPercentage() {
  return `${(Math.random() * 100).toFixed(2)}%`;
}

这只是 Quick Fixes 菜单能够提供的一小部分能力。它能做的事情非常多,而我们现在只是刚刚触及表面。继续探索、继续尝试,你会逐步发现它更完整的潜力。

小结

本章介绍了那些让 TypeScript 在 VS Code 中变得如此高效的 IDE 强大功能。后台运行的 TypeScript 服务器会在你编写代码时实时提供反馈与辅助。

自动补全大概是最有价值的功能之一。由于 TypeScript 理解代码中各个值的类型,它能够给出智能建议。你还可以通过 CTRL + 空格 手动触发自动补全,用来探索 API、发现可用的属性和方法。

TypeScript 的错误检查既能捕获运行时错误(例如在 null 上调用方法),也能捕获虽然不会让应用崩溃、但可能导致 bug 的潜在问题。随着你输入代码,红色波浪线会立刻出现,而悬停提示则会解释问题所在。

悬停功能可以让你检查任意变量或声明,查看它被推导出的类型,从而帮助你理解代码在做什么。再结合 JSDoc 注释,你还可以为这些悬停提示提供更丰富的文档说明。

Go to DefinitionGo to References 这样的导航能力,能帮助你在大型代码库中高效穿梭。Rename Symbol 则可以安全地在整个项目中重命名变量,而不会误伤无关代码。

自动导入和快速修复可以进一步简化你的工作流,自动处理那些繁琐的事情,比如补全导入语句,或者把一段代码块重构成独立函数。当工具偶尔出问题时,重启 TypeScript 服务器通常就能解决。

即使是在 JavaScript 文件中,你也能享受到上述许多能力,因为 TypeScript 服务器同样会分析 JavaScript 文件。这也让 JavaScript 用户迁移到完整的 TypeScript 时,整个体验显得自然、熟悉,而且几乎没有门槛。