? 订阅专栏学习TS不迷路:TypeScript从入门到精通
?️ 博主的前端之路(猿创征文一等奖作品):前端之行,任重道远(来自大三学长的万字自述)
?分享博主自用牛客网:一个非常全面的面试刷题求职网站,前端开发者必备的刷题网站,真的超级好用?
知识目录
一、介绍1、JavaScript最大的问题2、什么是TypeScript3、JS , ES , TS 的关系4、为什么使用TypeScript5、配置TypeScript环境 二、数据类型1、基元类型2、数组3、any4、函数5、对象6、unknown7、其它类型voidobjectneverFunction 8、联合类型9、类型别名10、接口11、类型断言12、文字类型13、null和undefined14、枚举15、不太常见的原语bigintsymbol 三、类型缩小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、成员的可见性publicprotectedprivate参数属性注意事项 4、静态成员特殊静态名称没有静态类 5、静态块6、泛型类7、this指向箭头函数this参数 8、this类型9、基于类型守卫的this10、类表达式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>
,ItemType
指数组元素的类型,
Array<ItemType>
声明类型的方式使用了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 };
对于指定类型的对象其值不符合指定的类型时抛出错误:
可选属性在指定的类型属性名后加上一个?
,可以指定该属性为可选属性:
let obj2: { x?: number; y: number } = { y: 2, // x 是可选属性,对象内不含x属性时将不再抛出错误};
不能直接对可选属性进行操作,不然就会抛出错误:
这很好理解,因为可选属性没有限制用户必传,如果访问一个不存在的属性,将获得值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) {}}
被永不为真的类型保护所约束下的变量类型
function Sw(a: boolean) { switch (a) { case true: return a; case false: return a; default: // 这个分支永远不可能到达 // 此时 _a类型为 never const _a = a; return _a; }}
never
类型可以分配给每个类型,但是,没有任何类型可以分配给never
(除了never
本身)
never
类型在实际开发中几乎是使用不到,最大的用处可能就是用来表达一个总是抛出异常的函数的返回值类型了
Function
全局类型Function
描述了 JavaScript
中所有函数值上的属性,如bind
、call
、apply
和其他属性,即 Function
类型的值可以被任何函数赋值,并且总是可以被调用(不会受参数的限制),这些调用返回的都是 any
类型
let fn: Function;fn = () => {};fn = function () {};fn = function (a: number): number { return a;};// 虽然fn的值是一个必须接受一个number类型参数的函数// 但因为fn类型为Function,调用fn时可以不传参数fn();fn('1',2,true) // 还可以随便传参const a = fn(1); // a的类型依旧为any
从上面调用fn
的例子可以知道这并不安全,一般来说最好避免,因为 any
返回类型都不安全,并且也失去了参数的类型限制
一般情况下,想表示一个函数几乎不会使用Function
,而是使用函数类型
8、联合类型
定义联合类型联合类型是由两个或多个其他类型组成的类型,表示可能是这些类型中的任何一种的值。我们将这些类型中的每一种称为联合类型的成员。
多个类型之间使用|
分割:
function getId(id: string | number) { console.log("id=", id);}getId(1);getId("1");
这个例子中getId
接收的参数id
可为string
类型也可为number
类型,当类型不匹配时依旧会抛出错误:
在使用联合类型时需要注意的是:不能盲目将联合类型的数据当成单独类型的数据进行操作,不然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
版之前,类型别名可能出现在错误消息中,有时会代替等效的匿名类型(这可能是可取的,也可能是不可取的)。接口将始终在错误消息中命名。类型别名可能不参与声明合并,但接口可以。接口只能用于声明对象的形状,不能重命名基元。接口名称将始终以其原始形式出现在错误消息中,但仅当它们按名称使用时。 建议优先使用接口,接口满足不了时再使用类型别名
11、类型断言
有时,你会获得有关 TypeScript
不知道的值类型的信息。
例如,如果你正在使用document.getElementById
,TypeScript
只知道这将返回某种类型的HTMLElement
,但你可能知道你的页面将始终具有HTMLCanvasElement
给定 ID
的值 。
在这种情况下,你可以使用类型断言as
来指定更具体的类型:
const myCanvas = document.getElementById("main_canvas") as HTMLCanvasElement;
与类型注释一样,类型断言由编译器删除,不会影响代码的运行时行为。
还可以使用尖括号语法(除非代码在.tsx
文件中),它是等效的:
const myCanvas = <HTMLCanvasElement>document.getElementById("main_canvas");
提醒:因为类型断言在编译时被移除,所以没有与类型断言相关联的运行时检查。null
如果类型断言错误,则不会出现异常。
TypeScript
只允许类型断言转换为更具体或不太具体的类型版本。此规则可防止“不可能”的强制,例如:
类型断言应该用于:在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 ? 0 : a > b ? 1 : -1;}
当然,你可以将这些与非文字类型结合使用:
interface Options {width: number;}function configure(x: Options | "auto") {// ...}configure({ width: 100 });configure("auto");configure("automatic"); // 报错
还有一种文字类型:布尔文字。只有两种布尔文字类型,它们是类型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?type=blog", method: "GET" as "GET",};>
表示:“我确定req.method
始终拥有文字类型 "GET"
”
方案二:
handleRequest(req.url, req.method as "GET");
表示:“我知道req.method
具有"GET"
值”。
2. 可以使用as const
将类型转换为类型文字
将整个对象转换成类型文字:
const req = { url: "https://blog.csdn.net/m0_51969330?type=blog", method: "GET",} as const;
只将method
转换成类型文字:
const req = { url: "https://blog.csdn.net/m0_51969330?type=blog", method: "GET" as const,};
该as const
后缀就像const
定义,确保所有属性分配的文本类型,而不是一个更一般的string
或number
。
13、null和undefined
JavaScript
有两个原始值用于表示不存在或未初始化的值: null
和undefined
TypeScript
有两个对应的同名类型。这些类型的行为取决于您是否设置tsconfig.json/strictNullChecks
选择。
strictNullChecks
表示在进行类型检查时,是否考虑“null”和“undefined”
strictNullChecks=false
时,下述代码不会报错: function doSomething(x: string | null) { console.log("Hello, " + x.toUpperCase());}
strictNullChecks=true
时(strict= true时所有的严格类型检查选项都默认为true
),上述代码会报错:
避免报错正确的做法:
function doSomething(x: string | null) {if (x === null) {// 做一些事} else {console.log("Hello, " + x.toUpperCase());}}
非空断言运算符( !
后缀) !
在任何表达式之后写入实际上是一种类型断言,即确定该值不是null
or undefined
:
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?: () => void; fly?: () => void };function move(animal: Fish | Bird | Human) { if ("swim" in animal) { // animal: Fish | Human animal; } else { // animal: Bird | Human animal; }}
可选属性还将存在于缩小的两侧,这就是上面的分支中都存在Human
类型的原因
5、instanceof 操作符缩小
JavaScript
中instanceof
用于判断一个变量是否是某个对象的实例,instanceof
运算符与 typeof
运算符相似,用于识别正在处理的对象的类型。与 typeof
方法不同的是,instanceof
方法要求开发者明确地确认对象为某特定类型。
function logValue(x: Date | string) { if (x instanceof Date) { // x类型缩小为Date console.log(x.toUTCString()); } else { // x类型缩小为string console.log(x.toUpperCase()); }}logValue(new Date()); // Tue, 26 Jul 2022 07:37:10 GMTlogValue("hello ts"); // HELLO TS
6、分配缩小
当我们为任何变量赋值时,TypeScript
会查看赋值的右侧并适当缩小左侧。
let x = Math.random() < 0.5 ? 10 : "hello world!"; // let x: string | numberx = 1;console.log(x); // 赋值过后,此时的x缩小为number类型x = "Ailjx";console.log(x); // 赋值过后,此时的x缩小为string类型// 出错了!x = true; // 不能将类型“boolean”分配给类型“string | number”。console.log(x); // let x: string | number
请注意,这些分配中的每一个都是有效的。即使在我们第一次赋值后观察到的类型x
更改为 number
,我们仍然可以将string
赋值给x
。这是因为x
开始的类型是string | number
。
7、不适用的union type(联合类型)
想象一下我们正试图对圆形和方形等形状进行编码。圆记录了它们的半径,方记录了它们的边长。我们将使用一个叫做 kind
的字段来告诉我们正在处理的是哪种形状。这里是定义Shape
的第一个尝试。
interface Shape { kind: "circle" | "square"; radius?: number; sideLength?: number;}
注意,这里使用的是字符串文字类型的联合。"circle"
和"square "
分别告诉我们应该把这个形状当作一个圆形还是方形。通过使用 "circle" | "square "
而不是 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?: number; y?: number;}
使用接口定义了一个对象类型,其中的属性都为可选属性,不能够直接使用可选属性,需要先对其进行判空操作:
function ObjFn(obj: PaintOptions) { if (obj.x && obj.y) { // 对可选属性进行存在性判断 console.log(obj.x + obj.y); }}
其实这不是唯一的方式,我们也可以对可选属性设置个默认值,当该属性不存在时,使用我们设置的默认值即可,看下面这个例子:
function ObjFn({ x = 1, y = 2 }: PaintOptions) { console.log(x + y);}ObjFn({ x: 4, y: 5 }); // log: 9ObjFn({}); // log: 3
在这里,我们为 ObjFn
的参数使用了一个解构模式,并为 x
和 y
提供了默认值。现在x
和 y
都肯定存在于 ObjFn
的主体中,但对于 ObjFn
的任何调用者来说是可选的。
只读属性
在TypeScript
中使用readonly
修饰符可以定义只读属性:
interface NameType { readonly name: string; // 只读属性}function getName(obj: NameType) { // 可以读取 'obj.name'. console.log(obj.name); // 但不能重新设置值 obj.name = "Ailjx";}
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<FnType>
,之后函数的参数box
的类型为Box<FnType>
(将接收到的参数传递给Box
),newContents
的类型为FnType
,函数返回值也是FnType
类型
观察常量a
,它调用setContents
函数时传入了string
,string
就会替换掉setContents
函数中的所有FnType
,则函数的两个参数的类型就是{conents:string}
和string
,函数返回值也是string
类型
其实这里调用setContents
函数时我们可以不去手动传递类型参数,TypeScript
会非常聪明的根据我们调用函数传入的参数类型推断出FnType
是什么,就像常量b
和c
的使用一样
类型别名结合泛型
类型别名也可以是通用的,我们完全可以使用类型别名重新定义 Box<Type>
:
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<number>
和Array<string>
的缩写。
Array
泛型对象的部分源码:
interface Array<Type> { /** * 获取或设置数组的长度。 */ length: number; /** * 移除数组中的最后一个元素并返回。 */ pop(): Type | undefined; /** * 向一个数组添加新元素,并返回数组的新长度。 */ push(...items: Type[]): number; // ...}
现代JavaScript
还提供了其他通用的数据结构,比如 Map<K, V>
, Set<T>
, 和 Promise<T>
。这实际上意味着,由于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<string>
与普通数组一样也能够简写,可简写为: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?];function getLength(arr: MyArr) { const [x, y, z] = arr; // z的类型为number|undefined console.log(`数组长度:${arr.length} `);}getLength([3, 4]); //数组长度:2getLength([3, 4, 5]); // 数组长度:3getLength([3, 4, "5"]); // err:不能将类型“string”分配给类型“number”。
其余元素
元组也可以有其余元素,这些元素必须是 array/tuple
类型:
type Arr1 = [string, number, ...boolean[]];type Arr2 = [string, ...boolean[], number];type Arr3 = [...boolean[], string, number];const a: Arr1 = ["Ailjx", 3, true, false, true, false, true];
Arr1
描述了一个元组,其前两个元素分别是字符串和数字,但后面可以有任意数量的布尔。Arr2
描述了一个元组,其第一个元素是字符串,然后是任意数量的布尔运算,最后是一个数字。Arr3
描述了一个元组,其起始元素是任意数量的布尔运算,最后是一个字符串,然后是一个数字。 应用
function fn(...args: [string, number, ...boolean[]]) { const [name, version, ...input] = args; console.log(name, version, input); // 1 1 [ true, false ] console.log('参数数量:',args.length); // 参数数量:4 // ...}fn("1", 1, true, false);
几乎等同于:
function fn(name: string, version: number, ...input: boolean[]) { console.log(name, version, input); // 1 1 [ true, false ] console.log(input.length + 2); // 参数数量:4 // ...}fn("1", 1, true, false);
8、只读元组类型
tuple
类型有只读特性,可以通过在它们前面加上一个readonly
修饰符来指定:
let arr: readonly [string, number] = ["1", 1];arr[0] = "9"; // err:无法分配到 "0" ,因为它是只读属性。
在大多数代码中,元组往往被创建并不被修改,所以在可能的情况下,将类型注释为只读元组是一个很好的默认。
带有 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?: number) { if (n) { // 操作可选参数之前一定要先判断其是否存在 console.log(n + 1); return } console.log("未传参数");}fn(); // '未传参数'fn(1); // 2// 当一个参数是可选的,调用者总是可以传递未定义的参数,因为这只是模拟一个 "丢失 "的参数fn(undefined); // '未传参数' (与fn()效果相同)
也可以使用默认值:
function fn(n: number = 1) { if (n) { console.log(n + 1); return; } console.log("未传参数");}fn(); // 2fn(1); // 2// 当一个参数是可选的,调用者总是可以传递未定义的参数,因为这只是模拟一个 "丢失 "的参数fn(undefined); // 2 (与fn()效果相同)
6、函数重载
有时我们需要以不同的方式(传递数量不同的参数)来调用函数,但是我们调用的方式是有限的,这时如果是使用可选参数就会出现问题
例如我们希望一个函数只能接收一个参数或三个参数,不能接收其它数量的参数,我们尝试使用可选参数来实现:
function fn(a: number, b?: number, c?: number) {}fn(1);fn(1, 2, 3);fn(1, 2); // 并不会报错
可以看到我们可以给函数传递两个参数,这显然不符合我们的需求,这种情况下我们就可以通过编写重载签名来指定调用函数的不同方式:
// 重载签名function fn(a: number): void; // 接收一个参数的签名function fn(a: number, b: number, c: number): void; // 接收三个参数的签名// 实现签名(函数主体)function fn(a: number, b?: number, c?: number) {}
这里有几种重载签名,函数就有几种方式调用
可以看到这完美实现了我们的需求!
上述使用重载签名与实现签名共同组合定义的函数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 ? "Ailjx" : [1, 2]);
这样即避免了报错,又使代码变得更加简洁,这时你就会发现那两行重载签名是多么的没用,所以在可能的情况下,总是倾向于使用联合类型的参数而不是重载参数
在函数中声明this
TypeScript
将通过代码流分析推断this
在函数中应该是什么,例如:
interface User { name: string; setName: (newName: string) => void;}const user: User = { name: "Ailjx", setName: function (newName: string) { this.name = newName; },};
一般情况下这已经足够了,但是在一些情况下,您可能需要更多地控制this
对象代表的内容
JavaScript
规范声明你不能有一个名为this
的参数,因此 TypeScript
使用该语法空间让你能够在函数体中声明this
的类型:
interface User { name: string; setName: (newName: string) => void;}const user: User = { name: "Ailjx", // 手动声明this的类型为User setName: function (this: User, newName: string) { this.name = newName; },};
上面我们在函数的参数中加上了this:User
,指定了this
的类型为User
,这里的this
代表的并不是形参(因为JavaScript
中this
不能作为形参),在编译后的JavaScript
代码中它会自动去除掉:
// 上述代码编译后的JS"use strict";const user = { name: "Ailjx", setName: function (newName) { this.name = newName; },};
注意:
this
类型的声明需要在函数的第一个参数的位置上
不能在箭头函数中声明this
类型
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、泛型
在前面我们已经大致了解了泛型的基本使用,在这一节中我们将对泛型进行进一步的补充
泛型类型
在函数类型详解的泛型函数(通用函数) 中我们创建了在一系列类型上工作的通用函数,在这一节中,我们将探讨函数本身的类型以及如何创建通用接口
泛型函数的类型与非泛型函数的类型一样,类型参数列在前面,与函数声明类似:
泛型函数的类型格式:<Type>(param:TypeToParamType) => TypeToReturnType
普通函数类型格式:(param:paramType) => returnType
先看一个我们之前定义过的一个通用函数:
function getFirstElement<Type>(arr: Type[]): Type | undefined { return arr[0];}
它的类型就是<Type>(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<string>
而不仅仅是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<string>
:
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<T>
接收一个函数类型并产生其返回类型:
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 ? number : string;// type Example2 = stringtype Example2 = RegExp extends Animal ? number : string;
条件类型表达式是通过extends
进行约束和判断
配合泛型使用
先看一个简单的例子:
type Flatten<T> = T extends any[] ? T[number] : T;// 提取出元素类型。// type Str = stringtype Str = Flatten<string[]>;// 单独一个类型。// type Num = numbertype Num = Flatten<number>;
当 Flatten
被赋予一个数组类型时,它使用一个带有数字的索引访问来获取数组的元素类型。否则,它只是返回它被赋予的类型。
在看一个复杂的例子,实现一个获取id
或name
的对象格式的函数getIdOrNameObj
:
interface IId { id: number;}interface IName { name: string;}// 条件类型配合泛型对类型进行判断和选择type IdOrName<T extends number | string> = T extends number ? IId : IName;function getIdOrNameObj<T extends number | string>(idOrName: T): IdOrName<T> { if (typeof idOrName === "number") { return { id: idOrName, } as IdOrName<T>; } else { return { name: idOrName, } as IdOrName<T>; }}const myId = getIdOrNameObj(1); // myId类型为IIdconst myName = getIdOrNameObj("Ailjx"); // myName类型为IName```### 类型推理在条件类型的 `extends`子句中我们可以使用 `infer` 声明来推断元素类型> `infer` 声明只能在条件类型的 `extends`子句中使用例如,我们使用`infer` 关键字来改写上面的`Flatten`:```typescripttype Flatten<T> = T extends Array<infer Item> ? Item : T;// type Str = stringtype Str = Flatten<string[]>;// type Str = numbertype Num = Flatten<number[]>;
这里使用 infer
关键字来声明性地引入一个名为 Item
的新的通用类型变量
这里infer Item
相当于一个占位,它暂时代表Array
中元素的类型,当Flatten
类型参数被赋值为数组后,TypeScript
就会自动推断出extends
语句中Array
中元素的类型,这时infer Item
这个占位就指向了数组元素的类型,之后就能直接使用Item
来代指数组元素的类型了
这使得我们不用再使用索引访问类型T[number]
"手动 "提取数组元素的类型了
使用 infer
关键字从函数类型中提取出返回类型:
// 当GetReturnType接收类型为函数签名时返回函数返回值类型,否者直接返回接收的类型type GetReturnType<Type> = Type extends (...args: never[]) => infer Return ? Return : Type;// type Num = numbertype Num = GetReturnType<() => number>;// type Str = stringtype Str = GetReturnType<(x: string) => string>;// type Bools = boolean[]type Bools = GetReturnType<(a: boolean, b: boolean) => boolean[]>;// type Arr=any[]type Arr = GetReturnType<any[]>;
当从一个具有多个调用签名的类型(如重载函数的类型)进行推断时,从最后一个签名进行推断:
declare function stringOrNum(x: string): number;declare function stringOrNum(x: number): string;declare function stringOrNum(x: string | number): string | number;// type T1 = string | numbertype T1 = ReturnType<typeof stringOrNum>;
declare
可以向TypeScript
域中引入一个变量,这可以解决在重载函数只有重载签名而没有实现签名时的报错
分布式条件类型
当条件类型作用于一个通用类型时,当给定一个联合类型时,它就变成了分布式的:
type ToArray<Type> = Type extends any ? Type[] : never;// type StrArrOrNumArr = string[] | number[]type StrArrOrNumArr = ToArray<string | number>;
将一个联合类型string | number
插入ToArray
,那么条件类型将被应用于该联合的每个成员
StrArrOrNumArr
分布在string | number
;条件类型会对联合的每个成员类型进行映射:ToArray<string> | ToArray<number>
最终返回string[] | number[]
取消分布式:
如果不需要分布式的这种行为,我们可以使用方括号[]
包围extends
关键字的两边
type ToArray<Type> = [Type] extends [any] ? Type[] : never;// type StrArrOrNumArr = (string|number)[]type StrArrOrNumArr = ToArray<string | number>;
6、映射类型
当一个类型可以以另一个类型为基础创建新类型。
映射类型建立在索引签名的语法上。
映射类型是一种通用类型,它使用 PropertyKeys
的联合(经常通过keyof
创建)迭代键来创建一个类型:
type OptionsFlags<Type> = { [Property in keyof Type]: boolean;};
在这个例子中, OptionsFlags
将从Type
类型中获取所有属性,并将它们的值的类型改为boolean
:
type Obj = { name: string; age: number;};type FeatureOptions = OptionsFlags<Obj>;/* type FeatureOptions = { name: boolean; age: boolean; } */
映射修改器
在映射过程中,有两个额外的修饰符可以应用: readonly
和?
,它们分别影响可变性和可选性,可以通过用-
或+
作为前缀来删除或添加这些修饰符(不加修饰符就默认是+
):
type OptionsFlags<Type> = {// 删除readonly和?,readonly在前,?在后 -readonly [Property in keyof Type]-?: boolean;};type Obj = { readonly name: string; age?: number;};type FeatureOptions = OptionsFlags<Obj>;/* type FeatureOptions = { name: boolean; age: boolean; } */
通过as做key重映射
在TypeScript 4.1
及以后的版本中,可以通过映射类型中的as
子句修改映射类型中的键名:
type OptionsFlags<Type> = { // 将键重命名为哦、 [Property in keyof Type as "o"]: Type[Property];};type Obj = { name: string; age: number;};type FeatureOptions = OptionsFlags<Obj>;/* type FeatureOptions = { o:string|number } */
上面是将所有键名都更改成了'o'
我们也可以利用模板字面类型,在之前的属性名称的基础上进行更改:
type Getters<Type> = { [Property in keyof Type as `get${Capitalize<string & Property>}`]: () => Type[Property];};interface Person { name: string; age: number; location: string;}/* type LazyPerson = { getName: () => string; getAge: () => number; getLocation: () => string; } */type LazyPerson = Getters<Person>;
Capitalize
为TS
内置类型,能将传入的字符串类型首字母转为大写string & Property
通过交叉类型,确保Capitalize
接收的为字符串类型 可以通过条件类型Exclude
根据键名产生never
,从而过滤掉该键:
type RemoveKindField<Type> = { [Property in keyof Type as Exclude<Property, "kind">]: Type[Property];};interface Circle { kind: "circle"; radius: number;}/* type KindlessCircle = { radius: number; } */type KindlessCircle = RemoveKindField<Circle>;
Exclude
为TS
内置类型:type Exclude<T, U> = T extends U ? never : T
可以映射任意的联合体:
type EventConfig<Events extends { kind: string }> = { [E in Events as E["kind"]]: (event: E) => void;};type SquareEvent = { kind: "square"; x: number; y: number };type CircleEvent = { kind: "circle"; radius: number };/* type Config = { square: (event: SquareEvent) => void; circle: (event: CircleEvent) => void; } */type Config = EventConfig<SquareEvent | CircleEvent>;
进一步探索
映射类型与本篇文章中指出的其他功能配合得很好,例如,下面这个使用条件类型的映射类型,它根据一个对象类型的属性show
是否被设置为字面类型true
而返回true
或false
:
type ExtractShow<Type> = { [Property in keyof Type]: Type[Property] extends { show: true } ? true : false;};type PermissionInfo = { home: { url: string; show: true }; about: { url: string; show: true }; admin: { url: string };};/* type judge = { home: true; about: true; admin: false; }*/type judge = ExtractShow<PermissionInfo>;
八、类
1、类成员
类属性
在一个类上声明字段,创建一个公共的可写属性:
class Point { x: number; y: number;}const pt = new Point();pt.x = 0;pt.y = 0;
表示在类Point
上声明了类型为number
的两个属性,这时编译器可能会报错:
这由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
会根据其初始化值来推断其类型:
如果你打算通过构造函数以外的方式来初始化一个字段,为了避免报错,你可以使用以下方法:
确定的赋值断言操作符!
使用可选属性?
明确添加未定义属性(与可选属性原理相同) class Point { // 没有初始化,但没报错 x!: number; // 赋值断言! y?: number; // 可选属性? z: number | undefined; // 添加未定义类型}const pt = new Point();console.log(pt.x, pt.y, pt.z); // undefined undefined undefinedpt.x = 1;pt.y = 2;pt.z = 3;console.log(pt.x, pt.y, pt.z); // 1 2 3
readonly
readonly
修饰符,修饰只读属性,可以防止在构造函数之外对字段进行赋值:
设置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?: string) { if (name === undefined) { // 调用基类的greet方法 super.greet(); } else { console.log(`Hello, ${name.toUpperCase()}`); } }}const d = new Derived();d.greet(); // "Hello, world!"d.greet("reader"); // "Hello, READER"
通过基类引用来引用派生类实例是合法的,并且是非常常见的:
// 通过基类引用来引用派生类实例// b的类型引用的是基类Base,但其可以引用Base的派生类实例const b: Base = new Derived();// 没问题b.greet();
TypeScript
强制要求派生类总是其基类的一个子类型,如果违法约定就会报错:
上面报错是因为“(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?: number) {}}const a = new A(1, 2, 3);console.log(a.a); // 1
注意事项
像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: Box<string>const b = new Box("hello!");// 等同于const b = new Box<string>("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参数
在【TypeScript】深入学习TypeScript函数中我们提到过this
参数,TypeScript
检查调用带有this
参数的函数,是否在正确的上下文中进行
我们可以不使用箭头函数,而是在方法定义中添加一个this
参数,以静态地确保方法被正确调用:
class MyClass { name = "MyClass"; getName(this: MyClass) { return this.name; }}const c = new MyClass();// 正确c.getName();// 错误const g = c.getName;console.log(g());
这种方法做出了与箭头函数方法相反的取舍:
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
类型谓词详见【TypeScript】TypeScript中类型缩小(含类型保护)与类型谓词
class Box { // 利用类型谓词,当this类型是A的实例时,确保将this类型缩小为A类型 isA(): this is A { return this instanceof A; } isB(): this is B { return this instanceof B; }}class A extends Box { Apath: string = "A";}class B extends Box { Bpath: string = "B";}// fso的类型为基类Box,它可能是A,也可能是Bconst fso: Box = Math.random() > 0.5 ? new A() : new B();if (fso.isA()) { // fso.isA()为true时(说明Box的this类型指向了A,即可知道此时fso具体为A), // 其通过类型谓词将fso缩小为了A类型,此时就可以安全调用A特有的属性 console.log(fso.Apath);} else if (fso.isB()) { console.log(fso.Bpath);}
配合接口使用:
class Box { isNetworked(): this is Networked & this { return this.networked; } // networked属性控制Box是否包含Networked接口类型 constructor(private networked: boolean) {} // 这里使用了在构造器参数列表中声明属性}interface Networked { host: string;}const A: Box = new Box(true);// A.host = "12"; // ❌❌外界直接使用host属性报错:类型“Box”上不存在属性“host”if (A.isNetworked()) { // 此时A类型变成了Networked & this,可以安全使用host属性了 A.host = "12"; console.log(A.host); // 12}
基于 this
的类型保护的一个常见用例,是允许对一个特定字段进行懒惰验证。例如,这种情况下,当hasValue
被验证为真时,Box
类型缩小,value
属性失去了可选性,就能直接使用了:
class Box<T> { value?: T; // 根据value值是否存在来缩小类型 hasValue(): this is { value: T } { return this.value !== undefined; }}const box = new Box<string>();// value可能未定义需要使用可选连?console.log(box.value?.toUpperCase());if (box.hasValue()) { // 这时Box类型已经缩小为{value:string}了,value不再是可选属性了,可以不使用可选连?了 console.log(box.value.toUpperCase());}
10、类表达式
类表达式与类声明非常相似,唯一真正的区别是,类表达式不需要一个名字,我们可以通过它们最终绑定的任何标识符来引用它们:
const someClass = class<Type> { content: Type; constructor(value: Type) { this.content = value; }};// type m=someClass<string>const m = new someClass("Hello, world");
11、抽象类和成员
使用abstract
定义的一个方法或字段称为抽象成员,它是一个没有提供实现的方法或字段,这些成员必须存在于一个使用abstract
定义的抽象类中,该类不能直接实例化:
abstract class Base { abstract getName(): string; printName() { console.log("Hello, " + this.getName()); }}// ❌❌❌报错:无法创建抽象类的实例const b = new Base();
抽象类的作用是作为子类的基类,实现所有的抽象成员:
// 创建一个派生类实现抽象成员class Derived extends Base { getName() { return "world"; }}const d = new Derived();d.printName(); // Hello world
如果抽象类的派生类不实现它的抽象成员则会报错:
抽象构造签名
向上面这个例子,如果你想要写一个函数,能够接受所有抽象类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 ? number : string;// 正确const p: Person = new Employee();
空的类通常是其他任何东西的基类:
class Person { name: string = "A"; age: number = 1;}class Employee { salary: number = 99;}class N {}// type A = numbertype A = Person extends N ? number : string;// type B = numbertype B = Employee extends N ? number : string;function fn(x: N) {}// 以下调用均可fn(Person);fn(Employee);fn(window);fn({});fn(fn);
13、混入mixin
混入: 通过组合更简单的部分类来构建一个类,这称为混入,通俗来讲就是合并多个对象以得到一个高级的对象
在上一节中,我们提到过类使用extends
只能扩展一个类,即只能将两个类合并,但其实我们可以通过混入使多个类合并到一个类上(将其它多个类混入进一个基类中),我们先定义一个混入函数applyMixins
:
// 它可以存在于你代码库的任何地方function applyMixins(derivedCtor: any, constructors: any[]) { constructors.forEach((baseCtor) => { // Object.getOwnPropertyNames()方法返回一个由指定对象的所有自身属性的属性名(包括不可枚举属性但不包括 Symbol 值作为名称的属性)组成的数组。 Object.getOwnPropertyNames(baseCtor.prototype).forEach((name) => { // Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性,并返回此对象。 Object.defineProperty( derivedCtor.prototype, name, // Object.getOwnPropertyDescriptor() 方法返回指定对象上一个自有属性对应的属性描述符。 Object.getOwnPropertyDescriptor(baseCtor.prototype, name) || Object.create(null) // Object.create() 方法用于创建一个新对象,使用现有的对象来作为新创建对象的原型(prototype) ); }); });}
使用applyMixins
:
class Jumpable { jump() {}}class Duckable { duck() {}}// 基类class Sprite { x = 0; y = 0;}// 创建一个接口,将预期的混合函数与你的基础函数同名,合并在一起// 其目的是使最终的Sprite使用其它两个类上的属性时有代码提示且不报错interface Sprite extends Jumpable, Duckable {}// 在运行时,通过JS将混入应用到基类中applyMixins(Sprite, [Jumpable, Duckable]);let player = new Sprite();player.jump();player.duck();console.log(player.x, player.y);
这里我们将Jumpable
和Duckable
混入进了Sprite
中,上面如果不定义Sprite
接口会报错:
上面的例子只是一个实现混入的一种方式(编写混入函数),混入是一种技巧功能,可以由多种方式实现
九、模块
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
文件中定义模块(这类似于命名空间),需要使用module
关键字和模块的引用名称,这些名称将可用于以后的导入,例如:
// type.d.tsdeclare module "A" { export type a = string; // ...}declare module "B" { export type a = number; // ...}
上面我们在type.d.ts
声明文件中使用declare module
的形式定义了两个环境模块 A
和B
,使用时根据环境模块的名称(A
或B
)使用import
语句可直接将其导入:
import { a } from "A";let num: a = "1"; // type num =string
导入时同样支持别名等import
语法:
import * as moduleA from "A";let num: moduleA.a = "1"; // type num =string
外部文件只能引用环境模块declare module
内的类型声明!
速记环境模块
如果您不想在使用新模块之前花时间写出声明,或者你的新模块还没有任何类型声明,你可以使用速记环境模块来快速定义一个空内容模块:
// type.d.tsdeclare module "User";
所有来自速记模块的导入都将具有any
类型:
import a, { c } from "User";a(c);
速记模块就是空内容的环境模块,这其实没多大用处
5、TypeScript模块选项
模块解析选项
模块解析是指从import
或require
语句中获取一个字符串,并确定该字符串所指的文件的过程
TypeScript
包括两种解析策略:
Node
当编译器选项(tsconfig.json
配置文件)中 module
不是commonjs
时,经典策略是默认的,是为了向后兼容。
Node
策略复制了Node.js
在CommonJS
模式下的工作方式,对.ts
和.d.ts
有额外的检查
在TypeScript
中,有许多TSConfig
标志影响模块策略:
关于这些策略如何工作的全部细节,你可以参考《模块解析》
模块输出选项
tsconfig.json
配置文件中有两个选项影响JavaScript
的输出:
target
,它决定了TS
代码编译成JS
的版本module
,它决定了哪些代码用于模块之间的相互作用 所有模块之间的通信都是通过模块加载器进行的,编译器选项 module
决定了使用哪一个
在运行时,模块加载器负责在执行一个模块之前定位和执行该模块的所有依赖项
可以在TSConfig 模块参考 中看到所有可用的选项以及它们编译出的JavaScript
代码是什么样子
6、TypeScript命名空间
TypeScript
有自己的模块格式,称为 命名空间(namespaces)
,这比ES
模块标准要早
这种语法对于创建复杂的定义文件有很多有用的功能,并且在DefinitelyTyped
中仍然被积极使用。虽然没有被废弃,但命名空间中的大部分功能都存在于ES Modules
中,官方建议使用它来与JavaScript
的方向保持一致
更多关于命名空间的信息可见: 【TypeScript】深入学习TypeScript命名空间
十、枚举
1、数字型枚举
一个枚举可以用 enum
关键字来定义:
enum Direction { Up = 1, Down, Left, Right,}
上面就是一个数字枚举,其中 Up
被初始化为 1 ,所有下面的成员从这一点开始自动递增,即 Up
的值是 1 , Down
是 2 , Left
是3 , Right
是4
如果我们不使用初始化器,即不对Up
赋值,则Up
的值默认是0,之后的成员依旧开始递增
枚举的使用方式与对象类似:
enum Gender { male, female,}console.log(Gender.male, Gender.female); // 0 1
枚举可用作类型: 数字型枚举用作类型时能匹配任何数字,即想当于number
类型
// 对于数字型枚举,这个a变量只能保存male和female,或任何数字let a: Gender;a = Gender.male;a = Gender.female;a = 99;// a = "Ailjx"; // err :不能将类型“"Ailjx"”分配给类型“Gender”。
常量成员
每个枚举成员都有一个与之关联的值,可以是常量或计算值
在以下情况下,枚举成员被认为是常量:
没有初始化器(未初始化值)
如果是第一个成员就自动被赋值为0,如果不是第一个成员就自动被赋值为上一个值加1,无论如何都有一个确定的值,所以被认为是常量
// E1,E2,E3中的所有枚举成员都是常数enum E1 { X, Y, Z,}enum E2 { A = 1, B, C,}enum E3 {D,}
枚举成员用以下常量枚举表达式进行初始化:
枚举表达式的字面意思(基本上是一个字符串字面量或一个数字字面量)
enum E1 { x = "1"}enum E2 { x = 1 }
对先前定义的常量枚举成员的引用(可以来自不同的枚举)
enum E1 {x,}enum E2 {x = E1.x, y = E2.x,}
一个括号内的常量枚举表达式
应用于常量枚举表达式的 +
, -
, ~
一元运算符之一
enum E { x = -9,}
+
, -
, *
, /
, %
, <<
, >>
, >>
, &
, |
, ^
以常量枚举表达式为操作数的二元运算符
enum E1 {x = 1,}enum E2 {x = E1.x + 1,}
如果常量枚举表达式被评估为NaN
或Infinity
,这是一个编译时错误
计算成员
除了常量成员之外的就都是计算成员:
let a = 1;function fn() { return 1;}enum E { x = ++a, y = a, z = fn(), A = "Ailjx".length,}
枚举E
中的所有成员都是计算成员
成员顺序
数字枚举可以混合在计算成员和常量成员中,但没有初始化器的枚举成员要么需要放在第一位,要么必须在常量成员之后(因为只有在常量成员之后才会自增1):
let a = 1;enum E { x, y = a,}
这个例子中y
是计算成员,则x
只能放在y
的前边即第一位,不然会报错:
因为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、模块化空间
命名空间结合TypeScript模块化,可以将其抽离到一个单独的ts
文件内,变成模块化的空间:
// src/a.tsexport namespace A { export let s = 99;}
引入并使用命名空间:
// src/hello.tsimport { A } from "./a";console.log(A.s); // 99
5、空间别名
使用 import q = x.y.z
来为常用对象创建更短的名称:
namespace A { export namespace B { export class C { constructor() { console.log(999); } } }}import MyC = A.B.C;new MyC(); // 999 与new A.B.C()等价new A.B.C(); // 999
没想到import
语法还能这样用,虽然很奇怪,但这在一些场景下应该会很实用
从这里也可以看出命名空间是可以嵌套使用的
6、命名空间与模块
命名空间: 相当于内部模块,主要用于组织代码,避免命名冲突
命名空间是一种特定于 TypeScript
的代码组织方式,它只是全局命名空间中命名的 JavaScript
对象,这使得命名空间成为一个非常简单易用的构造
就像所有全局命名空间污染一样,使用它很难识别组件依赖关系,尤其是在大型应用程序中
模块: 外部模块的简称,侧重代码的复用,一个模块里能够包含多个命名空间
模块可以包含代码和声明,依赖于模块加载器(如CommonJs/Require.js
)或支持ES
模块的运行,模块提供了更好的代码重用,更强的隔离性和更好的捆绑工具支持
同样值得注意的是,对于Node.js
应用程序,模块是默认的,官方在现代代码中推荐模块而不是命名空间
从EC6
开始,模块是语言的原生部分,所有兼容的引擎实现都应该支持,因此,对于新项目,模块将是推荐的代码组织机制
十二、装饰器
1、装饰器
装饰器是一种特殊的声明,可以附加到类声明
、方法
、访问器
、属性
或参数
上
装饰器使用@expression
的形式,其中expression
必须是一个函数,该函数将在运行时被调用,并带有关于被装饰的声明的信息
例如,给定装饰器@Ailjx
,那么我们就可以编写以下函数:
// target接收被装饰的对象function Ailjx(target: any) {// 对 "target"做一些事情 ...}// 使用装饰器Ailjx@Ailjx// ....(被装饰器对象装饰的内容:类声明、方法、访问器、属性或参数)
注意: 装饰器必须写到被装饰内容的上面,中间不能隔行,以类装饰器为例:
function Ailjx(target: any) { // target接收的是类A的构造函数 const a = new target(); console.log(a); // A { a: 1 }}@Ailjx // 这行不能带分号;class A { a: number = 1;}
装饰器工厂
如果我们想自定义装饰器如何应用于声明,我们可以写一个装饰器工厂,装饰器工厂实际是一个高阶函数,它返回将在运行时被装饰器调用的表达式:
// value参数接收使用装饰器工厂时传递的参数function color(value: string) {// 这是装饰器工厂// 可以做一些操作...// 返回的装饰器函数return function (target: any) {// target依旧为被装饰的对象// 这就是装饰器// 用 "target" 和 "value"做一些事情...};}
使用装饰器工厂可以传参数:
@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();