👉 订阅专栏学习TS不迷路:TypeScript从入门到精通
🖥️ 博主的前端之路(猿创征文一等奖作品):前端之行,任重道远(来自大三学长的万字自述)
🏆分享博主自用牛客网:一个非常全面的面试刷题求职网站,前端开发者必备的刷题网站,真的超级好用🍬
知识目录
一、介绍
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
允许您指定函数的输入和输出值的类型。
- 参数类型注释
声明函数时,可以在每个参数后添加类型注解,以声明函数接受的参数类型。参数类型注释位于参数名称之后:
// 参数类型定义functiongetName(name:string){console.log("Hello, "+ name);}
这里指定了
getName
函数接收一个
string
类型的参数,当参数类型不匹配时将抛出错误:
即使您的参数上没有类型注释,
TypeScript
仍会检查您是否传递了正确数量的参数!
- 返回类型注释
返回类型注释出现在参数列表之后,其指定了函数返回值的类型:
functiongetName(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
就会抛出错误提醒你。
正确的做法:
functionObjFn(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 instanceofArray){
a.push("0");}
这样代码就没问题了,这时如果你push方法写错了,编译器就会报错提示你了:
虽然有些麻烦,但是相比
any
类型说,更加安全,在代码编译期间,就能帮我们发现由于类型造成的问题,因此在大多的场景,建议使用
unknown
类型替代
any
。
7、其它类型
void
void
表示不返回值的函数的返回值:
functionA(){}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
类型表示的是那些永不存在的值的类型:
- 可以表示总是抛出异常或根本不会有返回值的函数的返回值类型
functionerror(msg:string):never{thrownewError(msg);}// 推断出fail返回值类型为neverfunctionfail(){returnerror("Ailjx");}// A函数会造成死循环,根本不会有返回值,可以用never来表示返回值类型functionA():never{while(true){}}
- 被永不为真的类型保护所约束下的变量类型
functionSw(a:boolean){switch(a){casetrue:return a;casefalse:return a;default:// 这个分支永远不可能到达// 此时 _a类型为 neverconst _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、联合类型
- 定义联合类型
联合类型是由两个或多个其他类型组成的类型,表示可能是这些类型中的任何一种的值。我们将这些类型中的每一种称为联合类型的成员。
多个类型之间使用
|
分割:
functiongetId(id:string|number){console.log("id=", id);}getId(1);getId("1");
这个例子中
getId
接收的参数
id
可为
string
类型也可为
number
类型,当类型不匹配时依旧会抛出错误:
- 使用联合类型
在使用联合类型时需要注意的是:不能盲目将联合类型的数据当成单独类型的数据进行操作,不然
TypeScript
将抛出错误提醒你:
这里直接对联合类型
id
进行字符串上的
toUpperCase
操作,
TypeScript
会自动检测
id
联合类型的成员是否都具有
toUpperCase
属性,这里检测到联合类型的成员
number
类型并不具有
toUpperCase
属性,所以会抛出错误提示用户。
正确的做法是:
functiongetId(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
来声明类型:
typeId=number|string;// 在类型注释中直接使用类型别名functiongetId(id: Id){console.log("id=", id);}getId(1);getId("1");
在定义类型别名以及后面讲到的接口时,都建议将首字母进行大写,如上面例子中的
Id
- 类型别名也可以声明复杂的类型:
typePoint={
x:number;
y:number;};functionprintCoord(pt: Point){console.log("坐标x的值是: "+ pt.x);console.log("坐标y的值是: "+ pt.y);}printCoord({ x:100, y:100});
- 扩展类型(交叉类型)
类型别名可以使用交叉点
&
来扩展类型:
typeUser={
name:string;};typeAdmin= User &{
isAdmin:boolean;};const admin: Admin ={
name:"Ailjx",
isAdmin:true,};
这里
Admin
在
User
基础上扩展了
isAdmin
类型,当使用
Admin
并赋予的类型不匹配时将抛出错误:
注意这里报的错,在下面的接口与类型别名的区别中会详细分析这个报错。
梳理一下,之所以称其为类型别名,就是因为它只是用一个名称来指向一种类型,当用户需要使用该种类型时可直接使用该名称,方便复用,也方便将类型与业务代码抽离开来。
10、接口
一个接口声明
interface
是另一种方式来命名对象类型:
interfacePoint{
x:number;
y:number;}// 与前面的示例完全相同functionprintCoord(pt: Point){console.log("坐标x的值是: "+ pt.x);console.log("坐标y的值是: "+ pt.y);}printCoord({ x:100, y:100});
- 类型别名和接口之间的差异
类型别名和接口非常相似,在很多情况下你可以自由选择它们。几乎所有的功能都在
interface
中可用
type
,关键区别在于扩展新类型的方式不同:
前面提到类型别名是通过交叉点
&
来扩展类型,而接口的扩展是使用
extends
继承(这与
class
类的继承相似):
interfaceUser{
name:string;}interfaceAdminextendsUser{
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
也可以向现有的接口添加新字段:
interfaceMyWindow{
title:string;}interfaceMyWindow{
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";
就其本身而言,文字类型并不是很有价值,拥有一个只能有一个值的变量并没有多大用处!
但是通过将文字组合成联合,你可以表达一个更有用的概念——例如,只接受一组特定已知值的函数:
functionprintText(s:string, alignment:"left"|"right"|"center"){// ...}printText("Hello, world","left");printText("G'day, mate","centre");
alignment
只能被赋予
left
、
right
或
center
,这在组件库中非常常见!
数字文字类型的工作方式相同:
functioncompare(a:number, b:number):-1|0|1{return a === b ?0: a > b ?1:-1;}
当然,你可以将这些与非文字类型结合使用:
interfaceOptions{
width:number;}functionconfigure(x: Options |"auto"){// ...}configure({ width:100});configure("auto");configure("automatic");// 报错
还有一种文字类型:布尔文字。只有两种布尔文字类型,它们是类型
true
和
false
。类型
boolean
本身实际上只是联合类型
true
|
false
的别名。
- 文字推理
看这样一段代码:
functionhandleRequest(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",}asconst;
只将
method
转换成类型文字:
const req ={
url:"https://blog.csdn.net/m0_51969330?type=blog",
method:"GET"asconst,};
该
as const
后缀就像
const
定义,确保所有属性分配的文本类型,而不是一个更一般的
string
或
number
。
13、null和undefined
JavaScript
有两个原始值用于表示不存在或未初始化的值:
null
和
undefined
TypeScript
有两个对应的同名类型。这些类型的行为取决于您是否设置
tsconfig.json/strictNullChecks
选择。
strictNullChecks
表示在进行类型检查时,是否考虑“null”和“undefined”
strictNullChecks=false
时,下述代码不会报错:
functiondoSomething(x:string|null){console.log("Hello, "+ x.toUpperCase());}
strictNullChecks=true
时(strict= true时所有的严格类型检查选项都默认为true
),上述代码会报错:
避免报错正确的做法:
functiondoSomething(x:string|null){if(x ===null){// 做一些事}else{console.log("Hello, "+ x.toUpperCase());}}
- 非空断言运算符(
!
后缀)
!
在任何表达式之后写入实际上是一种类型断言,即确定该值不是
null
or
undefined
:
functionliveDangerously(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
出于类型保护的目的就会抛出错误,我们可以这样做:
functionpadLeft(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 类型守卫
functionprintAll(strs:string|string[]|null){if(typeof strs ==="object"){// strs被缩小为string[] | null类型for(const s of strs){console.log(s);}}elseif(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
中我们可以使用这些“空”值来做类型的真值缩小:
functionprintAll(strs:string|string[]|null){if(strs &&typeof strs ==="object"){// 增加判断str不是空的情况,即真值缩小// strs被缩小为string[]for(const s of strs){console.log(s);}}elseif(typeof strs ==="string"){// strs被缩小为string类型console.log(strs);}else{// 做点事}}
需要注意的是,这里真值缩小的语句不能放到最外边:
functionprintAll(strs:string|string[]|null){if(strs){// 不可以!!!if(typeof strs ==="object"){// ...}elseif(typeof strs ==="string"){// ...}else{// ... }}}
这种情况下我们可能不再正确处理空字符串的情况!
3、等值缩小
typescript
也可以使用分支语句做
===
,
!==
,
==
,和
!=
等值检查,来实现类型缩小。例如:
使用等值缩小解决上面真值缩小中可能不正确处理空字符串的情况:
functionprintAll(strs:string|string[]|null){if(strs !==null){// 正确地从strs 里移除null 。// ...}}
其它等值缩小的例子:
functionexample(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);}}functionmultiplyValue(container:number|null|undefined, factor:number){// 从类型中排除了undefined 和 nullif(container !=null){console.log(container);// 现在我们可以安全地乘以“container.value”了
container *= factor;}}
4、in 操作符缩小
JavaScript
中
in
操作符用于确定对象是否具有某个名称的属性,在
typescript
中可以使用它来根据类型对象中是否含有某一属性来进行类型缩小:
typeFish={swim:()=>void};typeBird={fly:()=>void};typeHuman={ swim?:()=>void; fly?:()=>void};functionmove(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
方法要求开发者明确地确认对象为某特定类型。
functionlogValue(x: Date |string){if(x instanceofDate){// x类型缩小为Dateconsole.log(x.toUTCString());}else{// x类型缩小为stringconsole.log(x.toUpperCase());}}logValue(newDate());// 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 | number
x =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
的第一个尝试。
interfaceShape{
kind:"circle"|"square";
radius?:number;
sideLength?:number;}
注意,这里使用的是字符串文字类型的联合。
"circle"
和
"square "
分别告诉我们应该把这个形状当作一个圆形还是方形。通过使用
"circle" | "square "
而不是
string
,可以避免拼写错误的问题。
编写一个
getArea
函数,根据它处理的是圆形还是方形来应用正确的逻辑。
我们首先尝试处理圆形:
functiongetArea(shape: Shape){if(shape.kind ==="circle"){return Math.PI* shape.radius!**2;}}
因为
radius
是可选属性,直接使用会报
对象可能为“未定义”
的,这里使用了非空断言运算符( ! 后缀) 来规避报错。
上面这种写法不是理想的,类型检查器没有办法根据种类属性知道
radius
或
sideLength
是否存在(使得我们不得不使用非空断言运算符(
!
后缀))。我们需要把我们知道的东西传达给类型检查器。考虑到这一点,让我们重新定义一下
Shape
:
interfaceCircle{
kind:"circle";
radius:number;}interfaceSquare{
kind:"square";
sideLength:number;}typeShape= Circle | Square;
在这里,我们正确地将
Shape
分成了两种类型,为
kind
属性设置了不同的值,但是
radius
和
sideLength
在它们各自的类型中被声明为必需的属性。
functiongetArea(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
,而不需要任何讨厌的非空断言
!后缀
:
functiongetArea(shape: Shape){switch(shape.kind){// shape: Circlecase"circle":return Math.PI* shape.radius **2;// shape: Squarecase"square":return shape.sideLength **2;}}
由此可见联合类型有时并不适用。
8、never 类型与穷尽性检查
在缩小范围时,你可以将一个联合体的选项减少到你已经删除了所有的可能性并且什么都不剩的程度。
在这些情况下,
TypeScript
将使用一个
never
类型来代表一个不应该存在的状态。
never
类型可以分配给每个类型;但是,没有任何类型可以分配给
never
(除了
never
本身)。这意味着你可以使用缩小并依靠
never
的出现在
switch
语句中做详尽的检查。
例如,在上面的
getArea
函数中添加一个默认值,试图将形状分配给
never
,当每个可能的情况都没有被处理时,就会触发:
functiongetArea(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错误:
interfaceCircle{
kind:"circle";
radius:number;}interfaceSquare{
kind:"square";
sideLength:number;}interfaceTriangle{
kind:"triangle";
sideLength:number;}typeShape= Circle | Square | Triangle;
9、控制流分析
到目前为止,我们已经通过一些基本示例来说明
TypeScript
如何在特定分支中缩小范围。但是除了从每个变量中走出来,并在
if
、
while
、
条件
等中寻找类型保护之外,还有更多事情要做。例如:
functionpadLeft(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
使用这种流分析来缩小类型,因为它遇到了类型保护和赋值。当一个变量被分析时,控制流可以一次又一次地分裂和重新合并,该变量可以被观察到在每个点上有不同的类型。
functionexample(){let x:string|number|boolean;
x = Math.random()<0.5;// x类型缩小为booleanconsole.log(x);if(Math.random()<0.5){
x ="hello";// x类型缩小为stringconsole.log(x);}else{
x =100;// x类型缩小为numberconsole.log(x);}// 注意:上面的if语句中至少有一个分支会执行,所以x不再可能是boolean类型// x类型缩小为string | numberreturn x;}let x =example();
x ="hello";
x =100;
x =true;// error:不能将类型“boolean”分配给类型“string | number”。
四、类型谓词
类型谓词是类型缩小的一种方式,之所以单独提出来讲,是因为它与上面我们熟知的
JavaScript
中本就含有的方式不同。
TypeScript
中的类型谓词在函数上工作,如果函数返回
true
,则将参数的类型缩小为类型谓词中限定的类型。
我们先定义一个判断变量为字符串的函数:
functionisStr(x:string|number){returntypeof x ==="string";}
当我们使用这个函数会发现它竟然起不到任何作用:
functiontoUpperCase(x:string|number){if(isStr(x)){// 报错,x 的类型依旧为string | number,未被缩小
x.toUpperCase();}}
这时就可以使用类型谓词,显式地告诉
TypeScript
,如果
isStr
的返回值为
true
,则形参的类型是一个字符串:
functionisStr(x:string|number): x isstring{returntypeof x ==="string";}functiontoUpperCase(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 isstring=>{// 一些操作returnisStr(item);});
使用类型谓词安全严格的实现一个掷筛子的程序见大佬文章:TypeScript 基础 — 类型谓词
五、对象
1、属性修改器
可选属性
在基本数据类型中,我们已经提到了对象的可选属性,在这里我们再深入去了解一下它:
interfacePaintOptions{
x?:number;
y?:number;}
使用接口定义了一个对象类型,其中的属性都为可选属性,不能够直接使用可选属性,需要先对其进行判空操作:
functionObjFn(obj: PaintOptions){if(obj.x && obj.y){// 对可选属性进行存在性判断console.log(obj.x + obj.y);}}
其实这不是唯一的方式,我们也可以对可选属性设置个默认值,当该属性不存在时,使用我们设置的默认值即可,看下面这个例子:
functionObjFn({ 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
修饰符可以定义只读属性:
interfaceNameType{readonly name:string;// 只读属性}functiongetName(obj: NameType){// 可以读取 'obj.name'.console.log(obj.name);// 但不能重新设置值
obj.name ="Ailjx";}
readonly
修饰符只能限制一个属性本身不能被重新写入,对于复杂类型的属性,其内部依旧可以改变:
interfaceInfo{readonly friend:string[];readonly parent:{ father:string; mother:string};}functiongetInfo(obj: Info){// 正常运行
obj.friend[0]="one";
obj.parent.father ="MyFather";// 报错
obj.friend =["one"];
obj.parent ={ father:"MyFather", mother:"MyMother"};}
TypeScript
在检查两个类型的属性是否兼容时,并不考虑这些类型的属性是
否是
readonly
,所以
readony
属性也可以通过别名来改变:
interfacePerson{
name:string;
age:number;}interfaceReadonlyPerson{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
而言,只读属性不会在运行时改变任何行为,但在类型检查期间,一个标记为只读的属性不能被写入。
索引签名
在一些情况下,我们可能不知道对象内所有属性的名称,那属性名称都不知道,我们该怎么去定义这个对象的类型呢?
这时我们可以使用一个索引签名来描述可能的值的类型:
interfaceIObj{[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
类型的:
interfaceIObj{[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
,称之为数字索引器和字符串索引器,支持两种类型的索引器是可能的,但是从数字索引器返回的类型必须是字符串索引器返回的类型的子类型(这一点特别重要!),如:
interfaceAnimal{
name:string;}interfaceDogextendsAnimal{
breed:string;}interfaceIObj{[index:number]: Dog;[index:string]: Animal;}
- 从上面的代码中可以知道的是
Dog
是Animal
的子类,所以上述代码是可选的,如果换一下顺序就不行了:
字符串索引签名强制要求所有的属性与它的返回类型相匹配。
- 在下面的例子中,
name
的类型与字符串索引的类型不匹配,类型检查器会给出一个错误:> 数字索引签名没有该限制
然而,如果索引签名是属性类型的联合,不同类型的属性是可以接受的:
interfaceIObj{[index:string]:number|string;
length:number;// ok
name:string;// ok}
索引签名也可以设置为只读:
2、扩展类型
在数据类型的接口中我们简单介绍过扩展类型,在这里再详细讲一下:
interfaceUser{
name:string;
age:number;}interfaceAdmin{
isAdmin:true;
name:string;
age:number;}
这里声明了两个类型接口,但仔细发现它们其实是相关的(
Admin
是
User
的一种),并且它们之间重复了一些属性,这时就可以使用
extends
扩展:
interfaceUser{
name:string;
age:number;}interfaceAdminextendsUser{
isAdmin:true;}
接口上的
extends
关键字,允许我们有效地从其他命名的类型中复制成员,并添加我们想要的任何新成员。
这对于减少我们不得不写的类型声明模板,以及表明同一属性的几个不同声明可能是相关的意图来说,是非常有用的。例如,
Admin
不需要重复
name
和
age
属性,而且因为
name
和
age
源于
User
,我们会知道这两种类型在某种程度上是相关的。
接口也可以从多个类型中扩展:
interfaceUser{
name:string;}interfaceAge{
age:number;}interfaceAdminextendsUser, Age {
isAdmin:true;}
多个父类使用
,
分割
3、交叉类型
在数据类型的类型别名中我们已经介绍过交叉类型
&
,这里就不再过多的说了:
interfaceColorful{
color:string;}interfaceCircle{
radius:number;}typeColorfulCircle= Colorful & Circle;const cc: ColorfulCircle ={
color:"red",
radius:42,};
4、泛型对象类型
如果我们有一个盒子类型,它的内容可以为字符串,数字,布尔值,数组,对象等等等等,那我们去定义它呢?这样吗:
interfaceBox{
contents:any;}
现在,内容属性的类型是任意,这很有效,但我们知道
any
会导致
TypeScript
失去编译时的类型检查,这显然是不妥的
我们可以使用
unknown
,但这意味着在我们已经知道内容类型的情况下,我们需要做预防性检查,或者使用容易出错的类型断言:
interfaceBox{
contents:unknown;}let x: Box ={
contents:"hello world",};// 我们需要检查 'x.contents'if(typeof x.contents ==="string"){console.log(x.contents.toLowerCase());}// 或者用类型断言console.log((x.contents asstring).toLowerCase());
这显得复杂了一些,并且也不能保证
TypeScript
能够追踪到
contents
具体的类型
针对这种需求,我们就可以使用泛型对象类型,做一个通用的
Box
类型,声明一个类型参数:
// 这里的Type是自定义的interfaceBox<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
可以追踪到它具体的类型。
- 复杂一点的应用:使用泛型对象类型实现通用函数
interfaceBox<Type>{
contents: Type;}functionsetContents<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>
:
typeBox<Type>={
contents: Type;};
由于类型别名与接口不同,它不仅可以描述对象类型,我们还可以用它来编写其他类型的通用辅助类型:
typeOrNull<Type>= Type |null;typeOneOrMany<Type>= Type | Type[];typeOneOrManyOrNull<Type>= OrNull<OneOrMany<Type>>;typeOneOrManyOrNullStrings= OneOrManyOrNull<string>;
上面的例子中嵌套使用了类型别名,多思考一下不难看懂的
通用对象类型通常是某种容器类型,它的工作与它们所包含的元素类型无关。数据结构以这种方式工作是很理想的,这样它们就可以在不同的数据类型中重复使用。
5、数组类型
和上面的
Box
类型一样,
Array
本身也是一个通用类型,
number[]
或
string[] 这
实际上只是
Array<number>
和
Array<string>
的缩写。
Array
泛型对象的部分源码:
interfaceArray<Type>{/**
* 获取或设置数组的长度。
*/
length:number;/**
* 移除数组中的最后一个元素并返回。
*/pop(): Type |undefined;/**
* 向一个数组添加新元素,并返回数组的新长度。
*/push(...items: Type[]):number;// ...}
现代
JavaScript
还提供了其他通用的数据结构,比如
Map<K, V>
,
Set<T>
, 和
Promise<T>
。这实际上意味着,由于
Map
、
Set
和
Promise
的行为方式,它们可以与任何类型的集合一起工作。
6、只读数组类型
ReadonlyArray
是一个特殊的类型,描述了不应该被改变的数组。
functiondoStuff(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
类型,它确切地知道包含多少个元素,以及它在特定位置包含哪些类型。
typeMyType=[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”的参数
对元组进行解构:
functionfn(a:[string,number]){const[str, num]= a;console.log(str);// type str=stringconsole.log(num);// type num=number}
- 这里需要注意的是我们解构出的数据是一个常数,不能被修改:
functionfn(a:[string,number]){const[str, num]= a;console.log(a[1]++);// okconsole.log(num++);// err:无法分配到 "num" ,因为它是常数}
可选的元组
元组可以通过在元素的类型后面加上
?
使其变成可选的,它只能出现在数组末尾,而且还能影响到数组长度。
typeMyArr=[number,number,number?];functiongetLength(arr: MyArr){const[x, y, z]= arr;// z的类型为number|undefinedconsole.log(`数组长度:${arr.length}`);}getLength([3,4]);//数组长度:2getLength([3,4,5]);// 数组长度:3getLength([3,4,"5"]);// err:不能将类型“string”分配给类型“number”。
其余元素
元组也可以有其余元素,这些元素必须是
array/tuple
类型:
typeArr1=[string,number,...boolean[]];typeArr2=[string,...boolean[],number];typeArr3=[...boolean[],string,number];const a: Arr1 =["Ailjx",3,true,false,true,false,true];
Arr1
描述了一个元组,其前两个元素分别是字符串和数字,但后面可以有任意数量的布尔。Arr2
描述了一个元组,其第一个元素是字符串,然后是任意数量的布尔运算,最后是一个数字。Arr3
描述了一个元组,其起始元素是任意数量的布尔运算,最后是一个字符串,然后是一个数字。
应用
functionfn(...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);
几乎等同于:
functionfn(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
:
// 类型别名方式typeFnType=(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
对象内使用函数类型
interfaceObj{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
中,函数除了可调用之外,还可以具有属性,如:
functionfn(){return99}
fn.age =1// 在函数中写入属性age
console.log(fn.age,fn());// 1 99
然而,函数类型表达式的语法不允许声明属性,如果想声明函数的属性的类型,可以在一个对象类型中写一个调用签名:
typeFnType={
age:number;(param:number):number;};functiongetFnAge(fn: FnType){console.log(fn.age,fn(99));}functionfn(a:number){return a;}
fn.age =18;getFnAge(fn);// 18 99
注意:与函数类型表达式相比,语法略有不同:在参数列表和返回类型之间使用
:
而不是
=>
FnType
也可以使用接口声明:
interfaceFnType{
age:number;(param:number):number;}
3、构造签名
在
JavaScript
中存在一种使用new操作符调用的构造函数:
// Fn就是一个构造函数// ES5写法// function Fn(age) {// this.age = age// }// ES6可以这么写classFn{// 添加构造函数(构造器)constructor(age){this.age = age
}}const f =newFn(18)
console.log(f.age);// 18
用
new
关键字来调用的函数,都称为构造函数,构造函数首字母一般大写,其作用是在创建对象的时候用来初始化对象,就是给对象成员赋初始值
ES6
的
class
为构造函数的语法糖,即
class
的本质是构造函数。
class
的继承
extends
本质为构造函数的原型链的继承。
在
TypeScript
中可以通过在调用签名前面添加
new
关键字来写一个构造签名:
classFn{
age:number;constructor(age:number){this.age = age;}}// 可以使用接口这么写// interface FnType {// new (param: number): Fn; // 构造签名// }typeFnType=new(param:number)=> Fn;functiongetFnAge(fn: FnType){const f =newfn(18);// f类型为Fnconsole.log(f.age);// 18}getFnAge(Fn);
类型
FnType
代表的是一个实例类型为
Fn
(或包含
Fn
)的构造函数,即
class
类
Fn
或其子类:
- 实例:即
new
出的结果,如上面的f
- 构造签名中的返回值类型为类名
- 从这里可以看出
class
类可以直接作为类型使用
有些对象,如
JavaScript
的
Date
对象,可以在有
new
或没有
new
的情况下被调用。你可以在同一类型中任意地结合调用和构造签名:
interfaceCallOrConstruct{new(s:string): Date;():string;}functionfn(date: CallOrConstruct){let d =newdate("2022-7-28");console.log(d);// 2022-07-27T16:00:00.000Zlet n =date();console.log(n);// Thu Jul 28 2022 15:25:08 GMT+0800 (中国标准时间)}fn(Date);
4、泛型函数(通用函数)
在
TypeScript
中,当我们想描述两个值之间的对应关系时,会使用泛型
泛型就是把两个或多个具有相同类型的值联系起来
在对象类型详解中我们提到了使用泛型对象类型实现通用函数,这其实就是泛型函数的使用,这里再看一个简单的例子:
在写一个函数时,输入的类型与输出的类型有关,或者两个输入的类型以某种方式相关,这是常见的。让我们考虑一下一个返回数组中第一个元素的函数:
functiongetFirstElement(arr:any[]){return arr[0];}
这个函数完成了它的工作,但不幸的是它的返回类型是
any
,如果该函数能够返回具体的类型会更好, 通过在函数签名中声明一个类型参数来做到这一点:
// 在函数签名中声明一个类型参数functiongetFirstElement<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
自动推断并选择出来的
我们也可以使用多个类型参数:
// 实现一个独立版本的mapfunctionmap<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
可以自动推断出通用函数(泛型函数)调用中的类型参数,但这并不适用于所有情景,例如:
functioncombine<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
将类型参数限制在这个类型上:
functiongetLong<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
依旧能够推断出返回值类型
编写规范
- 类型参数下推> 规则: 可能的情况下,使用类型参数本身,而不是对其进行约束
// 推荐✅✅✅functionfirstElement1<Type>(arr: Type[]){return arr[0];}// a类型为number const a =firstElement1([1,2,3]);// 不推荐❌❌❌functionfirstElement2<Type extendsany[]>(arr: Type){return arr[0];}// b类型为any const b =firstElement2([1,2,3]);
- 使用更少的类型参数> 规则: 总是尽可能少地使用类型参数
// 推荐✅✅✅functionfilter1<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);
- 类型参数应出现两次> 规则: 如果一个类型的参数只出现在一个地方,请重新考虑你是否真的需要它
// 推荐✅✅✅functiongreet(s:string){console.log("Hello, "+ s);}// 不推荐❌❌❌functiongreet<Str extendsstring>(s: Str){console.log("Hello, "+ s);}
5、可选参数
在博主TypeScript专栏的前几篇文章中我们多次提到过可选属性,这里就不过多叙述了,直接放代码:
// n为可选参数,它的类型为number|undefinedfunctionfn(n?:number){if(n){// 操作可选参数之前一定要先判断其是否存在console.log(n +1);return}console.log("未传参数");}fn();// '未传参数'fn(1);// 2// 当一个参数是可选的,调用者总是可以传递未定义的参数,因为这只是模拟一个 "丢失 "的参数fn(undefined);// '未传参数' (与fn()效果相同)
也可以使用默认值:
functionfn(n:number=1){if(n){console.log(n +1);return;}console.log("未传参数");}fn();// 2fn(1);// 2// 当一个参数是可选的,调用者总是可以传递未定义的参数,因为这只是模拟一个 "丢失 "的参数fn(undefined);// 2 (与fn()效果相同)
6、函数重载
有时我们需要以不同的方式(传递数量不同的参数)来调用函数,但是我们调用的方式是有限的,这时如果是使用可选参数就会出现问题
例如我们希望一个函数只能接收一个参数或三个参数,不能接收其它数量的参数,我们尝试使用可选参数来实现:
functionfn(a:number, b?:number, c?:number){}fn(1);fn(1,2,3);fn(1,2);// 并不会报错
可以看到我们可以给函数传递两个参数,这显然不符合我们的需求,这种情况下我们就可以通过编写重载签名来指定调用函数的不同方式:
// 重载签名functionfn(a:number):void;// 接收一个参数的签名functionfn(a:number, b:number, c:number):void;// 接收三个参数的签名// 实现签名(函数主体)functionfn(a:number, b?:number, c?:number){}
这里有几种重载签名,函数就有几种方式调用
可以看到这完美实现了我们的需求!
上述使用重载签名与实现签名共同组合定义的函数
fn
就是一个重载函数,接下来我们深入探讨重载签名与实现签名:
重载签名与实现签名
实现签名就是函数的主体,一个普通的函数,这里就不多说了
重载签名格式:
function FnName(param: Type): returnType
FnName
:函数的名称,必须与实现签名(即函数的主体)的名称相同- 其余部分与函数类型表达式大致相同:
Type
为参数param
的类型,returnType
为函数返回值类型
注意事项:
- 重载签名必须要在实现签名的上边:
- 调用重载函数所传的参数数量必须是定义的重载签名的一种,即使函数主体没有声明形参:
- 重载签名必须与实现签名兼容:
编写规范
- 当重载签名有相同的参数数量时,不推荐使用重载函数
如我们编写一个返回字符串或数组长度的重载函数:
functionfn(x:string):number;functionfn(x:any[]):number;functionfn(x:string|any[]){return x.length;}
这个函数是好的,我们可以用字符串或数组来调用它。
然而,我们不能用一个即可能是字符串又可能是数组的值来调用它,因为
TypeScript
只能将一个函数调用解析为一个重载:
这里两个重载签名具有相同的参数数量和返回类型,我们完全可以改写一个非重载版本的函数:
functionfn(x:string|any[]){return x.length;}fn("Ailjx");fn([1,2]);// 不会再报错fn(Math.random()>0.5?"Ailjx":[1,2]);
这样即避免了报错,又使代码变得更加简洁,这时你就会发现那两行重载签名是多么的没用,所以在可能的情况下,总是倾向于使用联合类型的参数而不是重载参数
在函数中声明this
TypeScript
将通过代码流分析推断
this
在函数中应该是什么,例如:
interfaceUser{
name:string;setName:(newName:string)=>void;}const user: User ={
name:"Ailjx",setName:function(newName:string){this.name = newName;},};
一般情况下这已经足够了,但是在一些情况下,您可能需要更多地控制
this
对象代表的内容
JavaScript
规范声明你不能有一个名为
this
的参数,因此
TypeScript
使用该语法空间让你能够在函数体中声明
this
的类型:
interfaceUser{
name:string;setName:(newName:string)=>void;}const user: User ={
name:"Ailjx",// 手动声明this的类型为UsersetName: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
参数出现在所有其他参数之后,并使用
...
的语法:
functionmultiply(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]asconst;const angle = Math.atan2(...args);
8、参数解构
对于这样的函数:
typeNum={ a:number; b:number; c:number}functionsum(num: Num){console.log(num.a + num.b + num.c);}
可以使用解构语法:
typeNum={ a:number; b:number; c:number}functionsum({ a, b, c }: Num){console.log(a + b + c);}
9、函数的可分配性
一个具有
void
返回类型的上下文函数类型(
() => void
),在实现时,可以返回任何其他的值,但这些返回值的类型依旧是
void
:
typevoidFunc=()=>void;const f1:voidFunc=()=>{return1;};const f2:voidFunc=()=>2;const f3:voidFunc=function(){return3;};// 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
先看一个我们之前定义过的一个通用函数:
functiongetFirstElement<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]));
这时可以将对象字面类型移到一个接口中:
interfaceIfn{<FnType>(fnArr: FnType[]): FnType |undefined;}let fn: Ifn = getFirstElement;console.log(fn<number>([1,2,3]));
- 在一些情况下,我们还可以将通用参数移到整个接口的参数上,这使得我们可以看到我们的泛型是什么类型(例如
Ifn<string>
而不仅仅是Ifn
),使得类型参数对接口的所有其它成员可见:interfaceIfn<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>
:interfaceIfn{<FnType>(fnArr: FnType[]): FnType |undefined;}let fn: Ifn = getFirstElement;console.log(fn<string>(["1","2","3"]));
泛型类
泛型类在类的名字后面有一个角括号(
<>
)中的泛型参数列表:
classAdd<AddType>{
initVal: AddType|undefined;
add:((x: AddType, y: AddType)=> AddType)|undefined;}
使用:
let myNumber =newAdd<number>();
myNumber.initVal =1;
myNumber.add=function(x, y){return x + y;};console.log(myNumber.add(myNumber.initVal,18));// 19
let myStr =newAdd<string>();
myStr.initVal ="Ailjx";
myStr.add=function(x, y){return x + y;};console.log(myStr.add(myStr.initVal," OK"));// Ailjx OK
就像接口一样,把类型参数放在类本身,可以让我们确保类的所有属性都与相同的类型一起工作。
注意:一个类的类型有两个方面:静态方面和实例方面。通用类只在其实例侧而非静态侧具有通用性,所以在使用类时,静态成员不能使用类的类型参数。
泛型约束
在函数类型详解的泛型函数(通用函数) 中我们已经了解过了使用
extends
约束泛型,这一节我们继续深入
在泛型约束中使用类型参数
你可以声明一个受另一个类型参数约束的类型参数。
例如,我们想从一个给定名称的对象中获取一个属性。我们想确保我们不会意外地获取一个不存在于
obj
上的属性,所以我们要在这两种类型之间放置一个约束条件:
functiongetProperty<Type, Key extendskeyof Type>(obj: Type, key: Key){return obj[key];}
keyof
运算符接收一个对象类型,并产生其键的字符串或数字字面联合,下面会详细讲解
在泛型中使用类类型
在
TypeScript
中使用泛型创建工厂时,有必要通过其构造函数来引用类的类型,比如说:
functioncreate<Type>(c:new()=> Type): Type {returnnewc();}
create
函数代表接收一个构造函数,并返回其实例
参数
c
的类型使用的是构造签名,表示其接收一个构造函数,并且该构造函数实例的类型(
Type
)被当作了
create
函数的类型参数并在其它地方进行使用,如
create
的返回值类型就是引用了
Type
一个更高级的例子,使用原型属性来推断和约束类类型的构造函数和实例方之间的关系:
classAnimal{
numLegs:number=4;}classBeeextendsAnimal{
name:string="Bee";getName(){console.log(this.name);}}classLionextendsAnimal{
name:string="Lion";getName(){console.log(this.name);}}functioncreateInstance<Aextends Animal>(c:new()=>A):A{returnnewc();}createInstance(Bee).getName();// BeecreateInstance(Lion).getName();// Lion
这里的
createInstance
函数表示只能接收一个实例类型受限于
Animal
的构造函数,并返回其实例
2、keyof类型操作符
keyof
运算符接收一个对象类型,并产生其键的字符串或数字字面联合:
typeObjType={ x:number; y:number};const p1:keyof ObjType ="x";// 相当于// const p1: "x" | "y" = "x";
如果该类型有一个字符串或数字索引签名,
keyof
将返回这些类型:
typeArrayish={[n:number]:unknown};typeA=keyof Arrayish;// A为 numberconst a:A=1;typeMapish={[k:string]:boolean};typeM=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类型为string
n ="world";
n =100;// err:不能将类型“number”分配给类型“string”
结合其他类型操作符,你可以使用
typeof
来方便地表达许多模式。
例如我们想要获取函数返回值的类型:
TypeScript
中内置的类型ReturnType<T>
接收一个函数类型并产生其返回类型:typePredicate=(x:unknown)=>boolean;typeK= ReturnType<Predicate>;// k为boolean
- 如果直接在一个函数名上使用
ReturnType
,我们会看到一个指示性的错误: 为了指代值f
的类型,我们使用typeof
:functionf(){return{ x:10, y:3};}typeP= ReturnType<typeof f>;// P为{ x: number, y: number }
只有在标识符(即变量名)或其属性上使用
typeof
是合法的
4、索引访问类型
可以使用一个索引访问类型来查询一个类型上的特定属性的类型:
typePerson={ age:number; name:string; alive:boolean};typeAge= Person["age"];// Age类型为number
还可以配合联合类型
unions
、
keyof
或者其他类型进行使用:
interfacePerson{
name:string;
age:number;
alive:boolean;}// type I1 = string | numbertypeI1= Person["age"|"name"];const i11:I1=100;const i12:I1="";// type I2 = string | number | booleantypeI2= 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;
} */typePerson=typeof MyArray[number];const p: Person ={
name:"xiaoqian",
age:11,};// type Age = numbertypeAge=typeof MyArray[number]["age"];const age: Age =11;// 或者// type Age2 = numbertypeAge2= Person["age"];const age2: Age2 =11;
注意:
- 在索引时只能使用类型引用,不能使用变量引用:
- 可以使用类型别名来实现类似风格的重构:
typekey="age";typeAge= Person[key];
5、条件类型
在
TypeScript
我们可以使用三元表达式来判断一个类型:
interfaceAnimal{}interfaceDogextendsAnimal{}// type Example1 = numbertypeExample1= Dog extendsAnimal?number:string;// type Example2 = stringtypeExample2= RegExp extendsAnimal?number:string;
条件类型表达式是通过
extends
进行约束和判断
配合泛型使用
先看一个简单的例子:
typeFlatten<T>=Textendsany[]?T[number]:T;// 提取出元素类型。// type Str = stringtypeStr= Flatten<string[]>;// 单独一个类型。// type Num = numbertypeNum= Flatten<number>;
当
Flatten
被赋予一个数组类型时,它使用一个带有数字的索引访问来获取数组的元素类型。否则,它只是返回它被赋予的类型。
在看一个复杂的例子,实现一个获取
id
或
name
的对象格式的函数
getIdOrNameObj
:
interfaceIId{
id:number;}interfaceIName{
name:string;}// 条件类型配合泛型对类型进行判断和选择typeIdOrName<Textendsnumber|string>=Textendsnumber? IId : IName;functiongetIdOrNameObj<Textendsnumber|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`:
```typescript
typeFlatten<T>=TextendsArray<infer Item>? Item :T;// type Str = stringtypeStr= Flatten<string[]>;// type Str = numbertypeNum= Flatten<number[]>;
这里使用
infer
关键字来声明性地引入一个名为
Item
的新的通用类型变量
这里
infer Item
相当于一个占位,它暂时代表
Array
中元素的类型,当
Flatten
类型参数被赋值为数组后,
TypeScript
就会自动推断出
extends
语句中
Array
中元素的类型,这时
infer Item
这个占位就指向了数组元素的类型,之后就能直接使用
Item
来代指数组元素的类型了
这使得我们不用再使用索引访问类型
T[number]
"手动 "提取数组元素的类型了
使用
infer
关键字从函数类型中提取出返回类型:
// 当GetReturnType接收类型为函数签名时返回函数返回值类型,否者直接返回接收的类型typeGetReturnType<Type>= Type extends(...args:never[])=>infer Return
? Return
: Type;// type Num = numbertypeNum= GetReturnType<()=>number>;// type Str = stringtypeStr= GetReturnType<(x:string)=>string>;// type Bools = boolean[]typeBools= GetReturnType<(a:boolean, b:boolean)=>boolean[]>;// type Arr=any[]typeArr= GetReturnType<any[]>;
当从一个具有多个调用签名的类型(如重载函数的类型)进行推断时,从最后一个签名进行推断:
declarefunctionstringOrNum(x:string):number;declarefunctionstringOrNum(x:number):string;declarefunctionstringOrNum(x:string|number):string|number;// type T1 = string | numbertypeT1= ReturnType<typeof stringOrNum>;
declare
可以向
TypeScript
域中引入一个变量,这可以解决在重载函数只有重载签名而没有实现签名时的报错
分布式条件类型
当条件类型作用于一个通用类型时,当给定一个联合类型时,它就变成了分布式的:
typeToArray<Type>= Type extendsany? Type[]:never;// type StrArrOrNumArr = string[] | number[]typeStrArrOrNumArr= ToArray<string|number>;
将一个联合类型
string | number
插入
ToArray
,那么条件类型将被应用于该联合的每个成员
StrArrOrNumArr
分布在string | number
;- 条件类型会对联合的每个成员类型进行映射:
ToArray<string> | ToArray<number>
- 最终返回
string[] | number[]
取消分布式:
如果不需要分布式的这种行为,我们可以使用方括号
[]
包围
extends
关键字的两边
typeToArray<Type>=[Type]extends[any]? Type[]:never;// type StrArrOrNumArr = (string|number)[]typeStrArrOrNumArr= ToArray<string|number>;
6、映射类型
当一个类型可以以另一个类型为基础创建新类型。
映射类型建立在索引签名的语法上。
映射类型是一种通用类型,它使用
PropertyKeys
的联合(经常通过
keyof
创建)迭代键来创建一个类型:
typeOptionsFlags<Type>={[Property inkeyof Type]:boolean;};
在这个例子中,
OptionsFlags
将从
Type
类型中获取所有属性,并将它们的值的类型改为
boolean
:
typeObj={
name:string;
age:number;};typeFeatureOptions= OptionsFlags<Obj>;/*
type FeatureOptions = {
name: boolean;
age: boolean;
}
*/
映射修改器
在映射过程中,有两个额外的修饰符可以应用:
readonly
和
?
,它们分别影响可变性和可选性,可以通过用
-
或
+
作为前缀来删除或添加这些修饰符(不加修饰符就默认是
+
):
typeOptionsFlags<Type>={// 删除readonly和?,readonly在前,?在后-readonly[Property inkeyof Type]-?:boolean;};typeObj={readonly name:string;
age?:number;};typeFeatureOptions= OptionsFlags<Obj>;/*
type FeatureOptions = {
name: boolean;
age: boolean;
}
*/
通过as做key重映射
在
TypeScript 4.1
及以后的版本中,可以通过映射类型中的
as
子句修改映射类型中的键名:
typeOptionsFlags<Type>={// 将键重命名为哦、[Property inkeyof Type as"o"]: Type[Property];};typeObj={
name:string;
age:number;};typeFeatureOptions= OptionsFlags<Obj>;/*
type FeatureOptions = {
o:string|number
}
*/
上面是将所有键名都更改成了
'o'
我们也可以利用模板字面类型,在之前的属性名称的基础上进行更改:
typeGetters<Type>={[Property inkeyof Type as`get${Capitalize<string& Property>}`]:()=> Type[Property];};interfacePerson{
name:string;
age:number;
location:string;}/*
type LazyPerson = {
getName: () => string;
getAge: () => number;
getLocation: () => string;
}
*/typeLazyPerson= Getters<Person>;
Capitalize
为TS
内置类型,能将传入的字符串类型首字母转为大写string & Property
通过交叉类型,确保Capitalize
接收的为字符串类型
**可以通过条件类型
Exclude
根据键名产生
never
,从而过滤掉该键:**
typeRemoveKindField<Type>={[Property inkeyof Type as Exclude<Property,"kind">]: Type[Property];};interfaceCircle{
kind:"circle";
radius:number;}/*
type KindlessCircle = {
radius: number;
}
*/typeKindlessCircle= RemoveKindField<Circle>;
Exclude
为
TS
内置类型:
type Exclude<T, U> = T extends U ? never : T
可以映射任意的联合体:
typeEventConfig<Events extends{ kind:string}>={[Ein Events asE["kind"]]:(event:E)=>void;};typeSquareEvent={ kind:"square"; x:number; y:number};typeCircleEvent={ kind:"circle"; radius:number};/*
type Config = {
square: (event: SquareEvent) => void;
circle: (event: CircleEvent) => void;
}
*/typeConfig= EventConfig<SquareEvent | CircleEvent>;
进一步探索
映射类型与本篇文章中指出的其他功能配合得很好,例如,下面这个使用条件类型的映射类型,它根据一个对象类型的属性
show
是否被设置为字面类型
true
而返回
true
或
false
:
typeExtractShow<Type>={[Property inkeyof Type]: Type[Property]extends{ show:true}?true:false;};typePermissionInfo={
home:{ url:string; show:true};
about:{ url:string; show:true};
admin:{ url:string};};/*
type judge = {
home: true;
about: true;
admin: false;
}
*/typejudge= ExtractShow<PermissionInfo>;
八、类
1、类成员
类属性
在一个类上声明字段,创建一个公共的可写属性:
classPoint{
x:number;
y:number;}const pt =newPoint();
pt.x =0;
pt.y =0;
表示在类
Point
上声明了类型为
number
的两个属性,这时编译器可能会报错:
这由
tsconfig.json
下的
strictPropertyInitialization
字段控制:
strictPropertyInitialization
控制类字段是否需要在构造函数中初始化- 将其设为
false
可关闭该报错,但这是不提倡的
我们应该在声明属性时明确对其设置初始化器,这些初始化器将在类被实例化时自动运行:
classPoint{
x:number=0;
y:number=0;}const pt =newPoint();// Prints 0, 0console.log(`${pt.x}, ${pt.y}`);
或:
classPoint{
x:number;
y:number;constructor(){this.x =0;this.y =0;}}const pt =newPoint();// Prints 0, 0console.log(`${pt.x}, ${pt.y}`);
类中的类型注解是可选的,如果不指定,将会是一个隐含的
any
类型,但
TypeScript
会根据其初始化值来推断其类型:
如果你打算通过构造函数以外的方式来初始化一个字段,为了避免报错,你可以使用以下方法:
- 确定的赋值断言操作符
!
- 使用可选属性
?
- 明确添加未定义属性(与可选属性原理相同)
classPoint{// 没有初始化,但没报错
x!:number;// 赋值断言!
y?:number;// 可选属性?
z:number|undefined;// 添加未定义类型}const pt =newPoint();console.log(pt.x, pt.y, pt.z);// undefined undefined undefined
pt.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
与函数相似,可以添加带有类型注释的参数,默认值和重载:
classPoint{
x:number;
y:number;// 带类型注释和默认值的正常签名constructor(x:number=1, y:number=2){this.x = x;this.y = y;}}
classPoint{
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()
:
classBase{
k =4;}classDerivedextendsBase{constructor(){super();console.log(this.k);}}
方法
类上的函数属性称为方法,可以使用与函数和构造函数相同的所有类型注释:
classPoint{
x =10;
y =10;scale(n:number):void{this.x *= n;this.y *= n;}}
除了标准的类型注解,
TypeScript
并没有为方法添加其他新的东西。
注意: 在一个方法体中,仍然必须通过
this
访问字段和其他方法。方法体中的非限定名称将总是指代包围范围内的东西:
let x:number=0;classPoint{
x =10;scale(n:number):void{
x *= n;// 这是在修改第一行的x变量,不是类属性}}
Getters/Setters
使用
Getters/Setters
的规范写法:
classPoint{
_x =0;getx(){console.log("get");returnthis._x;}setx(value:number){console.log("set");this._x = value;}}let a =newPoint();// 调用了set
a.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
开始,可以有不同类型的访问器用于获取和设置:
classThing{
_size =0;getsize():number{returnthis._size;}setsize(value:string|number|boolean){// 可以有不同类型的let num =Number(value);// 不允许NaN、Infinity等if(!Number.isFinite(num)){this._size =0;return;}this._size = num;}}
索引签名
类也可以像其它对象类型一样使用索引签名,它们的索引签名的作用相同:
classMyClass{[s:string]:boolean|((s:string)=>boolean);check(s:string){returnthis[s]asboolean;}}
因为索引签名类型需要同时捕获方法的类型(这就是为什么上面的索引类型要
|((s: string) => boolean)
,其目的就是要兼容
check
方法),所以要有用地使用这些类型并不容易
一般来说,最好将索引数据存储在另一个地方,而不是在类实例本身
2、类继承
implements子句
implements
子句可以使类实现一个接口(使类的类型服从该接口),那么使用它就可以检查一个类是否满足了一个特定的接口:
interfaceAnimal{ping():void;}classDogimplementsAnimal{ping():void{console.log("旺!");}}// 报错:// 类“Cat”错误实现接口“Animal”:// 类型 "Cat" 中缺少属性 "ping",但类型 "Animal" 中需要该属性。classCatimplementsAnimal{pong():void{console.log("喵!");}}
类也可以实现多个接口,例如
class C implements A, B
注意:
implements
子句只是检查类的类型是否符合特定接口,它根本不会改变类的类型或其方法,如:一个类实现一个带有可选属性的接口并不能创建该属性:
extends子句
类可以从基类中扩展出来(称为派生类),派生类拥有其基类的所有属性和方法,也可以定义额外的成员:
classAnimal{move(){console.log("move");}}classDogextendsAnimal{woof(){console.log("woof");}}const d =newDog();// 基类的类方法
d.move();// 派生类自己的类方法
d.woof();
注意:
在对象类型详解的扩展类型部分中我们说到接口可以使用
extends
从多个类型中扩展:
extends User, Age
而类使用
extends
只能扩展一个类:
重写方法
派生类可以覆盖基类的字段或属性,并且可以使用
super.
语法来访问基类方法:
classBase{greet(){console.log("Hello, world!");}}classDerivedextendsBase{// 在Derived中重写greet方法greet(name?:string){if(name ===undefined){// 调用基类的greet方法super.greet();}else{console.log(`Hello, ${name.toUpperCase()}`);}}}const d =newDerived();
d.greet();// "Hello, world!"
d.greet("reader");// "Hello, READER"
通过基类引用来引用派生类实例是合法的,并且是非常常见的:
// 通过基类引用来引用派生类实例// b的类型引用的是基类Base,但其可以引用Base的派生类实例const b: Base =newDerived();// 没问题
b.greet();
TypeScript
强制要求派生类总是其基类的一个子类型,如果违法约定就会报错:
上面报错是因为“
(name: string) => void
”不是类型“
() => void
”的子类型,而先前使用的“
(name?: string) => void
”才是“
() => void
”子类型
这里是不是有人感觉我说反了,会感觉
() => void
是
(name?: string) => void
的子类型才对吧,那么我就来验证一下我的说法:
typeA=()=>void;typeB=(name?:string)=>void;typeC=BextendsA?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
的子类型
这就好像我们人类生了孩子,无论孩子的能力,眼光比父母大多少,他任然是父母的子类一样
初始化顺序
类初始化的顺序是:
- 基类的字段被初始化
- 基类构造函数运行
- 派生类的字段被初始化
- 派生类构造函数运行
classBase{
name ="base";constructor(){console.log(this.name);}}classDerivedextendsBase{
name ="derived";}// 打印 "base", 而不是 "derived"const d =newDerived();
继承内置类型
注意:如果你不打算继承
Array
、
Error
、
Map
等内置类型,或者你的编译目标明确设置为
ES6/ES2015
或以上,你可以跳过这一部分。
在
ES6/ES2015
中,返回对象的构造函数隐含地替代了任何调用
super(...)
的
this
的值。生成的构造函数代码有必要捕获
super(...)
的任何潜在返回值并将其替换为
this
因此,子类化
Error
、
Array
等可能不再像预期那样工作。这是由于
Error
、
Array
等的构造函数使用
ES6
的
new.target
来调整原型链;然而,在
ES5
中调用构造函数时,没有办法确保
new.target
的值。默认情况下,其他低级编译器(
ES5
以下)通常具有相同的限制。
看下面的一个子类:
classMsgErrorextendsError{constructor(m:string){super(m);}sayHello(){// this.message为基类Error上的属性return"hello "+this.message;}}const msgError =newMsgError("hello");console.log(msgError.sayHello());
上述代码,在编程成
ES6
及以上版本的
JS
后,能够正常运行,但当我们修改
tsconfig.json
的
target
为
ES5
时,使其编译成
ES5
版本的,你可能会发现:
- 方法在构造这些子类所返回的对象上可能是未定义的,所以调用
sayHello
会导致错误。 instanceof
将在子类的实例和它们的实例之间被打破,所以new MsgError("hello") instanceof MsgError)
将返回false
:console.log(newMsgError("hello")instanceofMsgError);// false
官方建议,可以在任何
super(...)
调用后立即手动调整原型:
classMsgErrorextendsError{constructor(m:string){super(m);// 明确地设置原型。// 将this上的原型设置为MsgError的原型
Object.setPrototypeOf(this, MsgError.prototype);}sayHello(){// this.message为基类Error上的属性return"hello "+this.message;}}const msgError =newMsgError("hello");console.log(msgError.sayHello());// hello helloconsole.log(newMsgError("hello")instanceofMsgError);// true
MsgError
的任何子类也必须手动设置原型。对于不支持
Object.setPrototypeOf
的运行时,可以使用
__proto__
来代替:
classMsgErrorextendsError{// 先声明一下__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 =newMsgError("hello");console.log(msgError.sayHello());// hello helloconsole.log(newMsgError("hello")instanceofMsgError);// true
不幸的是,这些变通方法在
Internet Explorer 10
和更早的版本上不起作用。我们可以手动将原型中的方法复制到实例本身(例如
MsgError.prototype
到
this
),但是原型链本身不能被修复。
3、成员的可见性
可以使用
TypeScript
来控制某些方法或属性对类外的代码是否可见
public
public
定义公共属性,是类成员的默认可见性,可以在任何地方被访问:
classGreeter{publicgreet(){console.log("hi!");}}const g =newGreeter();
g.greet();
因为
public
已经是默认的可见性修饰符,所以一般不需要在类成员上写它,但为了风格/可读性的原因,可能会选择这样做
protected
protected
定义受保护成员,仅对声明它们的类和其子类可见:
classGreeter{protected name ="Ailjx";greet(){console.log(this.name);}}classChildextendsGreeter{childGreet(){console.log(this.name);}}const g =newGreeter();const c =newChild();
g.greet();// Ailjx
c.childGreet();// Ailjx// ❌❌报错:属性“name”受保护,只能在类“Greeter”及其子类中访问。console.log(g.name);// 无权访问
- 暴露受保护的成员 派生类需要遵循它们的基类契约,但可以选择公开具有更多能力的基类的子类型,这包括将受保护的成员变成公开:
classBase{protected m =10;}classDerivedextendsBase{// 基类的受保持属性m被修改为公开的了// 没有修饰符,所以默认为公共public m =15;}const d =newDerived();console.log(d.m);// OK
private
private
定义私有属性,比
protected
还要严格,它仅允许在当前类中访问
classBase{private name ="Ailjx";greet(){// 只能在当前类访问console.log(this.name);}}classChildextendsBase{childGreet(){// 不能在子类中访问// ❌❌报错:属性“name”为私有属性,只能在类“Base”中访问console.log(this.name);}}const b =newBase();// 不能在类外访问// ❌❌报错:属性“name”为私有属性,只能在类“Base”中访问。console.log(b.name);// 无权访问
private
允许在类型检查时使用括号符号进行访问:
console.log(b["name"]);// "Ailjx"
因为私有
private
成员对派生类是不可见的,所以派生类不能像使用
protected
一样增加其可见性
- 跨实例访问
TypeScript
中同一个类的不同实例之间可以相互访问对方的私有属性:classA{private x =0;constructor(x:number){this.x = x;}publicsameAs(other:A){// 可以访问return other.x ===this.x;}}const a1 =newA(1);const a2 =newA(10);constis= a1.sameAs(a2);console.log(is);// false
参数属性
TypeScript
提供了特殊的语法,可以将构造函数参数变成具有相同名称和值的类属性,这些被称为参数属性,通过在构造函数参数前加上可见性修饰符
public
、
private
、
protected
或
readonly
中的一个来创建,由此产生的字段会得到这些修饰符:
classA{// c为私有的可选属性constructor(public a:number,protected b:number,private c?:number){}}const a =newA(1,2,3);console.log(a.a);// 1
注意事项
像
TypeScript
类型系统的其他方面一样,
private
和
protected
只在类型检查中被强制执行,这意味着在
JavaScript
的运行时结构,如
in
或简单的属性查询,仍然可以访问一个私有或保护的成员:
classMySafe{private secretKey =12345;}const s =newMySafe();// 报错:属性“secretKey”为私有属性,只能在类“MySafe”中访问。console.log(s.secretKey);
上方
TS
代码虽然会报错,但当我们运行其编译后的
JS
文件时会发现其正常的打印出了
12345
这意味着
private
和
protected
只起到了报错提示作用,并不会真正限制编译后的
JS
文件,即这些字段是软性私有的,不能严格执行私有特性
与
TypeScript
的
private
不同,
JavaScript
的
private
字段(
#
)在编译后仍然是
private
的,并且不提供前面提到的像括号符号访问那样的转义窗口,使其成为硬
private
:
classDog{
#barkAmount =0;constructor(){console.log(this.#barkAmount);// 0}}const dog =newDog();// 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)thrownewTypeError("Private accessor was defined without a getter");if(typeof state ==="function"? receiver !== state ||!f :!state.has(receiver))thrownewTypeError("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;classDog{constructor(){
_Dog_barkAmount.set(this,0);console.log(__classPrivateFieldGet(this, _Dog_barkAmount,"f"));// 0}}
_Dog_barkAmount =newWeakMap();const dog =newDog();// TS报错:类型“Dog”上不存在属性“barkAmount”,编译后的JS运行时打印undefinedconsole.log(dog.barkAmount);// TS报错:属性 "#barkAmount" 在类 "Dog" 外部不可访问,因为它具有专用标识符。// 编译后的JS也直接报错console.log(dog.);
如果你需要保护你的类中的值免受恶意行为的影响,你应该使用提供硬运行时隐私的机制,如闭包、
WeakMaps
或私有字段。请注意,这些在运行时增加的隐私检查可能会影响性能。
4、静态成员
类可以有静态成员,这些成员并不与类的特定实例相关联,它们可以通过类的构造函数对象本身来访问:
classMyClass{static x =0;staticprintX(){console.log(MyClass.x);// 等同于console.log(this.x);}}// 静态成员不需要newconsole.log(MyClass.x);// 0
MyClass.printX();// 0
静态成员也可以使用相同的
public
、
protected
和
private
可见性修饰符:
classMyClass{privatestatic x =0;staticprintX(){// okconsole.log(MyClass.x);// 等同于console.log(this.x);}}// 静态成员不需要new// ❌❌TS报错:属性“x”为私有属性,只能在类“MyClass”中访问console.log(MyClass.x);// ok
MyClass.printX();// 0
静态成员也会被继承:
classBase{static BaseName ="Ailjx";}classDerivedextendsBase{// 基类的静态成员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" classclassMyStaticClass{staticdoSomething(){}}// 首选 (备选 1)functiondoSomething(){}// 首选 (备选 2)const MyHelperObject ={dosomething(){},};
5、静态块
static
静态块允许你写一串有自己作用域的语句,可以访问包含类中的私有字段,这意味着我们可以用写语句的所有能力来写初始化代码,不泄露变量,并能完全访问我们类的内部结构:
classFoo{static #count =0;getcount(){return Foo.#count;}static{try{
Foo.#count +=100;console.log("初始化成功!");}catch{console.log("初始化错误!");}}}const a =newFoo();// 初始化成功console.log(a.count);// 100
6、泛型类
类和接口一样,可以是泛型的,当一个泛型类用
new
实例化时,其类型参数的推断方式与函数调用的方式相同:
classBox<Type>{
contents: Type;constructor(value: Type){this.contents = value;}}// const b: Box<string>const b =newBox("hello!");// 等同于const b = new Box<string>("hello!");
- 泛型类的静态成员不能引用类型参数:
7、this指向
在
JavaScript
中
this
指向是一个头疼的问题,默认情况下函数内
this
的值取决于函数的调用方式,在一些情况下这会出现意向不到的效果,如下方代码:
classMyClass{
name ="MyClass";getName(){returnthis.name;}}const c =newMyClass();const obj ={
name:"obj",
getName: c.getName,};// 输出 "obj", 而不是 "MyClass"console.log(obj.getName());
TypeScript
提供了一些方法来减少或防止这种错误:
箭头函数
classMyClass{
name ="MyClass";getName=()=>{returnthis.name;};}const c =newMyClass();const obj ={
name:"obj",
getName: c.getName,};// 输出 "MyClass", 而不是 "obj"console.log(obj.getName());
使用箭头函数也是有一些妥协的:
this
值保证在运行时是正确的,即使是没有经过TypeScript
检查的代码也是如此- 这将使用更多的内存,因为每个类实例将有它自己的副本,每个函数都是这样定义的
- 你不能在派生类中使用
super
调用基类方法,因为在原型链中没有入口可以获取基类方法:classMyClass{ name ="MyClass";getName=()=>{returnthis.name;};}classAextendsMyClass{ AName:string;constructor(){super();// getName为箭头函数时,调用super.getName()会报错// this.AName = super.getName();this.AName =this.getName();// 但一直能通过this.getName()调用}}const a =newA();console.log(a.AName);// MyClass
this参数
在【TypeScript】深入学习TypeScript函数中我们提到过
this
参数,
TypeScript
检查调用带有
this
参数的函数,是否在正确的上下文中进行
我们可以不使用箭头函数,而是在方法定义中添加一个
this
参数,以静态地确保方法被正确调用:
classMyClass{
name ="MyClass";getName(this: MyClass){returnthis.name;}}const c =newMyClass();// 正确
c.getName();// 错误const g = c.getName;console.log(g());
这种方法做出了与箭头函数方法相反的取舍:
JavaScript
调用者仍然可能在不知不觉中错误地使用类方法,如上面的例子:classMyClass{ name ="MyClass";getName(this: MyClass){returnthis.name;}}const c =newMyClass();const obj ={ name:"obj", getName: c.getName,};// 依旧输出 "obj", 而不是 "MyClass"console.log(obj.getName());
- 每个类定义只有一个函数被分配,而不是每个类实例一个函数
- 基类方法定义仍然可以通过
super
调用。
8、this类型
在类中,一个叫做
this
的特殊类型动态地指向当前类的类型,看下面的这个例子:
classBox{
contents:string="";set(value:string){this.contents = value;returnthis;}}
在这里,
TypeScript
推断出
set
方法的返回类型是
this
,而不是
Box
:
创建
Box
的一个子类:
classClearableBoxextendsBox{clear(){this.contents ="";}}const a =newClearableBox();// a类型为ClearableBoxconst b = a.set("hello");// b类型为ClearableBoxconsole.log(b);
这里可以看到
b
的类型竟然是
ClearableBox
,这说明此时
set
方法返回的
this
类型指向了当前的类
ClearableBox
(因为是在
ClearableBox
上调用的
set
)
**可以在参数类型注释中使用
this
:**
classBox{
contents:string="";// 类型注释中使用thissameAs(other:this){return other.contents ===this.contents;}}classClearableBoxextendsBox{
contents:string="Ailjx";}classB{
contents:string="";}const box =newBox();const clearableBox =newClearableBox();const b =newB();console.log(clearableBox.sameAs(box));// false// ❌❌❌报错// 类型“B”的参数不能赋给类型“ClearableBox”的参数// 类型 "B" 中缺少属性 "sameAs",但类型 "ClearableBox" 中需要该属性console.log(clearableBox.sameAs(b));
上面例子中可以看到派生类
ClearableBox
的
sameAs
方法能够接收基类的实例
但是当派生类中有额外的属性后,它就只能接收该同一派生类的其它实例了:
classBox{
contents:string="";sameAs(other:this){return other.contents ===this.contents;}}classClearableBoxextendsBox{
otherContents:string="Ailjx";}const box =newBox();const clearableBox =newClearableBox();// ❌❌❌报错:// 类型“Box”的参数不能赋给类型“ClearableBox”的参数。// 类型 "Box" 中缺少属性 "otherContents",但类型 "ClearableBox" 中需要该属性。console.log(clearableBox.sameAs(box));
9、基于类型守卫的this
我们可以在类和接口的方法的返回位置使用类型谓词
this is Type
,当与类型缩小混合时(例如
if
语句),目标对象的的类型将被缩小到指定的
Type
类型谓词详见【TypeScript】TypeScript中类型缩小(含类型保护)与类型谓词
classBox{// 利用类型谓词,当this类型是A的实例时,确保将this类型缩小为A类型isA():thisisA{returnthisinstanceofA;}isB():thisisB{returnthisinstanceofB;}}classAextendsBox{
Apath:string="A";}classBextendsBox{
Bpath:string="B";}// fso的类型为基类Box,它可能是A,也可能是Bconst fso: Box = Math.random()>0.5?newA():newB();if(fso.isA()){// fso.isA()为true时(说明Box的this类型指向了A,即可知道此时fso具体为A),// 其通过类型谓词将fso缩小为了A类型,此时就可以安全调用A特有的属性console.log(fso.Apath);}elseif(fso.isB()){console.log(fso.Bpath);}
配合接口使用:
classBox{isNetworked():thisis Networked &this{returnthis.networked;}// networked属性控制Box是否包含Networked接口类型constructor(private networked:boolean){}// 这里使用了在构造器参数列表中声明属性}interfaceNetworked{
host:string;}constA: Box =newBox(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
属性失去了可选性,就能直接使用了:
classBox<T>{
value?:T;// 根据value值是否存在来缩小类型hasValue():thisis{ value:T}{returnthis.value !==undefined;}}const box =newBox<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 =newsomeClass("Hello, world");
11、抽象类和成员
使用
abstract
定义的一个方法或字段称为抽象成员,它是一个没有提供实现的方法或字段,这些成员必须存在于一个使用
abstract
定义的抽象类中,该类不能直接实例化:
abstractclassBase{abstractgetName():string;printName(){console.log("Hello, "+this.getName());}}// ❌❌❌报错:无法创建抽象类的实例const b =newBase();
抽象类的作用是作为子类的基类,实现所有的抽象成员:
// 创建一个派生类实现抽象成员classDerivedextendsBase{getName(){return"world";}}const d =newDerived();
d.printName();// Hello world
如果抽象类的派生类不实现它的抽象成员则会报错:
抽象构造签名
向上面这个例子,如果你想要写一个函数,能够接受所有抽象类
Base
的派生类,你可能会这样写:
functiongreet(ctor:typeof Base){const instance =newctor();
instance.printName();}
这时
TypeScript
会告诉你这样写是不对的:
正确的做法应该是使用抽象构造签名:
functiongreet(ctor:new()=> Base){const instance =newctor();
instance.printName();}
完整示例:
abstractclassBase{abstractgetName():string;printName(){console.log("Hello, "+this.getName());}}classDerivedextendsBase{getName(){return"world";}}classDerived2extendsBase{getName(){return"world2";}}functiongreet(ctor:new()=> Base){const instance =newctor();
instance.printName();}greet(Derived);greet(Derived2);// ❌❌❌报错:类型“typeof Base”的参数不能赋给类型“new () => Base”的参数。// 无法将抽象构造函数类型分配给非抽象构造函数类型。greet(Base);
12、类之间的关系
- 相同的类可以互相替代使用:
classPoint1{ x =0; y =0;}classPoint2{ x =0; y =0;}// 正确const p: Point1 =newPoint2();
- 即使没有明确的继承,类之间的子类型关系也是存在的:
classPerson{ name:string="A"; age:number=1;}classEmployee{ name:string="A"; age:number=1; salary:number=99;}// type A = numbertypeA= Employee extendsPerson?number:string;// 正确const p: Person =newEmployee();
- 空的类通常是其他任何东西的基类:
classPerson{ name:string="A"; age:number=1;}classEmployee{ salary:number=99;}classN{}// type A = numbertypeA= Person extendsN?number:string;// type B = numbertypeB= Employee extendsN?number:string;functionfn(x:N){}// 以下调用均可fn(Person);fn(Employee);fn(window);fn({});fn(fn);
13、混入mixin
混入: 通过组合更简单的部分类来构建一个类,这称为混入,通俗来讲就是合并多个对象以得到一个高级的对象
在上一节中,我们提到过类使用
extends
只能扩展一个类,即只能将两个类合并,但其实我们可以通过混入使多个类合并到一个类上(将其它多个类混入进一个基类中),我们先定义一个混入函数
applyMixins
:
// 它可以存在于你代码库的任何地方functionapplyMixins(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
:
classJumpable{jump(){}}classDuckable{duck(){}}// 基类classSprite{
x =0;
y =0;}// 创建一个接口,将预期的混合函数与你的基础函数同名,合并在一起// 其目的是使最终的Sprite使用其它两个类上的属性时有代码提示且不报错interfaceSpriteextendsJumpable, Duckable {}// 在运行时,通过JS将混入应用到基类中applyMixins(Sprite,[Jumpable, Duckable]);let player =newSprite();
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.tsexportdefaultfunctionhelloWorld(){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.tsexportdefault"123";
// @filename: a.ts(与 hello.ts同级)import h from"./hello";console.log(h);// "123"
🚗 除了默认的导出,还可以通过省略
default
的
export
,导出多个变量和函数的:
// @filename: hello.tsexportvar a =3.14;exportlet b =1.41;exportconst c =1.61;exportclassD{}exportfunctionfn(num:number){console.log(num);}
可以只使用一个
export
导出:
var a =3.14;let b =1.41;const c =1.61;classD{}functionfn(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,newD());fn(1);
可以使用
import {old as new}
这样的格式来重命名一个导入:
// @filename: a.ts(与 hello.ts同级)// 仅引入a,c,fn 并重命名a和fnimport{ a asA, c, fn asFN}from"./hello";console.log(A, c);FN(1);
可以把所有导出的对象,用
* as name
,把它们放到同一个命名空间
name
:
// @filename: a.ts(与 hello.ts同级)// export导出的所有内容放到了命名空间 F 中import*asFfrom"./hello";console.log(F.a,F.c);F.fn(1);
🚗
export default
与
export
一起使用:
// @filename: hello.tsexportvar a =3.14;exportlet b =1.41;exportconst c =1.61;exportclassD{}exportfunctionfn(num:number){console.log(num);}exportdefaultfunctionhelloWorld(){console.log("Hello, world!");}
// @filename: a.ts(与 hello.ts同级)import hello,{ a, b, c,D, fn }from"./hello";console.log(a, b, c,newD());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 asA};
// @filename: a.ts(与 hello.ts同级)import{A}from"./hello";console.log(A);// 1
二次导出
🚗 一个模块可以引入并导出另一个模块的内容,这称为二次导出,一个二次导出并不在本地导入,也不引入本地变量
hello.ts
:
// @filename: hello.tsexportconst a ="hello";exportconst n =1;
word.ts
(与
hello.ts
同级):
// @filename: word.ts(与hello.ts同级)exportconst 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同级)exportconst 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同级)exportconst b ="word";// 相当于将hello.ts导出的内容全部引入到命名空间H中,后又将H导出export*asHfrom"./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.tsexporttypeCat={};exportinterfaceDog{}
// @filename: a.ts(与 hello.ts同级)import{ Cat, Dog }from"./hello";let a: Cat, b: Dog;
TypeScript
用两个概念扩展了
import
语法,用于声明一个类型的导入:
🚗
import type
这是一个导入语句,导入的变量只能用作类型:
// @filename: hello.tsexportconstcreateCatName=()=>"fluffy";
🚗 内联类型导入
TypeScript 4.5
还允许以
type
为前缀的单个导入,以表明导入的引用是一个类型:
// @filename: hello.tsexporttypeCat={};exportinterfaceDog{}exportconstcreateCatName=()=>"fluffy";
// @filename: a.ts(与 hello.ts同级)// 表明Cat和Dog为类型import{ createCatName,typeCat,typeDog}from"./hello";typeAnimals= 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同级)importH=require("./hello");console.log(H);// hello
3、CommonJS语法
若使用CommonJS语法报错,则需要先在项目根目录运行:
npm i --save-dev @types/node
安装声明文件
通过在一个全局调用的
module
上设置
exports
属性来导出:
// @filename: hello.tsfunctionabsolute(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.tsdeclaremodule"A"{exporttypea=string;// ...}declaremodule"B"{exporttypea=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.tsdeclaremodule"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
标志影响模块策略:
- moduleResolution
- baseUrl
- paths
- rootDirs
关于这些策略如何工作的全部细节,你可以参考《模块解析》
模块输出选项
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中的所有枚举成员都是常数enumE1{X,Y,Z,}enumE2{A=1,B,C,}enumE3{D,}
- 枚举成员用以下常量枚举表达式进行初始化:1. 枚举表达式的字面意思(基本上是一个字符串字面量或一个数字字面量)
enumE1{ x ="1"}enumE2{ x =1}
2. 对先前定义的常量枚举成员的引用(可以来自不同的枚举)enumE1{x,}enumE2{x =E1.x, y =E2.x,}
3. 一个括号内的常量枚举表达式4. 应用于常量枚举表达式的+
,-
,~
一元运算符之一enumE{ x =-9,}
5.+
,-
,*
,/
,%
,<<
,>>
,>>
,&
,|
,^
以常量枚举表达式为操作数的二元运算符enumE1{x =1,}enumE2{x =E1.x +1,}
如果常量枚举表达式被评估为NaN
或Infinity
,这是一个编译时错误
计算成员
除了常量成员之外的就都是计算成员:
let a =1;functionfn(){return1;}enumE{
x =++a,
y = a,
z =fn(),A="Ailjx".length,}
枚举
E
中的所有成员都是计算成员
成员顺序
数字枚举可以混合在计算成员和常量成员中,但没有初始化器的枚举成员要么需要放在第一位,要么必须在常量成员之后(因为只有在常量成员之后才会自增1):
let a =1;enumE{
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 )
当枚举中的所有成员都具有字面枚举值时,一些特殊的语义就会发挥作用:
- 枚举成员也能当作类型来用
enumE{A=1,B="Ailjx",C="Ailjx",D=-1,}interfaceAuthor{// 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
可以捕获我们可能会错误地比较值的错误:enumE{ Foo, Bar,}functionf(x:E){if(x !==E.Foo || x !==E.Bar){// ❌❌❌err:此条件将始终返回 "true",因为类型 "E.Foo" 和 "E.Bar" 没有重叠。//...}}
5、运行时的枚举
枚举是在运行时存在的真实对象,例如,下面这个枚举:
enumE{X,Y,Z,}
实际上可以传递给函数:
enumE{X,Y,Z,}functionf(obj:{X:number}){return obj.X;}// 可以正常工作,因为'E'有一个名为'X'的属性,是一个数字。f(E);
6、编译时的枚举
尽管枚举是运行时存在的真实对象,但
keyof
关键字对枚举的工作方式与对典型对象的预期完全不同:
// 对象类型interfaceA{
b:number;
c:number;}// type T = "b"|"c"typeT=keyofA;let a:T="b";
a ="c";
// 枚举类型enumE{X,Y,Z,}// type T = "toString" | "toFixed" | "toExponential" | "toPrecision" | "valueOf" | "toLocaleString"typeT=keyofE;
从上可以看到,我们不能使用
keyof
来获取枚举类型键的字面联合类型
可以使用
keyof typeof
来获得一个将枚举类型所有键表示为字符串的类型:
// 枚举类型enumE{X,Y,Z,}// type T = "X" | "Y" | "Z"typeT=keyoftypeofE;
从这,我们反向思考能发现对枚举类型使用
typeof
能够获得该枚举的对象类型:
// 枚举类型enumE{X,Y,Z,}// type T = { X: number, Y: number, Z: number }typeT=typeofE;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
修饰符来定义的:
constenumE{A,}
常量枚举只能使用常量枚举表达式( 常量枚举不能有计算成员),并且与常规枚举不同,它们在编译期间会被完全删除:
constenumE{A,}let arr =E.A;
编译后:
"use strict";let arr =0/* E.A */;
普通的枚举(去掉
const
修饰符)编译为:
"use strict";varE;(function(E){E[E["A"]=0]="A";})(E||(E={}));let arr =E.A;
7、环境枚举
使用
declare
来定义环境枚举,环境枚举成员初始化表达式必须是常数表达式(不能有计算成员):
8、对象与枚举
在现代
TypeScript
中,一般不需要使用枚举,因为一个对象的常量就足够了
constenum EDirection {
Up,
Down,
Left,
Right,}// (enum member) EDirection.Up = 0
EDirection.Up;// 将枚举作为一个参数// dir的类似于number类似functionwalk(dir: EDirection){}walk(EDirection.Left);walk(99)// ok
将上述代码改写成对象形式:
const ODirection ={
Up:0,
Down:1,
Left:2,
Right:3,}asconst;// (property) Up: 0
ODirection.Up;// 相比使用枚举,需要一个额外的行来推算出类型typeDirection=typeof ODirection[keyoftypeof ODirection];functionrun(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;exportconst str ="Ailjx";exporttypeS=string;exportfunctionf(){}exportclassN{}}
这里定义了一个名为
Ailjx
的命名空间,在外部可以使用
Ailjx.
的形式访问其内部通过
export
暴露的成员:
const s: Ailjx.S= Ailjx.str;
Ailjx.f();newAiljx.N();// 类型“typeof Ailjx”上不存在属性“a”// console.log(Ailjx.a);// err
从上面可以看出
TypeScript
的命名空间实际上就像一层大的容器,将内容包裹在其中,将其私有化,这就避免了外部其它变量与其内容命名冲突的问题
2、空间合并
命名空间之间的合并
多个相同名称的命名空间会自动进行合并,这就使得命名空间可以访问或修改同一名称下其它空间
export
的成员:
namespace Ailjx {exportlet a =1;}namespace Ailjx {
a =2;exportlet 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
:
namespaceA{// s没有export,它只在当前空间有效let s =0;exportfunctiongetS1(){console.log(s);}}namespaceA{// s没有export,它只在当前空间有效let s =1;exportfunctiongetS2(){console.log(s);}}A.getS1();// 0A.getS2();// 1
从这可以看出
TypeScript
相同命名的空间并不只是简单的合并,这与闭包有些相似,然而当你查看上方代码编译后的
js
文件,你就会发现
TypeScript
的命名空间就是以闭包的形式实现的,见下方第三部分实现原理
命名空间与类合并
先看一个例子:
classAlbum{
label: Album.AlbumLabel;}namespace Album {exportclassAlbumLabel{}}
这给了用户提供了一种描述内部类的方法,合并成员的可见性规则与合并命名空间 中描述的相同,所以这里我们必须导出
AlbumLabel
类,以便
合并后的类能看到它,最终的结果是一个类在另一个类里面管理
你也可以使用命名空间来为现有的类添加更多的静态成员
命名空间与函数合并
JavaScript
的中可以在函数上添加属性来进一步扩展该函数,
TypeScript
使用声明合并,以类型安全的方式构建这样的定义:
functionfn(name:string):string{return fn.prefix + name + fn.suffix;}namespace fn {exportlet suffix =" !";exportlet prefix ="Hello, ";}console.log(fn("Ailjx"));// "Hello, Ailjx !"
命名空间与枚举合并
命名空间可用于扩展具有静态成员的枚举:
enum Color {
red =1,
green =2,
blue =4,}namespace Color {exportfunctionmixColor(colorName:string){if(colorName =="yellow"){return Color.red + Color.green;}elseif(colorName =="white"){return Color.red + Color.green + Color.blue;}elseif(colorName =="magenta"){return Color.red + Color.blue;}elseif(colorName =="cyan"){return Color.green + Color.blue;}}}console.log(Color.mixColor("white"));// 7
3、实现原理
上面命名空间
A
编译后的
js
代码:
"use strict";"use strict";varA;(function(A){// s没有export,它只在当前空间有效let s =0;functiongetS1(){console.log(s);}A.getS1 = getS1;})(A||(A={}));(function(A){// s没有export,它只在当前空间有效let s =1;functiongetS2(){console.log(s);}A.getS2 = getS2;})(A||(A={}));A.getS1();// 0A.getS2();// 1
再看一个
export
暴露成员的命名空间:
namespaceB{exportlet s =0;}namespaceB{
s =1;}
编译后的
js
:
"use strict";varB;(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.tsexportnamespaceA{exportlet s =99;}
引入并使用命名空间:
// src/hello.tsimport{A}from"./a";console.log(A.s);// 99
5、空间别名
使用
import q = x.y.z
来为常用对象创建更短的名称:
namespaceA{exportnamespaceB{exportclassC{constructor(){console.log(999);}}}}import MyC =A.B.C;newMyC();// 999 与new A.B.C()等价newA.B.C();// 999
没想到
import
语法还能这样用,虽然很奇怪,但这在一些场景下应该会很实用
从这里也可以看出命名空间是可以嵌套使用的
6、命名空间与模块
命名空间: 相当于内部模块,主要用于组织代码,避免命名冲突
命名空间是一种特定于
TypeScript
的代码组织方式,它只是全局命名空间中命名的
JavaScript
对象,这使得命名空间成为一个非常简单易用的构造
就像所有全局命名空间污染一样,使用它很难识别组件依赖关系,尤其是在大型应用程序中
模块: 外部模块的简称,侧重代码的复用,一个模块里能够包含多个命名空间
模块可以包含代码和声明,依赖于模块加载器(如
CommonJs/Require.js
)或支持
ES
模块的运行,模块提供了更好的代码重用,更强的隔离性和更好的捆绑工具支持
同样值得注意的是,对于
Node.js
应用程序,模块是默认的,官方在现代代码中推荐模块而不是命名空间
从
EC6
开始,模块是语言的原生部分,所有兼容的引擎实现都应该支持,因此,对于新项目,模块将是推荐的代码组织机制
十二、装饰器
1、装饰器
装饰器是一种特殊的声明,可以附加到
类声明
、
方法
、
访问器
、
属性
或
参数
上
装饰器使用
@expression
的形式,其中
expression
必须是一个函数,该函数将在运行时被调用,并带有关于被装饰的声明的信息
例如,给定装饰器
@Ailjx
,那么我们就可以编写以下函数:
// target接收被装饰的对象functionAiljx(target:any){// 对 "target"做一些事情 ...}// 使用装饰器Ailjx@Ailjx// ....(被装饰器对象装饰的内容:类声明、方法、访问器、属性或参数)
注意: 装饰器必须写到被装饰内容的上面,中间不能隔行,以类装饰器为例:
functionAiljx(target:any){// target接收的是类A的构造函数const a =newtarget();console.log(a);// A { a: 1 }}@Ailjx// 这行不能带分号;classA{
a:number=1;}
装饰器工厂
如果我们想自定义装饰器如何应用于声明,我们可以写一个装饰器工厂,装饰器工厂实际是一个高阶函数,它返回将在运行时被装饰器调用的表达式:
// value参数接收使用装饰器工厂时传递的参数functioncolor(value:string){// 这是装饰器工厂// 可以做一些操作...// 返回的装饰器函数returnfunction(target:any){// target依旧为被装饰的对象// 这就是装饰器// 用 "target" 和 "value"做一些事情...};}
使用装饰器工厂可以传参数:
@color('Ailjx')// ....(被装饰器对象装饰的内容)
2、装饰器组合
多个装饰器可以应用于一个声明,例如:
@f@g
x
@f
@g
为两个装饰器,
x
为被装饰内容
当多个装饰器应用于单个声明时,它们的评估(计算)类似于数学中的函数组合,在这个模型中,当组合函数
f
和
g
时,得到的复合
(f∘g)(x)
等价于
f(g(x))
因此,在
TypeScript
中对单个声明评估多个装饰器时执行以下步骤:
- 每个装饰器的表达式都是从上到下评估的
- 然后将结果作为函数从下到上调用
我们可以使用装饰器工厂来观察此评估(计算)顺序:
functionfirst(){console.log("first(): first装饰器工厂");returnfunction(target:any){console.log("first(): first装饰器函数");};}functionsecond(){console.log("second(): second装饰器工厂");returnfunction(target:any){console.log("second(): second装饰器函数");};}@first()@second()classC{}
打印结果:
first(): first装饰器工厂
second(): second装饰器工厂
second(): second装饰器函数
first(): first装饰器函数
- 先从上到下打印装饰器工厂印证了:每个装饰器的表达式都是从上到下评估计算的
- 再从下到上打印装饰器函数印证了:将结果作为函数从下到上调用
如果不使用装饰器工厂,直接使用装饰器,那么就会直接从下到上调用(因为从上到下评估装饰器表达式的过程已经在
TypeScript
内部执行了):
functionfirst(target:any){console.log("first():first装饰器函数");}functionsecond(target:any){console.log("second(): second装饰器函数");}@first@secondclassC{}
打印结果:
second(): second装饰器函数
first():first装饰器函数
3、类装饰器
何为类装饰器?
- 类装饰器是在类声明之前声明的
- 类装饰器应用于类的构造函数,可用于观察、修改或替换类定义
- 类装饰器不能在声明文件(
.d.ts
)或任何其他环境上下文中使用(如declare
类)>> declare>
> 用来表示声明其后面的全局变量的类型,之后我会出单独的一篇文章对其详细讲解) - 类装饰器的表达式将在运行时作为函数调用,类的构造函数将作为其唯一参数传入其中
- 如果类装饰器返回一个值,它将用提供的构造函数替换类声明:
functionclassDecorators(constructor:Function){returnclass{constructor(){console.log("B");}};}@classDecoratorsclassCla{}newCla();// 打印出B
注意: 如果您选择返回一个新的构造函数,您必须注意维护原始原型,因为在运行时应用装饰器的逻辑不会为您执行此操作,上面这个例子显然并没有注意到这一点,建议的做法见下方的:通过类装饰器覆盖原先的类声明
通过类装饰器修改类:
functionsealed(constructor:Function){
Object.seal(constructor);
Object.seal(constructor.prototype);}@sealedclassBugReport{
type ="report";
title:string;constructor(t:string){this.title = t;}}
Object.seal()方法封闭一个对象,阻止添加新属性并将所有现有属性标记为不可配置,当前属性的值只要原来是可写的就依旧可以改变
当
@sealed
被执行时,它将同时封闭构造函数和它的原型,因此将阻止在运行时通过访问
BugReport.prototype
或通过定义
BugReport
本身的属性来向该类添加或删除任何进一步的功能
注意:
ES2015
类实际上只是基于原型的构造函数的语法糖,所以其依旧具有
prototype
属性
这个装饰器并不能阻止类对
BugReport
进行
extends
子类化扩展操作
通过类装饰器覆盖原先的类声明:
functionclassDecorators<Textends{new(...args:any[]):{}}>(
constructor:T){returnclassextends constructor {
name ="A";getName(){console.log(this.name);}};}@classDecoratorsclassCla{
name:string;constructor(t:string){this.name = t;}}const c =newCla("Ailjx");console.log(c.name);// 会打印A,而不是Ailjx// 注意,装饰器不会改变TypeScript的类型// 因此,类型系统对新的属性`reportingURL`是不可知的。
c.getName();// ❌❌❌err:类型“C”上不存在属性“getName”
在这个例子中,类装饰器器返回了一个继承于基类
C
的新类,在这个新类中我们修改了
name
属性的默认值,并增加了
getName
方法,这个新类将覆盖原先的类
C
,并很好的维护了原始的原型
这里还使用了泛型、类型操作、构造函数签名方面的知识,如果有需要可以查看TypeScript从入门到精通专栏中的前几篇文章
但这里仍旧存在一个问题,就是我们无法直接访问新增的这个getName方法,我们可以这样做:
(c asany).getName();// A
但这不够优雅!发挥我们的想象,我们完全可以利用混入mixin思想来改写一下这个例子,来实现完美的效果:
functionclassDecorators(){returnfunction<Textends{new(...args:any[]):{}}>(constructor:T){returnclassextends constructor {
name ="A";getName(){console.log(this.name);}};};}const Cla =classDecorators()(class{
name:string;constructor(t:string){this.name = t;}});const c =newCla("Ailjx");console.log(c.name);// 会打印A,而不是Ailjx
c.getName();// A
这里我们放弃了类装饰器,而是使用一个高阶函数实现混入来改造这个例子,使其达到我们想要的效果
由此可见装饰器有时并不一定是最好的选择,仁者见仁智者见智
4、方法装饰器
什么是方法装饰器?
- 方法装饰器在方法声明之前声明
- 方法装饰器应用于方法的属性描述符,可用于观察、修改或替换方法定义
- 方法装饰器不能用于声明文件、重载或任何其他环境上下文(例如在
declare
类中) - 如果方法装饰器返回一个值,它将替换掉该方法的属性描述符(注意:并不是简单的只替换该函数)
- 方法装饰器的表达式将在运行时作为函数调用,并有固定的三个参数
方法装饰器的三个参数:
- 第一个参数:静态成员的类的构造函数,或者实例成员(也就是普通成员)的类的原型- 静态成员:
functiongetNameDecorators(target:any, propertyKey:string, descriptor:PropertyDescriptor){console.log(target);// 将打印类cla的构造函数}classcla{@getNameDecoratorsstaticgetName(){// 静态成员console.log("Ailjx");}}const c =newcla();
打印结果:- 实例成员:functiongetNameDecorators(target:any, propertyKey:string, descriptor:PropertyDescriptor){console.log(target);// 将打印类cla的原型}classcla{@getNameDecoratorsgetName(){// 实例成员console.log("Ailjx");}}const c =newcla();
打印结果: - 第二个参数:该成员的名称,类型为
string
- 第三个参数:该成员的属性描述符,类型固定为
PropertyDescriptor
> 在 >> Javascript>
> 中, >> 属性>
> 由一个字符串类型的名字(>> name>
> )和一个属性描述符(>> property descriptor>
> )对象 构成> 注意: 如果>> tsconfig.json>
> 中>> target>
> 小于>> ES5>
> ,属性描述符将无法定义!
示例:
functiongetNameDecorators(target:any, propertyKey:string, descriptor: PropertyDescriptor){// 修改属性描述符writable为false,使该属性的值不能被改变(不影响下面设置value)
descriptor.writable =false;// 修改属性描述符value(该属性的值 ),设置一个新的值
descriptor.value=function(){console.log("大帅哥");};}classcla{@getNameDecoratorsgetName(){console.log("Ailjx");}}const c =newcla();
c.getName();// 打印:大帅哥
c.getName=()=>{console.log("大漂亮");};// ❌❌❌运行时报错,因为getName的writable属性描述为false,getName的值不能被修改
方法装饰器同样能写出装饰器工厂的形式
5、访问器装饰器
访问装饰器与方法装饰器大致相同
- 访问器装饰器在访问器(
get/set
)声明之前被声明 - 访问器装饰器被应用于访问器的属性描述符,可以用来观察、修改或替换访问器的定义
- 访问器装饰器不能在声明文件中使用,也不能在任何其他环境中使用(比如在
declare
类中) - 不能同时装饰单个成员的
get
和set
访问器,这是因为装饰器适用于一个属性描述符,它结合了获取和设置访问器,而不是每个单独声明 - 如果访问器装饰器返回一个值,它将替换掉该成员的属性描述符
- 访问器装饰器的表达式将在运行时作为一个函数被调用,有以下三个参数:1. 静态成员的类的构造函数,或者实例成员的类的原型2. 该成员的名称,类型为
string
3. 该成员的属性描述符,类型固定为PropertyDescriptor
示例:
functionconfigurable(value:boolean){returnfunction(
target:any,
propertyKey:string,
descriptor: PropertyDescriptor
){// 属性描述符configurable:当且仅当指定对象的属性描述可以被改变或者属性可被删除时,为 true。
descriptor.configurable = value;};}classcla{private _name ="Ailjx";@configurable(true)getname(){returnthis._name;}}const c =newcla();
6、属性装饰器
- 属性装饰器在一个属性声明之前被声明
- 属性装饰器不能在声明文件中使用,也不能在任何其他环境下使用(比如在
declare
类中) - 属性装饰器的表达式将在运行时作为一个函数被调用,有以下两个参数: 1. 静态成员的类的构造函数,或者实例成员的类的原型2. 成员的名称
示例:
functionnameDecorator(target:any, propertyKey:string){console.log(target, propertyKey);}classcla{@nameDecorator
name:string="Ailjx";}
目前属性装饰器好像并没有什么用途,在官方文档中只给了一个记录有关属性元数据的例子,但装饰器元数据是一项实验性功能,可能会在未来的版本中引入重大更改,所以这里就先不多了
7、参数装饰器
- 参数装饰器在参数声明之前声明
- 参数装饰器应用于类构造函数或方法声明的函数
- 参数装饰器不能用于声明文件、重载或任何其他环境上下文(例如在
declare
类中) - 参数装饰器的返回值被忽略
- 参数装饰器的表达式将在运行时作为函数调用,并带有以下三个参数:1. 静态成员的类的构造函数,或者实例成员的类的原型2. 该成员的姓名(函数的名称),类型为
string | symbol
3. 函数参数列表中参数的序号索引,类型为number
注意: 参数装饰器只能用于观察已在方法上声明的参数
示例:
functiondecorator(
target: Object,
propertyKey:string,
parameterIndex:number){console.log(propertyKey, parameterIndex);// getName 1}classcla{// 注意@decorator的位置getName(name:string,@decorator age:number){}}
8、装饰器应用顺序
对于类内部各种声明的装饰器,有一个明确的应用顺序:
- 先从上到下应用实例成员的装饰器,对于每个实例成员,首先是参数装饰器,然后是方法、访问器或属性装饰器
- 然后从上到下应用静态成员的装饰器,对于每个静态成员,先是参数装饰器,然后是方法、存取器或属性装饰器。
- 之后应用构造函数
constructor
上的参数装饰器 - 最后应用类的类装饰器
代码演示:
functionclassDec(constructor:Function){console.log("类装饰器");}functionstaAttDec(target:any, propertyKey:string){console.log("静态成员属性装饰器");}functionattDec(target:any, propertyKey:string){console.log("属性装饰器");}functionconParamDec(
target: Object,
propertyKey:string,
parameterIndex:number){console.log("构造函数参数装饰器");}functionparamDec(target: Object, propertyKey:string, parameterIndex:number){console.log("参数装饰器");}functionfnDec(
target:any,
propertyKey:string,
descriptor: PropertyDescriptor
){console.log("方法装饰器");}@classDecclasscla{@staAttDecstatic a =1;@attDec
name =1;constructor(@conParamDec a:number){}@fnDecfn(@paramDec a:number){}}
打印结果:
属性装饰器
参数装饰器
方法装饰器
静态成员属性装饰器
构造函数参数装饰器
类装饰器
9、使用装饰器封装通用的try catch
在
api
请求封装过程中,几乎都会使用到
try catch
来捕获错误,但对封装的每一个
api
请求函数都手动进行
try catch
的话,势必会带来很多麻烦,如:
let info:any;classApi{// 对每一个封装的api请求函数使用try catch捕获错误getNews(){try{return info.news;}catch(error){console.log("获取新闻失败!");}}getUser(){try{return info.user;}catch(error){console.log("获取用户失败!");}}//....}const api =newApi();
api.getNews();
api.getUser();
如果封装的请求比较少的话这样做还可以接受,但如果
api
请求非常多,那该怎么办?
这里给出一个使用方法装饰器来实现统一
try catch
的小案例:
let info:any;functionapiDec(mag:string){returnfunction(
target:any,
propertyKey:string,
descriptor: PropertyDescriptor
){const fn = descriptor.value;try{fn();}catch(error){console.log("请求错误:"+ mag, error);}};}classApi{@apiDec("获取新闻失败!")getNews(){return info.news;}@apiDec("获取用户失败!")getUser(){return info.user;}}const api =newApi();
api.getNews();
api.getUser();
版权归原作者 海底烧烤店ai 所有, 如有侵权,请联系我们删除。