侧边栏壁纸
博主头像
前端自习室博主等级

折腾是进步的阶梯

  • 累计撰写 30 篇文章
  • 累计创建 0 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

typescript入门笔记(二):深入类型

lumozx
2023-01-10 / 0 评论 / 0 点赞 / 49 阅读 / 26702 字

注:本文typescript版本为5.0.2


实际上,这一章的标题并非是最好的选择,但对于typescript来说,类型就是他的本质,而我们经过不断学习,了解他与javascript的最大的不同就是类型,因此作为一篇尽可能由浅入深的文章,我们下一步就是深入。

在上一章中,我们了解到了基本类型,但这并没有体现typescript的优势,因为他们无法应对复杂的开发环境,在很多时候,我们的业务逻辑并不一定只是单一的类型,多个类型进行组合才是常见的情况,因此在我们预习了typescript的基本类型后,接下来就是看看他们怎么进行组合。

创建类型

我们已经有不少基本类型了,但这些基本类型无法满足我们的业务需求,因为存在着各种封装逻辑,因此类型也需要进行封装,那么我们需要基于原有类型,创建出新的类型,从而更好的规范代码,那么在typescript中,与创建类型有关的功能都有哪些呢?

别名

在上一章,我们在interface的地方提到过别名,虽然是一笔带过,但并不是不重要,反而,这个功能是typescript最重要的功能之一,他是复杂的类型体操的入门基石。

他的使用很简单,通过type关键字声明一个类型,这个类型是一组特定类型的封装,可以让我们在别的地方来复用。

可以看到,我们在这里分别定义了字面量类型、函数类型,甚至像接口一样定义了对象类型,那么看起来很简单,那么这里就需要重提,typeinterface如何进行取舍呢?在上一章,我们提到了type的适用范围大于interface,那么我们来详细列举一下他们的适用范围:

  • interface:描述对象、类的结构、函数,如果进行扩展的话,可以使用extends关键字,同名的interface会自动合并,合并的时候会要求兼容原接口的结构,但无法使用类型工具

  • type: 描述函数、联合类型、工具类型等一切可以抽离的类型,如果进行扩展的话,使用交叉类型,若类型不兼容会推导为never类型,可以表达元组、联合类型,可以使用类型工具

这里需要强调一点,type使用交叉类型扩展类型的时候,不会抛出错误,而是不兼容的类型会被推导为never,对于兼容类型,会取最大相同的类型作为结果、但interface使用extends、同名接口合并的时候,对于非兼容类型,会抛出错误,提示处理。

这么看,interface是描述对象暴露的接口,不应该用于复杂的类型逻辑,最多局限在泛型约束和索引类型,而type是一组类型的别名,可以对类型进行复杂编程,如果是描述对象,接口继承的性能要比交叉类型要好。

在上面,我们提到了工具类型,是的,我可以通过泛型,让别名变成工具类型。

因此,我们将type变为类似函数一样,通过传入类型,得到了一个新的类型,这个功能可以让我们在项目中确保自己处理空值的属性相关操作。

这里用到了泛型,这个之后会讲,在这里,别名变成了工具类型,可以根据传入的泛型来进行基本的类型操作。

联合类型和交叉类型

上文中我们多次提到了联合类型,也提到了交叉类型,那么他们是怎么样的呢?

实际上他们使用的符号分别是

  • 交叉类型使用的 & ,也就是按位与。

  • 联合类型使用的|,也就是按位或。

正如运算符所表达的,交叉类型需要实现里面所有的类型,这也是上文提到tpye的扩展手段,而联合类型,只需要实现联合类型中的一个类型,就可以认为符合当前的联合类型。

我们看一下交叉类型

在这里,我们定义的info对象,符合DepartmentItemInfo类型,而DepartmentItemInfo类型同时包含UserInfo类型和DepartmentItem类型中的所有字段,也就是他们之中的和。

那么此时就有思考小能手就会问,如果两个冲突的结构进行交叉,那么得出来的类型是怎么样的呢?我们来看一下

我们看到,不管是接口还是原始类型,他们的交叉类型是never,也就是不存在的类型,这么一想确实很合理,确实没有一种类型同时符合两个不同的原始类型。

这里需要注意的是,交叉类型的合并,还会发生在内部同属性的交叉。

primitiveProp 作为属性之一,进行交叉后,类型变成了never,但objectProp交叉后,得到了新的类型。

那么说完交叉类型,接下来看看联合类型。

NumberId只需要满足1 、 2、string即可。

那么接下来,我们将联合类型和交叉类型组合起来,看一下他们如何交互。

如同或与运算一样,由于两侧都有 1,那么1就是Test类型之一,而左侧有类型string,右侧'1'是string的子类型,因此也符合string,因此Test的类型是1 | '1'

索引类型

目前为止,我们了解了基本类型、联合类型、交叉类型,但有一种情况我们没法很好的处理,比如,我现在有一个对象,对象的是一千个键值对组成,其中的value都是固定类型,那么我写一千个类型似乎不太现实,那么就需要用到索引类型。

我们看到,索引类型可以快速声明一组类型相同的键值对,也可以处理未知属性的键值对。

从上面的例子,我们看到key是有类型的,key的类型有三项,string、number、symbol,但是在JavaScript中,实际上除了symbol最后都会被转成string

因此他存在以下表现

因此,在key中,type为string的包容性最强。

既然对象的key也会定义类型,那么我们如何获取到这些类型呢,换句话说,如何进行引索类型查询呢。typescript提供了一个叫做keyof的操作符,他可以将对象中的所有对象的所有键转化为对应的字面量类型,组成联合类型,亦或者返回单一类型。

那么,既然我们能获取到key的类型,那么value的类型,是不是同样可以获取到了,答案是是的,我们可以通过类似JavaScript获取value的方式,获取value的类型。

从上面的例子来看,他们与JavaScript的获取value的方式仅仅是类似,但传值、实现的功能都不同,不止是可以传入具体的字符串字面量类型,甚至可以通过keyof的操作符,一次性获取所有value的,也可以使用索引类型,来获取索引类型value的类型。

映射类型

仅仅单看索引类型,感觉他们的作用很有限,因此,他们需要搭配映射类型,来实现更高级的类型编程。那么,什么是映射类型呢?他是一个类型工具,主要基于键名映射到键值,我们直接看一下例子。

在key方面,我们通过keyof拿到了key所有的类型,然后用in操作符遍历出来,从而拿到了原类型的所有key的类型,然后通过T[K],获取到了所有key对应的value的类型。

从而实现了克隆类型,其中的in操作符,就是映射类型的语法,而其他的是索引类型的语法。

类型守卫

在上文中,我们学习了如何创建一个新的类型,同时我们知道,类型和类型之间是不同的,那么typescript是如何确保这些不同的呢,本章节就让我们来了解一下。

typeof与is

typeof大家都很熟悉,在JavaScript之中,返回变量的类型,在typescript中,也得到了应用,他可以返回一个typescript类型。绝大部分情况下,typeof返回的类型就是当你把鼠标悬浮在变量名上时出现的推导后的类型,并且是最窄的推导程度(即到字面量类型的级别)。因此也不必担心混用了多种 typeof,在逻辑代码中使用的 typeof一定会是 JavaScript中的 typeof,而类型代码(如类型标注、类型别名中等)中的一定是类型查询的 typeof

同时,为了更好地避免这种情况,也就是隔离类型层和逻辑层,类型查询操作符后是不允许使用表达式的。

想象一下,如果我们将所有变量都设置上类型,那么,当调用函数的时候,当入参接受多个基础类型,我们怎么保证,string类型不会跑到数字运算,boolean不会走到字符串拼接上,换句话说,如何让不同的类型能走他们对应的逻辑呢?

结合上文,我们可以通过typeof,来对不同的类型进行分流,这个是一个最低限度的逻辑分流。

OK,那我们来把判断逻辑提取出来,行程一个公共函数,这样我们就是有了一个判断类型的工具函数了,但想法很好。但结果很骨感

竟然报错了,我明明只是把逻辑提取出来,为什么在isString没有进行分流呢?实际上错误原因显而易见,就是没有进行逻辑分流,换句话说,就是ts不认识isString的结果,那么什么情况下,ts不认识isString的结果呢?答案呼之欲出——上下文。

typescript是根据上下文的类型来进行信息手机,但isString并不在上线文中,typescript并不明白在什么情况下会返回true,他只知道isString返回的是布尔值,那么既然知道了问题所在,那么我们就针对这个问题进行处理,既然typescript不认识,那么我们进行一下标注,他就认识了

typescript引入了is关键字,来显式地提供类型信息,因此在isString中,我们不再使用布尔值来进行标注,而是使用input is string,显式地标注函数逻辑,存在is关键字的类型,会被typescript的类型控制收集到。但这里需要注意到,收集的依赖仅仅是input is string,一切以此标注为准,即使函数内的逻辑是判断是否是number类型。在一定程度上,这个与其说是标注,倒不如说是断言。

in与instanceof

他们也在JavaScript中出现过,in操作符在JavaScript中,一般用于判断某个熟悉是否存在对象或者原型链上,那么我们同样可以用in操作符,来进行类型控制。

同样的,也可以使用instanceof来进行类型控制,instanceof在JavaScript中,判断的是原型链级别的关系,也就是说当前变量是否是继承某个类的结果。

断言守卫

断言守卫如同测试用例的断言一样,如果是出现了与预期不同的结果,会直接抛出错误,也就是说,断言守卫与普通的类型守卫是不同的,在普通的类型守卫中,类型守卫通过if等语法进行类型分流,从而导入其他逻辑,但断言守卫如果不通过,断言守卫就会抛出一个错误。

当然这个属于是约定上的抛出错误,如果不写return或者return void,那么依然是正确的。

上面是一个断言守卫的代码示例,一个未知的name变量传入断言函数,由于在这里设定了断言守卫,判断失败会直接抛出错误,从而终止后续代码的执行,因此,typescript默认断言守卫之后执行的代码都是成功的,而让断言守卫执行成功,之后name是number的情况,也就是说,name类型被断言成了number。

可能会有人在想,如果name是字符串的话,岂不会在toFixed的地方报错吗?事实上,这需要具体实现逻辑的配合,需要在对应的断言函数内部,实现非number抛出错误的逻辑。

泛型

我们终于来到了泛型,泛型在typescript之中无处不在,也是类型编程思维的基础。

我们在之前已经用了了泛型

上面的类型别名可以看做一个函数,T就是他的变量,他的返回值就是包含着T的联合类型。由此看来,类型别名的泛型,大部分是用来作为工具类型封装。

除了映射类型、索引类型等类型工具以外,还有一个非常重要的工具:条件类型。条件泛型经常作为筛选类型的工具。

参数

既然提到了泛型类似参数,那么函数中的参数有默认值,那么泛型也存在这个功能,如同函数一样,我们只需要在泛型定义的地方加上默认值即可。

在不输入默认值的情况下,将以定义泛型的默认值为准。

除了认值以外,泛型还能做到一样函数参数做不到的事:泛型约束。也就是说,泛型可以约束传入的类型,当类型不符合约束的时候就拒绝进行后面的逻辑。虽然在函数逻辑中可以实现,但在参数层面目前没有对应的操作符。

在泛型中,我们可以通过extends来约束参数。

在上图中,我们不仅使用了约束条件,还使用了默认参数,当UserId并没有传入泛型的时候,我们可以为这个泛型参数声明一个默认值。

既然我们搞定了一个参数,那么多参数下的泛型,应该是什么样子的呢?其实多泛型参数其实就像接受更多参数的函数,其内部的类型操作会更加抽象,表现在参数需要进行的类型操作会更加复杂。

我们来看下面的例子。

这里定义了ProcessInput类型,由于第二个和第三个参数存在默认值,这个类型可以接受1-3个参数,第一个参数类型是任意类型,第二个参数必须是第一个参数的子类型,第三个参数必须是第二个参数的子类型。

因为设置了默认值,因此只有一个参数的时候,第二个参数会被赋值为第一个参数,第三个参数会被赋值为第二个参数

对象类型

由于泛型提供了对类型结构的复用能力,我们也经常在对象类型结构中使用泛型。最常见的一个例子应该还是响应类型结构的泛型处理:

在这个例子中,我们将接口返回进行了包装,同时我们可以注意到,这里的泛型在Promise中发生了嵌套,泛型嵌套在项目中也会经常被使用。

这些结构看起来很复杂,但其实就是简单的泛型参数填充而已。就像我们会封装请求库、请求响应拦截器一样,对请求中的参数、响应中的数据的类型的封装其实也不应该落下。甚至在理想情况下,这些结构体封装应该在请求库封装一层中就被处理掉。

直到目前为止,我们针对泛型了解并不怎么深入,但这里需要提醒一下,我们前面说的类型体操,一大部分都是泛型进行了参与。

接下来,我们要来看看泛型的另一个功能:类型的自动提取。

函数泛型

假设我们这里有一个多功能函数,对于这个函数,我们有以下需求

  • 如果参数是字符串,将字符串反转

  • 如果参数是数字,倍数*10

  • 如果参数是对象,修改对象中的某个属性

我们来想想看,这个函数的入参和返回值应该是什么类型。

首先排除any。如果使用联合类型的话,也容易出问题,联合类型虽然比any更加约束类型,但他的入参和返回值并没有进行关联,因此存在入参和返回值不对等的情况。

那么用重载?重载是一个好方法,解决了上面遇到了大部分问题,但他自身也有问题:他维护起来成本太高了。那么既然提到了泛型,让我们来看看泛型能做什么吧。

第一个函数使用了普通的函数泛型,这个函数声明了一个泛型参数 T,并将参数的类型与返回值类型指向这个泛型参数。这样,在这个函数接收到参数时,T 会自动地被填充为这个参数的类型。这也就意味着你不再需要预先确定参数的可能类型了,而在返回值与参数类型关联的情况下,也可以通过泛型参数来进行运算,

既然我们学会了普通的函数泛型,来让我们进行进阶,来看看第二个函数泛型。

第二个函数泛型接受一个元组,这个元组两个分别用T、U来表示,T为元组的第一项,U是元组的第二项,他们都是数字的子类型,而返回值,他们的顺序将颠到。

既然前两个都学会了,让我们来看点进阶的,第三个函数,这个是pick函数,接受两个参数,一个对象,然后接受一个对象属性名组成的数组,并从这个对象中截取选择的属性部分。

在这个函数中,我们分别使用了T、U类型,T约束条件是object的子类型,也就是对象,而U,并非是第二个参数整体,而是第二个参数作为数组的单个元素类型,我们使用前面学到的keyof,来约束每个元素都是对象的key值。

而返回值是用的内置方法Pick,与pick 函数的作用一致,对一个对象结构进行裁剪。

他的源码贴到下面,可以看到本是也是基于keyof来实现的。

到这里,应该能理解函数中如何使用泛型,但需要注意的是,并非所有函数都适合泛型,泛型的作用是类型计算和类型约束,如果在函数中使用了泛型,发现并没有用到以上两个功能,那么使用泛型就没有意义,或者用any来代替

类的泛型

Class 中的泛型和函数中的泛型非常类似,只不过函数中泛型是参数和返回值类型,Class 中的泛型则是属性、方法、乃至装饰器等。同时 Class 内的方法还可以再声明自己独有的泛型参数。我们直接来看完整的示例:

可以看出,跟函数类型是十分类似的,大体区别是泛型定义移动了到class上,在上面的案例,enqueue 方法的入参类型 TType 被约束为队列类型的子类型,这里需要注意,虽然逻辑上直接使用TElementType来代替TType是可以的,但会丢失【被约束为队列类型的子类型】的约束,而变为严格为TElementType,子类型会报错。

而 enqueueWithUnknownType 方法中的 TType 类型参数则不会受此约束,它会在其被调用时再对应地填充,同时也会在返回值类型中被使用。换句话说,可以看做一个any。

内置方法的泛型

比较常见的当属Promise的泛型,在你填充 Promise 的泛型以后,其内部的 resolve 方法也自动填充了泛型。

当然,还有更常用的数组,虽然数组可以使用T[]来定义,不过Array<T>也是可以定义一个数组。数组的泛型会贯穿整个内置方法,不论是push还是includes,在内部预先注入了泛型。

reduce 方法是相对特殊的一个,它的类型声明存在几种不同的重载:

  • 当你不传入初始值时,泛型参数会从数组的元素类型中进行填充。

  • 当你传入初始值时,如果初始值的类型与数组元素类型一致,则使用数组的元素类型进行填充。

  • 当你传入一个数组类型的初始值,reduce 的泛型参数会默认从这个初始值推导出的类型进行填充。可能会导致never。

小结

我们这里更加深入到了typescript。同时大家可以发现,这里的风格发生了一些变化,因为之后的知识会越来越难,单纯的文字没法很好地抽象出结果,于是打量贴代码,毕竟这种更加一目了然。同时整个文章会以我的理解为锚点,尽可能通俗易懂地把每个知识点讲清楚。因为接下来的知识会更加宽泛、复杂,比如类型系统的层级和运算逻辑。所以这并非我们的终点,而是我们新的起点。

0
博主关闭了所有页面的评论