第一部分:起步
接下来:1 关于本书
一、关于这本书
- 1.1 这本书的主页在哪里?
- 1.2 这本书包括什么内容?
- 1.3 我为我的钱得到了什么?
- 1.4 我如何预览内容?
- 1.5 我如何报告错误?
- 1.6 图标带有注释是什么意思?
- 1.7 致谢
1.1 这本书的主页在哪里?
“Tackling TypeScript”的主页是exploringjs.com/tackling-ts/
1.2 这本书包括什么内容?
这本书分为两部分:
- 第一部分是 TypeScript 的快速入门,快速教会您基本知识。
- 第二部分深入探讨了语言,并详细介绍了许多重要主题。
这本书不是参考书,而是为了补充官方 TypeScript 手册。
必需知识: 您必须了解 JavaScript。如果您想刷新您的知识:我的书“JavaScript for impatient programmers”可以在线免费阅读。
1.3 我为我的钱得到了什么?
如果您购买了这本书,您将获得:
- 当前内容有四个无数字版权限制的版本:
- PDF 文件
- ZIP 存档,无广告 HTML
- EPUB 文件
- MOBI 文件
- 任何添加到这个版本的未来内容。我能添加多少取决于这本书的销售情况。
1.4 我如何预览内容?
在这本书的主页上,有关于这本书所有版本的详细预览。
1.5 我如何报告错误?
- 这本书的 HTML 版本在每一章的结尾都有评论链接。
- 它们跳转到 GitHub 问题,您也可以直接访问GitHub 问题。
1.6 图标带有注释是什么意思?
阅读说明
解释了如何最好地阅读内容(以何种顺序,省略什么等)。
外部内容
指向额外的外部内容。
Git 存储库
提到了一个相关的 Git 存储库。
提示
给出一个提示。
问题
提出并回答一个问题(类似 FAQ)。
警告
警告有关陷阱等。
细节
提供了类似脚注的额外细节。
1.7 致谢
在每一章的结尾,这本书的贡献者都在章节中得到了确认。
二、为什么使用 TypeScript?
原文:
exploringjs.com/tackling-ts/ch_why-typescript.html
译者:飞龙
- 2.1 [使用 TypeScript 的好处]
- 2.1.1 [更多错误可以被静态(不运行代码)检测到]
- 2.1.2 [记录参数无论如何都是一个好习惯]
- 2.1.3 [TypeScript 提供了额外的文档层]
- 2.1.4 [JavaScript 的类型定义改进了自动补全]
- 2.1.5 [TypeScript 使重构更安全]
- 2.1.6 [TypeScript 可以将新功能编译成旧代码]
- 2.2 [使用 TypeScript 的缺点]
- 2.3 [TypeScript 的迷思]
- 2.3.1 [TypeScript 代码比较庞大]
- 2.3.2 [TypeScript 试图用 C#或 Java 替换 JavaScript]
如果你已经确定会学习和使用 TypeScript,可以跳过本章。
如果你还不确定 - 这一章是我的推销。
2.1 [使用 TypeScript 的好处]
2.1.1 更多错误可以被静态(不运行代码)检测到
当你在集成开发环境中编辑 TypeScript 代码时,如果你拼错了名称,错误地调用函数等,你会收到警告。
考虑以下两行代码:
function func() {} funcc();
对于第二行,我们得到了这个警告:
Cannot find name 'funcc'. Did you mean 'func'?
另一个例子:
const a = 0; const b = true; const result = a + b;
这次,最后一行的错误消息是:
Operator '+' cannot be applied to types 'number' and 'boolean'.
2.1.2 记录参数无论如何都是一个好习惯
记录函数和方法的参数是许多人都会做的事情:
/** * @param {number} num - The number to convert to string * @returns {string} `num`, converted to string */ function toString(num) { return String(num); }
通过{number}
和{string}
指定类型并不是必需的,但英文描述中也提到了它们。
如果我们使用 TypeScript 的符号来记录类型,我们会得到这些信息被检查一致性的额外好处:
function toString(num: number): string { return String(num); }
2.1.3 TypeScript 提供了额外的文档层
每当我将 JavaScript 代码迁移到 TypeScript 时,我都会注意到一个有趣的现象:为了找到函数或方法的参数的适当类型,我必须检查它在哪里被调用。这意味着静态类型给了我本地的信息,否则我必须在其他地方查找。
而且我确实发现理解 TypeScript 代码库比理解 JavaScript 代码库更容易:TypeScript 提供了额外的文档层。
这种额外的文档也有助于团队合作,因为清楚了代码的使用方式,而且 TypeScript 经常会在我们做错事情时提醒我们。
2.1.4 JavaScript 的类型定义改进了自动补全
如果 JavaScript 代码有类型定义,那么编辑器可以使用它们来改进自动补全。
除了使用 TypeScript 的语法之外,还可以通过 JSDoc 注释提供所有类型信息 - 就像我们在本章开头所做的那样。在这种情况下,TypeScript 也可以检查代码的一致性并生成类型定义。有关更多信息,请参见 TypeScript 手册中的“类型检查 JavaScript 文件”章节。
2.1.5 TypeScript 使重构更安全
重构是许多集成开发环境提供的自动化代码转换。
重命名方法是重构的一个例子。在纯 JavaScript 中这样做可能会很棘手,因为相同的名称可能指代不同的方法。TypeScript 对方法和类型的连接有更多信息,这使得在那里重命名方法更安全。
2.1.6 TypeScript 可以将新功能编译为旧代码
TypeScript 倾向于快速支持 ECMAScript 阶段 4 的功能(这些功能计划包括在下一个 ECMAScript 版本中)。当我们编译为 JavaScript 时,编译器选项--target
让我们指定输出与兼容的 ECMAScript 版本。然后,任何不兼容的功能(后来引入的)将被编译为等效的兼容代码。
请注意,对于较旧的 ECMAScript 版本的这种支持并不需要 TypeScript 或静态类型:JavaScript 编译器 Babel也可以做到,但它将 JavaScript 编译为 JavaScript。
2.2 使用 TypeScript 的缺点
- 这是 JavaScript 之上的一个附加层:更复杂,更多要学习的东西,等等。
- 它在编写代码时引入了一个编译步骤。
- 只有当 npm 软件包具有静态类型定义时才能使用。
- 如今,许多软件包要么附带类型定义,要么在DefinitelyTyped上有可用的类型定义。然而,尤其是后者偶尔可能会有些错误,这会导致一些在没有静态类型的情况下不会出现的问题。
- 偶尔很难正确使用静态类型。我的建议是尽可能保持简单 - 例如:不要过度使用泛型和类型变量。
2.3 TypeScript 的神话
2.3.1 TypeScript 代码很庞大
TypeScript 代码可以非常庞大。但不一定非得如此。例如,由于类型推断,我们通常可以少用一些类型注释:
function selectionSort(arr: number[]) { // (A) for (let i=0; i<arr.length; i++) { const minIndex = findMinIndex(arr, i); [arr[i], arr[minIndex]] = [arr[minIndex], arr[i]]; // swap } } function findMinIndex(arr: number[], startIndex: number) { // (B) let minValue = arr[startIndex]; let minIndex = startIndex; for (let i=startIndex+1; i < arr.length; i++) { const curValue = arr[i]; if (curValue < minValue) { minValue = curValue; minIndex = i; } } return minIndex; } const arr = [4, 2, 6, 3, 1, 5]; selectionSort(arr); assert.deepEqual( arr, [1, 2, 3, 4, 5, 6]);
这个 TypeScript 代码与 JavaScript 代码不同的唯一位置是 A 行和 B 行。
TypeScript 的编写方式有多种风格:
- 在面向对象编程(OOP)风格中使用类和 OOP 模式
- 在函数式编程(FP)风格中使用函数模式
- 在 OOP 和 FP 的混合中
- 等等。
2.3.2 TypeScript 试图用 C#或 Java 替换 JavaScript
最初,TypeScript 确实发明了一些自己的语言构造(例如枚举)。但自 ECMAScript 6 以来,它主要坚持成为 JavaScript 的严格超集。
我的印象是 TypeScript 团队喜欢 JavaScript,并且不想用“更好”的东西(例如 Dart)来替换它。他们确实希望尽可能地使 JavaScript 代码具有静态类型。许多新的 TypeScript 功能都是出于这种愿望驱动的。
三、关于 TypeScript 的免费资源
原文:
exploringjs.com/tackling-ts/ch_resources-on-typescript.html
译者:飞龙
JavaScript 书籍:
- 如果你在这本书中看到一个你不理解的 JavaScript 特性,你可以在我的免费在线阅读的书籍《JavaScript for impatient programmers》中查找。一些章节末尾的“进一步阅读”部分参考了这本书。
TypeScript 书籍:
- 《TypeScript 手册》是语言的一个很好的参考。我认为“解决 TypeScript”是对该书的补充。
- “TypeScript 深入” 作者Basarat Ali Syed
更多资料:
- 《“TypeScript 语言规范”》(https://github.com/microsoft/TypeScript/blob/master/doc/spec-ARCHIVED.md)解释了语言的更低层次。
- Marius Schulz发布了关于 TypeScript 的博客文章和电子邮件新闻简报“TypeScript Weekly”。
- TypeScript 存储库有完整 ECMAScript 标准库的类型定义。阅读它们是练习 TypeScript 类型表示法的简单方法。
第二部分:用 TypeScript 入门
原文:
exploringjs.com/tackling-ts/pt_getting-started.html
译者:飞龙
接下来:4 TypeScript 是如何工作的?鸟瞰视角
四、TypeScript 是如何工作的?鸟瞰
原文:
exploringjs.com/tackling-ts/ch_typescript-workflows.html
译者:飞龙
- 4.1 TypeScript 项目的结构(ch_typescript-workflows.html#the-structure-of-typescript-projects)
- 4.1.1
tsconfig.json
- 4.2 通过集成开发环境(IDE)编程 TypeScript
- 4.3 TypeScript 编译器生成的其他文件
- 4.3.1 为了从 TypeScript 中使用 npm 包,我们需要类型信息
- 4.4 使用 TypeScript 编译器处理普通 JavaScript 文件
本章概述了 TypeScript 的工作原理:典型 TypeScript 项目的结构是什么?什么被编译了?如何使用 IDE 编写 TypeScript?
4.1 TypeScript 项目的结构
这是 TypeScript 项目的一种可能的文件结构:
typescript-project/ dist/ ts/ src/ main.ts util.ts test/ util_test.ts tsconfig.json
解释:
- 目录
ts/
包含 TypeScript 文件:
- 子目录
ts/src/
包含实际的代码。 - 子目录
ts/test/
包含代码的测试。
- 目录
dist/
是编译器输出的存储位置。 - TypeScript 编译器将
ts/
中的 TypeScript 文件编译为dist/
中的 JavaScript 文件。例如:
ts/src/main.ts
被编译为dist/src/main.js
(可能还有其他文件)
tsconfig.json
用于配置 TypeScript 编译器。
4.1.1 tsconfig.json
tsconfig.json
的内容如下:
{ "compilerOptions": { "rootDir": "ts", "outDir": "dist", "module": "commonjs", ··· } }
我们已经指定:
- TypeScript 代码的根目录是
ts/
。 - TypeScript 编译器保存其输出的目录是
dist/
。 - 输出文件的模块格式是 CommonJS。
4.2 通过集成开发环境(IDE)编程 TypeScript
JavaScript 的两个流行的 IDE 是:
- Visual Studio Code(免费)
- WebStorm(需购买)
本节的观察结果是关于 Visual Studio Code 的,但也可能适用于其他 IDE。
一个重要的事实是,Visual Studio Code 以两种独立的方式处理 TypeScript 源代码:
- 检查打开文件的错误:这是通过所谓的语言服务器完成的。语言服务器独立于特定的编辑器存在,并为 Visual Studio Code 提供与语言相关的服务:检测错误、重构、自动补全等。与服务器的通信通过基于 JSON-RPC 的协议进行(RPC代表远程过程调用)。该协议提供的独立性意味着服务器可以用几乎任何编程语言编写。
- 要记住的重要事实是:语言服务器只列出当前打开文件的错误,不会编译 TypeScript,它只是静态分析。
- 构建(将 TypeScript 文件编译为 JavaScript 文件):在这里,我们有两种选择。
- 我们可以通过外部命令运行构建工具。例如,TypeScript 编译器
tsc
有一个--watch
模式,可以在输入文件发生更改时将其编译为输出文件。因此,每当我们在 IDE 中保存一个 TypeScript 文件时,我们立即得到相应的输出文件。 - 我们可以在 Visual Studio Code 中运行
tsc
。为了做到这一点,它必须安装在我们当前正在工作的项目内部,或者全局安装(通过 Node.js 包管理器 npm)。通过构建,我们可以获得完整的错误列表。有关在 Visual Studio Code 中编译 TypeScript 的更多信息,请参阅该 IDE 的官方文档。
4.3 TypeScript 编译器生成的其他文件
给定一个 TypeScript 文件main.ts
,TypeScript 编译器可以生成几种不同的产物。最常见的是:
- JavaScript 文件:
main.js
- 声明文件:
main.d.ts
(包含类型信息;类似于.ts
文件但不包含 JavaScript 代码) - 源映射文件:
main.js.map
TypeScript 通常不是通过.ts
文件交付的,而是通过.js
文件和.d.ts
文件:
- JavaScript 代码包含实际的功能,并且可以通过纯 JavaScript 来使用。
- 声明文件可以帮助编程编辑器进行自动补全和类似的服务。这些信息使得纯 JavaScript 可以通过 TypeScript 来消耗。然而,即使我们使用纯 JavaScript,也可以从中受益,因为它可以提供更好的自动补全和更多功能。
源映射为main.js
中的每个部分指定了main.ts
中的哪个部分产生了它。除其他外,这些信息使得运行时环境可以执行 JavaScript 代码,同时显示 TypeScript 代码的行号在错误消息中。
4.3.1 为了从 TypeScript 中使用 npm 包,我们需要类型信息
npm 注册表是一个庞大的 JavaScript 代码存储库。如果我们想要在 TypeScript 中使用 JavaScript 包,我们需要它的类型信息:
- 包本身可能包括
.d.ts
文件,甚至是完整的 TypeScript 代码。 - 如果没有,我们可能仍然可以使用它:DefinitelyTyped是一个为纯 JavaScript 包编写声明文件的存储库。
DefinitelyTyped 的声明文件位于@types
命名空间中。因此,如果我们需要一个像lodash
这样的包的声明文件,我们必须安装@types/lodash
包。
4.4 使用 TypeScript 编译器处理纯 JavaScript 文件
TypeScript 编译器也可以处理纯 JavaScript 文件:
- 使用
--allowJs
选项,TypeScript 编译器会将输入目录中的 JavaScript 文件复制到输出目录中。好处是:当从 JavaScript 迁移到 TypeScript 时,我们可以从 JavaScript 和 TypeScript 文件的混合开始,然后慢慢将更多的 JavaScript 文件转换为 TypeScript。 - 使用
--checkJs
选项,编译器还会对 JavaScript 文件进行类型检查(此选项需要--allowJs
开启才能工作)。它会尽可能地进行类型检查,考虑到可用的有限信息。可以通过文件内的注释来配置哪些文件需要进行检查:
- 显式排除:如果 JavaScript 文件包含注释
// @ts-nocheck
,则不会进行类型检查。 - 显式包含:如果没有
--checkJs
,则可以使用注释// @ts-check
来对单个 JavaScript 文件进行类型检查。
- TypeScript 编译器使用通过 JSDoc 注释指定的静态类型信息(请参见下面的示例)。如果我们认真对待,我们可以完全静态地对纯 JavaScript 文件进行类型标注,甚至可以从中派生出声明文件。
- 使用
--noEmit
选项,编译器不会产生任何输出,只进行文件的类型检查。
这是一个 JSDoc 注释的示例,为函数add()
提供了静态类型信息:
/** * @param {number} x - The first operand * @param {number} y - The second operand * @returns {number} The sum of both operands */ function add(x, y) { return x + y; }
更多信息:Type-Checking JavaScript Files 在 TypeScript 手册中。
五、尝试 TypeScript
原文:
exploringjs.com/tackling-ts/ch_trying-out-typescript.html
译者:飞龙
- 5.1 TypeScript Playground
- 5.2 TS Node
本章提供了快速尝试 TypeScript 的技巧。
5.1 TypeScript Playground
TypeScript Playground是一个用于 TypeScript 代码的在线编辑器。其功能包括:
- 支持完整的 IDE 风格编辑:自动补全等。
- 显示静态类型错误。
- 显示将 TypeScript 代码编译成 JavaScript 的结果。它还可以在浏览器中执行结果。
Playground 非常适用于快速实验和演示。它可以将 TypeScript 代码片段和编译器设置保存到 URL 中,非常适合与他人分享这些片段。以下是一个示例 URL:
5.2 TS Node
TS Node是 Node.js 的 TypeScript 版本。其用例包括:
- TS Node 提供了一个用于 TypeScript 的 REPL(命令行):
$ ts-node > const twice = (x: string) => x + x; > twice('abc') 'abcabc' > twice(123) Error TS2345: Argument of type '123' is not assignable to parameter of type 'string'.
- TS Node 使一些 JavaScript 工具能够直接执行 TypeScript 代码。它会自动将 TypeScript 代码编译成 JavaScript 代码,并将其传递给工具,而无需我们做任何事情。以下 shell 命令演示了如何在JavaScript 单元测试框架 Mocha中使用它:
mocha --require ts-node/register --ui qunit testfile.ts
使用npx ts-node
来运行 REPL 而无需安装它。
六、本书中使用的符号
原文:
exploringjs.com/tackling-ts/ch_book-notation.html
译者:飞龙
- 6.1 测试断言(动态)(ch_book-notation.html#test-assertions-dynamic)
- 6.2 类型断言(静态)
本章解释了代码示例中使用的功能,但不是 TypeScript 本身的一部分。
6.1 测试断言(动态)
本书中显示的代码示例通过单元测试自动测试。操作的预期结果通过来自Node.js 模块assert
的以下断言函数进行检查:
assert.equal()
通过===
测试相等性assert.deepEqual()
通过深度比较嵌套对象(包括数组)来测试相等性。assert.throws()
如果回调参数没有抛出异常,则会报错。
这是使用这些断言的一个例子:
import {strict as assert} from 'assert'; assert.equal(3 + ' apples', '3 apples'); assert.deepEqual( [...['a', 'b'], ...['c', 'd']], ['a', 'b', 'c', 'd']); assert.throws( () => eval('null.myProperty'), TypeError);
第一行的 import 语句使用了严格断言模式(使用===
而不是==
)。通常在代码示例中会省略这一行。
6.2 类型断言(静态)
您还会看到静态类型断言。
%inferred-type
只是普通 TypeScript 中的一个注释,描述了 TypeScript 为下一行推断的类型:
// %inferred-type: number let num = 123;
@ts-expect-error
在 TypeScript 中抑制静态错误。在本书中,抑制的错误总是会被提及。这在普通的 TypeScript 中既不是必需的,也不会有任何作用。
assert.throws( // @ts-expect-error: Object is possibly 'null'. (2531) () => null.myProperty, TypeError);
请注意,我们以前需要使用eval()
来避免 TypeScript 的警告。
七、TypeScript 的基本要点
原文:
exploringjs.com/tackling-ts/ch_typescript-essentials.html
译者:飞龙
- 7.1 你将学到什么
- 7.2 指定类型检查的全面性
- 7.3 TypeScript 中的类型
- 7.4 类型注解
- 7.5 类型推断
- 7.6 通过类型表达式指定类型
- 7.7 两种语言级别:动态 vs. 静态
- 7.8 类型别名
- 7.9 数组类型标注
- 7.9.1 数组作为列表
- 7.9.2 数组作为元组
- 7.10 函数类型
- 7.10.1 更复杂的示例
- 7.10.2 函数声明的返回类型
- 7.10.3 可选参数
- 7.10.4 剩余参数
- 7.11 联合类型
- 7.11.1 默认情况下,
undefined
和null
不包括在类型中 - 7.11.2 明确省略
- 7.12 可选 vs. 默认值 vs.
undefined|T
- 7.13 对象类型标注
- 7.13.1 通过接口将对象作为记录进行类型标注
- 7.13.2 TypeScript 的结构类型 vs. 名义类型
- 7.13.3 对象字面量类型
- 7.13.4 可选属性
- 7.13.5 方法
- 7.14 类型变量和泛型类型
- 7.14.1 示例:值的容器
- 7.15 示例:泛型类
- 7.15.1 示例:映射
- 7.15.2 函数和方法的类型变量
- 7.15.3 更复杂的函数示例
- 7.16 结论:理解初始示例
本章介绍了 TypeScript 的基本要点。
7.1 你将学到什么
阅读完本章后,你应该能够理解以下 TypeScript 代码:
interface Array<T> { concat(...items: Array<T[] | T>): T[]; reduce<U>( callback: (state: U, element: T, index: number, array: T[]) => U, firstState?: U ): U; // ··· }
你可能会认为这很神秘。我同意你的看法!但是(正如我希望证明的那样),这种语法相对容易学习。一旦你理解了它,它可以立即、准确和全面地总结代码的行为方式,而无需阅读冗长的英文描述。
7.2 指定类型检查的全面性
TypeScript 编译器可以配置的方式有很多。一个重要的选项组控制编译器对 TypeScript 代码的检查程度。通过--strict
激活最大设置,我建议始终使用它。这使得程序稍微难以编写,但我们也获得了静态类型检查的全部好处。
这就是你现在需要了解的关于--strict
的一切
如果想了解更多细节,请继续阅读。
将--strict
设置为true
,会将以下所有选项设置为true
:
--noImplicitAny
:如果 TypeScript 无法推断类型,我们必须指定类型。这主要适用于函数和方法的参数:使用这个设置,我们必须注释它们。--noImplicitThis
:如果this
的类型不清晰,则会报错。--alwaysStrict
:尽可能使用 JavaScript 的严格模式。--strictNullChecks
:null
不是任何类型的一部分(除了它自己的类型null
),如果它是可接受的值,必须明确提及。--strictFunctionTypes
:启用对函数类型的更严格检查。--strictPropertyInitialization
:类定义中的属性必须初始化,除非它们可以有值undefined
。
我们将在本书的后面看到更多的编译器选项,当我们开始使用 TypeScript 创建 npm 包和 web 应用时。TypeScript 手册中有关于它们的全面文档。
7.3 TypeScript 中的类型
在本章中,类型只是一组值。JavaScript 语言(不是 TypeScript!)只有八种类型:
- 未定义:只有一个元素
undefined
的集合 - Null:只有一个元素
null
的集合 - 布尔:只有两个元素
false
和true
的集合 - 数字:所有数字的集合
- BigInt:所有任意精度整数的集合
- 字符串:所有字符串的集合
- 符号:所有符号的集合
- 对象:所有对象的集合(包括函数和数组)
所有这些类型都是动态的:我们可以在运行时使用它们。
TypeScript 为 JavaScript 带来了一个额外的层次:静态类型。这些只在编译或类型检查源代码时存在。每个存储位置(变量、属性等)都有一个静态类型,用于预测其动态值。类型检查确保这些预测成真。
还有很多可以静态检查的内容(不运行代码)。例如,如果函数toString(num)
的参数num
的静态类型是number
,那么函数调用toString('abc')
是非法的,因为参数'abc'
的静态类型错误。
7.4 类型注释
function toString(num: number): string { return String(num); }
在上一个函数声明中有两种类型注释:
- 参数
num
:冒号后跟number
toString()
的结果:冒号后跟string
number
和string
都是类型表达式,用于指定存储位置的类型。
7.5 类型推断
通常,如果没有类型注释,TypeScript 可以推断出静态类型。例如,如果我们省略toString()
的返回类型,TypeScript 会推断它是string
:
// %inferred-type: (num: number) => string function toString(num: number) { return String(num); }
类型推断不是猜测:它遵循清晰的规则(类似于算术)来推导未明确指定类型的地方的类型。在这种情况下,返回语句应用了一个将任意值映射到字符串的函数String()
,将类型为number
的值num
映射到字符串并返回结果。这就是为什么推断的返回类型是string
。
如果位置的类型既没有明确指定也无法推断,TypeScript 会使用类型any
。这是所有值的类型和通配符,如果一个值具有该类型,我们可以做任何事情。
在--strict
中,只有在显式使用any
时才允许使用它。换句话说:每个位置必须有一个显式或推断的静态类型。在下面的例子中,参数num
都没有,我们会得到一个编译时错误:
// @ts-expect-error: Parameter 'num' implicitly has an 'any' type. (7006) function toString(num) { return String(num); }
7.6 通过类型表达式指定类型
类型注解的冒号后面的类型表达式从简单到复杂不等,创建方式如下。
基本类型是有效的类型表达式:
- JavaScript 的动态类型的静态类型:
undefined
,null
boolean
,number
,bigint
,string
symbol
object
。
- 特定于 TypeScript 的类型:
Array
(在 JavaScript 中不是严格的类型)any
(所有值的类型)- 等等。
有许多种方式将基本类型组合成新的复合类型。例如,通过类型运算符,它们类似于集合运算符并集(∪
)和交集(∩
)组合集合的方式。我们很快会看到如何做到这一点。
7.7 两种语言级别:动态 vs. 静态
TypeScript 有两种语言级别:
- 动态级别由 JavaScript 管理,并且包括运行时的代码和值。
- 静态级别由 TypeScript(不包括 JavaScript)管理,并且在编译时包括静态类型。
我们可以在语法中看到这两个级别:
const undef: undefined = undefined;
- 在动态级别上,我们使用 JavaScript 声明一个变量
undef
并用值undefined
初始化它。 - 在静态级别上,我们使用 TypeScript 指定变量
undef
的静态类型为undefined
。
请注意,相同的语法undefined
,根据它是在动态级别还是静态级别使用,意思不同。
尝试培养对两种语言级别的认识
这在很大程度上有助于理解 TypeScript。
7.8 类型别名
使用type
我们可以为现有类型创建一个新名称(别名):
type Age = number; const age: Age = 82;
7.9 数组的类型
数组在 JavaScript 中扮演两种角色(可能是一种或两种):
- 列表:所有元素具有相同的类型。数组的长度不同。
- 元组:数组的长度是固定的。元素通常不具有相同的类型。
7.9.1 数组作为列表
有两种方式来表达数组arr
被用作一个所有元素都是数字的列表的事实:
let arr1: number[] = []; let arr2: Array<number> = [];
通常情况下,如果有赋值,TypeScript 可以推断变量的类型。在这种情况下,我们实际上必须帮助它,因为对于空数组,它无法确定元素的类型。
稍后我们会回到尖括号表示法(Array<number>
)。
7.9.2 数组作为元组
如果我们将二维点存储在一个数组中,那么我们使用该数组作为元组。看起来是这样的:
let point: [number, number] = [7, 5];
对于数组字面量,类型注解是必需的,因为 TypeScript 会推断列表类型,而不是元组类型:
// %inferred-type: number[] let point = [7, 5];
元组的另一个例子是Object.entries(obj)
的结果:一个数组,每个obj
的属性都有一个[key, value]对。
// %inferred-type: [string, number][] const entries = Object.entries({ a: 1, b: 2 }); assert.deepEqual( entries, [[ 'a', 1 ], [ 'b', 2 ]]);
推断的类型是元组的数组。
7.10 函数类型
这是一个函数类型的例子:
(num: number) => string
这种类型包括每个接受一个number
类型的参数并返回一个string
的函数。让我们在类型注解中使用这种类型:
const toString: (num: number) => string = // (A) (num: number) => String(num); // (B)
通常,我们必须为函数指定参数类型。但在这种情况下,num
在 B 行的类型可以从 A 行的函数类型中推断出来,我们可以省略它:
const toString: (num: number) => string = (num) => String(num);
如果我们省略toString
的类型注解,TypeScript 会从箭头函数中推断出类型:
// %inferred-type: (num: number) => string const toString = (num: number) => String(num);
这次,num
必须有一个类型注解。
7.10.1 更复杂的例子
下面的例子更加复杂:
function stringify123(callback: (num: number) => string) { return callback(123); }
我们使用函数类型来描述stringify123()
的参数callback
。由于这个类型注解,TypeScript 拒绝了以下函数调用。
// @ts-expect-error: Argument of type 'NumberConstructor' is not // assignable to parameter of type '(num: number) => string'. // Type 'number' is not assignable to type 'string'.(2345) stringify123(Number);
但它接受这个函数调用:
assert.equal( stringify123(String), '123');
7.10.2 函数声明的返回类型
TypeScript 通常可以推断函数的返回类型,但允许显式指定它们并且偶尔是有用的(至少,它不会有害)。
对于stringify123()
,指定返回类型是可选的,看起来像这样:
function stringify123(callback: (num: number) => string): string { return callback(123); }
7.10.2.1 特殊的返回类型void
void
是函数的一个特殊返回类型:它告诉 TypeScript 函数总是返回undefined
。
它可能会显式地这样做:
function f1(): void { return undefined; }
或者它可能会隐式地这样做:
function f2(): void {}
然而,这样的函数不能明确返回除undefined
之外的值:
function f3(): void { // @ts-expect-error: Type '"abc"' is not assignable to type 'void'. (2322) return 'abc'; }
7.10.3 可选参数
标识符后面的问号表示参数是可选的。例如:
function stringify123(callback?: (num: number) => string) { if (callback === undefined) { callback = String; } return callback(123); // (A) }
TypeScript 只有在确保callback
不是undefined
时才允许我们在 A 行进行函数调用(如果参数被省略,则它是undefined
)。
7.10.3.1 参数默认值
TypeScript 支持参数默认值:
function createPoint(x=0, y=0): [number, number] { return [x, y]; } assert.deepEqual( createPoint(), [0, 0]); assert.deepEqual( createPoint(1, 2), [1, 2]);
默认值使参数变为可选。通常我们可以省略类型注释,因为 TypeScript 可以推断类型。例如,它可以推断x
和y
都是number
类型。
如果我们想添加类型注释,那么会是这样的。
function createPoint(x:number = 0, y:number = 0): [number, number] { return [x, y]; }
7.10.4 剩余参数
我们还可以在 TypeScript 参数定义中使用剩余参数。它们的静态类型必须是数组(列表或元组):
function joinNumbers(...nums: number[]): string { return nums.join('-'); } assert.equal( joinNumbers(1, 2, 3), '1-2-3');
7.11 联合类型
变量持有的值(一次一个值)可能是不同类型的成员。在这种情况下,我们需要联合类型。例如,在以下代码中,stringOrNumber
的类型是string
或number
:
function getScore(stringOrNumber: string|number): number { if (typeof stringOrNumber === 'string' && /^\*{1,5}$/.test(stringOrNumber)) { return stringOrNumber.length; } else if (typeof stringOrNumber === 'number' && stringOrNumber >= 1 && stringOrNumber <= 5) { return stringOrNumber } else { throw new Error('Illegal value: ' + JSON.stringify(stringOrNumber)); } } assert.equal(getScore('*****'), 5); assert.equal(getScore(3), 3);
stringOrNumber
的类型是string|number
。类型表达式s|t
的结果是类型s
和t
的集合论并集(解释为集合)。
7.11.1 默认情况下,undefined
和null
不包括在类型中
在许多编程语言中,null
是所有对象类型的一部分。例如,在 Java 中,当变量的类型是String
时,我们可以将其设置为null
,Java 不会抱怨。
相反,在 TypeScript 中,undefined
和null
由单独的不相交类型处理。如果我们想允许它们,我们需要联合类型,如undefined|string
和null|string
:
let maybeNumber: null|number = null; maybeNumber = 123;
否则,我们会得到一个错误:
// @ts-expect-error: Type 'null' is not assignable to type 'number'. (2322) let maybeNumber: number = null; maybeNumber = 123;
请注意,TypeScript 不强制我们立即初始化(只要我们在初始化之前不从变量中读取):
let myNumber: number; // OK myNumber = 123;
7.11.2 明确省略
回想一下之前的这个函数:
function stringify123(callback?: (num: number) => string) { if (callback === undefined) { callback = String; } return callback(123); // (A) }
让我们重写stringify123()
,使参数callback
不再是可选的:如果调用者不想提供一个函数,他们必须明确传递null
。结果如下。
function stringify123( callback: null | ((num: number) => string)) { const num = 123; if (callback === null) { // (A) callback = String; } return callback(num); // (B) } assert.equal( stringify123(null), '123'); // @ts-expect-error: Expected 1 arguments, but got 0\. (2554) assert.throws(() => stringify123());
再次,我们必须在进行函数调用之前处理callback
不是函数的情况(A 行),否则我们会得到一个错误。
7.12 可选 vs. 默认值 vs. undefined|T
以下三个参数声明非常相似:
- 参数是可选的:
x?: number
- 参数有默认值:
x = 456
- 参数有联合类型:
x: undefined | number
如果参数是可选的,可以省略。在这种情况下,它的值是undefined
:
function f1(x?: number) { return x } assert.equal(f1(123), 123); // OK assert.equal(f1(undefined), undefined); // OK assert.equal(f1(), undefined); // can omit
如果参数有默认值,则在参数被省略或设置为undefined
时使用该值:
function f2(x = 456) { return x } assert.equal(f2(123), 123); // OK assert.equal(f2(undefined), 456); // OK assert.equal(f2(), 456); // can omit
如果参数具有联合类型,则不能省略,但是我们可以将其设置为undefined
:
function f3(x: undefined | number) { return x } assert.equal(f3(123), 123); // OK assert.equal(f3(undefined), undefined); // OK // @ts-expect-error: Expected 1 arguments, but got 0\. (2554) f3(); // can’t omit
7.13 对象类型
与数组类似,对象在 JavaScript 中扮演两种角色(有时混合在一起):
- 记录:在开发时已知的固定数量的属性。每个属性可以具有不同的类型。
- 字典:在开发时不知道名称的任意数量的属性。所有属性都具有相同的类型。
在本章中,我们忽略了对象作为字典的部分-它们在§15.4.5“索引签名:对象作为字典”中有涵盖。顺便说一句,Map 通常是字典的更好选择。
7.13.1 通过接口对对象作为记录进行类型标注
接口描述对象作为记录。例如:
interface Point { x: number; y: number; }
我们也可以用逗号分隔成员:
interface Point { x: number, y: number, }
7.13.2 TypeScript 的结构类型与名义类型
TypeScript 类型系统的一个重要优势是它是结构化的,而不是名义化的。也就是说,接口Point
匹配所有具有适当结构的对象:
interface Point { x: number; y: number; } function pointToString(pt: Point) { return `(${pt.x}, ${pt.y})`; } assert.equal( pointToString({x: 5, y: 7}), // compatible structure '(5, 7)');
相反,在 Java 的名义类型系统中,我们必须在每个类中明确声明它实现的接口。因此,一个类只能实现在其创建时存在的接口。
7.13.3 对象文字类型
对象文字类型是匿名接口:
type Point = { x: number; y: number; };
对象文字类型的一个好处是它们可以内联使用:
function pointToString(pt: {x: number, y: number}) { return `(${pt.x}, ${pt.y})`; }
7.13.4 可选属性
如果属性可以省略,我们在其名称后面加上一个问号:
interface Person { name: string; company?: string; }
在下面的示例中,john
和jane
都符合接口Person
:
const john: Person = { name: 'John', }; const jane: Person = { name: 'Jane', company: 'Massive Dynamic', };
7.13.5 方法
接口也可以包含方法:
interface Point { x: number; y: number; distance(other: Point): number; }
就 TypeScript 的类型系统而言,方法定义和其值为函数的属性是等价的:
interface HasMethodDef { simpleMethod(flag: boolean): void; } interface HasFuncProp { simpleMethod: (flag: boolean) => void; } const objWithMethod: HasMethodDef = { simpleMethod(flag: boolean): void {}, }; const objWithMethod2: HasFuncProp = objWithMethod; const objWithOrdinaryFunction: HasMethodDef = { simpleMethod: function (flag: boolean): void {}, }; const objWithOrdinaryFunction2: HasFuncProp = objWithOrdinaryFunction; const objWithArrowFunction: HasMethodDef = { simpleMethod: (flag: boolean): void => {}, }; const objWithArrowFunction2: HasFuncProp = objWithArrowFunction;
我的建议是使用最能表达属性设置方式的语法。
7.14 类型变量和通用类型
回顾 TypeScript 的两个语言级别:
- 值存在于动态级别。
- 类型存在于静态级别。
同样:
- 普通函数存在于动态级别,是值的工厂,并且具有表示值的参数。参数在括号之间声明:
const valueFactory = (x: number) => x; // definition const myValue = valueFactory(123); // use
- 通用类型存在于静态级别,是类型的工厂,并且具有表示类型的参数。参数在尖括号之间声明:
type TypeFactory<X> = X; // definition type MyType = TypeFactory<string>; // use
命名类型参数
在 TypeScript 中,通常使用单个大写字符(如T
,I
和O
)作为类型参数。但是,任何合法的 JavaScript 标识符都是允许的,而且更长的名称通常使代码更容易理解。
7.14.1 示例:值的容器
// Factory for types interface ValueContainer<Value> { value: Value; } // Creating one type type StringContainer = ValueContainer<string>;
Value
是一个类型变量。可以在尖括号之间引入一个或多个类型变量。
7.15 示例:一个通用类
类也可以有类型参数:
class SimpleStack<Elem> { #data: Array<Elem> = []; push(x: Elem): void { this.#data.push(x); } pop(): Elem { const result = this.#data.pop(); if (result === undefined) { throw new Error(); } return result; } get length() { return this.#data.length; } }
类SimpleStack
具有类型参数Elem
。当我们实例化类时,我们还为类型参数提供一个值:
const stringStack = new SimpleStack<string>(); stringStack.push('first'); stringStack.push('second'); assert.equal(stringStack.length, 2); assert.equal(stringStack.pop(), 'second');
7.15.1 示例:Maps
在 TypeScript 中,Map 是带有泛型的。例如:
const myMap: Map<boolean,string> = new Map([ [false, 'no'], [true, 'yes'], ]);
由于类型推断(基于new Map()
的参数),我们可以省略类型参数:
// %inferred-type: Map<boolean, string> const myMap = new Map([ [false, 'no'], [true, 'yes'], ]);
7.15.2 函数和方法的类型变量
函数定义可以像这样引入类型变量:
function identity<Arg>(arg: Arg): Arg { return arg; }
我们使用函数如下。
// %inferred-type: number const num1 = identity<number>(123);
由于类型推断,我们可以再次省略类型参数:
// %inferred-type: 123 const num2 = identity(123);
请注意,TypeScript 推断出了类型123
,这是一个具有一个数字的集合,比类型number
更具体。
7.15.2.1 箭头函数和方法
箭头函数也可以有类型参数:
const identity = <Arg>(arg: Arg): Arg => arg;
这是方法的类型参数语法:
const obj = { identity<Arg>(arg: Arg): Arg { return arg; }, };
7.15.3 更复杂的函数示例
function fillArray<T>(len: number, elem: T): T[] { return new Array<T>(len).fill(elem); }
类型变量T
在此代码中出现了四次:
- 它是通过
fillArray<T>
引入的。因此,它的范围是函数。 - 它首次用于参数
elem
的类型注释。 - 它第二次用于指定
fillArray()
的返回类型。 - 它也被用作构造函数
Array()
的类型参数。
我们可以在调用fillArray()
(行 A)时省略类型参数,因为 TypeScript 可以从参数elem
中推断出T
:
// %inferred-type: string[] const arr1 = fillArray<string>(3, '*'); assert.deepEqual( arr1, ['*', '*', '*']); // %inferred-type: string[] const arr2 = fillArray(3, '*'); // (A)
7.16 结论:理解初始示例
让我们使用我们之前学到的知识来理解我们之前看到的代码片段:
interface Array<T> { concat(...items: Array<T[] | T>): T[]; reduce<U>( callback: (state: U, element: T, index: number, array: T[]) => U, firstState?: U ): U; // ··· }
这是一个数组的接口,其元素的类型为T
:
- 方法
.concat()
有零个或多个参数(通过 rest 参数定义)。每个参数的类型为T[]|T
。也就是说,它要么是T
值的数组,要么是单个T
值。 - 方法
.reduce()
引入了自己的类型变量U
。U
用于表示以下实体都具有相同类型的事实:
callback()
的state
参数callback()
的结果.reduce()
的可选参数firstState
.reduce()
的结果
- 除了
state
,callback()
还有以下参数:
element
,其类型与数组元素的类型T
相同index
;一个数字- 具有类型
T
的元素的array
八、通过 TypeScript 创建基于 CommonJS 的 npm 包
原文:
exploringjs.com/tackling-ts/ch_npm-cjs-typescript.html
译者:飞龙
- 8.1 所需知识
- 8.2 限制
- 8.3 存储库
ts-demo-npm-cjs
- 8.4
.gitignore
- 8.5
.npmignore
- 8.6
package.json
- 8.6.1 脚本
- 8.6.2
dependencies
vs.devDependencies
- 8.6.3 更多关于
package.json
的信息
- 8.7
tsconfig.json
- 8.8 TypeScript 代码
- 8.8.1
index.ts
- 8.8.2
index_test.ts
本章描述了如何使用 TypeScript 为基于 CommonJS 模块格式的 npm 包创建包。
GitHub 存储库:ts-demo-npm-cjs
在本章中,我们正在探索存储库ts-demo-npm-cjs
,可以在 GitHub 上下载。(我故意没有将其发布为 npm 包。)
8.1 所需知识
您应该对以下内容有大致了解:
- CommonJS 模块 – 一种起源于服务器端 JavaScript 并为其设计的模块格式。它由服务器端 JavaScript 平台Node.js推广。CommonJS 模块先于 JavaScript 的内置ECMAScript 模块出现,并且仍然被工具(IDE、构建工具等)广泛使用和支持良好。
- TypeScript 的模块 – 其语法基于 ECMAScript 模块。但它们经常被编译为 CommonJS 模块。
- npm 包 – 通过 npm 包管理器安装的包含文件的目录。它们可以包含 CommonJS 模块、ECMAScript 模块和各种其他文件。
8.2 限制
在本章中,我们正在使用 TypeScript 目前最好支持的内容:
- 我们所有的 TypeScript 代码都被编译为带有文件扩展名
.js
的 CommonJS 模块。 - 所有外部导入也是 CommonJS 模块。
特别是在 Node.js 上,TypeScript 目前并不真正支持 ECMAScript 模块和除.js
以外的文件扩展名。
8.3 存储库ts-demo-npm-cjs
这就是存储库ts-demo-npm-cjs
的结构:
ts-demo-npm-cjs/ .gitignore .npmignore dist/ (created on demand) package.json ts/ src/ index.ts test/ index_test.ts tsconfig.json
除了用于包的package.json
之外,存储库还包含:
ts/src/index.ts
:包的实际代码ts/test/index_test.ts
:index.ts
的测试tsconfig.json
:TypeScript 编译器的配置数据
package.json
包含用于编译的脚本:
- 输入:目录
ts/
(TypeScript 代码) - 输出:目录
dist/
(CommonJS 模块;该目录尚不存在于存储库中)
这是两个 TypeScript 文件的编译结果所在的地方:
ts/src/index.ts --> dist/src/index.js ts/test/index_test.ts --> dist/test/index_test.js
8.4 .gitignore
这个文件列出了我们不想检入 git 的目录:
node_modules/ dist/
解释:
node_modules/
是通过npm install
设置的。dist/
目录中的文件是由 TypeScript 编译器创建的(稍后会详细介绍)。
8.5 .npmignore
在确定应该上传哪些文件到 npm 注册表时,我们有不同于 git 的需求。因此,除了.gitignore
之外,我们还需要文件.npmignore
:
ts/
两个不同之处是:
- 我们希望上传 TypeScript 编译成 JavaScript 的结果(目录
dist/
)。 - 我们不想上传 TypeScript 源文件(目录
ts/
)。
请注意,npm 默认忽略node_modules/
目录。
8.6 package.json
package.json
看起来像这样:
{ ··· "type": "commonjs", "main": "./dist/src/index.js", "types": "./dist/src/index.d.ts", "scripts": { "clean": "shx rm -rf dist/*", "build": "tsc", "watch": "tsc --watch", "test": "mocha --ui qunit", "testall": "mocha --ui qunit dist/test", "prepack": "npm run clean && npm run build" }, "// devDependencies": { "@types/node": "Needed for unit test assertions (assert.equal() etc.)", "shx": "Needed for development-time package.json scripts" }, "devDependencies": { "@types/lodash": "···", "@types/mocha": "···", "@types/node": "···", "mocha": "···", "shx": "···" }, "dependencies": { "lodash": "···" } }
让我们来看看这些属性:
type
:值"commonjs"
表示.js
文件被解释为 CommonJS 模块。主要
:如果有所谓的裸导入,只提到当前包的名称,那么这就是将被导入的模块。types
指向一个声明文件,其中包含当前包的所有类型定义。
接下来的两个小节涵盖了剩余的属性。
8.6.1 脚本
属性scripts
定义了可以通过npm run
调用的各种命令。例如,脚本clean
通过npm run clean
调用。前面的package.json
包含以下脚本:
clean
使用跨平台包shx
通过其对 Unix shell 命令rm
的实现来删除编译结果。shx
支持各种 shell 命令,而无需为我们可能想要使用的每个命令单独安装包。build
和watch
使用 TypeScript 编译器tsc
根据tsconfig.json
编译 TypeScript 文件。tsc
必须全局或本地安装(在当前包内),通常通过 npm 包typescript
。test
和testall
使用单元测试框架 Mocha来运行一个测试或所有测试。prepack
:这个脚本在打包 tarball 之前运行(由于npm pack
,npm publish
或从 git 安装)。
请注意,当我们使用 IDE 时,我们不需要脚本build
和watch
,因为我们可以让 IDE 构建构件。但它们对于脚本prepack
是必需的。
8.6.2 dependencies
vs. devDependencies
dependencies
应该只包含导入包时需要的包。这不包括用于运行测试等的包。
以@types/
开头的包为那些没有 TypeScript 类型定义的包提供了 TypeScript 类型定义。没有前者,我们就不能使用后者。这些是正常的依赖项还是开发依赖项?这取决于:
- 如果我们包的类型定义引用另一个包中的类型定义,则该包是正常的依赖项。
- 否则,该包只在开发时需要,并且是开发依赖项。
8.6.3 更多关于package.json
的信息
- “Awesome npm scripts”提供了编写跨平台脚本的技巧。
package.json
的 npm 文档解释了该文件的各种属性。scripts
的 npm 文档解释了package.json
属性scripts
。
8.7 tsconfig.json
{ "compilerOptions": { "rootDir": "ts", "outDir": "dist", "target": "es2019", "lib": [ "es2019" ], "module": "commonjs", "esModuleInterop": true, "strict": true, "declaration": true, "sourceMap": true } }
rootDir
:我们的 TypeScript 文件位于哪里?outDir
:编译结果应该放在哪里?target
:目标 ECMAScript 版本是什么?如果 TypeScript 代码使用目标版本不支持的功能,则将其编译为仅使用支持功能的等效代码。lib
:TypeScript 应该意识到哪些平台功能?可能包括 ECMAScript 标准库和浏览器的 DOM。Node.js API 通过包@types/node
以不同的方式得到支持。module
:指定编译输出的格式。
其余选项由官方tsconfig.json
文档解释。
8.8 TypeScript 代码
8.8.1 index.ts
该文件提供了包的实际功能:
import endsWith from 'lodash/endsWith'; export function removeSuffix(str: string, suffix: string) { if (!endsWith(str, suffix)) { throw new Error(JSON.stringify(suffix)} + ' is not a suffix of ' + JSON.stringify(str)); } return str.slice(0, -suffix.length); }
它使用库 Lodash 的endsWith()
函数。这就是为什么 Lodash 是一个正常的依赖项-它在运行时需要。
8.8.2 index_test.ts
该文件包含了对index.ts
的单元测试。
import { strict as assert } from 'assert'; import { removeSuffix } from '../src/index'; test('removeSuffix()', () => { assert.equal( removeSuffix('myfile.txt', '.txt'), 'myfile'); assert.throws(() => removeSuffix('myfile.txt', 'abc')); });
我们可以这样运行测试:
npm t dist/test/index_test.js
- npm 命令
t
是 npm 命令test
的缩写。 - npm 命令
test
是run test
的缩写(运行package.json
中的test
脚本)。
如您所见,我们正在运行测试的编译版本(在dist/
目录中),而不是 TypeScript 代码。
有关单元测试框架 Mocha 的更多信息,请参阅其主页。
九、创建 Web 应用程序通过 TypeScript 和 webpack
原文:
exploringjs.com/tackling-ts/ch_webpack-typescript.html
译者:飞龙
- 9.1 所需知识
- 9.2 限制
- 9.3 存储库
ts-demo-webpack
- 9.4
package.json
- 9.5
webpack.config.js
- 9.6
tsconfig.json
- 9.7
index.html
- 9.8
main.ts
- 9.9 安装、构建和运行 Web 应用程序
- 9.9.1 在 Visual Studio Code 中构建
- 9.10 在没有加载器的情况下使用 webpack:
webpack-no-loader.config.js
本章描述了如何通过 TypeScript 和 webpack 创建 Web 应用程序。我们将仅使用 DOM API,而不是特定的前端框架。
GitHub 存储库:ts-demo-webpack
存储库ts-demo-webpack
我们在本章中使用的,可以从 GitHub 下载。
9.1 所需知识
你应该对以下内容有大致的了解:
9.2 限制
在本章中,我们坚持使用 TypeScript 最好支持的内容:CommonJS 模块,捆绑为脚本文件。
9.3 存储库ts-demo-webpack
这是存储库ts-demo-webpack
的结构:
ts-demo-webpack/ build/ (created on demand) html/ index.html package.json ts/ src/ main.ts tsconfig.json webpack.config.js
Web 应用程序的构建方式如下:
- 输入:
ts/
中的 TypeScript 文件- 所有通过 npm 安装并由 TypeScript 文件导入的 JavaScript 代码
html/
中的 HTML 文件
- 输出 - 目录
build/
中的完整 Web 应用程序:
- TypeScript 文件被编译为 JavaScript 代码,与通过 npm 安装的 JavaScript 组合,并写入脚本文件
build/main-bundle.js
。这个过程称为捆绑,main-bundle.js
是一个捆绑文件。 - 每个 HTML 文件都被复制到
build/
中。
两个输出任务都由 webpack 处理:
- 通过 webpack 的插件
copy-webpack-plugin
将html/
中的文件复制到build/
中。 - 本章探讨了两种不同的捆绑工作流程:
- 要么 webpack 直接将 TypeScript 文件编译成捆绑包,借助加载器
ts-loader
。 - 或者我们自己编译 TypeScript 文件,将其编译为 JavaScript 文件,放在目录
dist/
中(就像我们在上一章中所做的那样)。然后 webpack 不需要加载器,只需要捆绑 JavaScript 文件。
- 本章大部分内容都是关于使用
ts-loader
的 webpack。最后,我们简要地看一下其他工作流程。
9.4 package.json
package.json
包含项目的元数据:
{ "private": true, "scripts": { "tsc": "tsc", "tscw": "tsc --watch", "wp": "webpack", "wpw": "webpack --watch", "serve": "http-server build" }, "dependencies": { "@types/lodash": "···", "copy-webpack-plugin": "···", "http-server": "···", "lodash": "···", "ts-loader": "···", "typescript": "···", "webpack": "···", "webpack-cli": "···" } }
属性的工作如下:
"private": true
表示如果我们不提供包名称和包版本,npm 不会抱怨。- 脚本:
tsc, tscw
:这些脚本直接调用 TypeScript 编译器。如果我们使用ts-loader
,则不需要它们。但是,如果我们在不使用ts-loader
的情况下使用 webpack(如本章末尾所示),它们是有用的。wp
:运行 webpack 一次,编译所有内容。wpw
:以监视模式运行 webpack,它会监视输入文件,并只编译更改的文件。serve
:运行服务器http-server
并提供完全组装的 Web 应用程序的目录build/
。
- 依赖项:
- 与 webpack 相关的四个软件包:
webpack
:webpack 的核心webpack-cli
:核心的命令行界面ts-loader
:用于将.ts
文件编译为 JavaScript 的加载器copy-webpack-plugin
:一个插件,将文件从一个位置复制到另一个位置
ts-loader
所需:typescript
- 为 Web 应用提供服务:
http-server
- TypeScript 代码使用的库加上类型定义:
lodash
,@types/lodash
9.5 webpack.config.js
这是我们配置 webpack 的方式:
const path = require('path'); const CopyWebpackPlugin = require('copy-webpack-plugin'); module.exports = { ··· entry: { main: "./ts/src/main.ts", }, output: { path: path.resolve(__dirname, 'build'), filename: "[name]-bundle.js", }, resolve: { // Add ".ts" and ".tsx" as resolvable extensions. extensions: [".ts", ".tsx", ".js"], }, module: { rules: [ // all files with a `.ts` or `.tsx` extension will be handled by `ts-loader` { test: /\.tsx?$/, loader: "ts-loader" }, ], }, plugins: [ new CopyWebpackPlugin([ { from: './html', } ]), ], };
属性:
entry
:入口点是 webpack 开始收集输出包数据的文件。首先将入口点文件添加到包中,然后是入口点的导入,然后是导入的导入,依此类推。属性entry
的值是一个对象,其属性键指定入口点的名称,其属性值指定入口点的路径。output
指定输出包的路径。当存在多个入口点(因此存在多个输出包)时,[name]
主要有用。在组装路径时,它将被入口点的名称替换。resolve
配置 webpack 如何将模块的规范符(ID)转换为文件的位置。module
配置加载程序(处理文件的插件)等。plugins
配置插件,可以以各种方式更改和增强 webpack 的行为。
有关配置 webpack 的更多信息,请参阅webpack 网站。
9.6 tsconfig.json
此文件配置 TypeScript 编译器:
{ "compilerOptions": { "rootDir": "ts", "outDir": "dist", "target": "es2019", "lib": [ "es2019", "dom" ], "module": "commonjs", "esModuleInterop": true, "strict": true, "sourceMap": true } }
如果我们使用ts-loader
与 webpack,则不需要选项outDir
。但是,如果我们在本章后面解释的情况下使用 webpack 而不使用加载程序,则需要它。
9.7 index.html
这是 Web 应用程序的 HTML 页面:
<!doctype html> <html> <head> <meta charset="UTF-8"> <title>ts-demo-webpack</title> </head> <body> <div id="output"></div> <script src="main-bundle.js"></script> </body> </html>
具有 ID"output"
的<div>
是 Web 应用程序显示其输出的位置。main-bundle.js
包含捆绑代码。
9.8 main.ts
这是 Web 应用程序的 TypeScript 代码:
import template from 'lodash/template'; const outputElement = document.getElementById('output'); if (outputElement) { const compiled = template(` <h1><%- heading %></h1> Current date and time: <%- dateTimeString %> `.trim()); outputElement.innerHTML = compiled({ heading: 'ts-demo-webpack', dateTimeString: new Date().toISOString(), }); }
- 步骤 1:我们使用Lodash 的函数
template()
将具有自定义模板语法的字符串转换为函数compiled()
,该函数将数据映射到 HTML。字符串定义了两个要通过数据填充的空白:
<%- heading %>
<%- dateTimeString %>
- 步骤 2:将
compiled()
应用于数据(具有两个属性的对象)以生成 HTML。
9.9 安装,构建和运行 Web 应用程序
首先,我们需要安装我们的 web 应用程序依赖的所有 npm 包:
npm install
然后,我们需要通过package.json
中的脚本运行在上一步安装的 webpack:
npm run wpw
从现在开始,webpack 会监视存储库中的文件以进行更改,并在检测到任何更改时重新构建 web 应用程序。
在另一个命令行中,我们现在可以启动一个 Web 服务器,该服务器在本地主机上提供build/
的内容:
npm run serve
如果我们转到 Web 服务器打印的 URL,我们可以看到 Web 应用程序正在运行。
请注意,简单的重新加载可能不足以在更改后看到结果-由于缓存。您可能需要在重新加载时按住 shift 键来强制重新加载。
9.9.1 在 Visual Studio Code 中构建
除了从命令行构建外,我们还可以通过 Visual Studio Code 内部进行构建,通过所谓的构建任务:
- 从“终端”菜单中执行“配置默认构建任务…”。
- 选择“npm: wpw”。
- 问题匹配器处理工具输出到问题(信息,警告和错误)列表的转换。默认情况下,在这种情况下工作良好。如果要明确,可以在
.vscode/tasks.json
中指定一个值:
"problemMatcher": ["$tsc-watch"],
我们现在可以通过“终端”菜单中的“运行构建任务…”来启动 webpack。
9.10 在没有加载程序的情况下使用 webpack:webpack-no-loader.config.js
除了使用ts-loader
之外,我们还可以首先将 TypeScript 文件编译为 JavaScript 文件,然后通过 webpack 捆绑这些文件。前两个步骤中的第一个步骤的工作原理在上一章中有描述。
现在我们不必配置ts-loader
,我们的 webpack 配置文件更简单:
const path = require('path'); module.exports = { entry: { main: "./dist/src/main.js", }, output: { path: path.join(__dirname, 'build'), filename: '[name]-bundle.js', }, plugins: [ new CopyWebpackPlugin([ { from: './html', } ]), ], };
请注意,entry.main
是不同的。在另一个配置文件中,它是:
"./ts/src/main.ts"
为什么我们要在捆绑之前生成中间文件?一个好处是我们可以使用 Node.js 运行一些 TypeScript 代码的单元测试。
十、迁移到 TypeScript 的策略
原文:
exploringjs.com/tackling-ts/ch_migrating-to-typescript.html
译者:飞龙
- 10.1 三种策略
- 10.2 策略:混合 JavaScript/TypeScript 代码库
- 10.3 策略:向普通 JavaScript 文件添加类型信息
- 10.4 策略:通过快照测试 TypeScript 错误迁移大型项目
- 10.5 结论
本章概述了从 JavaScript 迁移到 TypeScript 的策略。它还提到了进一步阅读的材料。
10.1 三种策略
这是迁移到 TypeScript 的三种策略:
- 我们可以支持代码库中 JavaScript 和 TypeScript 文件的混合。我们从只有 JavaScript 文件开始,然后逐渐将更多文件切换到 TypeScript。
- 我们可以保留当前(非 TypeScript)的构建流程和我们只有 JavaScript 的代码库。我们通过 JSDoc 注释添加静态类型信息,并将 TypeScript 用作类型检查器(而不是编译器)。一旦一切都正确类型化,我们就切换到 TypeScript 进行构建。
- 对于大型项目,在迁移过程中可能会出现太多 TypeScript 错误。然后快照测试可以帮助我们找到已修复的错误和新错误。
更多信息:
- “从 JavaScript 迁移” in the TypeScript Handbook
10.2 策略:混合 JavaScript/TypeScript 代码库
如果我们使用编译器选项--allowJs
,TypeScript 编译器支持 JavaScript 和 TypeScript 文件的混合:
- TypeScript 文件被编译。
- JavaScript 文件只是简单地复制到输出目录(经过一些简单的类型检查)。
起初,只有 JavaScript 文件。然后,我们逐个将文件切换到 TypeScript。在此过程中,我们的代码库将继续被编译。
这是tsconfig.json
的样子:
{ "compilerOptions": { ··· "allowJs": true } }
更多信息:
10.3 策略:向普通 JavaScript 文件添加类型信息
这种方法的工作方式如下:
- 我们继续使用我们当前的构建基础设施。
- 我们运行 TypeScript 编译器,但只作为类型检查器(编译器选项
--noEmit
)。除了编译器选项--allowJs
(用于允许和复制 JavaScript 文件),我们还必须使用编译器选项--checkJs
(用于对 JavaScript 文件进行类型检查)。 - 我们通过 JSDoc 注释(见下面的示例)和声明文件添加类型信息。
- 一旦 TypeScript 的类型检查器不再抱怨,我们就可以使用编译器构建代码库。现在不急于从
.js
文件切换到.ts
文件,因为整个代码库已经完全静态类型化。我们甚至现在可以生成类型文件(文件扩展名.d.ts
)。
这是我们如何通过 JSDoc 注释为普通 JavaScript 指定静态类型的方式:
/** * @param {number} x - The first operand * @param {number} y - The second operand * @returns {number} The sum of both operands */ function add(x, y) { return x + y; }
/** @typedef {{ prop1: string, prop2: string, prop3?: number }} SpecialType */ /** @typedef {(data: string, index?: number) => boolean} Predicate */
更多信息:
- §4.4 “使用 TypeScript 编译器处理普通 JavaScript 文件”
- “我们如何逐渐迁移到 TypeScript 在 Unsplash” by Oliver Joseph Ash
10.4 策略:通过快照测试 TypeScript 错误迁移大型项目
在大型 JavaScript 项目中,切换到 TypeScript 可能会产生太多错误 - 无论我们选择哪种方法。然后,快照测试 TypeScript 错误可能是一个选择:
- 我们第一次在整个代码库上运行了 TypeScript 编译器。
- 编译器产生的错误成为我们的初始快照。
- 当我们在代码库上工作时,我们将新的错误输出与之前的快照进行比较:
- 有时现有的错误会消失。然后我们可以创建一个新的快照。
- 有时会出现新的错误。然后我们要么修复这些错误,要么创建一个新的快照。
更多信息:
10.5 结论
我们已经快速了解了迁移到 TypeScript 的策略。再给两个建议:
- 开始你的迁移实验:在提交到其中一个之前,尝试各种策略并玩弄你的代码库。
- 然后制定一个明确的前进计划。与团队讨论优先级:
- 有时,快速完成迁移可能更重要。
- 有时,在迁移过程中代码保持完全可用可能更重要。
- 等等…
第三部分:基本类型
原文:
exploringjs.com/tackling-ts/pt_basic-types.html
译者:飞龙
下一步:11 顶级类型any
和unknown
十一、The top types any and unknown
原文:
exploringjs.com/tackling-ts/ch_any-unknown.html
译者:飞龙
- 11.1 TypeScript 的两个顶级类型
- 11.2 顶级类型
any
- 11.2.1 示例:
JSON.parse()
- 11.2.2 示例:
String()
- 11.3 顶级类型
unknown
在 TypeScript 中,any
和unknown
是包含所有值的类型。在本章中,我们将研究它们是什么以及它们可以用于什么。
11.1 TypeScript 的两个顶级类型
any
和unknown
是 TypeScript 中所谓的顶级类型。引用Wikipedia:
顶级类型是通用类型,有时被称为通用超类型,因为在任何给定类型系统中,所有其他类型都是子类型。在大多数情况下,它是包含感兴趣的类型系统中的每个可能值的类型。
也就是说,当将类型视为值的集合时(有关类型是什么的更多信息,请参见[content not included]),any
和unknown
是包含所有值的集合。顺便说一句,TypeScript 还有底部类型never
,它是空集。
11.2 顶级类型any
如果一个值的类型是any
,我们可以对它做任何事情:
function func(value: any) { // Only allowed for numbers, but they are a subtype of `any` 5 * value; // Normally the type signature of `value` must contain .propName value.propName; // Normally only allowed for Arrays and types with index signatures value[123]; }
每种类型都可以分配给类型any
:
let storageLocation: any; storageLocation = null; storageLocation = true; storageLocation = {};
类型any
可以分配给每种类型:
function func(value: any) { const a: null = value; const b: boolean = value; const c: object = value; }
使用any
会失去 TypeScript 静态类型系统通常给我们的任何保护。因此,只有在无法使用更具体的类型或unknown
时,才应该使用它作为最后的手段。
11.2.1 示例:JSON.parse()
JSON.parse()
的结果取决于动态输入,这就是为什么返回类型是any
(我已经从签名中省略了参数reviver
):
JSON.parse(text: string): any;
在类型unknown
存在之前,JSON.parse()
被添加到 TypeScript 中。否则,它的返回类型可能是unknown
。
11.2.2 示例:String()
将任意值转换为字符串的函数String()
具有以下类型签名:
interface StringConstructor { (value?: any): string; // call signature // ··· }
11.3 顶级类型unknown
类型unknown
是类型any
的类型安全版本。每当你考虑使用any
时,先尝试使用unknown
。
any
允许我们做任何事情,而unknown
则更加限制。
在对类型为unknown
的值执行任何操作之前,我们必须通过以下方式先缩小它们的类型:
- 类型断言:
function func(value: unknown) { // @ts-expect-error: Object is of type 'unknown'. value.toFixed(2); // Type assertion: (value as number).toFixed(2); // OK }
- 相等性:
function func(value: unknown) { // @ts-expect-error: Object is of type 'unknown'. value * 5; if (value === 123) { // equality // %inferred-type: 123 value; value * 5; // OK } }
- 类型守卫:
function func(value: unknown) { // @ts-expect-error: Object is of type 'unknown'. value.length; if (typeof value === 'string') { // type guard // %inferred-type: string value; value.length; // OK } }
- 断言函数:
function func(value: unknown) { // @ts-expect-error: Object is of type 'unknown'. value.test('abc'); assertIsRegExp(value); // %inferred-type: RegExp value; value.test('abc'); // OK } /** An assertion function */ function assertIsRegExp(arg: unknown): asserts arg is RegExp { if (! (arg instanceof RegExp)) { throw new TypeError('Not a RegExp: ' + arg); } }
十二、TypeScript 枚举:它们是如何工作的?可以用于什么?
原文:
exploringjs.com/tackling-ts/ch_enums.html
译者:飞龙
- 12.1 基础知识
- 12.1.1 数字枚举
- 12.1.2 基于字符串的枚举
- 12.1.3 异构枚举
- 12.1.4 省略初始化器
- 12.1.5 枚举成员名称的大小写
- 12.1.6 枚举成员名称加引号
- 12.2 指定枚举成员的值(高级)
- 12.2.1 字面量枚举成员
- 12.2.2 常量枚举成员
- 12.2.3 计算的枚举成员
- 12.3 数字枚举的缺点
- 12.3.1 缺点:日志记录
- 12.3.2 缺点:松散的类型检查
- 12.3.3 建议:优先使用基于字符串的枚举
- 12.4 枚举的用例
- 12.4.1 用例:位模式
- 12.4.2 用例:多个常量
- 12.4.3 用例:比布尔值更自描述
- 12.4.4 用例:更好的字符串常量
- 12.5 运行时的枚举
- 12.5.1 反向映射
- 12.5.2 运行时的基于字符串的枚举
- 12.6
const
枚举
- 12.6.1 编译非常量枚举
- 12.6.2 编译常量枚举
- 12.7 编译时的枚举
- 12.7.1 枚举是对象
- 12.7.2 字面量枚举的安全检查
- 12.7.3
keyof
和枚举
- 12.8 致谢
本章回答以下两个问题:
- TypeScript 的枚举是如何工作的?
- 它们可以用于什么?
在下一章中,我们将看看枚举的替代方案。
12.1 基础知识
boolean
是一个具有有限值的类型:false
和true
。使用枚举,TypeScript 允许我们自己定义类似的类型。
12.1.1 数字枚举
这是一个数字枚举:
enum NoYes { No = 0, Yes = 1, // trailing comma } assert.equal(NoYes.No, 0); assert.equal(NoYes.Yes, 1);
解释:
No
和Yes
被称为枚举NoYes
的成员。- 每个枚举成员都有一个名称和一个值。例如,第一个成员的名称是
No
,值是0
。 - 以等号开头并指定值的成员定义部分称为初始化器。
- 与对象文字一样,允许并忽略尾随逗号。
我们可以将成员用作字面量,例如true
,123
或'abc'
。例如:
function toGerman(value: NoYes) { switch (value) { case NoYes.No: return 'Nein'; case NoYes.Yes: return 'Ja'; } } assert.equal(toGerman(NoYes.No), 'Nein'); assert.equal(toGerman(NoYes.Yes), 'Ja');
12.1.2 基于字符串的枚举
我们也可以使用字符串作为枚举成员的值:
enum NoYes { No = 'No', Yes = 'Yes', } assert.equal(NoYes.No, 'No'); assert.equal(NoYes.Yes, 'Yes');
12.1.3 异构枚举
最后一种枚举称为异构。异构枚举的成员值是数字和字符串的混合:
enum Enum { One = 'One', Two = 'Two', Three = 3, Four = 4, } assert.deepEqual( [Enum.One, Enum.Two, Enum.Three, Enum.Four], ['One', 'Two', 3, 4] );
异构枚举很少使用,因为它们的应用很少。
遗憾的是,TypeScript 只支持数字和字符串作为枚举成员的值。不允许其他值,比如符号。
12.1.4 省略初始化器
我们可以在两种情况下省略初始化器:
- 我们可以省略第一个成员的初始化器。然后该成员的值为 0(零)。
- 如果前一个成员有数字值,则可以省略成员的初始化器。然后当前成员的值为前一个成员的值加一。
这是一个没有任何初始化程序的数字枚举:
enum NoYes { No, Yes, } assert.equal(NoYes.No, 0); assert.equal(NoYes.Yes, 1);
这是一个异构枚举,其中省略了一些初始化程序:
enum Enum { A, B, C = 'C', D = 'D', E = 8, // (A) F, } assert.deepEqual( [Enum.A, Enum.B, Enum.C, Enum.D, Enum.E, Enum.F], [0, 1, 'C', 'D', 8, 9] );
请注意,我们不能省略行 A 中的初始化程序,因为前一个成员的值不是数字。
12.1.5 枚举成员名称的大小写
有几个命名常量的先例(在枚举或其他地方):
- 传统上,JavaScript 使用全大写的名称,这是它从 Java 和 C 继承的约定:
Number.MAX_VALUE
Math.SQRT2
- 众所周知的符号是以小写字母开头的驼峰命名,因为它们与属性名称相关:
Symbol.asyncIterator
- TypeScript 手册使用以大写字母开头的驼峰命名。这是标准的 TypeScript 风格,我们在
NoYes
枚举中使用了它。
12.1.6 引用枚举成员名称
与 JavaScript 对象类似,我们可以引用枚举成员的名称:
enum HttpRequestField { 'Accept', 'Accept-Charset', 'Accept-Datetime', 'Accept-Encoding', 'Accept-Language', } assert.equal(HttpRequestField['Accept-Charset'], 1);
没有办法计算枚举成员的名称。对象文字支持通过方括号进行计算属性键。
12.2 指定枚举成员值(高级)
TypeScript 通过初始化方式区分三种枚举成员:
- 文字枚举成员:
- 要么没有初始化程序
- 或者通过数字文字或字符串文字初始化。
- 常量枚举成员 是通过在编译时可以计算结果的表达式初始化的。
- 计算的枚举成员 是通过任意表达式初始化的。
到目前为止,我们只使用了文字成员。
在前面的列表中,提到的成员不太灵活,但支持更多功能。继续阅读以获取更多信息。
12.2.1 文字枚举成员
如果枚举成员的值已指定,则该枚举成员是文字:
- 隐式地
- 或通过数字文字(包括否定的数字文字)
- 或通过字符串文字。
如果枚举只有文字成员,我们可以将这些成员用作类型(类似于如何使用数字文字作为类型):
enum NoYes { No = 'No', Yes = 'Yes', } function func(x: NoYes.No) { // (A) return x; } func(NoYes.No); // OK // @ts-expect-error: Argument of type '"No"' is not assignable to // parameter of type 'NoYes.No'. func('No'); // @ts-expect-error: Argument of type 'NoYes.Yes' is not assignable to // parameter of type 'NoYes.No'. func(NoYes.Yes);
行 A 中的 NoYes.No
是枚举成员类型。
此外,文字枚举支持完整性检查(稍后我们将详细介绍)。
12.2.2 常量枚举成员
如果枚举成员的值可以在编译时计算,则该枚举成员是常量。因此,我们可以隐式地指定其值(也就是说,我们让 TypeScript 为我们指定它)。或者我们可以明确指定它,并且只允许使用以下语法:
- 数字文字或字符串文字
- 对先前定义的常量枚举成员的引用(在当前枚举或以前的枚举中)
- 括号
- 一元运算符
+
、-
、~
- 二进制运算符
+
、-
、*
、/
、%
、<<
、>>
、>>>
、&
、|
、^
这是一个枚举的例子,其成员都是常量(稍后我们将看到该枚举的用法):
enum Perm { UserRead = 1 << 8, // bit 8 UserWrite = 1 << 7, UserExecute = 1 << 6, GroupRead = 1 << 5, GroupWrite = 1 << 4, GroupExecute = 1 << 3, AllRead = 1 << 2, AllWrite = 1 << 1, AllExecute = 1 << 0, }
通常,常量成员不能用作类型。但是,仍然执行完整性检查。
12.2.3 计算的枚举成员
计算的枚举成员 的值可以通过任意表达式指定。例如:
enum NoYesNum { No = 123, Yes = Math.random(), // OK }
这是一个数字枚举。基于字符串的枚举和异构枚举更受限制。例如,我们不能使用方法调用来指定成员值:
enum NoYesStr { No = 'No', // @ts-expect-error: Computed values are not permitted in // an enum with string valued members. Yes = ['Y', 'e', 's'].join(''), }
TypeScript 不会对计算的枚举成员执行完整性检查。
12.3 数字枚举的缺点
12.3.1 缺点:记录
在记录数字枚举的成员时,我们只看到数字:
enum NoYes { No, Yes } console.log(NoYes.No); console.log(NoYes.Yes); // Output: // 0 // 1
12.3.2 缺点:松散的类型检查
在将枚举用作类型时,静态允许的值不仅仅是枚举成员的值-任何数字都被接受:
enum NoYes { No, Yes } function func(noYes: NoYes) {} func(33); // no error!
为什么没有更严格的静态检查?Daniel Rosenwasser 解释:
这种行为是由位操作驱动的。有时候
SomeFlag.Foo | SomeFlag.Bar
旨在产生另一个SomeFlag
。而不是得到number
,你不想要强制转换回SomeFlag
。我认为如果我们重新使用 TypeScript 并且仍然有枚举,我们会为位标志制定一个单独的构造。
很快我们将更详细地演示枚举如何用于位模式。
12.3.3 建议:更喜欢基于字符串的枚举
我的建议是更喜欢基于字符串的枚举(为了简洁起见,本章并不总是遵循此建议):
enum NoYes { No='No', Yes='Yes' }
一方面,日志输出对人类更有用:
console.log(NoYes.No); console.log(NoYes.Yes); // Output: // 'No' // 'Yes'
另一方面,我们获得了更严格的类型检查:
function func(noYes: NoYes) {} // @ts-expect-error: Argument of type '"abc"' is not assignable // to parameter of type 'NoYes'. func('abc'); // @ts-expect-error: Argument of type '"Yes"' is not assignable // to parameter of type 'NoYes'. func('Yes'); // (A)
甚至不允许等于成员值的字符串(行 A)。
12.4 枚举的用例
12.4.1 用例:位模式
在Node.js 文件系统模块中,有几个函数具有参数mode
。它通过数字编码指定文件权限,这是 Unix 的遗留物:
- 权限指定了三类用户的权限:
- 用户:文件的所有者
- 组:与文件关联的组的成员
- 所有人:所有人
- 按类别,可以授予以下权限:
- r(读取):允许类别中的用户读取文件
- w(写入):允许类别中的用户更改文件
- x(执行):允许类别中的用户运行文件
这意味着权限可以由 9 位表示(每个类别有 3 个权限):
用户 | 组 | 所有人 | |
权限 | r,w,x | r,w,x | r,w,x |
位 | 8, 7, 6 | 5, 4, 3 | 2, 1, 0 |
Node.js 不这样做,但我们可以使用枚举来处理这些标志:
enum Perm { UserRead = 1 << 8, // bit 8 UserWrite = 1 << 7, UserExecute = 1 << 6, GroupRead = 1 << 5, GroupWrite = 1 << 4, GroupExecute = 1 << 3, AllRead = 1 << 2, AllWrite = 1 << 1, AllExecute = 1 << 0, }
位模式通过按位或进行组合:
// User can change, read and execute. // Everyone else can only read and execute. assert.equal( Perm.UserRead | Perm.UserWrite | Perm.UserExecute | Perm.GroupRead | Perm.GroupExecute | Perm.AllRead | Perm.AllExecute, 0o755); // User can read and write. // Group members can read. // Everyone can’t access at all. assert.equal( Perm.UserRead | Perm.UserWrite | Perm.GroupRead, 0o640);
12.4.1.1 位模式的替代方案
位模式的主要思想是有一组标志,可以选择这些标志的任何子集。
因此,使用真实集合来选择子集是执行相同任务的更直接的方式:
enum Perm { UserRead = 'UserRead', UserWrite = 'UserWrite', UserExecute = 'UserExecute', GroupRead = 'GroupRead', GroupWrite = 'GroupWrite', GroupExecute = 'GroupExecute', AllRead = 'AllRead', AllWrite = 'AllWrite', AllExecute = 'AllExecute', } function writeFileSync( thePath: string, permissions: Set<Perm>, content: string) { // ··· } writeFileSync( '/tmp/hello.txt', new Set([Perm.UserRead, Perm.UserWrite, Perm.GroupRead]), 'Hello!');
12.4.2 用例:多个常量
有时,我们有一组属于一起的常量:
const off = Symbol('off'); const info = Symbol('info'); const warn = Symbol('warn'); const error = Symbol('error');
这是枚举的一个很好的用例:
enum LogLevel { off = 'off', info = 'info', warn = 'warn', error = 'error', }
枚举的一个好处是常量名称被分组并嵌套在命名空间LogLevel
中。
另一个是我们自动获得了类型LogLevel
。如果我们想要这样的类型用于常量,我们需要更多的工作:
type LogLevel = | typeof off | typeof info | typeof warn | typeof error ;
有关此方法的更多信息,请参见§13.1.3“符号单例类型的联合”。
12.4.3 用例:比布尔值更具自描述性
当布尔值用于表示替代方案时,枚举通常更具自描述性。
12.4.3.1 布尔示例:有序 vs. 无序列表
例如,要表示列表是否有序,我们可以使用布尔值:
class List1 { isOrdered: boolean; // ··· }
然而,枚举更具自描述性,并且具有额外的好处,即如果需要,我们可以随后添加更多的替代方案。
enum ListKind { ordered, unordered } class List2 { listKind: ListKind; // ··· }
12.4.3.2 布尔示例:错误处理模式
同样,我们可以通过布尔值指定如何处理错误:
function convertToHtml1(markdown: string, throwOnError: boolean) { // ··· }
或者我们可以通过枚举值来实现:
enum ErrorHandling { throwOnError = 'throwOnError', showErrorsInContent = 'showErrorsInContent', } function convertToHtml2(markdown: string, errorHandling: ErrorHandling) { // ··· }
12.4.4 用例:更好的字符串常量
考虑以下创建正则表达式的函数。
const GLOBAL = 'g'; const NOT_GLOBAL = ''; type Globalness = typeof GLOBAL | typeof NOT_GLOBAL; function createRegExp(source: string, globalness: Globalness = NOT_GLOBAL) { return new RegExp(source, 'u' + globalness); } assert.deepEqual( createRegExp('abc', GLOBAL), /abc/ug); assert.deepEqual( createRegExp('abc', 'g'), // OK /abc/ug);
我们可以使用枚举而不是字符串常量:
enum Globalness { Global = 'g', notGlobal = '', } function createRegExp(source: string, globalness = Globalness.notGlobal) { return new RegExp(source, 'u' + globalness); } assert.deepEqual( createRegExp('abc', Globalness.Global), /abc/ug); assert.deepEqual( // @ts-expect-error: Argument of type '"g"' is not assignable to parameter of type 'Globalness | undefined'. (2345) createRegExp('abc', 'g'), // error /abc/ug);
这种方法的好处是什么?
- 它更简洁。
- 它稍微更安全:类型
Globalness
只接受成员名称,而不是字符串。
12.5 运行时的枚举
TypeScript 将枚举编译为 JavaScript 对象。例如,考虑以下枚举:
enum NoYes { No, Yes, }
TypeScript 将此枚举编译为:
var NoYes; (function (NoYes) { NoYes[NoYes["No"] = 0] = "No"; NoYes[NoYes["Yes"] = 1] = "Yes"; })(NoYes || (NoYes = {}));
在此代码中,进行了以下赋值:
NoYes["No"] = 0; NoYes["Yes"] = 1; NoYes[0] = "No"; NoYes[1] = "Yes";
有两组赋值:
- 前两个赋值将枚举成员名称映射到值。
- 接下来的两个赋值将值映射到名称。这使得反向映射成为可能,接下来我们将看一下。
12.5.1 反向映射
给定一个数字枚举:
enum NoYes { No, Yes, }
正常映射是从成员名称到成员值:
// Static (= fixed) lookup: assert.equal(NoYes.Yes, 1); // Dynamic lookup: assert.equal(NoYes['Yes'], 1);
数字枚举还支持从成员值到成员名称的反向映射:
assert.equal(NoYes[1], 'Yes');
反向映射的一个用例是打印枚举成员的名称:
function getQualifiedName(value: NoYes) { return 'NoYes.' + NoYes[value]; } assert.equal( getQualifiedName(NoYes.Yes), 'NoYes.Yes');
12.5.2 运行时的基于字符串的枚举
基于字符串的枚举在运行时具有更简单的表示。
考虑以下枚举。
enum NoYes { No = 'NO!', Yes = 'YES!', }
它编译为以下 JavaScript 代码:
var NoYes; (function (NoYes) { NoYes["No"] = "NO!"; NoYes["Yes"] = "YES!"; })(NoYes || (NoYes = {}));
TypeScript 不支持基于字符串的枚举的反向映射。
12.6 const
枚举
如果枚举以关键字const
为前缀,则在运行时没有表示。相反,直接使用其成员的值。
12.6.1 编译非 const 枚举
要观察这种效果,让我们首先检查以下非 const 枚举:
enum NoYes { No = 'No', Yes = 'Yes', } function toGerman(value: NoYes) { switch (value) { case NoYes.No: return 'Nein'; case NoYes.Yes: return 'Ja'; } }
TypeScript 将此代码编译为:
"use strict"; var NoYes; (function (NoYes) { NoYes["No"] = "No"; NoYes["Yes"] = "Yes"; })(NoYes || (NoYes = {})); function toGerman(value) { switch (value) { case NoYes.No: return 'Nein'; case NoYes.Yes: return 'Ja'; } }
12.6.2 编译 const 枚举
这与以前的代码相同,但现在枚举是 const:
const enum NoYes { No, Yes, } function toGerman(value: NoYes) { switch (value) { case NoYes.No: return 'Nein'; case NoYes.Yes: return 'Ja'; } }
现在,枚举的表示作为构造体消失了,只剩下其成员的值:
function toGerman(value) { switch (value) { case "No" /* No */: return 'Nein'; case "Yes" /* Yes */: return 'Ja'; } }
12.7 编译时的枚举
12.7.1 枚举是对象
TypeScript 将(非 const)枚举视为对象:
enum NoYes { No = 'No', Yes = 'Yes', } function func(obj: { No: string }) { return obj.No; } assert.equal( func(NoYes), // allowed statically! 'No');
12.7.2 对文字枚举的安全检查
当我们接受枚举成员的值时,通常希望确保:
- 我们不会收到非法值。
- 我们不会忘记考虑任何枚举成员的值。如果我们稍后添加成员,这一点尤其重要。
继续阅读以获取更多信息。我们将使用以下枚举进行工作:
enum NoYes { No = 'No', Yes = 'Yes', }
12.7.2.1 防止非法值
在以下代码中,我们采取了两项措施防止非法值:
function toGerman1(value: NoYes) { switch (value) { case NoYes.No: return 'Nein'; case NoYes.Yes: return 'Ja'; default: throw new TypeError('Unsupported value: ' + JSON.stringify(value)); } } assert.throws( // @ts-expect-error: Argument of type '"Maybe"' is not assignable to // parameter of type 'NoYes'. () => toGerman1('Maybe'), /^TypeError: Unsupported value: "Maybe"$/);
措施是:
- 在编译时,类型
NoYes
防止非法值传递给参数value
。 - 在运行时,如果出现意外值,将使用
default
情况抛出异常。
12.7.2.2 通过完整性检查防止遗漏情况
我们可以采取更多措施。以下代码执行完整性检查:如果我们忘记考虑所有枚举成员,TypeScript 将警告我们。
class UnsupportedValueError extends Error { constructor(value: never) { super('Unsupported value: ' + value); } } function toGerman2(value: NoYes) { switch (value) { case NoYes.No: return 'Nein'; case NoYes.Yes: return 'Ja'; default: throw new UnsupportedValueError(value); } }
完整性检查是如何工作的?对于每种情况,TypeScript 推断value
的类型:
function toGerman2b(value: NoYes) { switch (value) { case NoYes.No: // %inferred-type: NoYes.No value; return 'Nein'; case NoYes.Yes: // %inferred-type: NoYes.Yes value; return 'Ja'; default: // %inferred-type: never value; throw new UnsupportedValueError(value); } }
在默认情况下,TypeScript 推断value
的类型为never
,因为我们永远不会到达那里。但是,如果我们向NoYes
添加一个成员.Maybe
,那么value
的推断类型将是NoYes.Maybe
。而该类型在编译时与new UnsupportedValueError()
的参数的类型never
静态不兼容。这就是为什么我们在编译时会得到以下错误消息:
Argument of type 'NoYes.Maybe' is not assignable to parameter of type 'never'.
方便的是,这种完整性检查也适用于if
语句:
function toGerman3(value: NoYes) { if (value === NoYes.No) { return 'Nein'; } else if (value === NoYes.Yes) { return 'Ja'; } else { throw new UnsupportedValueError(value); } }
12.7.2.3 检查完整性的另一种方法
或者,如果我们指定返回类型,还可以获得完整性检查:
function toGerman4(value: NoYes): string { switch (value) { case NoYes.No: const x: NoYes.No = value; return 'Nein'; case NoYes.Yes: const y: NoYes.Yes = value; return 'Ja'; } }
如果我们向NoYes
添加一个成员,那么 TypeScript 会抱怨toGerman4()
可能会返回undefined
。
这种方法的缺点:
- 这种方法不适用于
if
语句(更多信息)。 - 不会在运行时执行检查。
12.7.3 keyof
和枚举
我们可以使用keyof
类型运算符来创建元素为枚举成员键的类型。当我们这样做时,我们需要将keyof
与typeof
结合使用:
enum HttpRequestKeyEnum { 'Accept', 'Accept-Charset', 'Accept-Datetime', 'Accept-Encoding', 'Accept-Language', } // %inferred-type: "Accept" | "Accept-Charset" | "Accept-Datetime" | // "Accept-Encoding" | "Accept-Language" type HttpRequestKey = keyof typeof HttpRequestKeyEnum; function getRequestHeaderValue(request: Request, key: HttpRequestKey) { // ··· }
12.7.3.1 在没有typeof
的情况下使用keyof
如果我们在没有typeof
的情况下使用keyof
,则会得到不同且不太有用的类型:
// %inferred-type: "toString" | "toFixed" | "toExponential" | // "toPrecision" | "valueOf" | "toLocaleString" type Keys = keyof HttpRequestKeyEnum;
keyof HttpRequestKeyEnum
与keyof number
相同。
12.8 致谢
- 感谢 Disqus 用户
@spira_mirabilis
对本章的反馈。
十三、TypeScript 中枚举的替代方案
原文:
exploringjs.com/tackling-ts/ch_enum-alternatives.html
译者:飞龙
- 13.1 单例值的联合
- 13.1.1 原始文字类型
- 13.1.2 字符串文字类型的联合
- 13.1.3 符号单例类型的联合
- 13.1.4 本节的结论:联合类型 vs. 枚举
- 13.2 鉴别联合
- 13.2.1 步骤 1:将语法树作为类层次结构
- 13.2.2 步骤 2:将语法树作为类的联合类型
- 13.2.3 步骤 3:将语法树作为鉴别联合
- 13.2.4 鉴别联合 vs. 普通联合类型
- 13.3 对象字面量作为枚举
- 13.3.1 具有字符串值属性的对象字面量
- 13.3.2 使用对象字面量作为枚举的优缺点
- 13.4 枚举模式
- 13.5 枚举和枚举替代方案的总结
- 13.6 致谢
上一章探讨了 TypeScript 枚举的工作原理。在本章中,我们将看看枚举的替代方案。
13.1 单例值的联合
枚举将成员名称映射到成员值。如果我们不需要或不想要间接引用,我们可以使用所谓的原始文字类型的联合 - 每个值一个。在我们能够深入了解细节之前,我们需要了解原始文字类型。
13.1.1 原始文字类型
快速回顾:我们可以将类型视为值的集合。
单例类型是具有一个元素的类型。原始文字类型是单例类型:
type UndefinedLiteralType = undefined; type NullLiteralType = null; type BooleanLiteralType = true; type NumericLiteralType = 123; type BigIntLiteralType = 123n; // --target must be ES2020+ type StringLiteralType = 'abc';
UndefinedLiteralType
是具有单个元素undefined
的类型,等等。
在这里需要注意两个语言级别(我们在本书的早些时候已经遇到了这些级别)。考虑以下变量声明:
const abc: 'abc' = 'abc';
- 第一个’abc’表示一种类型(字符串文字类型)。
- 第二个’abc’表示一个值。
原始文字类型的两个用例是:
- 字符串参数的重载使得以下方法调用的第一个参数决定第二个参数的类型:
elem.addEventListener('click', myEventHandler);
- 我们可以使用原始文字类型的联合来定义类型,通过列举其成员:
type IceCreamFlavor = 'vanilla' | 'chocolate' | 'strawberry';
继续阅读有关第二个用例的更多信息。
13.1.2 字符串文字类型的联合
我们将从枚举开始,然后将其转换为字符串文字类型的联合。
enum NoYesEnum { No = 'No', Yes = 'Yes', } function toGerman1(value: NoYesEnum): string { switch (value) { case NoYesEnum.No: return 'Nein'; case NoYesEnum.Yes: return 'Ja'; } } assert.equal(toGerman1(NoYesEnum.No), 'Nein'); assert.equal(toGerman1(NoYesEnum.Yes), 'Ja');
NoYesStrings
是NoYesEnum
的联合类型版本:
type NoYesStrings = 'No' | 'Yes'; function toGerman2(value: NoYesStrings): string { switch (value) { case 'No': return 'Nein'; case 'Yes': return 'Ja'; } } assert.equal(toGerman2('No'), 'Nein'); assert.equal(toGerman2('Yes'), 'Ja');
类型NoYesStrings
是字符串文字类型'No'
和'Yes'
的联合。联合类型运算符|
与集合论的联合运算符∪
相关。
13.1.2.1 字符串文字类型的联合可以进行穷尽性检查
以下代码演示了对字符串文字类型的联合进行穷尽性检查:
// @ts-expect-error: Function lacks ending return statement and // return type does not include 'undefined'. (2366) function toGerman3(value: NoYesStrings): string { switch (value) { case 'Yes': return 'Ja'; } }
我们忘记了’No’的情况,TypeScript 警告我们该函数可能返回不是字符串的值。
我们也可以更明确地检查穷尽性:
class UnsupportedValueError extends Error { constructor(value: never) { super('Unsupported value: ' + value); } } function toGerman4(value: NoYesStrings): string { switch (value) { case 'Yes': return 'Ja'; default: // @ts-expect-error: Argument of type '"No"' is not // assignable to parameter of type 'never'. (2345) throw new UnsupportedValueError(value); } }
现在 TypeScript 警告我们,如果 value
是 'No'
,则会到达 default
情况。
有关穷尽性检查的更多信息
有关此主题的更多信息,请参见§12.7.2.2 “通过穷尽性检查防止遗漏情况”。
13.1.2.2 缺点:字符串文字的联合类型在类型安全性上不如其他类型
字符串文字联合的一个缺点是非成员值可能被误认为是成员:
type Spanish = 'no' | 'sí'; type English = 'no' | 'yes'; const spanishWord: Spanish = 'no'; const englishWord: English = spanishWord;
这是合理的,因为西班牙语的 'no'
和英语的 'no'
是相同的值。实际问题在于没有办法给它们不同的标识。
13.1.3 符号单例类型的联合
13.1.3.1 示例:LogLevel
我们也可以使用符号单例类型的联合,而不是字符串文字类型的联合。这次让我们从一个不同的枚举开始:
enum LogLevel { off = 'off', info = 'info', warn = 'warn', error = 'error', }
转换为符号单例类型的联合,如下所示:
const off = Symbol('off'); const info = Symbol('info'); const warn = Symbol('warn'); const error = Symbol('error'); // %inferred-type: unique symbol | unique symbol | // unique symbol | unique symbol type LogLevel = | typeof off | typeof info | typeof warn | typeof error ;
为什么我们在这里需要 typeof
?off
等是值,不能出现在类型方程中。类型运算符 typeof
通过将值转换为类型来解决此问题。
让我们考虑前面示例的两种变体。
13.1.3.2 变体 #1:内联符号
我们可以内联符号(而不是引用单独的 const
声明)吗?遗憾的是,类型运算符 typeof
的操作数必须是标识符或由点分隔的标识符“路径”。因此,这种语法是非法的:
type LogLevel = typeof Symbol('off') | ···
13.1.3.3 变体 #2:let
而不是 const
我们可以使用 let
而不是 const
来声明变量吗?(这不一定是一种改进,但仍然是一个有趣的问题。)
我们不能这样做,因为我们需要 TypeScript 为 const
声明的变量推断出更窄的类型:
// %inferred-type: unique symbol const constSymbol = Symbol('constSymbol'); // %inferred-type: symbol let letSymbol1 = Symbol('letSymbol1');
使用 let
,LogLevel
只是 symbol
的别名。
const
断言通常解决这种问题。但在这种情况下不起作用:
// @ts-expect-error: A 'const' assertions can only be applied to references to enum // members, or string, number, boolean, array, or object literals. (1355) let letSymbol2 = Symbol('letSymbol2') as const;
13.1.3.4 在函数中使用 LogLevel
以下函数将 LogLevel
的成员转换为字符串:
function getName(logLevel: LogLevel): string { switch (logLevel) { case off: return 'off'; case info: return 'info'; case warn: return 'warn'; case error: return 'error'; } } assert.equal( getName(warn), 'warn');
13.1.3.5 符号单例类型的联合 vs. 字符串文字类型的联合
这两种方法如何比较?
- 穷尽性检查对两者都适用。
- 使用符号更加冗长。
- 每个符号“文字”都创建一个独特的符号,不会与任何其他符号混淆。对于字符串文字来说并非如此。详情请继续阅读。
回想一下西班牙语的 'no'
被误认为是英语的 'no'
的例子:
type Spanish = 'no' | 'sí'; type English = 'no' | 'yes'; const spanishWord: Spanish = 'no'; const englishWord: English = spanishWord;
如果我们使用符号,我们就不会有这个问题:
const spanishNo = Symbol('no'); const spanishSí = Symbol('sí'); type Spanish = typeof spanishNo | typeof spanishSí; const englishNo = Symbol('no'); const englishYes = Symbol('yes'); type English = typeof englishNo | typeof englishYes; const spanishWord: Spanish = spanishNo; // @ts-expect-error: Type 'unique symbol' is not assignable to type 'English'. (2322) const englishWord: English = spanishNo;
13.1.4 本节的结论:联合类型 vs. 枚举
联合类型和枚举有一些共同点:
- 我们可以自动完成成员值。但我们做法不同:
- 使用枚举后,我们在枚举名称和点之后获得自动完成。
- 使用联合类型,我们必须显式触发自动完成。
- 穷尽性检查对两者也适用。
但它们也有不同之处。联合符号单例类型的缺点是:
- 它们稍微冗长。
- 它们没有成员的命名空间。
- 从它们迁移到不同的结构(如果有必要的话)稍微困难一些:更容易找到枚举成员值被提及的地方。
联合符号单例类型的优势是:
- 它们不是自定义的 TypeScript 语言构造,因此更接近纯 JavaScript。
- 字符串枚举只在编译时是类型安全的。符号单例类型的联合在运行时也是类型安全的。
- 这一点尤其重要,如果我们编译后的 TypeScript 代码与纯 JavaScript 代码交互。
13.2 辨别联合
要理解它们的工作原理,请考虑表示表达式的数据结构 语法树:
1 + 2 + 3
语法树要么是:
- 一个数字
- 两个语法树的相加
下一步:
- 我们将首先为语法树创建一个面向对象的类层次结构。
- 然后我们将把它转换为稍微更加功能化的东西。
- 最后,我们将得到一个歧视联合。
13.2.1 第 1 步:将语法树作为类层次结构
这是一个典型的面向对象的语法树实现:
// Abstract = can’t be instantiated via `new` abstract class SyntaxTree1 {} class NumberValue1 extends SyntaxTree1 { constructor(public numberValue: number) { super(); } } class Addition1 extends SyntaxTree1 { constructor(public operand1: SyntaxTree1, public operand2: SyntaxTree1) { super(); } }
SyntaxTree1
是NumberValue1
和Addition1
的超类。关键字public
在语法上是为了方便:
- 声明实例属性
.numberValue
- 通过参数
numberValue
初始化该属性
这是使用SyntaxTree1
的示例:
const tree = new Addition1( new NumberValue1(1), new Addition1( new NumberValue1(2), new NumberValue1(3), // trailing comma ), // trailing comma );
注意:JavaScript 中允许在参数列表中使用尾随逗号 自 ECMAScript 2016 以来。
13.2.2 第 2 步:将语法树作为类的联合类型
如果我们通过联合类型定义语法树(行 A),我们就不需要面向对象的继承:
class NumberValue2 { constructor(public numberValue: number) {} } class Addition2 { constructor(public operand1: SyntaxTree2, public operand2: SyntaxTree2) {} } type SyntaxTree2 = NumberValue2 | Addition2; // (A)
由于NumberValue2
和Addition2
没有超类,它们不需要在它们的构造函数中调用super()
。
有趣的是,我们以与以前相同的方式创建树:
const tree = new Addition2( new NumberValue2(1), new Addition2( new NumberValue2(2), new NumberValue2(3), ), );
13.2.3 第 3 步:将语法树作为歧视联合
最后,我们转向了歧视联合。这些是SyntaxTree3
的类型定义:
interface NumberValue3 { kind: 'number-value'; numberValue: number; } interface Addition3 { kind: 'addition'; operand1: SyntaxTree3; operand2: SyntaxTree3; } type SyntaxTree3 = NumberValue3 | Addition3;
我们已经从类切换到了接口,因此从类的实例切换到了普通对象。
歧视联合的接口必须至少有一个共同的属性,并且该属性必须对每个属性具有不同的值。该属性称为歧视器或标签。SyntaxTree3
的歧视器是.kind
。它的类型是字符串字面类型。
比较:
- 实例的直接类由其原型确定。
- 歧视联合的成员类型由其歧视器确定。
这是一个与SyntaxTree3
匹配的对象:
const tree: SyntaxTree3 = { // (A) kind: 'addition', operand1: { kind: 'number-value', numberValue: 1, }, operand2: { kind: 'addition', operand1: { kind: 'number-value', numberValue: 2, }, operand2: { kind: 'number-value', numberValue: 3, }, } };
我们在 A 行不需要类型注释,但它有助于确保数据具有正确的结构。如果我们不在这里这样做,我们以后会发现问题。
在下一个示例中,tree
的类型是歧视联合。每次检查其歧视器(行 C)时,TypeScript 都会相应地更新其静态类型:
function getNumberValue(tree: SyntaxTree3) { // %inferred-type: SyntaxTree3 tree; // (A) // @ts-expect-error: Property 'numberValue' does not exist on type 'SyntaxTree3'. // Property 'numberValue' does not exist on type 'Addition3'.(2339) tree.numberValue; // (B) if (tree.kind === 'number-value') { // (C) // %inferred-type: NumberValue3 tree; // (D) return tree.numberValue; // OK! } return null; }
在 A 行,我们还没有检查过歧视器.kind
。因此,tree
的当前类型仍然是SyntaxTree3
,我们无法在 B 行访问属性.numberValue
(因为联合类型的类型只有一个具有此属性)。
在 D 行,TypeScript 知道.kind
是'number-value'
,因此可以推断出tree
的类型为NumberValue3
。这就是为什么在下一行访问.numberValue
是可以的,这次。
13.2.3.1 实现歧视联合的函数
我们用一个实现歧视联合的函数示例来结束这一步。
如果有一个操作可以应用于所有子类型的成员,则类和歧视联合的方法不同:
- 面向对象的方法:使用类时,通常使用多态方法,其中每个类都有不同的实现。
- 功能方法:使用歧视联合时,通常使用一个处理所有可能情况并通过检查其参数的歧视器来决定要执行什么操作的单个函数。
以下示例演示了功能方法。歧视器在 A 行进行检查,并确定执行哪个switch
情况。
function syntaxTreeToString(tree: SyntaxTree3): string { switch (tree.kind) { // (A) case 'addition': return syntaxTreeToString(tree.operand1) + ' + ' + syntaxTreeToString(tree.operand2); case 'number-value': return String(tree.numberValue); } } assert.equal(syntaxTreeToString(tree), '1 + 2 + 3');
请注意,TypeScript 对歧视联合执行穷尽性检查:如果我们忘记了某种情况,TypeScript 会警告我们。
这是先前代码的面向对象版本:
abstract class SyntaxTree1 { // Abstract = enforce that all subclasses implement this method: abstract toString(): string; } class NumberValue1 extends SyntaxTree1 { constructor(public numberValue: number) { super(); } toString(): string { return String(this.numberValue); } } class Addition1 extends SyntaxTree1 { constructor(public operand1: SyntaxTree1, public operand2: SyntaxTree1) { super(); } toString(): string { return this.operand1.toString() + ' + ' + this.operand2.toString(); } } const tree = new Addition1( new NumberValue1(1), new Addition1( new NumberValue1(2), new NumberValue1(3), ), ); assert.equal(tree.toString(), '1 + 2 + 3');
13.2.3.2 可扩展性:面向对象的方法 vs. 功能方法
每种方法都很好地实现了一种可扩展性:
- 使用面向对象的方法,如果我们想要添加新操作,就必须修改每个类。但是,添加新类型不需要对现有代码进行任何更改。
- 使用功能方法时,如果我们想要添加新类型,就必须修改每个函数。相反,添加新操作很简单。
13.2.4 歧视联合 vs. 普通联合类型
辨别联合和普通联合类型有两个共同点:
- 没有成员值的命名空间。
- TypeScript 执行穷举检查。
接下来的两个小节探讨了辨别联合相对于普通联合的两个优势:
13.2.4.1 好处:描述性属性名称
通过辨别联合,值得到了描述性的属性名称。让我们比较一下:
普通联合:
type FileGenerator = (webPath: string) => string; type FileSource1 = string|FileGenerator;
辨别联合:
interface FileSourceFile { type: 'FileSourceFile', nativePath: string, } interface FileSourceGenerator { type: 'FileSourceGenerator', fileGenerator: FileGenerator, } type FileSource2 = FileSourceFile | FileSourceGenerator;
现在阅读源代码的人立即知道字符串是什么:一个本地路径名。
13.2.4.2 好处:当部分是无法区分时,我们也可以使用它
以下辨别联合不能作为普通联合实现,因为我们无法在 TypeScript 中区分联合的类型。
interface TemperatureCelsius { type: 'TemperatureCelsius', value: number, } interface TemperatureFahrenheit { type: 'TemperatureFahrenheit', value: number, } type Temperature = TemperatureCelsius | TemperatureFahrenheit;
13.3 对象文字作为枚举
在 JavaScript 中,实现枚举的常见模式如下:
const Color = { red: Symbol('red'), green: Symbol('green'), blue: Symbol('blue'), };
我们可以尝试在 TypeScript 中使用它如下:
// %inferred-type: symbol Color.red; // (A) // %inferred-type: symbol type TColor2 = // (B) | typeof Color.red | typeof Color.green | typeof Color.blue ; function toGerman(color: TColor): string { switch (color) { case Color.red: return 'rot'; case Color.green: return 'grün'; case Color.blue: return 'blau'; default: // No exhaustiveness check (inferred type is not `never`): // %inferred-type: symbol color; // Prevent static error for return type: throw new Error(); } }
遗憾的是,Color
的每个属性的类型都是symbol
(A 行),而TColor
(B 行)是symbol
的别名。因此,我们可以将任何符号传递给toGerman()
,TypeScript 在编译时不会抱怨:
assert.equal( toGerman(Color.green), 'grün'); assert.throws( () => toGerman(Symbol())); // no static error!
const
断言通常在这种情况下有所帮助,但这次不行:
const ConstColor = { red: Symbol('red'), green: Symbol('green'), blue: Symbol('blue'), } as const; // %inferred-type: symbol ConstColor.red;
唯一修复这个问题的方法是通过常量:
const red = Symbol('red'); const green = Symbol('green'); const blue = Symbol('blue'); // %inferred-type: unique symbol red; // %inferred-type: unique symbol | unique symbol | unique symbol type TColor2 = typeof red | typeof green | typeof blue;
13.3.1 具有字符串值属性的对象文字
const Color = { red: 'red', green: 'green', blue: 'blue', } as const; // (A) // %inferred-type: "red" Color.red; // %inferred-type: "red" | "green" | "blue" type TColor = | typeof Color.red | typeof Color.green | typeof Color.blue ;
我们需要在 A 行使用as const
,这样Color
的属性就不会有更一般的string
类型。然后TColor
也有一个比string
更具体的类型。
与使用具有符号值属性的对象作为枚举相比,具有字符串值属性的对象为:
- 在开发时更好,因为我们得到穷举检查,并且可以为值派生一个狭窄的类型(不使用外部常量)。
- 在运行时更糟糕,因为字符串可能被误认为是枚举值。
13.3.2 使用对象文字作为枚举的优势和劣势
优势:
- 我们有一个值的命名空间。
- 我们不使用自定义构造,更接近纯 JavaScript。
- 我们可以为枚举值派生一个狭窄的类型(如果我们使用字符串值属性)。
- 对这种类型执行穷举检查。
劣势:
- 没有动态成员检查(没有额外工作)。
- 非枚举值可以在静态或运行时被误认为是枚举值(如果我们使用字符串值属性)。
13.4 枚举模式
以下示例演示了受 Java 启发的枚举模式,它适用于纯 JavaScript 和 TypeScript:
class Color { static red = new Color(); static green = new Color(); static blue = new Color(); } // @ts-expect-error: Function lacks ending return statement and return type // does not include 'undefined'. (2366) function toGerman(color: Color): string { // (A) switch (color) { case Color.red: return 'rot'; case Color.green: return 'grün'; case Color.blue: return 'blau'; } } assert.equal(toGerman(Color.blue), 'blau');
遗憾的是,TypeScript 不执行穷举检查,这就是为什么我们在 A 行得到一个错误的原因。
13.5 枚举和枚举替代方案的总结
以下表格总结了 TypeScript 中枚举及其替代方案的特点:
唯一 | 命名空间 | 迭代 | 内存 CT | 内存 RT | 穷举 | |
数字枚举 | - |
✔ |
✔ |
✔ |
- |
✔ |
字符串枚举 | ✔ |
✔ |
✔ |
✔ |
- |
✔ |
字符串联合 | - |
- |
- |
✔ |
- |
✔ |
符号联合 | ✔ |
- |
- |
✔ |
- |
✔ |
辨别联合 | - (1) |
- |
- |
✔ |
- (2) |
✔ |
符号属性 | ✔ |
✔ |
✔ |
- |
- |
- |
字符串属性 | - |
✔ |
✔ |
✔ |
- |
✔ |
枚举模式 | ✔ |
✔ |
✔ |
✔ |
✔ |
- |
表格列的标题:
- 唯一值:没有非枚举值可以被误认为是枚举值。
- 枚举键的命名空间
- 是否可以遍历枚举值?
- 编译时值的成员检查:是否有枚举值的狭窄类型?
- 运行时值的成员检查:
- 对于枚举模式,运行时成员检查是
instanceof
。 - 请注意,如果可以遍历枚举值,成员检查可以相对容易地实现。
- 穷举检查(TypeScript 静态检查)
表格单元格中的脚注:
- 辨别联合并不是真正独特的,但是将值误认为联合成员的可能性相对较小(特别是如果我们为辨别属性使用唯一的名称)。
- 如果辨别属性有一个足够独特的名称,它可以用来检查成员资格。
13.6 致谢
- 感谢Kirill Sukhomlin提出了如何为对象文字定义
TColor
的建议。