Skip to content
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待
虚位以待

基础知识

JavaScript 中的每一个值都有一组你可以通过运行不同操作来观察到的行为。 这听起来很抽象,但举一个简单的例子,考虑一下我们可能对一个名为 message 的变量运行的一些操作。

js
// 访问 'message' 上的属性 'toLowerCase'
// 然后调用它
message.toLowerCase();

// 调用 'message'
message();

如果我们分解一下,第一行可运行的代码访问了一个名为 toLowerCase 的属性,然后调用了它。 第二行尝试直接调用 message

但假设我们不知道 message 的值——这很常见——我们就无法可靠地说出尝试运行这些代码会得到什么结果。 每个操作的行为完全取决于我们最初拥有的值。

  • message 是可调用的吗?
  • 它上面有一个名为 toLowerCase 的属性吗?
  • 如果有,toLowerCase 甚至是可调用的吗?
  • 如果这两个值都是可调用的,它们返回什么?

这些问题的答案通常是我们写 JavaScript 时记在脑子里的东西,我们得希望自己把所有细节都弄对了。

假设 message 是按以下方式定义的。

js
const message = "Hello World!";

正如你可能猜到的,如果我们尝试运行 message.toLowerCase(),我们将得到相同字符串的小写版本。

第二行代码呢? 如果你熟悉 JavaScript,你就会知道这会失败并抛出一个异常:

txt
TypeError: message is not a function

如果我们能避免这样的错误就太好了。

当我们运行代码时,JavaScript 运行时决定如何操作的方式是弄清值的 类型——它具有什么样的行为和能力。 这就是 TypeError 所暗示的部分意思——它表明字符串 "Hello World!" 不能作为函数被调用。

对于某些值,例如原始类型 stringnumber,我们可以在运行时使用 typeof 操作符来识别它们的类型。 但对于其他东西,比如函数,没有相应的运行时机制来识别它们的类型。 例如,考虑这个函数:

js
function fn(x) {
  return x.flip();
}

通过阅读代码,我们可以 观察到 这个函数只有在被赋予一个具有可调用 flip 属性的对象时才能工作,但 JavaScript 并没有以一种我们在代码运行时可以检查的方式公开这些信息。 在纯 JavaScript 中,要判断 fn 对特定值做了什么,唯一的方法就是调用它并看看会发生什么。 这种行为使得在代码运行之前很难预测代码会做什么,这意味着在编写代码时更难知道你的代码会做什么。

从这个角度看,类型 就是描述哪些值可以传递给 fn、哪些值会导致崩溃的概念。 JavaScript 只真正提供 动态 类型——运行代码看看会发生什么。

替代方案是使用 静态 类型系统来预测代码在运行 之前 应该做什么。

静态类型检查

回想一下我们之前尝试将 string 作为函数调用时得到的 TypeError大多数人 都不喜欢在运行代码时遇到任何类型的错误——这些被认为是 bug! 当我们编写新代码时,我们会尽力避免引入新的 bug。

如果我们只添加一点代码,保存文件,重新运行代码,并立即看到错误,我们也许能够快速定位问题;但情况并非总是如此。 我们可能没有足够彻底地测试该功能,所以我们可能永远不会真正遇到可能会抛出的潜在错误! 或者,如果我们幸运地目睹了错误,我们可能最终进行了大规模重构并添加了大量不同的代码,我们不得不费力地从中查找问题。

理想情况下,我们可以有一个工具帮助我们在代码运行 之前 发现这些错误。 这就是像 TypeScript 这样的静态类型检查器所做的。 静态类型系统 描述了当我们运行程序时,我们的值将具有的形状和行为。 像 TypeScript 这样的类型检查器使用这些信息,并在事情可能出错时告诉我们。

ts
const 
message
= "hello!";
message();
This expression is not callable. Type 'String' has no call signatures.
Try

用 TypeScript 运行最后一个示例,我们将在首次运行代码之前收到一条错误消息。

非异常失败

到目前为止,我们一直在讨论某些事情,比如运行时错误——JavaScript 运行时告诉我们它认为某些事情是荒谬的情况。 这些情况出现是因为 ECMAScript 规范 对语言在遇到意外情况时应如何表现有明确的指令。

例如,规范规定尝试调用不可调用的东西应该抛出错误。 这听起来像是“显而易见的行为”,但你可以想象,访问对象上不存在的属性也应该抛出错误。 相反,JavaScript 给了我们不同的行为,并返回值 undefined

js
const user = {
  name: "Daniel",
  age: 26,
};

user.location; // 返回 undefined

最终,静态类型系统必须决定在其系统中应将哪些代码标记为错误,即使它是“合法的”JavaScript,不会立即抛出错误。 在 TypeScript 中,以下代码会生成关于 location 未定义的错误:

ts
const 
user
= {
name
: "Daniel",
age
: 26,
};
user
.location;
Property 'location' does not exist on type '{ name: string; age: number; }'.
Try

虽然有时这意味着在你能表达的内容上有所取舍,但目的是捕获我们程序中的合法 bug。 而 TypeScript 捕获了 很多 合法 bug。

例如:拼写错误,

ts
const 
announcement
= "Hello World!";
// 你能多快发现拼写错误?
announcement
.toLocaleLowercase();
announcement
.toLocalLowerCase();
// 我们可能想写的是这个...
announcement
.
toLocaleLowerCase
();
Try

未调用的函数,

ts
function 
flipCoin
() {
// 本意是 Math.random() return Math.random < 0.5;
Operator '<' cannot be applied to types '() => number' and 'number'.
}
Try

或基本逻辑错误。

ts
const 
value
=
Math
.
random
() < 0.5 ? "a" : "b";
if (
value
!== "a") {
// ... } else if (value === "b") {
This comparison appears to be unintentional because the types '"a"' and '"b"' have no overlap.
// 哎呀,不可达 }
Try

类型用于工具

TypeScript 可以在我们犯错时捕获错误。 这很棒,但 TypeScript 还能 首先防止我们犯这些错误。

类型检查器拥有信息来检查我们是否在变量和其他属性上访问了正确的属性。 一旦它有了这些信息,它还可以开始 建议 你可能想要使用哪些属性。

这意味着 TypeScript 也可以用于编辑代码,核心类型检查器可以在你键入时提供错误消息和代码补全。 这就是人们谈论 TypeScript 工具时经常提到的部分内容。

ts
import 
express
from "express";
const
app
=
express
();
app
.
get
("/", function (
req
,
res
) {
res
.sen
});
app
.
listen
(3000);
Try

TypeScript 非常重视工具,这不仅仅局限于你键入时的补全和错误。 支持 TypeScript 的编辑器可以提供“快速修复”来自动修复错误,提供重构来轻松重组代码,以及提供有用的导航功能,例如跳转到变量的定义,或查找对给定变量的所有引用。 所有这些都建立在类型检查器之上,并且是完全跨平台的,所以 你最喜欢的编辑器很可能支持 TypeScript

tsc,TypeScript 编译器

我们一直在讨论类型检查,但我们还没有使用我们的类型检查器。 让我们来认识一下我们的新朋友 tsc,TypeScript 编译器。 首先,我们需要通过 npm 获取它。

sh
npm install -g typescript

这会全局安装 TypeScript 编译器 tsc。 如果你更愿意从本地的 node_modules 包中运行 tsc,可以使用 npx 或类似的工具。

现在,让我们移动到一个空文件夹,并尝试编写我们的第一个 TypeScript 程序:hello.ts

ts
// 向世界问好。
console
.
log
("Hello world!");
Try

注意这里没有花里胡哨的东西;这个“hello world”程序看起来与你用 JavaScript 编写的“hello world”程序完全相同。 现在,让我们通过运行 tsc 命令来对它进行类型检查,该命令由 typescript 包为我们安装。

sh
tsc hello.ts

嗒哒!

等等,到底 “嗒哒”什么? 我们运行了 tsc,什么也没发生! 嗯,没有类型错误,所以控制台中没有输出,因为没有什么可报告的。

但是再检查一下——我们反而得到了一些 文件 输出。 如果我们查看当前目录,我们会在 hello.ts 旁边看到一个 hello.js 文件。 这是 tsc 编译转换 我们的 hello.ts 文件为纯 JavaScript 文件后的输出。 如果我们检查内容,我们会看到 TypeScript 在处理 .ts 文件后输出的内容:

js
// 向世界问好。
console.log("Hello world!");

在这种情况下,TypeScript 几乎没有什么要转换的,所以它看起来和我们写的一模一样。 编译器尝试发出清晰可读的、看起来像人写的代码。 虽然这并不总是那么容易,但 TypeScript 会一致地缩进,注意我们的代码何时跨越多行,并尝试保留注释。

如果我们 确实 引入了一个类型检查错误呢? 让我们重写 hello.ts

ts
// 这是一个工业级的通用问候函数:
function 
greet
(
person
,
date
) {
console
.
log
(`Hello ${
person
}, today is ${
date
}!`);
}
greet
("Brendan");
Try

如果我们再次运行 tsc hello.ts,注意我们在命令行上得到了一个错误!

txt
Expected 2 arguments, but got 1.

TypeScript 告诉我们忘记向 greet 函数传递一个参数了,这是理所当然的。 到目前为止,我们只编写了标准的 JavaScript,但类型检查仍然能够发现我们代码中的问题。 谢谢你,TypeScript!

有错误时仍可生成文件

你可能没有注意到上一个例子中的一件事是,我们的 hello.js 文件又变了。 如果我们打开那个文件,我们会看到内容基本上看起来仍然和我们的输入文件一样。 考虑到 tsc 报告了代码中的错误,这可能有点令人惊讶,但这是基于 TypeScript 的核心价值观之一:大多数时候, 比 TypeScript 更了解情况。

重申一下之前的观点,类型检查代码限制了你可以运行的程序种类,因此在类型检查器接受什么样的事情上存在权衡。 大多数时候这没问题,但在某些情况下,这些检查会碍事。 例如,想象一下你将 JavaScript 代码迁移到 TypeScript 并引入类型检查错误。 最终你会为类型检查器清理干净,但原来的 JavaScript 代码已经可以工作了! 为什么把它转换成 TypeScript 就应该阻止你运行它呢?

所以 TypeScript 不会妨碍你。 当然,随着时间的推移,你可能希望对错误采取更防御性的态度,并让 TypeScript 表现得更加严格。 在这种情况下,你可以使用 noEmitOnError 编译器选项。 尝试更改你的 hello.ts 文件并使用该标志运行 tsc

sh
tsc --noEmitOnError hello.ts

你会注意到 hello.js 永远不会被更新。

显式类型

到目前为止,我们还没有告诉 TypeScript persondate 是什么。 让我们编辑代码,告诉 TypeScript person 是一个 string,并且 date 应该是一个 Date 对象。 我们还将使用 date 上的 toDateString() 方法。

ts
function 
greet
(
person
: string,
date
: Date) {
console
.
log
(`Hello ${
person
}, today is ${
date
.
toDateString
()}!`);
}
Try

我们所做的是在 persondate 上添加 类型注解 来描述 greet 可以用什么类型的值来调用。 你可以将这个签名解读为 "greet 接受一个 string 类型的 person 和一个 Date 类型的 date"。

有了这个,TypeScript 可以告诉我们其他情况下 greet 可能被错误调用的情况。 例如...

ts
function 
greet
(
person
: string,
date
: Date) {
console
.
log
(`Hello ${
person
}, today is ${
date
.
toDateString
()}!`);
}
greet
("Maddison", Date());
Argument of type 'string' is not assignable to parameter of type 'Date'.
Try

嗯? TypeScript 在我们的第二个参数上报了错,但为什么呢?

也许令人惊讶的是,在 JavaScript 中调用 Date() 返回一个 string。 另一方面,用 new Date() 构造一个 Date 实际上给了我们期望的结果。

不管怎样,我们可以快速修复这个错误:

ts
function 
greet
(
person
: string,
date
: Date) {
console
.
log
(`Hello ${
person
}, today is ${
date
.
toDateString
()}!`);
}
greet
("Maddison", new
Date
());
Try

请记住,我们并不总是需要编写显式的类型注解。 在许多情况下,即使我们省略了类型,TypeScript 甚至可以为我们 推断(或“弄清楚”)类型。

ts
let 
msg
= "hello there!";
Try

即使我们没有告诉 TypeScript msg 的类型是 string,它也能够推断出来。 这是一个特性,当类型系统最终会推断出相同的类型时,最好不要添加注解。

注意:前面代码示例中的消息气泡是你的编辑器在悬停在该词上时会显示的内容。

类型擦除

让我们看看当我们用 tsc 编译上面的 greet 函数输出 JavaScript 时会发生什么:

ts
"use strict";
function greet(person, date) {
    console.log("Hello ".concat(person, ", today is ").concat(date.toDateString(), "!"));
}
greet("Maddison", new Date());
Try

这里注意两点:

  1. 我们的 persondate 参数不再有类型注解。
  2. 我们的“模板字符串”——那个使用反引号(` 字符)的字符串——被转换为带有拼接的普通字符串。

关于第二点稍后会详细说明,现在让我们关注第一点。 类型注解不是 JavaScript 的一部分(或者严格来说是 ECMAScript 的一部分),所以确实没有任何浏览器或其他运行时可以原封不动地运行 TypeScript。 这就是 TypeScript 首先需要编译器的原因——它需要某种方式来剥离或转换任何 TypeScript 特定的代码,以便你可以运行它。 大多数 TypeScript 特定的代码会被擦除,同样,这里的类型注解被完全擦除了。

记住:类型注解永远不会改变你程序的运行时行为。

降级

上面的另一个区别是我们的模板字符串被从

js
`Hello ${person}, today is ${date.toDateString()}!`;

重写为

js
"Hello ".concat(person, ", today is ").concat(date.toDateString(), "!");

为什么会这样?

模板字符串是 ECMAScript 2015(又名 ECMAScript 6、ES2015、ES6 等——别问了)版本中的一项功能。 TypeScript 能够将代码从较新版本的 ECMAScript 重写为较旧的版本,例如 ECMAScript 3 或 ECMAScript 5(又名 ES5)。 这种从更新或“更高”版本的 ECMAScript 迁移到更旧或“更低”版本的过程有时被称为 降级

默认情况下,TypeScript 以 ES5 为目标,这是一个非常古老的 ECMAScript 版本。 我们可以通过使用 target 选项来选择更近期的版本。 使用 --target es2015 运行会将 TypeScript 的目标更改为 ECMAScript 2015,这意味着代码应该能够在任何支持 ECMAScript 2015 的地方运行。 所以运行 tsc --target es2015 hello.ts 会给我们以下输出:

js
function greet(person, date) {
  console.log(`Hello ${person}, today is ${date.toDateString()}!`);
}
greet("Maddison", new Date());

虽然默认目标是 ES5,但绝大多数现代浏览器都支持 ES2015。 因此,大多数开发者可以安全地将 ES2015 或更高版本指定为目标,除非与某些古老浏览器的兼容性很重要。

严格性

不同的用户使用 TypeScript 在类型检查器中寻找不同的东西。 有些人正在寻找一种更宽松的、可选加入的体验,这可以帮助仅验证其程序的某些部分,并且仍然拥有不错的工具支持。 这是 TypeScript 的默认体验,其中类型是可选的,推断采用最宽松的类型,并且不检查潜在的 null/undefined 值。 就像 tsc 在有错误时仍会生成文件一样,这些默认设置是为了不碍事。 如果你正在迁移现有的 JavaScript,这可能是一个可取的第一步。

相比之下,许多用户更喜欢让 TypeScript 尽快尽可能地验证,这就是该语言也提供严格性设置的原因。 这些严格性设置将静态类型检查从一个开关(要么检查你的代码,要么不检查)变成了更接近刻度盘的东西。 你把这个刻度盘转得越远,TypeScript 就会为你检查得越多。 这可能需要一些额外的工作,但总的来说,从长远来看,这是值得的,并且可以实现更彻底的检查和更准确的工具。 如果可能,一个新的代码库应该始终打开这些严格性检查。

TypeScript 有几个可以打开或关闭的类型检查严格性标志,除非另有说明,我们所有的示例都将在启用所有标志的情况下编写。 CLI 中的 strict 标志,或者在 tsconfig.json 中设置 "strict": true 会同时打开它们,但我们可以单独选择关闭它们。 你应该知道的两个最大的标志是 noImplicitAnystrictNullChecks

noImplicitAny

回想一下,在某些地方,TypeScript 不会尝试为我们推断类型,而是退回到最宽松的类型:any。 这不是可能发生的最坏的事情——毕竟,退回到 any 只是普通的 JavaScript 体验而已。

然而,使用 any 通常会违背使用 TypeScript 的初衷。 你的程序类型化程度越高,你获得的验证和工具支持就越多,这意味着你在编码时遇到的 bug 就越少。 打开 noImplicitAny 标志将会对任何类型被隐式推断为 any 的变量发出错误。

strictNullChecks

默认情况下,像 nullundefined 这样的值可以被赋值给任何其他类型。 这可以使编写某些代码更容易,但忘记处理 nullundefined 是世界上无数 bug 的根源——有些人认为这是一个 价值数十亿美元的错误strictNullChecks 标志使处理 nullundefined 更加明确,并 让我们不必 担心是否 忘记 处理 nullundefined