订阅专栏学习TS不迷路:TypeScript从入门到精通
️ 博主的前端之路(猿创征文一等奖作品):前端之行,任重道远(来自大三学长的万字自述)
分享博主自用牛客网:一个非常全面的面试刷题求职网站,前端开发者必备的刷题网站,真的超级好用
知识目录
- 一、介绍
- 1、JavaScript最大的问题
- 2、什么是TypeScript
- 3、JS , ES , TS 的关系
- 4、为什么使用TypeScript
- 5、配置TypeScript环境
- 二、数据类型
- 1、基元类型
- 2、数组
- 3、any
- 4、函数
- 5、对象
- 6、unknown
- 7、其它类型
- void
- object
- never
- Function
- 8、联合类型
- 9、类型别名
- 10、接口
- 11、类型断言
- 12、文字类型
- 13、null和undefined
- 14、枚举
- 15、不太常见的原语
- bigint
- symbol
- 三、类型缩小
- 1、typeof 类型守卫
- 2、真值缩小
- 3、等值缩小
- 4、in 操作符缩小
- 5、instanceof 操作符缩小
- 6、分配缩小
- 7、不适用的union type(联合类型)
- 8、never 类型与穷尽性检查
- 9、控制流分析
- 四、类型谓词
- 五、对象
- 1、属性修改器
- 可选属性
- 只读属性
- 索引签名
- 2、扩展类型
- 3、交叉类型
- 4、泛型对象类型
- 类型别名结合泛型
- 5、数组类型
- 6、只读数组类型
- 7、元组类型
- 可选的元组
- 其余元素
- 应用
- 8、只读元组类型
- 六、函数
- 1、函数类型表达式
- 对象内使用函数类型
- 2、调用签名
- 3、构造签名
- 4、泛型函数(通用函数)
- 类型推断
- 指定类型参数
- 限制条件
- 编写规范
- 5、可选参数
- 6、函数重载
- 重载签名与实现签名
- 编写规范
- 在函数中声明this
- 7、参数展开运算符
- 形参展开
- 实参展开
- 8、参数解构
- 9、函数的可分配性
- 七、类型操作
- 1、泛型
- 泛型类型
- 泛型类
- 泛型约束
- 在泛型中使用类类型
- 2、keyof类型操作符
- 3、typeof类型操作符
- 4、索引访问类型
- 5、条件类型
- 配合泛型使用
- 分布式条件类型
- 6、映射类型
- 映射修改器
- 通过as做key重映射
- 进一步探索
- 八、类
- 1、类成员
- 类属性
- readonly
- 构造器
- 方法
- Getters/Setters
- 索引签名
- 2、类继承
- implements子句
- extends子句
- 重写方法
- 初始化顺序
- 继承内置类型
- 3、成员的可见性
- public
- protected
- private
- 参数属性
- 注意事项
- 4、静态成员
- 特殊静态名称
- 没有静态类
- 5、静态块
- 6、泛型类
- 7、this指向
- 箭头函数
- this参数
- 8、this类型
- 9、基于类型守卫的this
- 10、类表达式
- 11、抽象类和成员
- 抽象构造签名
- 12、类之间的关系
- 13、混入mixin
- 九、模块
- 1、模块定义
- 2、ES模块语法
- 导出别名
- 二次导出
- TS特定的语法
- 3、CommonJS语法
- 4、环境模块
- 速记环境模块
- 5、TypeScript模块选项
- 模块解析选项
- 模块输出选项
- 6、TypeScript命名空间
- 十、枚举
- 1、数字型枚举
- 常量成员
- 计算成员
- 成员顺序
- 2、字符串枚举
- 3、异构枚举
- 4、联合枚举和枚举成员类型
- 5、运行时的枚举
- 6、编译时的枚举
- 反向映射
- 常量枚举
- 7、环境枚举
- 8、对象与枚举
- 十一、命名空间
- 1、空间声明
- 2、空间合并
- 命名空间之间的合并
- 命名空间与类合并
- 命名空间与函数合并
- 命名空间与枚举合并
- 3、实现原理
- 4、模块化空间
- 5、空间别名
- 6、命名空间与模块
- 十二、装饰器
- 1、装饰器
- 装饰器工厂
- 2、装饰器组合
- 3、类装饰器
- 4、方法装饰器
- 5、访问器装饰器
- 6、属性装饰器
- 7、参数装饰器
- 8、装饰器应用顺序
- 9、使用装饰器封装通用的try catch
一、介绍
1、JavaScript最大的问题
程序员编写的最常见的错误类型可以描述为类型错误:在预期不同类型的值的地方使用了某种类型的值。这可能是由于简单的拼写错误、无法理解库的 API 表面、对运行时行为的错误假设或其他错误。
使用JavaScript
编写代码最突出的问题就是类型检查问题:由于JavaScript
是弱类型语言,使得大多数使用者只能在代码运行阶段才能发现类型错误问题,这就使得错误不能被及时发现和修复,为之后的开发埋下了隐患。
JavaScript
没有表达不同代码单元之间关系的能力。结合 JavaScript
相当奇特的运行时语义,语言和程序复杂性之间的这种不匹配使得 JavaScript
开发成为一项难以大规模管理的任务。
TypeScript
的目标是成为 JavaScript
程序的静态类型检查器——换句话说,是一个在代码运行之前运行的工具(静态)并确保程序的类型正确(类型检查),使得我们能够在代码编写阶段就能及时发现类型错误问题。
2、什么是TypeScript
TypeScript
是一种由微软开发的自由和开源的编程语言。它是 JavaScript
的一个超集,而且本质上向这个语言添加了可选的静态类型和基于类的面向对象编程。
TypeScript
是一种非常受欢迎的 JavaScript
语言扩展。它在现有的 JavaScript
语法之上加入了一层类型层,而这一层即使被删除,也丝毫不会影响运行时的原有表现。许多人认为 TypeScript
“只是一个编译器”,但更好的理解其实是把 TypeScript
看作两个独立的系统:编译器(即处理语法的部分)和语言工具(即处理与编辑器集成的部分)。
3、JS , ES , TS 的关系
1995年:JavaScript诞生
当时的网景公司正凭借其
Navigator
浏览器成为Web
时代开启时最著名的第一代互联网公司。由于网景公司希望能在静态
HTML
页面上添加一些动态效果,于是 Brendan Eich 在两周之内设计出了
JavaScript
语言。之所以起名叫
JavaScript
,是原因是当时Java
语言非常红火,想要蹭一波热度而已,实际上JavaScript
除了语法上有点像Java
,其他部分基本上没啥关系。1997年:ECMAScript诞生
因为网景开发了
JavaScript
,一年后微软又模仿JavaScript
开发了JScript
,为了让JavaScript
成为全球标准,几个公司联合ECMA
(European Computer Manufacturers Association)(欧洲计算机制造商协会)组织制定了JavaScript
语言的标准,被称为ECMAScript
标准。2015年:TypeScript诞生
TypeScript
是JavaScript
的超集(最终会被编译成JavaScript
代码),即包含JavaScript
的所有元素,能运行JavaScript
的代码,并扩展了JavaScript
的语法。相比于JavaScript
,它还增加了静态类型、类、模块、接口和类型注解方面的功能,更易于大项目的开发。TypeScript
提供最新的和不断发展的JavaScript
特性,包括那些来自 2015 年的ECMAScript
和未来的提案中的特性,比如异步功能和Decorators
,以帮助建立健壮的组件。
一句话总结三者关系:ECMAScript
是标准语言,JavaScript
是ECMAScript
的实现,TypeScript
是JavaScript
的超集。
4、为什么使用TypeScript
TypeScript
扩展了JavaScript
,提供强大的类型检查和语法提示功能,结合诸如VS code
这类编译器,能够极大的提高开发效率,降低项目后期的维护成本:
5、配置TypeScript环境
在学习TypeScript
之前我们需要先全局安装tsc
TypeScript
编译器。
npm i -g typescript
自己创建一个项目(文件夹),在项目根目录终端运行:
tsc -init
此时项目根目录下会生成一个配置文件 tsconfig.json
,这里给出我使用的配置:
{"compilerOptions": {/* TS编译成JS的版本*/"target": "es6",/* 入口文件:指定源文件中的根文件夹。 */"rootDir": "./src",/* 编译出口文件:指定输出文件夹。 */"outDir": "./dist",/* 严格模式 */"strict": true,}}
项目根目录下创建src
文件夹,在这个文件内创建并编写你的ts
文件
在项目根目录终端输入tsc --watch
就会开始持续监听src
目录下的所有ts
文件,文件更改时会自动将其编译成js
文件到dist
目录下
如果想要自己手动编译某一特定ts
文件,可以在ts
文件所在目录下运行tsc xxx.ts
,这时编译后的js
文件会放在与该ts
文件同级的地方
注意:我们使用
TypeScript
的目的就是检验代码并纠错,尽量使自己的代码变得足够规范,所以建议应始终使用"strict": true
二、数据类型
1、基元类型
JavaScript
有三个非常常用的类型: string
, number
,和boolean
。
它们在 TypeScript
中都有对应的类型,并且这些名称与在 JavaScript
应用typeof
返回的类型的名称相同:
string
表示字符串值,如"Hello, world"
number
表示数字值,如 42 。JavaScript
没有一个特殊的整数运行时值,所以没有等价于int
或float
类型, 一切都只是number
boolean
只有两个值true
和false
类型名称
String
,Number
, 和Boolean
(以大写字母开头)是合法的,但指的是一些很少出现在
代码中的特殊内置类型。对于类型,始终使用string
,number
, 或boolean
。
在TypeScript
中,当你使用const
, var
, 或let
时可以直接在变量后加上类型注释: type
就可以显式指定变量的类型为type
:
var str: string = "hello,world";const num: number = 42;let boo: boolean = true;
但是,在大多数情况下,这不是必需的。只要有可能,TypeScript
就会尝试自动推断代码中的类型。例如,变量的类型是根据其初始化器的类型推断出来的:
// 不需要类型定义--“myName”自动推断为类型“string”let myName = "AiLjx";
对于已经声明类型的变量,对其赋值时只能赋予其相同类型的数据,否者TypeScript
将会抛出错误:
2、数组
指定数组的类型可以使用ItemType[]
或者Array
,ItemType
指数组元素的类型,
Array
声明类型的方式使用了TypeScript
的泛型语法,对于泛型,之后我会出单独的一篇博文对其详细的介绍
const arr1:number[]=[1,2,3]const arr2:string[]=['1','2','3']
同样的,对已经指定类型的数组赋不同类型的值,或向其添加不同类型的数据时,TypeScript将会抛出错误:
3、any
TypeScript
还有一个特殊类型 any
,当你不希望某个特定值导致类型检查错误时,可以使用它。
当一个值的类型是any
时,可以访问它的任何属性,将它分配给任何类型的值,或者几乎任何其他语法上的东西都合法的:
src/01-type.ts
:
let obj: any = { x: 0 };// 以下代码行都不会抛出编译器错误。// 使用'any'将禁用所有进一步的类型检查obj.foo();obj();obj.bar = 100;obj = "hello";const n: number = obj;
但在运行环境下执行代码可能是错误的:
在项目根目录下(
tsconfig.json
所在路径)通过运行tsc --watch
(此命令执行一次后会持续监听入口目录下的文件,其发生变化时会自动重新编译)由typescript
包编译src
目录下的文件,编译后的文件就在dist
目录下(前提是tsconfig.json
中配置了"outDir": "./dist"
):
进入到 dist
目录中,在 node
环境里运行代码,果然报错了。
当你不想写出长类型只是为了让 TypeScript
相信特定的代码行没问题时, any
类型很有用。
但万万不可大量使用any
类型,因为any
没有进行类型检查,使用它就相当于在使用原生JS
,失去了TS
的意义!!!
4、函数
TypeScript
允许您指定函数的输入和输出值的类型。
- 参数类型注释
声明函数时,可以在每个参数后添加类型注解,以声明函数接受的参数类型。参数类型注释位于参数名称之后:
// 参数类型定义function getName(name: string) { console.log("Hello, " + name);}
这里指定了getName
函数接收一个string
类型的参数,当参数类型不匹配时将抛出错误:
即使您的参数上没有类型注释,
TypeScript
仍会检查您是否传递了正确数量的参数!
- 返回类型注释
返回类型注释出现在参数列表之后,其指定了函数返回值的类型:
function getName(name: string): string { console.log("Hello, " + name); return "Hello, " + name;}
这里对getName
函数指定了其返回值是string
类型,当函数无返回值或返回值不是string
类型时将抛出错误:
与变量类型注释非常相似,通常不需要返回类型注释,因为
TypeScript
会根据其return
语句推断函数的返回类型。上面例子中的类型注释不会改变任何东西。某些代码库会出于文档目的明确指定返回类型,以防止意外更改或仅出于个人偏好。
- 匿名函数
匿名函数与函数声明有点不同。当一个函数出现在 TypeScript
可以确定它将如何被调用的地方时,该函数的参数会自动指定类型。
即使参数s
没有类型注释,TypeScript
也会使用forEach
函数的类型,以及数组的推断类型来确定s
的类型。这个过程称为上下文类型,因为函数发生在其中的上下文通知它应该具有什么类型。
与推理规则类似,你不需要明确了解这是如何发生的,但了解它的机制确实可以帮助你注意何时不需要类型注释。
从这里我们就能看出TypeScript
的强大之处,它不仅能够自动推断类型并发现错误,还能提示你错误的地方,以及修复建议,配合VS code
编译器还能实现快速修复:
5、对象
除了string
, number
, boolean
类型(又称基元类型)外,你将遇到的最常见的类型是对象类型。
这指的是任何带有属性的 JavaScript
值,几乎是所有属性!要定义对象类型,我们只需列出其属性及其类型。
let obj: { x: number; y: number } = { x: 1, y: 2 };
对于指定类型的对象其值不符合指定的类型时抛出错误:
- 可选属性
在指定的类型属性名后加上一个" />
这很好理解,因为可选属性没有限制用户必传,如果访问一个不存在的属性,将获得值undefined
,此时对其操作TypeScript
就会抛出错误提醒你。
正确的做法:
function ObjFn(obj: { x?: number; y: number }) { console.log(obj.y++); if (obj.x) { // 先判断可选属性是否存在 console.log(obj.x++); }}
6、unknown
与 any
类型类似,可以设置任何的类型值,随后可以更改类型,但unknown
要比any
更加安全,看个例子:
let a: any = "Ailjx";a = [];a.push("0");
上面代码在编译与运行时都是正常的,但是当我们手误写错了push
方法后你就会发现问题所在:
let a: any = "Ailjx";a = [];a.psh("0");
这段代码在编译时不会报错,只会在运行时报错,这就失去了TypeScript
在编译时检查错误的功能,在项目比较大时,参与的人多时,就很难避免这样类似的问题,因此unknown
类型出现了:
虽然我们将其类型更改为数组类型,但是编译器认为其依旧是unknown
类型,该类型没有push
方法,就会报错,除非我们先判断类型:
let a: unknown = "Ailjx";a = [];if (a instanceof Array) { a.push("0");}
这样代码就没问题了,这时如果你push方法写错了,编译器就会报错提示你了:
虽然有些麻烦,但是相比 any
类型说,更加安全,在代码编译期间,就能帮我们发现由于类型造成的问题,因此在大多的场景,建议使用 unknown
类型替代 any
。
7、其它类型
void
void
表示不返回值的函数的返回值:
function A() {}const a = A(); // type A = void
只要函数没有任何
return
语句,或者没有从这些返回语句中返回任何显式值,它的推断类型就是void
在
JavaScript
中,一个不返回任何值的函数将隐含地返回undefinded
的值,但是,在TypeScript
中,void
和undefined
是不一样的
object
特殊类型 object
指的是任何不是基元的值( string
、number
、bigint
、boolean
、symbol
、null
或 undefined
)(即对象)
这与空对象类型{}
不同,也与全局类型 Object
(大写的O
)不同, Object
类型一般永远也用不上,使用的都是object
let a: object; // a只能接受一个对象a = {};a = { name: "Ailjx",};a = function () {};a = 1; // err:不能将类型“number”分配给类型“object”
请注意,在
JavaScript
中,函数值是对象,它们有属性,在它们的原型链中有Object.prototype
,是Object
的实例,你可以对它们调用Object.key
等等,由于这个原因,函数类型在TypeScript
中被认为是object
!
never
never
类型表示的是那些永不存在的值的类型:
可以表示总是抛出异常或根本不会有返回值的函数的返回值类型
function error(msg: string): never { throw new Error(msg);}// 推断出fail返回值类型为neverfunction fail() { return error("Ailjx");}// A函数会造成死循环,根本不会有返回值,可以用never来表示返回值类型function A(): never { while (true) {}}
被永不为真的
- 使用联合类型
在使用联合类型时需要注意的是:不能盲目将联合类型的数据当成单独类型的数据进行操作,不然
TypeScript
将抛出错误提醒你:这里直接对联合类型
id
进行字符串上的toUpperCase
操作,TypeScript
会自动检测id
联合类型的成员是否都具有toUpperCase
属性,这里检测到联合类型的成员number
类型并不具有toUpperCase
属性,所以会抛出错误提示用户。正确的做法是:
function getId(id: string | number) { if (typeof id === "string") { // 在此分支中,TS自动检测id的类型为“string” console.log(id.toUpperCase()); } else { // 此处,TS自动检测id的类型为“number” console.log(id.toString()); }}
先使用判断语句确定
id
具体的类型,再对其进行操作(这称为类型缩小,博主后期会出另外的博文对其详细介绍),TypeScript
会非常智能的检测判断分支的语句中id
的类型。9、类型别名
前面我们声明类型都是直接在类型注释中编写类型来使用它们,这很方便,但是想要多次使用同一个类型,并用一个名称来引用它是很常见的。
这就可以使用类型别名
type
来声明类型:type Id = number | string;// 在类型注释中直接使用类型别名function getId(id: Id) { console.log("id=", id);}getId(1);getId("1");
在定义类型别名以及后面讲到的接口时,都建议将首字母进行大写,如上面例子中的
Id
- 类型别名也可以声明复杂的类型:
type Point = { x: number; y: number;};function printCoord(pt: Point) { console.log("坐标x的值是: " + pt.x); console.log("坐标y的值是: " + pt.y);}printCoord({ x: 100, y: 100 });
- 扩展类型(交叉类型)
类型别名可以使用交叉点
&
来扩展类型:type User = { name: string;};type Admin = User & { isAdmin: boolean;}; const admin: Admin = { name: "Ailjx", isAdmin: true,};
这里
Admin
在User
基础上扩展了isAdmin
类型,当使用Admin
并赋予的类型不匹配时将抛出错误:
注意这里报的错,在下面的接口与类型别名的区别中会详细分析这个报错。梳理一下,之所以称其为类型别名,就是因为它只是用一个名称来指向一种类型,当用户需要使用该种类型时可直接使用该名称,方便复用,也方便将类型与业务代码抽离开来。
10、接口
一个接口声明
interface
是另一种方式来命名对象类型:interface Point { x: number; y: number;}// 与前面的示例完全相同function printCoord(pt: Point) { console.log("坐标x的值是: " + pt.x); console.log("坐标y的值是: " + pt.y);}printCoord({ x: 100, y: 100 });
- 类型别名和接口之间的差异
类型别名和接口非常相似,在很多情况下你可以自由选择它们。几乎所有的功能都在
interface
中可用type
,关键区别在于扩展新类型的方式不同:前面提到类型别名是通过交叉点
&
来扩展类型,而接口的扩展是使用extends
继承(这与class
类的继承相似):interface User { name: string;}interface Admin extends User { isAdmin: boolean;}const admin: Admin = { name: "Ailjx", isAdmin: true,};
继承后的
Admin
接口包含父类User
中的所有类型,当使用Admin
并赋予的类型不匹配时将抛出错误:这里对比类型注意中抛出的错误会发现这么一个细节:
当我们不给使用Admin
类型的常量admin
添加name
属性时,使用类型别名扩展的会提示:但类型 "User" 中需要该属性
,而使用接口扩展的会提示:但类型 "Admin" 中需要该属性
从这里我们能看出类型别名的扩展是将父类
User
与扩展的类型{ isAdmin: boolean;}
一并交给Admin
引用,当使用Admin时实际是同时使用了User
和{ isAdmin: boolean;}
两种类型。而接口的扩展是直接继承父类
User
,在父类基础上添加了{ isAdmin: boolean;}
并生成一个新类型Admin
,使用Admin
时仅仅是使用了Admin
,与User
无关了interface
也可以向现有的接口添加新字段:interface MyWindow { title: string;}interface MyWindow { count: number;}const w: MyWindow = { title: "hello ts", count: 100,};
同名的
interface
会被TypeScript
合并到一起,这是类型别名所做不到的:- 在
TypeScript 4.2
版之前,类型别名类型断言应该用于:在
TypeScript
没有推断出确切的类型,而你又非常坚定其确切的类型是什么的情况12、文字类型
除了一般类型
string
和number
,我们可以在类型位置引用特定的字符串和数字,来限定变量只能为特定的值:let MyName: "Ailjx";
就其本身而言,文字类型并不是很有价值,拥有一个只能有一个值的变量并没有多大用处!
但是通过将文字组合成联合,你可以表达一个更有用的概念——例如,只接受一组特定已知值的函数:
function printText(s: string, alignment: "left" | "right" | "center") {// ...}printText("Hello, world", "left");printText("G'day, mate", "centre");
alignment
只能被赋予left
、right
或center
,这在组件库中非常常见!数字文字类型的工作方式相同:
function compare(a: number, b: number): -1 | 0 | 1 {return a === b " />
还有一种文字类型:布尔文字。只有两种布尔文字类型,它们是类型
true
和false
。类型boolean
本身实际上只是联合类型true
|false
的别名。- 文字推理
看这样一段代码:
function handleRequest(url: string, method: "GET" | "POST" | "GUESS") { // ...}const req = { url: "https://blog.csdn.net/m0_51969330?type=blog", method: "GET",};handleRequest(req.url, req.method);
感觉没毛病是吧,但其实TypeScript会抛出错误:
在上面的例子req.method
中推断是string
,不是"GET"
。因为代码可以在创建req
和调用之间进行评估,TypeScript
认为这段代码有错误。有两种方法可以解决这个问题。
1. 可以通过在任一位置添加类型断言来更改推理
方案一:const req = { url: "https://blog.csdn.net/m0_51969330" />
避免报错正确的做法:function doSomething(x: string | null) {if (x === null) {// 做一些事} else {console.log("Hello, " + x.toUpperCase());}}
- 非空断言运算符(
!
后缀)
!
在任何表达式之后写入实际上是一种类型断言,即确定该值不是null
orundefined
:function liveDangerously(x?: number | null) { // console.log(x.toFixed()); // 报错:对象可能为 "null" 或“未定义”。 console.log(x!.toFixed()); // 正确}
就像其他类型断言一样,这不会更改代码的运行时行为,因此仅当你知道该值不能是
null
或undefined
时!
的使用才是重要的。14、枚举
枚举是
TypeScript
添加到JavaScript
的一项功能,它允许描述一个值,该值可能是一组可能的命名常量之一。与大多数
TypeScript
功能不同,这不是JavaScript
的类型级别的添加,而是添加到语言和运行时的内容。因此,你确定你确实需要枚举在做些事情,否则请不要使用。可以在Enum 参考页 中阅读有关枚举的更多信息。TS
枚举:enum Direction {Up = 1,Down,Left,Right,}console.log(Direction.Up) // 1
编译后的
JS
代码:"use strict";var Direction;(function (Direction) { Direction[Direction["Up"] = 1] = "Up"; Direction[Direction["Down"] = 2] = "Down"; Direction[Direction["Left"] = 3] = "Left"; Direction[Direction["Right"] = 4] = "Right";})(Direction || (Direction = {}));console.log(Direction.Up); // 1
15、不太常见的原语
bigint
从
ES2020
开始,JavaScript
中有一个用于非常大的整数的原语BigInt
:// 通过bigint函数创建bigintconst oneHundred: bigint = BigInt(100);// 通过文本语法创建BigIntconst anotherHundred: bigint = 100n;
主意:使用
BigInt
和bigint
时需要将tsconfig.json
中的target
设置成es2020
以上(包含es2020
)的版本你可以在TypeScript 3.2 发行说明 中了解有关 BigInt 的更多信息。
symbol
JavaScript
中有一个原语Symbol()
,用于通过函数创建全局唯一引用:const firstName = Symbol("name");const secondName = Symbol("name");if (firstName === secondName) {// 这里的代码不可能执行}
三、类型缩小
先看一个例子:
我们没有明确检查padding
是否为number
,也没有处理它是string
的情况,此时TypeScript
出于类型保护的目的就会抛出错误,我们可以这样做:function padLeft(padding: number | string) { if (typeof padding === "number") { // 此时padding被缩小为number类型 return padding + 1; } // 此时padding被缩小为string类型 return padding;}
在
if
检查中,TypeScript
看到typeof padding ==="number"
,并将其理解为一种特殊形式的代码,称为类型保护类型保护是可执行运行时检查的一种表达式,用于确保该类型在一定的范围内。
类型保护也称类型守卫、类型防护等
TypeScript
遵循我们的程序可能采取的执行路径,以分析一个值在特定位置的最具体的可能类型。它查看这些特殊的检查(类型保护)和赋值,将类型细化为比声明的更具体的类型的过程被称为类型缩小。在许多编辑器中,我们可以观察这些类型的变化,我们经常会在我们的例子中这样做。
注意理解类型保护,类型缩小二者的含义和联系
TypeScript
可以理解几种不同的缩小结构:1、typeof 类型守卫
function printAll(strs: string | string[] | null) { if (typeof strs === "object") { // strs被缩小为string[] | null类型 for (const s of strs) { console.log(s); } } else if (typeof strs === "string") { // strs被缩小为string类型 console.log(strs); } else { // 做点事 }}
在这个例子中你会发现在第一个
if
分支中,我是说strs被缩小为string[] | null类型
,为什么这么说呢?因为在
JavaScript
中,typeof null
实际上也是"object
" ! 这是历史上的不幸事故之一。有足够经验的用户可能不会感到惊讶,但并不是每个人都在
JavaScript
中遇到过这种情况;幸运的是,typescript
让我们知道,strs
只缩小到string[] | null
,而不仅仅是string[]
,所以它肯定会报错:
我们需要使用真值缩小对其进一步的处理:2、真值缩小
在
if
语句中像下面这些值:0
NaN
""
(空字符串)0n
(bigint
零的版本)null
undefined
以上所有值强制都转换为
false
,其他值被强制转化为true
,在TypeScript
中我们可以使用这些“空”值来做类型的真值缩小:function printAll(strs: string | string[] | null) { if (strs && typeof strs === "object") { // 增加判断str不是空的情况,即真值缩小 // strs被缩小为string[] for (const s of strs) { console.log(s); } } else if (typeof strs === "string") { // strs被缩小为string类型 console.log(strs); } else { // 做点事 }}
需要注意的是,这里真值缩小的语句不能放到最外边:
function printAll(strs: string | string[] | null) { if (strs) { // 不可以!!! if (typeof strs === "object") { // ... } else if (typeof strs === "string") { // ... } else { // ... } }}
这种情况下我们可能不再正确处理空字符串的情况!
3、等值缩小
typescript
也可以使用分支语句做===
,!==
,==
,和!=
等值检查,来实现类型缩小。例如:使用等值缩小解决上面真值缩小中可能不正确处理空字符串的情况:
function printAll(strs: string | string[] | null) { if (strs !== null) { // 正确地从strs 里移除null 。 // ... }}
其它等值缩小的例子:
function example(x: string | number, y: string | boolean) { if (x === y) { // x与y完全相同时类型也相同,x,y都被缩小为string类型 x.toUpperCase(); y.toLowerCase(); } else { // 这个分支中x和y的类型并没有被缩小 console.log(x); console.log(y); }}function multiplyValue(container: number | null | undefined, factor: number) { // 从类型中排除了undefined 和 null if (container != null) { console.log(container); // 现在我们可以安全地乘以“container.value”了 container *= factor; }}
4、in 操作符缩小
JavaScript
中in
操作符用于确定对象是否具有某个名称的属性,在typescript
中可以使用它来根据类型对象中是否含有某一属性来进行类型缩小:type Fish = { swim: () => void };type Bird = { fly: () => void };type Human = { swim" /> 而不是
string
,可以避免拼写错误的问题。编写一个
getArea
函数,根据它处理的是圆形还是方形来应用正确的逻辑。我们首先尝试处理圆形:
function getArea(shape: Shape) { if (shape.kind === "circle") { return Math.PI * shape.radius! ** 2; }}
因为
radius
是可选属性,直接使用会报对象可能为“未定义”
的,这里使用了非空断言运算符( ! 后缀) 来规避报错。上面这种写法不是理想的,类型检查器没有办法根据种类属性知道
radius
或sideLength
是否存在(使得我们不得不使用非空断言运算符(!
后缀))。我们需要把我们知道的东西传达给类型检查器。考虑到这一点,让我们重新定义一下Shape
:interface Circle { kind: "circle"; radius: number;}interface Square { kind: "square"; sideLength: number;}type Shape = Circle | Square;
在这里,我们正确地将
Shape
分成了两种类型,为kind
属性设置了不同的值,但是radius
和sideLength
在它们各自的类型中被声明为必需的属性。function getArea(shape: Shape) { if (shape.kind === "circle") { return Math.PI * shape.radius ** 2; }}
这就摆脱了
!后缀
,当联合类型(union type
)中的每个类型都包含一个与字面类型相同的属性时,TypeScript
认为这是一个有区别的union
,并且可以缩小union
的成员。在上面这个例子中,
kind
就是那个相同的属性(这就是Shape
的判别属性)。检查kind
属性是否为"circle"
,就可以剔除Shape
中所有没有"circle"
类型属性的类型。这就把Shape
的范围缩小到了Circle
这个类型。同样的检查方法也适用于
switch
语句。现在我们可以试着编写完整的getArea
,而不需要任何讨厌的非空断言!后缀
:function getArea(shape: Shape) { switch (shape.kind) { // shape: Circle case "circle": return Math.PI * shape.radius ** 2; // shape: Square case "square": return shape.sideLength ** 2; }}
由此可见联合类型有时并不适用。
8、never 类型与穷尽性检查
在缩小范围时,你可以将一个联合体的选项减少到你已经删除了所有的可能性并且什么都不剩的程度。
在这些情况下,
TypeScript
将使用一个never
类型来代表一个不应该存在的状态。never
类型可以分配给每个类型;但是,没有任何类型可以分配给never
(除了never
本身)。这意味着你可以使用缩小并依靠never
的出现在switch
语句中做详尽的检查。例如,在上面的
getArea
函数中添加一个默认值,试图将形状分配给never
,当每个可能的情况都没有被处理时,就会触发:function getArea(shape: Shape) { switch (shape.kind) { case "circle": return Math.PI * shape.radius ** 2; case "square": return shape.sideLength ** 2; default: // 该分支下的shape既是never类型 const _exhaustiveCheck: never = shape; return _exhaustiveCheck; }}
当在
Shape
联盟中添加一个新成员,将导致TypeScript错误:interface Circle { kind: "circle"; radius: number;}interface Square { kind: "square"; sideLength: number;}interface Triangle { kind: "triangle"; sideLength: number;}type Shape = Circle | Square | Triangle;
9、控制流分析
到目前为止,我们已经通过一些基本示例来说明
TypeScript
如何在特定分支中缩小范围。但是除了从每个变量中走出来,并在if
、while
、条件
等中寻找类型保护之外,还有更多事情要做。例如:function padLeft(padding: number | string) { if (typeof padding === "number") { // 此时padding被缩小为number类型 return padding + 1; } // 此时padding被缩小为string类型 return padding;}
padLeft
从其第一个if
块中返回。TypeScript
能够分析这段代码,并看到在padding
是数字的情况下,主体的其余部分(return padding;
)是不可达的。因此,它能够将number
从padding
的类型中移除(从number | string
到string
),用于该函数的其余部分。这种基于可达性的代码分析被称为控制流分析,
TypeScript
使用这种流分析来缩小类型,因为它遇到了类型保护和赋值。当一个变量被分析时,控制流可以一次又一次地分裂和重新合并,该变量可以被观察到在每个点上有不同的类型。function example() { let x: string | number | boolean; x = Math.random() < 0.5; // x类型缩小为boolean console.log(x); if (Math.random() < 0.5) { x = "hello"; // x类型缩小为string console.log(x); } else { x = 100; // x类型缩小为number console.log(x); } // 注意:上面的if语句中至少有一个分支会执行,所以x不再可能是boolean类型 // x类型缩小为string | number return x;}let x = example();x = "hello";x = 100;x = true; // error:不能将类型“boolean”分配给类型“string | number”。
四、类型谓词
类型谓词是类型缩小的一种方式,之所以单独提出来讲,是因为它与上面我们熟知的
JavaScript
中本就含有的方式不同。TypeScript
中的类型谓词在函数上工作,如果函数返回true
,则将参数的类型缩小为类型谓词中限定的类型。我们先定义一个判断变量为字符串的函数:
function isStr(x: string | number) { return typeof x === "string";}
当我们使用这个函数会发现它竟然起不到任何作用:
function toUpperCase(x: string | number) { if (isStr(x)) { // 报错,x 的类型依旧为string | number,未被缩小 x.toUpperCase(); }}
这时就可以使用类型谓词,显式地告诉TypeScript
,如果isStr
的返回值为true
,则形参的类型是一个字符串:function isStr(x: string | number): x is string { return typeof x === "string";}function toUpperCase(x: string | number) { if (isStr(x)) { x.toUpperCase(); // x类型成功被缩小为string }}
在这个例子中,
x is string
是我们的类型谓词。谓词的形式是parameterName is Type
,其:parameterName
必须是当前函数签名中的参数名称,比如这个例子中parameterName
只能为x
Type
是当函数返回值为true
时参数的类型,它必须含与参数定义的类型中,比如这个例子中Type
不能为boolean
我们也可以使用类型守卫 (类型保护)
isStr
来过滤数组,获得string
的数组:const arr: (string | number)[] = [1, 2, "1", "2"];const strarr: string[] = arr.filter(isStr);// 对于更复杂的例子,该谓词可能需要重复使用:const strArr: string[] = arr.filter((item): item is string => { // 一些操作 return isStr(item);});
使用类型谓词安全严格的实现一个掷筛子的程序见大佬文章:TypeScript 基础 — 类型谓词
五、对象
1、属性修改器
可选属性
在基本数据类型中,我们已经提到了对象的可选属性,在这里我们再深入去了解一下它:
interface PaintOptions { x" />
readonly
修饰符只能限制一个属性本身不能被重新写入,对于复杂类型的属性,其内部依旧可以改变:interface Info { readonly friend: string[]; readonly parent: { father: string; mother: string };}function getInfo(obj: Info) { // 正常运行 obj.friend[0] = "one"; obj.parent.father = "MyFather"; // 报错 obj.friend = ["one"]; obj.parent = { father: "MyFather", mother: "MyMother" };}
TypeScript
在检查两个类型的属性是否兼容时,并不考虑这些类型的属性是
否是readonly
,所以readony
属性也可以通过别名来改变:interface Person { name: string; age: number;}interface ReadonlyPerson { readonly name: string; readonly age: number;}let writablePerson: Person = { name: "AiLjx", age: 18,};// 正常工作let readonlyPerson: ReadonlyPerson = writablePerson;console.log(readonlyPerson.age); // 打印 '18'// readonlyPerson.age++; // 报错writablePerson.age++;console.log(readonlyPerson.age); // 打印 '19'
这里有点绕,我们来梳理一下:
首先我们声明了两个几乎相同的接口类型
Person
和ReadonlyPerson
,不同的是ReadonlyPerson
里的属性都是只读的。之后我们定义了一个类型为
Person
的变量writablePerson
,可知这个变量内的属性的值是可修改的。接下来有意思的是
writablePerson
竟然能够赋值给类型为ReadonlyPerson
的变量readonlyPerson
,这就验证了TypeScript
在检查两个类型的属性是否兼容时,并不考虑这些类型的属性是否是readonly
,所以类型为Person
和ReadonlyPerson
的数据可以相互赋值。此时要明白变量
readonlyPerson
里面的属性都是只读的,我们直接通过readonlyPerson.age++
修改age
是会报错的,但有意思的是我们可以通过writablePerson.age++
修改writablePerson
中的age
,又因为对于引用类型的数据来说直接赋值就只是引用赋值(即浅拷贝),所以writablePerson
变化后readonlyPerson
也跟着变化了这样
readonlyPerson
中的只读属性就成功被修改了
对于
TypeScript
而言,只读属性不会在运行时改变任何行为,但在类型检查期间,一个标记为只读的属性不能被写入。索引签名
在一些情况下,我们可能不知道对象内所有属性的名称,那属性名称都不知道,我们该怎么去定义这个对象的类型呢?
这时我们可以使用一个索引签名来描述可能的值的类型:
interface IObj { [index: string]: string;}const obj0: IObj = {};const obj1: IObj = { name: "1" };const obj2: IObj = { name: "Ailjx", age: "18" };
上面就是使用索引签名定义的一个对象类型,注意其中
index
是自己自定义的,代表属性名的占位,对于对象来说index
的类型一般为string
(因为对象的key
值本身是string
类型的,但也有例外的情况,往下看就知道了)最后的
string
就代表属性的值的类型了,从这我们不难发现使用索引签名的前提是你知道值的类型。
这时细心的朋友应该能够发现,当
index
的类型为number
时,就能表示数组了,毕竟数组实质上就是一种对象,只不过它的key
其实就是数组的索引是number
类型的:interface IObj { [index: number]: string;}const arr: IObj = [];const arr1: IObj = ["Ailjx"];const obj: IObj = {}; // 赋值空对象也不会报错const obj1: IObj = { 1: "1" }; // 赋值key为数字的对象也不会报错
index: number
时不仅能够表示数组,也能够表示上面所示的两种对象,这就是上面提到的例外的情况。这是因为当用 "数字 “进行索引时,
JavaScript
实际上会在索引到一个对象之前将其转换为 “字符串”。这意味着用1 (一个数字)进行索引和用"1” (一个字符串)进行索引是一样的,所以两者需要一致。
索引签名的属性类型必须是
string
或number
,称之为数字索引器和字符串索引器,支持两种类型的索引器是可能的,但是从数字索引器返回的类型必须是字符串索引器返回的类型的子类型(这一点特别重要!),如:interface Animal { name: string;}interface Dog extends Animal { breed: string;}interface IObj { [index: number]: Dog; [index: string]: Animal;}
从上面的代码中可以知道的是
Dog
是Animal
的子类,所以上述代码是可选的,如果换一下顺序就不行了:
字符串索引签名强制要求所有的属性与它的返回类型相匹配。
在下面的例子中,
name
的类型与字符串索引的类型不匹配,类型检查器会给出一个错误:数字索引签名没有该限制
然而,如果索引签名是属性类型的联合,不同类型的属性是可以接受的:
interface IObj { [index: string]: number | string; length: number; // ok name: string; // ok}
索引签名也可以设置为只读:
2、扩展类型
在数据类型的接口中我们简单介绍过扩展类型,在这里再详细讲一下:
interface User { name: string; age: number;}interface Admin { isAdmin: true; name: string; age: number;}
这里声明了两个类型接口,但仔细发现它们其实是相关的(
Admin
是User
的一种),并且它们之间重复了一些属性,这时就可以使用extends
扩展:interface User { name: string; age: number;}interface Admin extends User { isAdmin: true;}
接口上的
extends
关键字,允许我们有效地从其他命名的类型中复制成员,并添加我们想要的任何新成员。这对于减少我们不得不写的类型声明模板,以及表明同一属性的几个不同声明可能是相关的意图来说,是非常有用的。例如,
Admin
不需要重复name
和age
属性,而且因为name
和age
源于User
,我们会知道这两种类型在某种程度上是相关的。接口也可以从多个类型中扩展:
interface User { name: string;}interface Age { age: number;}interface Admin extends User, Age { isAdmin: true;}
多个父类使用
,
分割3、交叉类型
在数据类型的类型别名中我们已经介绍过交叉类型
&
,这里就不再过多的说了:interface Colorful { color: string;}interface Circle { radius: number;}type ColorfulCircle = Colorful & Circle;const cc: ColorfulCircle = { color: "red", radius: 42,};
4、泛型对象类型
如果我们有一个盒子类型,它的内容可以为字符串,数字,布尔值,数组,对象等等等等,那我们去定义它呢?这样吗:
interface Box { contents: any;}
现在,内容属性的类型是任意,这很有效,但我们知道
any
会导致TypeScript
失去编译时的类型检查,这显然是不妥的我们可以使用
unknown
,但这意味着在我们已经知道内容类型的情况下,我们需要做预防性检查,或者使用容易出错的类型断言:interface Box { contents: unknown;}let x: Box = { contents: "hello world",};// 我们需要检查 'x.contents'if (typeof x.contents === "string") { console.log(x.contents.toLowerCase());}// 或者用类型断言console.log((x.contents as string).toLowerCase());
这显得复杂了一些,并且也不能保证
TypeScript
能够追踪到contents
具体的类型针对这种需求,我们就可以使用泛型对象类型,做一个通用的
Box
类型,声明一个类型参数:// 这里的Type是自定义的interface Box<Type> { contents: Type;}
当我们引用
Box
时,我们必须给一个类型参数来代替Type
:const str: Box<string> = { contents: "999", // contents类型为string}; // str类型等价于{ contents:string }const str1: Box<number> = { contents: 1, // contents类型为number}; // str1类型等价于{ contents:number }
这像不像是函数传参的形式?其实我们完全可以将
Type
理解为形参,在使用类型时通过泛型语法传入实参即可
这样我们不就实现了我们想要的效果了吗,
contents
的类型可以是我们指定的任意的类型,并且TypeScript
可以追踪到它具体的类型。- 复杂一点的应用:使用泛型对象类型实现通用函数
interface Box<Type> { contents: Type;}function setContents<FnType>(box: Box<FnType>, newContents: FnType): FnType { box.contents = newContents; return box.contents;}const a: string = setContents<string>({ contents: "Ailjx" }, "9");console.log(a); // '9'const b: number = setContents({ contents: 2 }, 2);console.log(b); // 2const c: boolean = setContents({ contents: true }, false);console.log(c); // false
这里在函数身上使用了泛型,定义了类型参数
FnType
:setContents
,之后函数的参数box
的类型为Box
(将接收到的参数传递给Box
),newContents
的类型为FnType
,函数返回值也是FnType
类型观察常量
a
,它调用setContents
函数时传入了string
,string
就会替换掉setContents
函数中的所有FnType
,则函数的两个参数的类型就是{conents:string}
和string
,函数返回值也是string
类型其实这里调用
setContents
函数时我们可以不去手动传递类型参数,TypeScript
会非常聪明的根据我们调用函数传入的参数类型推断出FnType
是什么,就像常量b
和c
的使用一样类型别名结合泛型
类型别名也可以是通用的,我们完全可以使用类型别名重新定义
Box
:type Box<Type> = { contents: Type;};
由于类型别名与接口不同,它不仅可以描述对象类型,我们还可以用它来编写其他类型的通用辅助类型:
type OrNull<Type> = Type | null;type OneOrMany<Type> = Type | Type[];type OneOrManyOrNull<Type> = OrNull<OneOrMany<Type>>;type OneOrManyOrNullStrings = OneOrManyOrNull<string>;
上面的例子中嵌套使用了类型别名,多思考一下不难看懂的
通用对象类型通常是某种容器类型,它的工作与它们所包含的元素类型无关。数据结构以这种方式工作是很理想的,这样它们就可以在不同的数据类型中重复使用。
5、数组类型
和上面的
Box
类型一样,Array
本身也是一个通用类型,number[]
或string[] 这
实际上只是Array
和Array
的缩写。Array
泛型对象的部分源码:interface Array<Type> { /** * 获取或设置数组的长度。 */ length: number; /** * 移除数组中的最后一个元素并返回。 */ pop(): Type | undefined; /** * 向一个数组添加新元素,并返回数组的新长度。 */ push(...items: Type[]): number; // ...}
现代
JavaScript
还提供了其他通用的数据结构,比如Map
,Set
, 和Promise
。这实际上意味着,由于Map
、Set
和Promise
的行为方式,它们可以与任何类型的集合一起工作。6、只读数组类型
ReadonlyArray
是一个特殊的类型,描述了不应该被改变的数组。function doStuff(values: ReadonlyArray<string>) { // 我们可以从 'values' 读数据... const copy = values.slice(); console.log(`第一个值是 ${values[0]}`); // ...但我们不能改变 'vulues' 的值。 values.push("hello!"); values[0] = "999";}
ReadonlyArray
与普通数组一样也能够简写,可简写为:readonly string[]
普通的
Array
可以分配给ReadonlyArray
:const roArray: ReadonlyArray<string> = ["red", "green", "blue"];
而
ReadonlyArray
不能分配给普通Array
:
7、元组类型
Tuple
类型是另一种Array
类型,它确切地知道包含多少个元素,以及它在特定位置包含哪些类型。type MyType = [number, string];const arr: MyType = [1, "1"];
这里的
MyType
就是一个元组类型,对于类型系统来说,MyType
描述了其索
引 0 包含数字和 索引1 包含字符串的数组,当类型不匹配时就会抛出错误:当我们试图索引超过元素的数量,我们会得到一个错误:
需要注意的是:
这里我们虽然只声明了数组的前两个元素的类型,但这不代表数组内只能有两个元素
我们依旧可以向其
push
新元素,但新元素的类型必须是我们声明过的类型之一并且添加新元素后虽然数组的长度变化了,但我们依旧无法通过索引访问新加入的元素(能访问到的索引依旧不超过先前类型定义时的元素数量)
const arr: MyType = [1, "1"];arr.push(3);arr.push("3");console.log(arr, arr.length); // [ 1, '1', 3, '3' ] 4console.log(arr[0], arr[1]); // 1 '1'// console.log(arr[2]); // err:长度为 "2" 的元组类型 "MyType" 在索引 "2" 处没有元素。// arr.push(true); // err:类型“boolean”的参数不能赋给类型“string | number”的参数
对元组进行解构:
function fn(a: [string, number]) { const [str, num] = a; console.log(str); // type str=string console.log(num); // type num=number}
这里需要注意的是我们解构出的数据是一个常数,不能被修改:
function fn(a: [string, number]) { const [str, num] = a; console.log(a[1]++); // ok console.log(num++); // err:无法分配到 "num" ,因为它是常数}
可选的元组
元组可以通过在元素的类型后面加上
?
使其变成可选的,它只能出现在数组末尾,而且还能影响到数组长度。type MyArr = [number, number, number" />
在大多数代码中,元组往往被创建并不被修改,所以在可能的情况下,将类型注释为只读元组是一个很好的默认。
带有
const
断言的数组字面量将被推断为只读元组类型,且元素的类型为文字类型:
与只读数组类型中一样,普通的元组可以赋值给只读的元组,但反过来不行:let readonlyArr: readonly [number, number];let arr1: [number, number] = [5, 5];readonlyArr = arr1; // oklet arr2: [number, number] = readonlyArr; // err
六、函数
1、函数类型表达式
函数类型格式为:
(param:Type) => returnType
Type
代表参数的类型(如果没有指定参数类型,它就隐含为any
类型),returnType
为函数返回值的类型- 支持多个参数和可选参数:
(a:number,b:string) =>void
returnType
为void
时,代表函数没有返回值
声明一个函数类型
FnType
:// 类型别名方式type FnType = (params: number) => void;// 接口方式// interface FnType {// (params: number): void;// }
正确使用
FnType
:const fn1: FnType = (a: number) => {}; fn1(1);
这里定义
fn1
函数时可以不手动定义形参的类型,因为TypeScript会根据其使用的函数类型(FnType
)自动推断出形参的类型:const fn1: FnType = (a) => {}; // ok: a自动推断出为number类型,效果同上
错误使用
FnType
:// err: 不能将类型“(a: any, b: any) => void”分配给类型“FnType”const fn3: FnType = (a, b) => {}; // 形参数量不对// err: 参数“a”和“params” 的类型不兼容,不能将类型“number”分配给类型“string”。const fn4: FnType = (a: string) => {}; // 形参类型与FnType类型中不合
有一点需要注意,当使用函数类型
FnType
的函数不具有形参时,TypeScript
并不会报错:const fn2: FnType = () => {}; // ok: 声明函数时不带参数不会报错
但是调用
fn2
时依旧需要传入函数类型FnType
中定义的参数数量:fn2(); // err:应有 1 个参数,但获得 0 个fn2(1) // ok
对象内使用函数类型
interface Obj { fn: (a: number) => void; // 也可以这样写 // fn(a: number): void;}const obj: Obj = { fn: (a) => { console.log(a); }, // 也可以这样写 // fn(a) { // console.log(a); // },};obj.fn(99);
2、调用签名
在
JavaScript
中,函数除了可调用之外,还可以具有属性,如:function fn() { return 99}fn.age = 1 // 在函数中写入属性ageconsole.log(fn.age, fn()); // 1 99
然而,函数类型表达式的语法不允许声明属性,如果想声明函数的属性的类型,可以在一个对象类型中写一个调用签名:
type FnType = { age: number; (param: number): number;};function getFnAge(fn: FnType) { console.log(fn.age, fn(99));}function fn(a: number) { return a;}fn.age = 18;getFnAge(fn); // 18 99
注意:与函数类型表达式相比,语法略有不同:在参数列表和返回类型之间使用
:
而不是=>
FnType
也可以使用接口声明:interface FnType { age: number; (param: number): number;}
3、构造签名
在
JavaScript
中存在一种使用new操作符调用的构造函数:// Fn就是一个构造函数// ES5写法// function Fn(age) {// this.age = age// }// ES6可以这么写class Fn {// 添加构造函数(构造器) constructor(age) { this.age = age }}const f = new Fn(18)console.log(f.age); // 18
用
new
关键字来调用的函数,都称为构造函数,构造函数首字母一般大写,其作用是在创建对象的时候用来初始化对象,就是给对象成员赋初始值ES6
的class
为构造函数的语法糖,即class
的本质是构造函数。class
的继承extends
本质为构造函数的原型链的继承。在
TypeScript
中可以通过在调用签名前面添加new
关键字来写一个构造签名:class Fn { age: number; constructor(age: number) { this.age = age; }}// 可以使用接口这么写// interface FnType {// new (param: number): Fn; // 构造签名// }type FnType = new (param: number) => Fn;function getFnAge(fn: FnType) { const f = new fn(18); // f类型为Fn console.log(f.age); // 18}getFnAge(Fn);
类型
FnType
代表的是一个实例类型为Fn
(或包含Fn
)的构造函数,即class
类Fn
或其子类:- 实例:即
new
出的结果,如上面的f
- 构造签名中的返回值类型为类名
- 从这里可以看出
class
类可以直接作为类型使用
有些对象,如
JavaScript
的Date
对象,可以在有new
或没有new
的情况下被调用。你可以在同一类型中任意地结合调用和构造签名:interface CallOrConstruct { new (s: string): Date; (): string;}function fn(date: CallOrConstruct) { let d = new date("2022-7-28"); console.log(d); // 2022-07-27T16:00:00.000Z let n = date(); console.log(n); // Thu Jul 28 2022 15:25:08 GMT+0800 (中国标准时间)}fn(Date);
4、泛型函数(通用函数)
在
TypeScript
中,当我们想描述两个值之间的对应关系时,会使用泛型泛型就是把两个或多个具有相同类型的值联系起来
在对象类型详解中我们提到了使用泛型对象类型实现通用函数,这其实就是泛型函数的使用,这里再看一个简单的例子:
在写一个函数时,输入的类型与输出的类型有关,或者两个输入的类型以某种方式相关,这是常见的。让我们考虑一下一个返回数组中第一个元素的函数:
function getFirstElement(arr: any[]) { return arr[0];}
这个函数完成了它的工作,但不幸的是它的返回类型是
any
,如果该函数能够返回具体的类型会更好, 通过在函数签名中声明一个类型参数来做到这一点:// 在函数签名中声明一个类型参数function getFirstElement<Type>(arr: Type[]): Type | undefined { return arr[0];}// s 是 'string' 类型const s = getFirstElement(["a", "b", "c"]);// n 是 'number' 类型const n = getFirstElement([1, 2, 3]);// u 是 undefined 类型const u = getFirstElement([]);
这样我们就在函数的输入(数组)和输出(返回值)之间建立了一个联系
类型推断
上面这个例子中,在我们使用
getFirstElement
函数时并没有指定类型,类型是由TypeScript
自动推断并选择出来的我们也可以使用多个类型参数:
// 实现一个独立版本的mapfunction map<Input, Output>( arr: Input[], func: (arg: Input) => Output): Output[] { return arr.map(func);}// 参数n的类型自动推断为字符串类型// numArr类型自动推断为number[]const numArr = map(["1", "2", "3"], (n) => parseInt(n));console.log(numArr); // [1,2,3]
在这个例子中,
TypeScript
可以推断出输入类型参数的类型(从给定的字符串数组),以及基于函数表达式的返回值(数字)的输出类型参数。指定类型参数
上面说到
TypeScript
可以自动推断出通用函数(泛型函数)调用中的类型参数,但这并不适用于所有情景,例如:function combine<Type>(arr1: Type[], arr2: Type[]): Type[] { return arr1.concat(arr2);}const arr = combine([1, 2, 3], ["hello"]);
上面我们实现了一个合并数组的函数,看上去它好像没什么问题,但实际上
TypeScript
已经抛出了错误:
这时我们就可以手动指定类型参数,告诉TS
这俩类型都是合法的:const arr = combine<number | string>([1, 2, 3], ["hello"]);
限制条件
我们可以使用一个约束条件来限制一个类型参数可以接受的类型。
让我们写一个函数,返回两个值中较长的值。要做到这一点,我们需要一个长度属性(类型为
number
)。我们可以通过写一个扩展子句extends
将类型参数限制在这个类型上:function getLong<Type extends { length: number }>(a: Type, b: Type) { if (a.length >= b.length) { return a; } else { return b; }}// longerArray 的类型是 'number[]'const longerArray = getLong([1, 2], [1, 2, 3]);// longerString 是 'alice'|'bob' 的类型。const longerString = getLong("alice", "bob");const obj1 = { name: "obj1", length: 9,};const obj2 = { name: "obj2", length: 5,};// longerObj 是 { name: string;length: number;} 的类型。const longerObj = getLong(obj1, obj2);// 错误! 数字没有'长度'属性const notOK = getLong(10, 100); // err:类型“number”的参数不能赋给类型“{ length: number; }”的参数。
Type extends { length: number }
就是说类型参数Type
只能接收含有类型为number
的属性length
的类型这个例子中我们并没有给
getLong
函数指定返回值类型,但TypeScript
依旧能够推断出返回值类型编写规范
类型参数下推
规则: 可能的情况下,使用类型参数本身,而不是对其进行约束
// 推荐✅✅✅function firstElement1<Type>(arr: Type[]) { return arr[0];}// a类型为number const a = firstElement1([1, 2, 3]);// 不推荐❌❌❌function firstElement2<Type extends any[]>(arr: Type) { return arr[0];}// b类型为any const b = firstElement2([1, 2, 3]);
使用更少的类型参数
规则: 总是尽可能少地使用类型参数
// 推荐✅✅✅function filter1<Type>(arr: Type[], func: (arg: Type) => boolean): Type[] { return arr.filter(func);}const arr1 = filter1([1, 2, 3], (n) => n === 1);// 不推荐❌❌❌function filter2<Type, Func extends (arg: Type) => boolean>( arr: Type[], func: Func): Type[] { return arr.filter(func);}// 这种写法,在想要手动指定参数时必须要指定两个,多次一举const arr2 = filter2<number, (arg: number) => boolean>( [1, 2, 3], (n) => n === 1);
类型参数应出现两次
规则: 如果一个类型的参数只出现在一个地方,请重新考虑你是否真的需要它
// 推荐✅✅✅function greet(s: string) { console.log("Hello, " + s);}// 不推荐❌❌❌function greet<Str extends string>(s: Str) { console.log("Hello, " + s);}
5、可选参数
在博主TypeScript专栏的前几篇文章中我们多次提到过可选属性,这里就不过多叙述了,直接放代码:
// n为可选参数,它的类型为number|undefinedfunction fn(n" />
这里有几种重载签名,函数就有几种方式调用
可以看到这完美实现了我们的需求!
上述使用重载签名与实现签名共同组合定义的函数
fn
就是一个重载函数,接下来我们深入探讨重载签名与实现签名:重载签名与实现签名
实现签名就是函数的主体,一个普通的函数,这里就不多说了
重载签名格式:
function FnName(param: Type): returnType
FnName
:函数的名称,必须与实现签名(即函数的主体)的名称相同- 其余部分与函数类型表达式大致相同:
Type
为参数param
的类型,returnType
为函数返回值类型
注意事项:
-
重载签名必须要在实现签名的上边:
-
调用重载函数所传的参数数量必须是定义的重载签名的一种,即使函数主体没有声明形参:
-
重载签名必须与实现签名兼容:
编写规范
- 当重载签名有相同的参数数量时,不推荐使用重载函数
如我们编写一个返回字符串或数组长度的重载函数:
function fn(x: string): number;function fn(x: any[]): number;function fn(x: string | any[]) { return x.length;}
这个函数是好的,我们可以用字符串或数组来调用它。
然而,我们不能用一个即可能是字符串又可能是数组的值来调用它,因为
TypeScript
只能将一个函数调用解析为一个重载:
这里两个重载签名具有相同的参数数量和返回类型,我们完全可以改写一个非重载版本的函数:function fn(x: string | any[]) { return x.length;}fn("Ailjx");fn([1, 2]);// 不会再报错fn(Math.random() > 0.5 " />
7、参数展开运算符
形参展开
和
JavaScript
中一样,rest
参数出现在所有其他参数之后,并使用...
的语法:function multiply(n: number, ...m: number[]) { return m.map((x) => n * x);}const a = multiply(10, 1, 2, 3, 4); // [10, 20, 30, 40]
rest
参数的类型默认是any[]
实参展开
在使用
push
方法时使用实参展开:const arr1 = [1, 2, 3];const arr2 = [4, 5, 6];arr1.push(...arr2);console.log(arr1); // [1,2,3,4,5,6]
在一些情况下,直接进行实参展开我们会遇到问题,如:
Math.atan2(y,x)
返回从原点(0,0)
到(x,y)
点的线段与x
轴正方向之间的平面角度 (弧度值),点击查看详情最直接的解决方案是使用
as const
文字断言:const args = [8, 5] as const;const angle = Math.atan2(...args);
8、参数解构
对于这样的函数:
type Num={ a: number; b: number; c: number }function sum(num: Num) {console.log(num.a + num.b + num.c);}
可以使用解构语法:
type Num={ a: number; b: number; c: number }function sum({ a, b, c }: Num) {console.log(a + b + c);}
9、函数的可分配性
一个具有
void
返回类型的上下文函数类型(() => void
),在实现时,可以返回任何其他的值,但这些返回值的类型依旧是void
:type voidFunc = () => void;const f1: voidFunc = () => { return 1;};const f2: voidFunc = () => 2;const f3: voidFunc = function () { return 3;};// v1,v2,v3的类型都是voidconst v1 = f1();const v2 = f2();const v3 = f3();console.log(v1, v2, v3); // 1 2 3
这种行为使得下面的代码是有效的:
const arr = [1, 2, 3];const num = [];arr.forEach((el) => num.push(el));
即使
push
方法返回值是一个数字,而forEach
方法期望得到一个返回类型为void
的函数,因为上面分析的原因,它们依旧可以组合在一起需要注意的是,当一个字面的函数定义有一个
void
的返回类型时,该函数必须不返回任何东西:七、类型操作
TypeScript
的类型系统允许用其他类型的术语来表达类型。通过结合各种类型操作符,我们可以用一种简洁、可维护的方式来表达复杂的操作和值。在本篇文章中,我们将介绍用现有的类型或值来表达一个新类型的方法:
- 泛型型 :带参数的类型
Keyof
类型操作符:keyof
操作符创建新类型Typeof
类型操作符 : 使用typeof
操作符来创建新的类型- 索引访问类型 :使用
Type['a']
语法来访问一个类型的子集 - 条件类型 :在类型系统中像if语句一样行事的类型
- 映射类型 :通过映射现有类型中的每个属性来创建类型
- 模板字面量类型 :通过模板字面字符串改变属性的映射类型
1、泛型
在前面我们已经大致了解了泛型的基本使用,在这一节中我们将对泛型进行进一步的补充
泛型类型
在函数类型详解的泛型函数(通用函数) 中我们创建了在一系列类型上工作的通用函数,在这一节中,我们将探讨函数本身的类型以及如何创建通用接口
泛型函数的类型与非泛型函数的类型一样,类型参数列在前面,与函数声明类似:
- 泛型函数的类型格式:
(param:TypeToParamType) => TypeToReturnType
- 普通函数类型格式:
(param:paramType) => returnType
先看一个我们之前定义过的一个通用函数:
function getFirstElement<Type>(arr: Type[]): Type | undefined { return arr[0];}
它的类型就是
(arr: Type[]) => Type | undefined
,我们可以将它赋值给同类型的函数fn
:let fn: <Type>(arr: Type[]) => Type | undefined = getFirstElement;console.log(fn<number>([1, 2, 3]));
我们也可以为类型中的通用类型参数使用一个不同的名字,只要类型变量的数量和类型变量的使用方式一致即可:
let fn: <FnType>(fnArr: FnType[]) => FnType | undefined = getFirstElement;console.log(fn<number>([1, 2, 3]));
我们也可以把泛型写成一个对象字面类型的调用签名:
let fn: { <FnType>(fnArr: FnType[]): FnType | undefined } = getFirstElement;console.log(fn<number>([1, 2, 3]));
这时可以将对象字面类型移到一个接口中:
interface Ifn { <FnType>(fnArr: FnType[]): FnType | undefined;}let fn: Ifn = getFirstElement;console.log(fn<number>([1, 2, 3]));
在一些情况下,我们还可以将通用参数移到整个接口的参数上,这使得我们可以看到我们的泛型是什么类型(例如
Ifn
而不仅仅是Ifn
),使得类型参数对接口的所有其它成员可见:interface Ifn<FnType> { (fnArr: FnType[]): FnType | undefined;}let strFn: Ifn<string> = getFirstElement;console.log(strFn(["1", "2", "3"]));console.log(strFn([1, 2, 3])); // err:不能将类型“number”分配给类型“string”
注意:这里的例子已经变了,不再是简单的将
getFirstElement
函数直接赋值给另一个函数,而是将类型参数为string
的getFirstElement
函数赋值给strFn
上述
strFn
相当于fn
:interface Ifn { <FnType>(fnArr: FnType[]): FnType | undefined;}let fn: Ifn = getFirstElement;console.log(fn<string>(["1", "2", "3"]));
泛型类
泛型类在类的名字后面有一个角括号(
)中的泛型参数列表:
class Add<AddType> { initVal: AddType| undefined; add: ((x: AddType, y: AddType) => AddType) | undefined;}
使用:
let myNumber = new Add<number>();myNumber.initVal = 1;myNumber.add = function (x, y) { return x + y;};console.log(myNumber.add(myNumber.initVal, 18)); // 19
let myStr = new Add<string>();myStr.initVal = "Ailjx";myStr.add = function (x, y) { return x + y;};console.log(myStr.add(myStr.initVal, " OK")); // Ailjx OK
就像接口一样,把类型参数放在类本身,可以让我们确保类的所有属性都与相同的类型一起工作。
注意:一个类的类型有两个方面:静态方面和实例方面。通用类只在其实例侧而非静态侧具有通用性,所以在使用类时,静态成员不能使用类的类型参数。
泛型约束
在函数类型详解的泛型函数(通用函数) 中我们已经了解过了使用
extends
约束泛型,这一节我们继续深入在泛型约束中使用类型参数
你可以声明一个受另一个类型参数约束的类型参数。
例如,我们想从一个给定名称的对象中获取一个属性。我们想确保我们不会意外地获取一个不存在于
obj
上的属性,所以我们要在这两种类型之间放置一个约束条件:function getProperty<Type, Key extends keyof Type>(obj: Type, key: Key) { return obj[key];}
keyof
运算符接收一个对象类型,并产生其键的字符串或数字字面联合,下面会详细讲解在泛型中使用类类型
在
TypeScript
中使用泛型创建工厂时,有必要通过其构造函数来引用类的类型,比如说:function create<Type>(c: new () => Type): Type { return new c();}
create
函数代表接收一个构造函数,并返回其实例参数
c
的类型使用的是构造签名,表示其接收一个构造函数,并且该构造函数实例的类型(Type
)被当作了create
函数的类型参数并在其它地方进行使用,如create
的返回值类型就是引用了Type
一个更高级的例子,使用原型属性来推断和约束类类型的构造函数和实例方之间的关系:
class Animal { numLegs: number = 4;}class Bee extends Animal { name: string = "Bee"; getName() { console.log(this.name); }}class Lion extends Animal { name: string = "Lion"; getName() { console.log(this.name); }}function createInstance<A extends Animal>(c: new () => A): A { return new c();}createInstance(Bee).getName(); // BeecreateInstance(Lion).getName(); // Lion
这里的
createInstance
函数表示只能接收一个实例类型受限于Animal
的构造函数,并返回其实例2、keyof类型操作符
keyof
运算符接收一个对象类型,并产生其键的字符串或数字字面联合:type ObjType = { x: number; y: number };const p1: keyof ObjType = "x";// 相当于// const p1: "x" | "y" = "x";
如果该类型有一个字符串或数字索引签名,
keyof
将返回这些类型:type Arrayish = { [n: number]: unknown };type A = keyof Arrayish; // A为 numberconst a: A = 1;type Mapish = { [k: string]: boolean };type M = keyof Mapish; // M为 string|numberconst m: M = "a";const m2: M = 10;
注意:在这个例子中,
M
是string|number
——这是因为JavaScript
对象的键总是被强制为字符串,所以obj[0]
总是与obj["0"]
相同。3、typeof类型操作符
在
JavaScript
中可以使用typeof
操作符获取某一变量的类型,在TypeScript
中我们可以使用它来在类型上下文中引用一个变量或属性的类型:let s = "hello";let n: typeof s; // n类型为stringn = "world";n = 100; // err:不能将类型“number”分配给类型“string”
结合其他类型操作符,你可以使用
typeof
来方便地表达许多模式。例如我们想要获取函数返回值的类型:
TypeScript
中内置的类型ReturnType
接收一个函数类型并产生其返回类型:type Predicate = (x: unknown) => boolean;type K = ReturnType<Predicate>; // k为boolean
如果直接在一个函数名上使用
ReturnType
,我们会看到一个指示性的错误:
为了指代值f
的类型,我们使用typeof
:function f() { return { x: 10, y: 3 };}type P = ReturnType<typeof f>; // P为{ x: number, y: number }
只有在标识符(即变量名)或其属性上使用
typeof
是合法的4、索引访问类型
可以使用一个索引访问类型来查询一个类型上的特定属性的类型:
type Person = { age: number; name: string; alive: boolean };type Age = Person["age"]; // Age类型为number
还可以配合联合类型
unions
、keyof
或者其他类型进行使用:interface Person { name: string; age: number; alive: boolean;}// type I1 = string | numbertype I1 = Person["age" | "name"];const i11: I1 = 100;const i12: I1 = "";// type I2 = string | number | booleantype I2 = Person[keyof Person];const i21: I2 = "";const i22: I2 = 100;const i23: I2 = false;
将索引访问类型和
typeof
,number
结合起来,方便地获取一个数组字面的元素类型:const MyArray = [ { name: "Alice", age: 15 }, { name: "Bob", age: 23 }, { name: "Eve", age: 38 },];/* type Person = { name: string; age: number; } */type Person = typeof MyArray[number];const p: Person = { name: "xiaoqian", age: 11,};// type Age = numbertype Age = typeof MyArray[number]["age"];const age: Age = 11;// 或者// type Age2 = numbertype Age2 = Person["age"];const age2: Age2 = 11;
注意:
在索引时只能使用类型引用,不能使用变量引用:
可以使用类型别名来实现类似风格的重构:
type key = "age";type Age = Person[key];
5、条件类型
在
TypeScript
我们可以使用三元表达式来判断一个类型:interface Animal {}interface Dog extends Animal {}// type Example1 = numbertype Example1 = Dog extends Animal " />
这由
tsconfig.json
下的strictPropertyInitialization
字段控制:strictPropertyInitialization
控制类字段是否需要在构造函数中初始化- 将其设为
false
可关闭该报错,但这是不提倡的
我们应该在声明属性时明确对其设置初始化器,这些初始化器将在类被实例化时自动运行:
class Point { x: number = 0; y: number = 0;}const pt = new Point();// Prints 0, 0console.log(`${pt.x}, ${pt.y}`);
或:
class Point { x: number; y: number; constructor() { this.x = 0; this.y = 0; }}const pt = new Point();// Prints 0, 0console.log(`${pt.x}, ${pt.y}`);
类中的类型注解是可选的,如果不指定,将会是一个隐含的
any
类型,但TypeScript
会根据其初始化值来推断其类型:如果你打算通过构造函数以外的方式来初始化一个字段,为了避免报错,你可以使用以下方法:
- 确定的赋值断言操作符
!
- 使用可选属性
" />
设置readonly
的属性只能在初始化表达式或constructor
中进行修改赋值,连类中的方法(如chg
)都不行构造器
类中的构造函数
constructor
与函数相似,可以添加带有类型注释的参数,默认值和重载:class Point { x: number; y: number; // 带类型注释和默认值的正常签名 constructor(x: number = 1, y: number = 2) { this.x = x; this.y = y; }}
class Point { x: number; y: number; // 重载 constructor(x: number); constructor(x: number, y: number); constructor(x: number = 1, y: number = 2) { this.x = x; this.y = y; }}
类的构造函数签名和函数签名之间只有一些区别:
- 构造函数不能有类型参数(泛型参数)
- 构造函数不能有返回类型注释——返回的总是类的实例类型
Super调用
就像在
JavaScript
中一样,如果你有一个基类,在使用任何this.成员
之前,你需要在构造器主体中调用super()
:class Base { k = 4;}class Derived extends Base { constructor() { super(); console.log(this.k); }}
方法
类上的函数属性称为方法,可以使用与函数和构造函数相同的所有类型注释:
class Point { x = 10; y = 10; scale(n: number): void { this.x *= n; this.y *= n; }}
除了标准的类型注解,
TypeScript
并没有为方法添加其他新的东西。注意: 在一个方法体中,仍然必须通过
this
访问字段和其他方法。方法体中的非限定名称将总是指代包围范围内的东西:let x: number = 0;class Point { x = 10; scale(n: number): void { x *= n; // 这是在修改第一行的x变量,不是类属性 }}
Getters/Setters
使用
Getters/Setters
的规范写法:class Point { _x = 0; get x() { console.log("get"); return this._x; } set x(value: number) { console.log("set"); this._x = value; }}let a = new Point();// 调用了seta.x = 8; // set // 调用了getconsole.log(a.x); // get 8
这里的命名规范:
- 以
_
开头定义用于get/set
的属性(与普通属性进行区别):_x
- 用
get
和set
前缀分别定义get/set
函数,函数名相同都为x
,表示这俩是属于_x
的get/set
函数 - 在访问和修改时直接
.x
触发get/set
,而不是._x
- 这样一来在使用时就像使用普通属性一样,如上
a.x = 8
和console.log(a.x)
TypeScript
对访问器有一些特殊的推理规则:如果存在
get
,但没有set
,则该属性自动是只读的如果没有指定
setter
参数的类型,它将从getter
的返回类型中推断出来访问器和设置器必须有相同的成员可见性(成员可见性下面会讲)
从
TypeScript 4.3
开始,可以有不同类型的访问器用于获取和设置:class Thing { _size = 0; get size(): number { return this._size; } set size(value: string | number | boolean) { // 可以有不同类型的 let num = Number(value); // 不允许NaN、Infinity等 if (!Number.isFinite(num)) { this._size = 0; return; } this._size = num; }}
索引签名
类也可以像其它对象类型一样使用索引签名,它们的索引签名的作用相同:
class MyClass { [s: string]: boolean | ((s: string) => boolean); check(s: string) { return this[s] as boolean; }}
因为索引签名类型需要同时捕获方法的类型(这就是为什么上面的索引类型要
|((s: string) => boolean)
,其目的就是要兼容check
方法),所以要有用地使用这些类型并不容易一般来说,最好将索引数据存储在另一个地方,而不是在类实例本身
2、类继承
implements子句
implements
子句可以使类实现一个接口(使类的类型服从该接口),那么使用它就可以检查一个类是否满足了一个特定的接口:interface Animal { ping(): void;}class Dog implements Animal { ping(): void { console.log("旺!"); }}// 报错:// 类“Cat”错误实现接口“Animal”:// 类型 "Cat" 中缺少属性 "ping",但类型 "Animal" 中需要该属性。class Cat implements Animal { pong(): void { console.log("喵!"); }}
类也可以实现多个接口,例如
class C implements A, B
注意:
implements
子句只是检查类的类型是否符合特定接口,它根本不会改变类的类型或其方法,如:一个类实现一个带有可选属性的接口并不能创建该属性:extends子句
类可以从基类中扩展出来(称为派生类),派生类拥有其基类的所有属性和方法,也可以定义额外的成员:
class Animal { move() { console.log("move"); }}class Dog extends Animal { woof() { console.log("woof"); }}const d = new Dog();// 基类的类方法d.move();// 派生类自己的类方法d.woof();
注意:
在对象类型详解的扩展类型部分中我们说到接口可以使用
extends
从多个类型中扩展:extends User, Age
而类使用
extends
只能扩展一个类:重写方法
派生类可以覆盖基类的字段或属性,并且可以使用
super.
语法来访问基类方法:class Base { greet() { console.log("Hello, world!"); }}class Derived extends Base {// 在Derived中重写greet方法 greet(name" />
上面报错是因为“(name: string) => void
”不是类型“() => void
”的子类型,而先前使用的“(name?: string) => void
”才是“() => void
”子类型这里是不是有人感觉我说反了,会感觉
() => void
是(name?: string) => void
的子类型才对吧,那么我就来验证一下我的说法:type A = () => void;type B = (name?: string) => void;type C = B extends A ? number : string;const num: C = 1;
这里可以看到
num
是number
类型,则type C=number
,则B extends A
成立,所以A
是B
的基类,B
是从A
扩展来的,则称B
是A
的子类型,这就印证了上面的结论其实这里子类型的"子"并不是说它是谁的一部分,而是说它是继承了谁
例如上面的类型
A
和B
,如果单从范围上讲,B
肯定是包含A
的,但就因为B
是在A
的基础上扩展开来的,是继承的A
,所以无论B
范围比A
大多少,它仍然是A
的子类型这就好像我们人类生了孩子,无论孩子的能力,眼光比父母大多少,他任然是父母的子类一样
初始化顺序
类初始化的顺序是:
- 基类的字段被初始化
- 基类构造函数运行
- 派生类的字段被初始化
- 派生类构造函数运行
class Base { name = "base"; constructor() { console.log(this.name); }}class Derived extends Base { name = "derived";}// 打印 "base", 而不是 "derived"const d = new Derived();
继承内置类型
注意:如果你不打算继承
Array
、Error
、Map
等内置类型,或者你的编译目标明确设置为ES6/ES2015
或以上,你可以跳过这一部分。在
ES6/ES2015
中,返回对象的构造函数隐含地替代了任何调用super(...)
的this
的值。生成的构造函数代码有必要捕获super(...)
的任何潜在返回值并将其替换为this
因此,子类化
Error
、Array
等可能不再像预期那样工作。这是由于Error
、Array
等的构造函数使用ES6
的new.target
来调整原型链;然而,在ES5
中调用构造函数时,没有办法确保new.target
的值。默认情况下,其他低级编译器(ES5
以下)通常具有相同的限制。看下面的一个子类:
class MsgError extends Error { constructor(m: string) { super(m); } sayHello() { // this.message为基类Error上的属性 return "hello " + this.message; }}const msgError = new MsgError("hello");console.log(msgError.sayHello());
上述代码,在编程成
ES6
及以上版本的JS
后,能够正常运行,但当我们修改tsconfig.json
的target
为ES5
时,使其编译成ES5
版本的,你可能会发现:方法在构造这些子类所返回的对象上可能是未定义的,所以调用
sayHello
会导致错误。instanceof
将在子类的实例和它们的实例之间被打破,所以new MsgError("hello") instanceof MsgError)
将返回false
:console.log(new MsgError("hello") instanceof MsgError); // false
官方建议,可以在任何
super(...)
调用后立即手动调整原型:class MsgError extends Error { constructor(m: string) { super(m); // 明确地设置原型。// 将this上的原型设置为MsgError的原型 Object.setPrototypeOf(this, MsgError.prototype); } sayHello() { // this.message为基类Error上的属性 return "hello " + this.message; }}const msgError = new MsgError("hello");console.log(msgError.sayHello()); // hello helloconsole.log(new MsgError("hello") instanceof MsgError); // true
MsgError
的任何子类也必须手动设置原型。对于不支持Object.setPrototypeOf
的运行时,可以使用__proto__
来代替:class MsgError extends Error { // 先声明一下__proto__,其类型就是当前类 // 不然调用this.__proto__时会报:类型“MsgError”上不存在属性“__proto__” __proto__: MsgError; constructor(m: string) { super(m); // 明确地设置原型。 this.__proto__ = MsgError.prototype; } sayHello() { // this.message为基类Error上的属性 return "hello " + this.message; }}const msgError = new MsgError("hello");console.log(msgError.sayHello()); // hello helloconsole.log(new MsgError("hello") instanceof MsgError); // true
不幸的是,这些变通方法在
Internet Explorer 10
和更早的版本上不起作用。我们可以手动将原型中的方法复制到实例本身(例如MsgError.prototype
到this
),但是原型链本身不能被修复。3、成员的可见性
可以使用
TypeScript
来控制某些方法或属性对类外的代码是否可见public
public
定义公共属性,是类成员的默认可见性,可以在任何地方被访问:class Greeter { public greet() { console.log("hi!"); }}const g = new Greeter();g.greet();
因为
public
已经是默认的可见性修饰符,所以一般不需要在类成员上写它,但为了风格/可读性的原因,可能会选择这样做protected
protected
定义受保护成员,仅对声明它们的类和其子类可见:class Greeter { protected name = "Ailjx"; greet() { console.log(this.name); }}class Child extends Greeter { childGreet() { console.log(this.name); }}const g = new Greeter();const c = new Child();g.greet(); // Ailjxc.childGreet(); // Ailjx// ❌❌报错:属性“name”受保护,只能在类“Greeter”及其子类中访问。console.log(g.name); // 无权访问
暴露受保护的成员
派生类需要遵循它们的基类契约,但可以选择公开具有更多能力的基类的子类型,这包括将受保护的成员变成公开:class Base { protected m = 10;}class Derived extends Base { // 基类的受保持属性m被修改为公开的了 // 没有修饰符,所以默认为公共public m = 15;}const d = new Derived();console.log(d.m); // OK
private
private
定义私有属性,比protected
还要严格,它仅允许在当前类中访问class Base { private name = "Ailjx"; greet() { // 只能在当前类访问 console.log(this.name); }}class Child extends Base { childGreet() { // 不能在子类中访问 // ❌❌报错:属性“name”为私有属性,只能在类“Base”中访问 console.log(this.name); }}const b = new Base();// 不能在类外访问// ❌❌报错:属性“name”为私有属性,只能在类“Base”中访问。console.log(b.name); // 无权访问
private
允许在类型检查时使用括号符号进行访问:console.log(b["name"]); // "Ailjx"
因为私有
private
成员对派生类是不可见的,所以派生类不能像使用protected
一样增加其可见性跨实例访问
TypeScript
中同一个类的不同实例之间可以相互访问对方的私有属性:class A { private x = 0; constructor(x: number) { this.x = x; } public sameAs(other: A) { // 可以访问 return other.x === this.x; }}const a1 = new A(1);const a2 = new A(10);const is = a1.sameAs(a2);console.log(is); // false
参数属性
TypeScript
提供了特殊的语法,可以将构造函数参数变成具有相同名称和值的类属性,这些被称为参数属性,通过在构造函数参数前加上可见性修饰符public
、private
、protected
或readonly
中的一个来创建,由此产生的字段会得到这些修饰符:class A { // c为私有的可选属性 constructor(public a: number, protected b: number, private c" />
注意事项
像
TypeScript
类型系统的其他方面一样,private
和protected
只在类型检查中被强制执行,这意味着在JavaScript
的运行时结构,如in
或简单的属性查询,仍然可以访问一个私有或保护的成员:class MySafe { private secretKey = 12345;}const s = new MySafe();// 报错:属性“secretKey”为私有属性,只能在类“MySafe”中访问。console.log(s.secretKey);
上方
TS
代码虽然会报错,但当我们运行其编译后的JS
文件时会发现其正常的打印出了12345
这意味着
private
和protected
只起到了报错提示作用,并不会真正限制编译后的JS
文件,即这些字段是软性私有的,不能严格执行私有特性与
TypeScript
的private
不同,JavaScript
的private
字段(#
)在编译后仍然是private
的,并且不提供前面提到的像括号符号访问那样的转义窗口,使其成为硬private
:class Dog { #barkAmount = 0; constructor() { console.log(this.#barkAmount); // 0 }}const dog = new Dog();// TS报错:类型“Dog”上不存在属性“barkAmount”,编译后的JS运行时打印undefinedconsole.log(dog.barkAmount);// TS报错:属性 "#barkAmount" 在类 "Dog" 外部不可访问,因为它具有专用标识符。// 编译后的JS也直接报错console.log(dog.#barkAmount);
上述代码在编译到
ES2021
或更低版本时,TypeScript
将使用WeakMaps来代替#
:"use strict";var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) { if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);};var _Dog_barkAmount;class Dog { constructor() { _Dog_barkAmount.set(this, 0); console.log(__classPrivateFieldGet(this, _Dog_barkAmount, "f")); // 0 }}_Dog_barkAmount = new WeakMap();const dog = new Dog();// TS报错:类型“Dog”上不存在属性“barkAmount”,编译后的JS运行时打印undefinedconsole.log(dog.barkAmount);// TS报错:属性 "#barkAmount" 在类 "Dog" 外部不可访问,因为它具有专用标识符。// 编译后的JS也直接报错console.log(dog.);
如果你需要保护你的类中的值免受恶意行为的影响,你应该使用提供硬运行时隐私的机制,如闭包、
WeakMaps
或私有字段。请注意,这些在运行时增加的隐私检查可能会影响性能。4、静态成员
类可以有静态成员,这些成员并不与类的特定实例相关联,它们可以通过类的构造函数对象本身来访问:
class MyClass { static x = 0; static printX() { console.log(MyClass.x); // 等同于console.log(this.x); }}// 静态成员不需要newconsole.log(MyClass.x); // 0MyClass.printX(); // 0
静态成员也可以使用相同的
public
、protected
和private
可见性修饰符:class MyClass { private static x = 0; static printX() { // ok console.log(MyClass.x); // 等同于console.log(this.x); }}// 静态成员不需要new// ❌❌TS报错:属性“x”为私有属性,只能在类“MyClass”中访问console.log(MyClass.x);// okMyClass.printX(); // 0
静态成员也会被继承:
class Base { static BaseName = "Ailjx";}class Derived extends Base {// 基类的静态成员BaseName被继承了 static myName = this.BaseName;}console.log(Derived.myName, Derived.BaseName); // Ailjx Ailjx
特殊静态名称
一般来说,从函数原型覆盖属性是不安全的/不可能的,因为类本身就是可以用
new
调用的函数,所以某些静态名称不能使用,像name
、length
和call
这样的函数属性,定义为静态成员是无效的:
没有静态类
TypeScript
(和JavaScript
)没有像C#
和Java
那样有一个叫做静态类的结构,这些结构体的存在,只是因为这些语言强制所有的数据和函数都在一个类里面因为这个限制在
TypeScript
中不存在,所以不需要它们,一个只有一个实例的类,在JavaScript
/TypeScript
中通常只是表示为一个普通的对象例如,我们不需要
TypeScript
中的 "静态类 "语法,因为一个普通的对象(甚至是顶级函数)也可以完成这个工作:// 不需要 "static" classclass MyStaticClass { static doSomething() {}}// 首选 (备选 1)function doSomething() {}// 首选 (备选 2)const MyHelperObject = { dosomething() {},};
5、静态块
static
静态块允许你写一串有自己作用域的语句,可以访问包含类中的私有字段,这意味着我们可以用写语句的所有能力来写初始化代码,不泄露变量,并能完全访问我们类的内部结构:class Foo { static #count = 0; get count() { return Foo.#count; } static { try { Foo.#count += 100; console.log("初始化成功!"); } catch { console.log("初始化错误!"); } }}const a = new Foo(); // 初始化成功console.log(a.count); // 100
6、泛型类
类和接口一样,可以是泛型的,当一个泛型类用
new
实例化时,其类型参数的推断方式与函数调用的方式相同:class Box<Type> { contents: Type; constructor(value: Type) { this.contents = value; }}// const b: Boxconst b = new Box("hello!");// 等同于const b = new Box("hello!");
泛型类的静态成员不能引用类型参数:
7、this指向
在
JavaScript
中this
指向是一个头疼的问题,默认情况下函数内this
的值取决于函数的调用方式,在一些情况下这会出现意向不到的效果,如下方代码:class MyClass { name = "MyClass"; getName() { return this.name; }}const c = new MyClass();const obj = { name: "obj", getName: c.getName,};// 输出 "obj", 而不是 "MyClass"console.log(obj.getName());
TypeScript
提供了一些方法来减少或防止这种错误:箭头函数
class MyClass { name = "MyClass"; getName = () => { return this.name; };}const c = new MyClass();const obj = { name: "obj", getName: c.getName,};// 输出 "MyClass", 而不是 "obj"console.log(obj.getName());
使用箭头函数也是有一些妥协的:
this
值保证在运行时是正确的,即使是没有经过TypeScript
检查的代码也是如此这将使用更多的内存,因为每个类实例将有它自己的副本,每个函数都是这样定义的
你不能在派生类中使用
super
调用基类方法,因为在原型链中没有入口可以获取基类方法:class MyClass { name = "MyClass"; getName = () => { return this.name; };}class A extends MyClass { AName: string; constructor() { super(); // getName为箭头函数时,调用super.getName()会报错 // this.AName = super.getName(); this.AName = this.getName(); // 但一直能通过this.getName()调用 }}const a = new A();console.log(a.AName); // MyClass
this参数
在
这种方法做出了与箭头函数方法相反的取舍:
JavaScript
调用者仍然可能在不知不觉中错误地使用类方法,如上面的例子:class MyClass { name = "MyClass"; getName(this: MyClass) { return this.name; }}const c = new MyClass();const obj = { name: "obj", getName: c.getName,};// 依旧输出 "obj", 而不是 "MyClass"console.log(obj.getName());
每个类定义只有一个函数被分配,而不是每个类实例一个函数
基类方法定义仍然可以通过
super
调用。
8、this类型
在类中,一个叫做
this
的特殊类型动态地指向当前类的类型,看下面的这个例子:class Box { contents: string = ""; set(value: string) { this.contents = value; return this; }}
在这里,
TypeScript
推断出set
方法的返回类型是this
,而不是Box
:
创建Box
的一个子类:class ClearableBox extends Box { clear() { this.contents = ""; }}const a = new ClearableBox(); // a类型为ClearableBoxconst b = a.set("hello"); // b类型为ClearableBoxconsole.log(b);
这里可以看到
b
的类型竟然是ClearableBox
,这说明此时set
方法返回的this
类型指向了当前的类ClearableBox
(因为是在ClearableBox
上调用的set
)可以在参数类型注释中使用
this
:class Box { contents: string = ""; // 类型注释中使用this sameAs(other: this) { return other.contents === this.contents; }}class ClearableBox extends Box { contents: string = "Ailjx";}class B { contents: string = "";}const box = new Box();const clearableBox = new ClearableBox();const b = new B();console.log(clearableBox.sameAs(box)); // false// ❌❌❌报错// 类型“B”的参数不能赋给类型“ClearableBox”的参数// 类型 "B" 中缺少属性 "sameAs",但类型 "ClearableBox" 中需要该属性console.log(clearableBox.sameAs(b));
上面例子中可以看到派生类
ClearableBox
的sameAs
方法能够接收基类的实例但是当派生类中有额外的属性后,它就只能接收该同一派生类的其它实例了:
class Box { contents: string = ""; sameAs(other: this) { return other.contents === this.contents; }}class ClearableBox extends Box { otherContents: string = "Ailjx";}const box = new Box();const clearableBox = new ClearableBox();// ❌❌❌报错:// 类型“Box”的参数不能赋给类型“ClearableBox”的参数。// 类型 "Box" 中缺少属性 "otherContents",但类型 "ClearableBox" 中需要该属性。console.log(clearableBox.sameAs(box));
9、基于类型守卫的this
我们可以在类和接口的方法的返回位置使用类型谓词
this is Type
,当与类型缩小混合时(例如if
语句),目标对象的的类型将被缩小到指定的Type
类型谓词详见
抽象构造签名
向上面这个例子,如果你想要写一个函数,能够接受所有抽象类
Base
的派生类,你可能会这样写:function greet(ctor: typeof Base) { const instance = new ctor(); instance.printName();}
这时
TypeScript
会告诉你这样写是不对的:
正确的做法应该是使用抽象构造签名:function greet(ctor: new () => Base) { const instance = new ctor(); instance.printName();}
完整示例:
abstract class Base { abstract getName(): string; printName() { console.log("Hello, " + this.getName()); }}class Derived extends Base { getName() { return "world"; }}class Derived2 extends Base { getName() { return "world2"; }}function greet(ctor: new () => Base) { const instance = new ctor(); instance.printName();}greet(Derived);greet(Derived2);// ❌❌❌报错:类型“typeof Base”的参数不能赋给类型“new () => Base”的参数。// 无法将抽象构造函数类型分配给非抽象构造函数类型。greet(Base);
12、类之间的关系
相同的类可以互相替代使用:
class Point1 { x = 0; y = 0;}class Point2 { x = 0; y = 0;}// 正确const p: Point1 = new Point2();
即使没有明确的继承,类之间的子类型关系也是存在的:
class Person { name: string = "A"; age: number = 1;}class Employee { name: string = "A"; age: number = 1; salary: number = 99;}// type A = numbertype A = Employee extends Person " />
上面的例子只是一个实现混入的一种方式(编写混入函数),混入是一种技巧功能,可以由多种方式实现
九、模块
1、模块定义
在
TypeScript
中,就像在EC5
中一样,任何包含顶级import
或export
的文件都被认为是一个模块相反的,一个没有任何顶级导入或导出声明的文件被视为一个脚本,其内容可在全局范围内使用(因此也可用于模块)
模块在自己的范围内执行,而不是在全局范围内。这意味着在模块中声明的变量、函数、类等在模块外是不可见的,除非它们被明确地用某种导出形式导出。相反,要使用从不同模块导出的变量、函数、类、接口等,必须使用导入的形式将其导入。
JavaScript
规范声明,任何没有export
或顶层await
的JavaScript
文件都应该被认为是一个脚本而不是一个模块如果你有一个目前没有任何导入或导出的文件,但你希望它被当作一个模块来处理,可以添加这一行:
export {};
这将改变该文件,使其成为一个什么都不输出的模块。无论你的模块目标是什么,这个语法都有效
TypeScript
中能够使用JavaScript
的模块化语法,并在此基础上提供了一些额外的语法2、ES模块语法
一个文件可以通过
export default
声明一个主要出口:// @filename: hello.tsexport default function helloWorld() { console.log("Hello, world!");}
一个文件中
export default
只能有一个通过
import
导入:// @filename: a.ts(与 hello.ts同级)import hello from "./hello";hello();
import
引入export default
导出的内容时可以自定义导入名称,如上面导出的函数名为helloWorld
,但引入时我们自定义了hello
的名称default
出口也可以只是数值:// @filename: hello.tsexport default "123";
// @filename: a.ts(与 hello.ts同级)import h from "./hello";console.log(h); // "123"
除了默认的导出,还可以通过省略
default
的export
,导出多个变量和函数的:// @filename: hello.tsexport var a = 3.14;export let b = 1.41;export const c = 1.61;export class D {}export function fn(num: number) { console.log(num);}
可以只使用一个
export
导出:var a = 3.14;let b = 1.41;const c = 1.61;class D {}function fn(num: number) { console.log(num);}export { a, b, c, D, fn };
通过
import
和{}
实现按需导入:// @filename: a.ts(与 hello.ts同级)import { a, b, c, D, fn } from "./hello";console.log(a, b, c, new D());fn(1);
可以使用
import {old as new}
这样的格式来重命名一个导入:// @filename: a.ts(与 hello.ts同级)// 仅引入a,c,fn 并重命名a和fnimport { a as A, c, fn as FN } from "./hello";console.log(A, c);FN(1);
可以把所有导出的对象,用
* as name
,把它们放到同一个命名空间name
:// @filename: a.ts(与 hello.ts同级)// export导出的所有内容放到了命名空间 F 中import * as F from "./hello";console.log(F.a, F.c);F.fn(1);
export default
与export
一起使用:// @filename: hello.tsexport var a = 3.14;export let b = 1.41;export const c = 1.61;export class D {}export function fn(num: number) { console.log(num);}export default function helloWorld() { console.log("Hello, world!");}
// @filename: a.ts(与 hello.ts同级)import hello, { a, b, c, D, fn } from "./hello";console.log(a, b, c, new D());fn(1);hello();
直接导入一个文件
通过
import "file Path"
导入一个文件,而不把任何变量纳入你的当前模块:// @filename: a.ts(与 hello.ts同级)import "./hello";
在这种情况下,
import
没有任何作用,但hello.ts
中的所有代码都将被解析,这可能引发影响其他对象的副作用导出别名
像导入时使用
as
定义别名一样,在导出时也可以使用as
定义导出的别名:// @filename: hello.tsconst a = 1;export { a as A };
// @filename: a.ts(与 hello.ts同级)import { A } from "./hello";console.log(A); // 1
二次导出
一个模块可以引入并导出另一个模块的内容,这称为二次导出,一个二次导出并不在本地导入,也不引入本地变量
hello.ts
:// @filename: hello.tsexport const a = "hello";export const n = 1;
word.ts
(与hello.ts
同级):// @filename: word.ts(与hello.ts同级)export const b = "word";// 该模块扩展了hello.ts模块,并向外暴露hello.ts模块的aexport { a } from "./hello";
a.ts
(与hello.ts
同级):// @filename: a.ts(与 hello.ts同级)import { a, b } from "./word";console.log(a, b); // hello word
另外,一个模块可以包裹一个或多个模块,并使用
export * from "module "
语法组合它们的所有导出:word.ts
(与hello.ts
同级):// @filename: word.ts(与hello.ts同级)export const b = "word";// 相当于将hello.ts导出的内容全部引入后又全部导出export * from "./hello";// 此文件相当于导出了b和a、n(来自hello.ts)
a.ts
(与hello.ts
同级):// @filename: a.ts(与 hello.ts同级)import { a, b, n } from "./word";console.log(a, b, n); // hello word 1
export * from "module "
语法也可以使用别名as
:export * as ns
作为一种速记方法来重新导出另一个有名字的模块word.ts
(与hello.ts
同级):// @filename: word.ts(与hello.ts同级)export const b = "word";// 相当于将hello.ts导出的内容全部引入到命名空间H中,后又将H导出export * as H from "./hello";
a.ts
(与hello.ts
同级):// @filename: a.ts(与 hello.ts同级)import { H, b } from "./word";console.log(H.a, b, H.n); // hello word 1
TS特定的语法
类型可以使用与
JavaScript
值相同的语法进行导出和导入:// @filename: hello.tsexport type Cat = {};export interface Dog {}
// @filename: a.ts(与 hello.ts同级)import { Cat, Dog } from "./hello";let a: Cat, b: Dog;
TypeScript
用两个概念扩展了import
语法,用于声明一个类型的导入:import type
这是一个导入语句,导入的变量只能用作类型:
// @filename: hello.tsexport const createCatName = () => "fluffy";
内联类型导入
TypeScript 4.5
还允许以type
为前缀的单个导入,以表明导入的引用是一个类型:// @filename: hello.tsexport type Cat = {};export interface Dog {}export const createCatName = () => "fluffy";
// @filename: a.ts(与 hello.ts同级)// 表明Cat和Dog为类型import { createCatName, type Cat, type Dog } from "./hello";type Animals = Cat | Dog;const name = createCatName();
export =
与import = require()
:export =
语法指定了一个从模块导出的单一对象,这可以是一个类,接口,命名空间,函数,或枚举,当使用export =
导出一个模块时,必须使用TypeScript
特定的import module=require("module")
来导入模块:// @filename: hello.tsconst a = "hello";export = a;
// @filename: a.ts(与 hello.ts同级)import H = require("./hello");console.log(H); // hello
3、CommonJS语法
若使用CommonJS语法报错,则需要先在项目根目录运行:
npm i --save-dev @types/node
安装声明文件通过在一个全局调用的
module
上设置exports
属性来导出:// @filename: hello.tsfunction absolute(num: number) { return num;}const a = 3;let b = 4;var c = 5;module.exports = { a, b, newC: c, // 将c以newC的名称导出 d: 12, // 直接导出一个值 fn: absolute, // 将absolute以fn的名称导出};
通过
require
语句导入:// @filename: a.ts(与 hello.ts同级)const m = require("./hello");console.log(m.a, m.b, m.newC, m.fn(1));
使用
JavaScript
中的解构功能来简化一下:// @filename: a.ts(与 hello.ts同级)const { d, fn } = require("./hello");console.log(d, fn(1));
4、环境模块
提前声明:
declare module
并不仅限于.d.ts
文件,在普通ts
文件中也可使用.d.ts
和declace
的介绍可见大佬文章:ts的.d.ts和declare究竟是干嘛用的我们可以使用顶级导出声明
declare
在自己的.d.ts
文件中定义模块(这类似于因为
x
没有初始化器,若它不在第一位,它就会在上一个成员的基础上加1,但若上一个成员是计算成员,这种行为就不会被TypeScript
处理了,并会抛出错误2、字符串枚举
在一个字符串枚举中,每个成员都必须用一个字符串或另一个字符串枚举成员进行常量初始化:
enum Direction { Up = "UP", Down = "DOWN", Left = "LEFT", Right = "RIGHT",}
字符串枚举没有自动递增的行为
字符串枚举用作类型时只能匹配到自身枚举成员:
enum Gender { male = "9", female = "Ailjx",}// 这个a变量只能保存male和female,数字和字符串都不行let a: Gender;a = Gender.male;a = Gender.female;// a = 9; // 不能将类型“9”分配给类型“Gender”// a = "Ailjx"; // err :不能将类型“"Ailjx"”分配给类型“Gender”。
3、异构枚举
字符串和数字成员混合的枚举称为异构枚举,但官方并不建议这么做:
enum BooleanLikeHeterogeneousEnum { No = 0, Yes = "YES",}
4、联合枚举和枚举成员类型
字面枚举成员是一个没有初始化值的常量枚举成员,或者其值被初始化为:
- 任何字符串(例如:
"foo"
,"bar"
,"baz"
)。 - 任何数字字头(例如: 1 , 100 )
- 应用于任何数字字面的单数减号(例如: -1 , -100 )
当枚举中的所有成员都具有字面枚举值时,一些特殊的语义就会发挥作用:
枚举成员也能当作类型来用
enum E { A = 1, B = "Ailjx", C = "Ailjx", D = -1,}interface Author { // E.A和E.D相当于number类型 age: E.A; age2: E.D; // 而E.B可不是简单的string类型,它限制了只有枚举E中值为"Ailjx"的成员才能赋值给name和name2 name: E.B; name2: E.B;}let c: Author = { age: 12, // ok age2: 36, // ok name: E.C, // ok // name2的类型为E.B,并非简单的是"Ailjx"字面类型,只有枚举E中的"Ailjx"才能对其赋值 name2: "Ailjx", // err:不能将类型“"Ailjx"”分配给类型“E.B”};
枚举类型本身有效地成为每个枚举成员的联合,使用联合枚举,类型系统能够利用它知道枚举本身中存在的确切值集的事实,正因为如此,
TypeScript
可以捕获我们可能会错误地比较值的错误:enum E { Foo, Bar,}function f(x: E) { if (x !== E.Foo || x !== E.Bar) { // ❌❌❌err:此条件将始终返回 "true",因为类型 "E.Foo" 和 "E.Bar" 没有重叠。 //... }}
5、运行时的枚举
枚举是在运行时存在的真实对象,例如,下面这个枚举:
enum E { X, Y, Z,}
实际上可以传递给函数:
enum E { X, Y, Z,}function f(obj: { X: number }) { return obj.X;}// 可以正常工作,因为'E'有一个名为'X'的属性,是一个数字。f(E);
6、编译时的枚举
尽管枚举是运行时存在的真实对象,但
keyof
关键字对枚举的工作方式与对典型对象的预期完全不同:// 对象类型interface A { b: number; c: number;}// type T = "b"|"c"type T = keyof A;let a: T = "b";a = "c";
// 枚举类型enum E { X, Y, Z,}// type T = "toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"type T = keyof E;
从上可以看到,我们不能使用
keyof
来获取枚举类型键的字面联合类型可以使用
keyof typeof
来获得一个将枚举类型所有键表示为字符串的类型:// 枚举类型enum E { X, Y, Z,}// type T = "X" | "Y" | "Z"type T = keyof typeof E;
从这,我们反向思考能发现对枚举类型使用
typeof
能够获得该枚举的对象类型:// 枚举类型enum E { X, Y, Z,}// type T = { X: number, Y: number, Z: number }type T = typeof E;const a: T = { X: 1, Y: 2, Z: 3 };
反向映射
数字枚举的成员还可以得到从枚举值到枚举名称的反向映射:
enum Enum { A,}let a = Enum.A; // a为枚举值console.log(a); // 0let nameOfA = Enum[a]; // 根据枚举值获得枚举名称(根据键值获得键名)console.log(nameOfA); // "A"
TypeScript
将其编译为以下JavaScript
:"use strict";var Enum;(function (Enum) { Enum[Enum["A"] = 0] = "A";})(Enum || (Enum = {}));let a = Enum.A; // a为枚举值console.log(a); // 0let nameOfA = Enum[a]; // 根据枚举值获得枚举名称(根据键值获得键名)console.log(nameOfA); // "A"
在此生成的代码中,枚举被编译成一个对象,该对象存储正向 (
name-> value
) 和反向 (value-> name
) 映射,对其他枚举成员的引用始终作为属性访问发出,并且从不内联字符串枚举成员不会被生成反向映射!
常量枚举
为了避免在访问枚举值时编译产生额外的生成代码和额外的间接性的代价,可以使用常量枚举,常量枚举使用枚举上的
const
修饰符来定义的:const enum E { A,}
常量枚举只能使用常量枚举表达式( 常量枚举不能有计算成员),并且与常规枚举不同,它们在编译期间会被完全删除:
const enum E { A,}let arr = E.A;
编译后:
"use strict";let arr = 0 /* E.A */;
普通的枚举(去掉
const
修饰符)编译为:"use strict";var E;(function (E) { E[E["A"] = 0] = "A";})(E || (E = {}));let arr = E.A;
7、环境枚举
使用
declare
来定义环境枚举,环境枚举成员初始化表达式必须是常数表达式(不能有计算成员):8、对象与枚举
在现代
TypeScript
中,一般不需要使用枚举,因为一个对象的常量就足够了const enum EDirection { Up, Down, Left, Right,}// (enum member) EDirection.Up = 0EDirection.Up;// 将枚举作为一个参数// dir的类似于number类似function walk(dir: EDirection) {}walk(EDirection.Left);walk(99) // ok
将上述代码改写成对象形式:
const ODirection = { Up: 0, Down: 1, Left: 2, Right: 3,} as const;// (property) Up: 0ODirection.Up;// 相比使用枚举,需要一个额外的行来推算出类型type Direction = typeof ODirection[keyof typeof ODirection];function run(dir: Direction) {} // type dir = 0 | 1 | 2 | 3run(ODirection.Right);run(99); // err:类型“99”的参数不能赋给类型“Direction”的参数
可以看到使用对象改写枚举反而会更安全,类型限制更准确。
十一、命名空间
1、空间声明
在代码量较大的情况下,为了避免各种变量命名的冲突,可将相似功能的函数、类、接口等放置到命名空间之中
TypeScript
的命名空间使用namespaces
声明,它可以将代码包裹起来,并可以使用export
选择性的向外暴露指定内容:namespace Ailjx { // a没有使用export向外暴露,在外部无法访问 let a; export const str = "Ailjx"; export type S = string; export function f() {} export class N {}}
这里定义了一个名为
Ailjx
的命名空间,在外部可以使用Ailjx.
的形式访问其内部通过export
暴露的成员:const s: Ailjx.S = Ailjx.str;Ailjx.f();new Ailjx.N();// 类型“typeof Ailjx”上不存在属性“a”// console.log(Ailjx.a);// err
从上面可以看出
TypeScript
的命名空间实际上就像一层大的容器,将内容包裹在其中,将其私有化,这就避免了外部其它变量与其内容命名冲突的问题2、空间合并
命名空间之间的合并
多个相同名称的命名空间会自动进行合并,这就使得命名空间可以访问或修改同一名称下其它空间
export
的成员:namespace Ailjx { export let a = 1;}namespace Ailjx { a = 2; export let b = 3;}console.log(Ailjx.a, Ailjx.b); // 2 3
没有
export
的成员只在当前命名空间有效,不会受合并的影响:namespace Ailjx { // s没有export,它只在当前空间有效 let s = 0;}namespace Ailjx { // 访问不到上个空间的s s = 1; //❌❌❌err:找不到名称“s”}
同一名称下的不同空间可以有相同名称的非
export
成员,如下面的变量s
:namespace A { // s没有export,它只在当前空间有效 let s = 0; export function getS1() { console.log(s); }}namespace A { // s没有export,它只在当前空间有效 let s = 1; export function getS2() { console.log(s); }}A.getS1(); // 0A.getS2(); // 1
从这可以看出
TypeScript
相同命名的空间并不只是简单的合并,这与闭包有些相似,然而当你查看上方代码编译后的js
文件,你就会发现TypeScript
的命名空间就是以闭包的形式实现的,见下方第三部分实现原理命名空间与类合并
先看一个例子:
class Album { label: Album.AlbumLabel;}namespace Album { export class AlbumLabel {}}
这给了用户提供了一种描述内部类的方法,合并成员的可见性规则与合并命名空间 中描述的相同,所以这里我们必须导出
AlbumLabel
类,以便
合并后的类能看到它,最终的结果是一个类在另一个类里面管理你也可以使用命名空间来为现有的类添加更多的静态成员
命名空间与函数合并
JavaScript
的中可以在函数上添加属性来进一步扩展该函数,TypeScript
使用声明合并,以类型安全的方式构建这样的定义:function fn(name: string): string { return fn.prefix + name + fn.suffix;}namespace fn { export let suffix = " !"; export let prefix = "Hello, ";}console.log(fn("Ailjx")); // "Hello, Ailjx !"
命名空间与枚举合并
命名空间可用于扩展具有静态成员的枚举:
enum Color { red = 1, green = 2, blue = 4,}namespace Color { export function mixColor(colorName: string) { if (colorName == "yellow") { return Color.red + Color.green; } else if (colorName == "white") { return Color.red + Color.green + Color.blue; } else if (colorName == "magenta") { return Color.red + Color.blue; } else if (colorName == "cyan") { return Color.green + Color.blue; } }}console.log(Color.mixColor("white")); // 7
3、实现原理
上面命名空间
A
编译后的js
代码:"use strict";"use strict";var A;(function (A) { // s没有export,它只在当前空间有效 let s = 0; function getS1() { console.log(s); } A.getS1 = getS1;})(A || (A = {}));(function (A) { // s没有export,它只在当前空间有效 let s = 1; function getS2() { console.log(s); } A.getS2 = getS2;})(A || (A = {}));A.getS1(); // 0A.getS2(); // 1
再看一个
export
暴露成员的命名空间:namespace B { export let s = 0;}namespace B { s = 1;}
编译后的
js
:"use strict";var B;(function (B) { B.s = 0;})(B || (B = {}));(function (B) { B.s = 1;})(B || (B = {}));
有一定经验的大佬看到编译后的
js
代码后,应该一下就能理解TypeScript
命名空间的实现原理原理解读:
每一个命名空间的名称在
js
中就是一个全局变量(相同名称的空间用的是同一个变量,我将该变量称为名称变量,如上方的var A;
var B;
,名称变量实际就是一个存储export
内容的对象)每一个命名空间在
js
中都是一个传入其对应名称变量的立即执行函数命名空间内通过
export
暴露的内容在js
中会挂载到其对应的名称变量中,这也就是同一名称不同空间的命名空间能够相互访问其内部export
成员的原因(因为它们接受的是同一个名称变量)命名空间内非
export
暴露的内容在js
中不会挂载到其对应的名称变量中,而只是在其立即执行函数中声明,并只对当前函数空间生效
4、模块化空间
命名空间结合};}
使用装饰器工厂可以传参数:
@color('Ailjx')// ....(被装饰器对象装饰的内容)
2、装饰器组合
多个装饰器可以应用于一个声明,例如:
@f@gx
@f
@g
为两个装饰器,x
为被装饰内容当多个装饰器应用于单个声明时,它们的评估(计算)类似于数学中的函数组合,在这个模型中,当组合函数
f
和g
时,得到的复合(f∘g)(x)
等价于f(g(x))
因此,在
TypeScript
中对单个声明评估多个装饰器时执行以下步骤:- 每个装饰器的表达式都是从上到下评估的
- 然后将结果作为函数从下到上调用
我们可以使用装饰器工厂来观察此评估(计算)顺序:
function first() { console.log("first(): first装饰器工厂"); return function (target: any) { console.log("first(): first装饰器函数"); };}function second() { console.log("second(): second装饰器工厂"); return function (target: any) { console.log("second(): second装饰器函数"); };}@first()@second()class C {}
打印结果:
first(): first装饰器工厂second(): second装饰器工厂second(): second装饰器函数first(): first装饰器函数
先从上到下打印装饰器工厂印证了:每个装饰器的表达式都是从上到下评估计算的
再从下到上打印装饰器函数印证了:将结果作为函数从下到上调用
如果不使用装饰器工厂,直接使用装饰器,那么就会直接从下到上调用(因为从上到下评估装饰器表达式的过程已经在
TypeScript
内部执行了):function first(target: any) { console.log("first():first装饰器函数");}function second(target: any) { console.log("second(): second装饰器函数");}@first@secondclass C {}
打印结果:
second(): second装饰器函数first():first装饰器函数
3、类装饰器
何为类装饰器?
类装饰器是在类声明之前声明的
类装饰器应用于类的构造函数,可用于观察、修改或替换类定义
类装饰器不能在声明文件(
.d.ts
)或任何其他环境上下文中使用(如declare
类)declare
用来表示声明其后面的全局变量的类型,之后我会出单独的一篇文章对其详细讲解)类装饰器的表达式将在运行时作为函数调用,类的构造函数将作为其唯一参数传入其中
如果类装饰器返回一个值,它将用提供的构造函数替换类声明:
function classDecorators(constructor: Function) { return class { constructor() { console.log("B"); } };}@classDecoratorsclass Cla {}new Cla(); // 打印出B
注意: 如果您选择返回一个新的构造函数,您必须注意维护原始原型,因为在运行时应用装饰器的逻辑不会为您执行此操作,上面这个例子显然并没有注意到这一点,建议的做法见下方的:通过类装饰器覆盖原先的类声明
通过类装饰器修改类:
function sealed(constructor: Function) { Object.seal(constructor); Object.seal(constructor.prototype);}@sealedclass BugReport { type = "report"; title: string; constructor(t: string) { this.title = t; }}
Object.seal()方法封闭一个对象,阻止添加新属性并将所有现有属性标记为不可配置,当前属性的值只要原来是可写的就依旧可以改变
当
@sealed
被执行时,它将同时封闭构造函数和它的原型,因此将阻止在运行时通过访问BugReport.prototype
或通过定义BugReport
本身的属性来向该类添加或删除任何进一步的功能注意:
ES2015
类实际上只是基于原型的构造函数的语法糖,所以其依旧具有prototype
属性这个装饰器并不能阻止类对
BugReport
进行extends
子类化扩展操作通过类装饰器覆盖原先的类声明:
function classDecorators<T extends { new (...args: any[]): {} }>( constructor: T) { return class extends constructor { name = "A"; getName() { console.log(this.name); } };}@classDecoratorsclass Cla { name: string; constructor(t: string) { this.name = t; }}const c = new Cla("Ailjx");console.log(c.name); // 会打印A,而不是Ailjx// 注意,装饰器不会改变TypeScript的类型// 因此,类型系统对新的属性`reportingURL`是不可知的。c.getName(); // ❌❌❌err:类型“C”上不存在属性“getName”
在这个例子中,类装饰器器返回了一个继承于基类
C
的新类,在这个新类中我们修改了name
属性的默认值,并增加了getName
方法,这个新类将覆盖原先的类C
,并很好的维护了原始的原型这里还使用了泛型、类型操作、构造函数签名方面的知识,如果有需要可以查看TypeScript从入门到精通专栏中的前几篇文章
但这里仍旧存在一个问题,就是我们无法直接访问新增的这个getName方法,我们可以这样做:
(c as any).getName(); // A
但这不够优雅!发挥我们的想象,我们完全可以利用混入mixin思想来改写一下这个例子,来实现完美的效果:
function classDecorators() { return function <T extends { new (...args: any[]): {} }>(constructor: T) { return class extends constructor { name = "A"; getName() { console.log(this.name); } }; };}const Cla = classDecorators()( class { name: string; constructor(t: string) { this.name = t; } });const c = new Cla("Ailjx");console.log(c.name); // 会打印A,而不是Ailjxc.getName(); // A
这里我们放弃了类装饰器,而是使用一个高阶函数实现混入来改造这个例子,使其达到我们想要的效果
由此可见装饰器有时并不一定是最好的选择,仁者见仁智者见智
4、方法装饰器
什么是方法装饰器?
- 方法装饰器在方法声明之前声明
- 方法装饰器应用于方法的属性描述符,可用于观察、修改或替换方法定义
- 方法装饰器不能用于声明文件、重载或任何其他环境上下文(例如在
declare
类中) - 如果方法装饰器返回一个值,它将替换掉该方法的属性描述符(注意:并不是简单的只替换该函数)
- 方法装饰器的表达式将在运行时作为函数调用,并有固定的三个参数
方法装饰器的三个参数:
第一个参数:静态成员的类的构造函数,或者实例成员(也就是普通成员)的类的原型
静态成员:
function getNameDecorators(target: any, propertyKey: string, descriptor:PropertyDescriptor) { console.log(target); // 将打印类cla的构造函数}class cla { @getNameDecorators static getName() { // 静态成员 console.log("Ailjx"); }}const c = new cla();
打印结果:
实例成员:
function getNameDecorators(target: any, propertyKey: string, descriptor:PropertyDescriptor) { console.log(target); // 将打印类cla的原型}class cla { @getNameDecorators getName() { // 实例成员 console.log("Ailjx"); }}const c = new cla();
打印结果:
第二个参数:该成员的名称,类型为
string
第三个参数:该成员的属性描述符,类型固定为
PropertyDescriptor
在
Javascript
中,属性
由一个字符串类型的名字(name
)和一个属性描述符(property descriptor
)对象 构成注意: 如果
tsconfig.json
中target
小于ES5
,属性描述符将无法定义!
示例:
function getNameDecorators(target: any, propertyKey: string, descriptor: PropertyDescriptor) { // 修改属性描述符writable为false,使该属性的值不能被改变(不影响下面设置value) descriptor.writable = false; // 修改属性描述符value(该属性的值 ),设置一个新的值 descriptor.value = function () { console.log("大帅哥"); };}class cla { @getNameDecorators getName() { console.log("Ailjx"); }}const c = new cla();c.getName(); // 打印:大帅哥c.getName = () => { console.log("大漂亮");}; // ❌❌❌运行时报错,因为getName的writable属性描述为false,getName的值不能被修改
方法装饰器同样能写出装饰器工厂的形式
5、访问器装饰器
访问装饰器与方法装饰器大致相同
访问器装饰器在访问器(
get/set
)声明之前被声明访问器装饰器被应用于访问器的属性描述符,可以用来观察、修改或替换访问器的定义
访问器装饰器不能在声明文件中使用,也不能在任何其他环境中使用(比如在
declare
类中)不能同时装饰单个成员的
get
和set
访问器,这是因为装饰器适用于一个属性描述符,它结合了获取和设置访问器,而不是每个单独声明如果访问器装饰器返回一个值,它将替换掉该成员的属性描述符
访问器装饰器的表达式将在运行时作为一个函数被调用,有以下三个参数:
- 静态成员的类的构造函数,或者实例成员的类的原型
- 该成员的名称,类型为
string
- 该成员的属性描述符,类型固定为
PropertyDescriptor
示例:
function configurable(value: boolean) { return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor ) { // 属性描述符configurable:当且仅当指定对象的属性描述可以被改变或者属性可被删除时,为 true。 descriptor.configurable = value; };}class cla { private _name = "Ailjx"; @configurable(true) get name() { return this._name; }}const c = new cla();
6、属性装饰器
- 属性装饰器在一个属性声明之前被声明
- 属性装饰器不能在声明文件中使用,也不能在任何其他环境下使用(比如在
declare
类中) - 属性装饰器的表达式将在运行时作为一个函数被调用,有以下两个参数:
- 静态成员的类的构造函数,或者实例成员的类的原型
- 成员的名称
示例:
function nameDecorator(target: any, propertyKey: string) { console.log(target, propertyKey);}class cla { @nameDecorator name: string = "Ailjx";}
目前属性装饰器好像并没有什么用途,在官方文档中只给了一个记录有关属性元数据的例子,但装饰器元数据是一项实验性功能,可能会在未来的版本中引入重大更改,所以这里就先不多了
7、参数装饰器
参数装饰器在参数声明之前声明
参数装饰器应用于类构造函数或方法声明的函数
参数装饰器不能用于声明文件、重载或任何其他环境上下文(例如在
declare
类中)参数装饰器的返回值被忽略
参数装饰器的表达式将在运行时作为函数调用,并带有以下三个参数:
- 静态成员的类的构造函数,或者实例成员的类的原型
- 该成员的姓名(函数的名称),类型为
string | symbol
- 函数参数列表中参数的序号索引,类型为
number
注意: 参数装饰器只能用于观察已在方法上声明的参数
示例:
function decorator( target: Object, propertyKey: string, parameterIndex: number) { console.log(propertyKey, parameterIndex); // getName 1}class cla { // 注意@decorator的位置 getName(name: string, @decorator age: number) {}}
8、装饰器应用顺序
对于类内部各种声明的装饰器,有一个明确的应用顺序:
- 先从上到下应用实例成员的装饰器,对于每个实例成员,首先是参数装饰器,然后是方法、访问器或属性装饰器
- 然后从上到下应用静态成员的装饰器,对于每个静态成员,先是参数装饰器,然后是方法、存取器或属性装饰器。
- 之后应用构造函数
constructor
上的参数装饰器 - 最后应用类的类装饰器
代码演示:
function classDec(constructor: Function) { console.log("类装饰器");}function staAttDec(target: any, propertyKey: string) { console.log("静态成员属性装饰器");}function attDec(target: any, propertyKey: string) { console.log("属性装饰器");}function conParamDec( target: Object, propertyKey: string, parameterIndex: number) { console.log("构造函数参数装饰器");}function paramDec(target: Object, propertyKey: string, parameterIndex: number) { console.log("参数装饰器");}function fnDec( target: any, propertyKey: string, descriptor: PropertyDescriptor) { console.log("方法装饰器");}@classDecclass cla { @staAttDec static a = 1; @attDec name = 1; constructor(@conParamDec a: number) {} @fnDec fn(@paramDec a: number) {}}
打印结果:
属性装饰器参数装饰器方法装饰器静态成员属性装饰器构造函数参数装饰器类装饰器
9、使用装饰器封装通用的try catch
在
api
请求封装过程中,几乎都会使用到try catch
来捕获错误,但对封装的每一个api
请求函数都手动进行try catch
的话,势必会带来很多麻烦,如:let info: any;class Api {// 对每一个封装的api请求函数使用try catch捕获错误 getNews() { try { return info.news; } catch (error) { console.log("获取新闻失败!"); } } getUser() { try { return info.user; } catch (error) { console.log("获取用户失败!"); } } //....}const api = new Api();api.getNews();api.getUser();
如果封装的请求比较少的话这样做还可以接受,但如果
api
请求非常多,那该怎么办?这里给出一个使用方法装饰器来实现统一
try catch
的小案例:let info: any;function apiDec(mag: string) { return function ( target: any, propertyKey: string, descriptor: PropertyDescriptor ) { const fn = descriptor.value; try { fn(); } catch (error) { console.log("请求错误:" + mag, error); } };}class Api { @apiDec("获取新闻失败!") getNews() { return info.news; } @apiDec("获取用户失败!") getUser() { return info.user; }}const api = new Api();api.getNews();api.getUser();
- 任何字符串(例如: