注:本文typescript版本为5.0.2
typescript笔记我们就在这一节结束了,虽然关于类型体操可以继续展开讲讲,列举各种体操类型,但对于笔记类型的文章来说,太过冗余的知识反而会对之后翻阅造成障碍,如果想要更多的类型编程练习,可以去刷一下题,https://github.com/type-challenges/type-challenges。在这一节,我们将继续讲一些类型相关的知识,同时把命名空间和装饰器讲一下,那些了零碎的的知识,将都被收集、打包到本节。
内置类型
我们之前提到过一些内置类型工具,自己也实现了一些类型工具,那么常用的类型工具都有哪些呢?他们的实现方式都是怎样的呢?
Parameters、ReturnType
Parameters这个类型工具用于提取参数类型,这个用之前知识就能判断出来他的实现方式
其中,T就是目标函数,然后通过infer推断出他的参数,这个之前的模式匹配我们已经学到了。
ReturnType是提取函数的返回值,同理,我们很容易理解他的实现方式
ConstructorParameters、InstanceType
那么,既然函数的参数可以被提取,那么特殊的函数——构造器的参数可以被提取吗?答案是可以的,ConstructorParameters的作用是提取构造器的参数类型,我们来看看源码。
ConstructorParameters、InstanceType
那么,既然函数的参数可以被提取,那么特殊的函数——构造器的参数可以被提取吗?答案是可以的,ConstructorParameters的作用是提取构造器的参数类型,我们来看看源码。
我们可以看到,T是传入的构造器,这里遇到了一个很早之前的学习到的标识符abstract,abstract的作用是表示当前是抽象类或者抽象方法,这里存在一个父类型和子类型的问题。
我们直接上代码举例。比如存在两个类,一个抽象类一个正常类,他们的区别是抽象类加了abstract,同时不允许直接实例化。
那么我们按照前文的extends,来判断他们的类型关系
也就是说抽象类是正常类的父类。那么回过头来看ConstructorParameters,判断当前类型是不是一个抽象类的子类,如果不加abstract,那么正常的类判断还好,如果T正好是一个抽象类,那么就不会通过校验,从而报错,与我们的需求不符,因为抽象类也是类。
过了abstract,其他的逻辑就与Parameters相同了,ConstructorParameters单独拎出来,是因为存在着抽象类是正常类的父类,这一个概念。
那么相对的,我们也可以提前构造器的返回值的类型,就是InstanceType
根据上面的逻辑,通过infer推断出返回值,然后返回
ThisParameterType、OmitThisParameter
我们都知道,函数里面可以通this,那么this的指向的类型我们也可以用来提取
T是待处理的函数,我们可以看到this这里面成为了参数一样的存在,我们通过infer来推断这个this,如果推断成功就进行返回。
那么。如果我们准确提取到了this,我们岂不是可以进行操作这个this的类型,甚至删除这个this了呢。
上文是OmitThisParameter的源码,我们可以看到他用了ThisParameterType来提取this,如果提取出来的是unknown,也就是说明他的this没有指向,这里还需要补充一点,他还有一个隐式判断,any,如果this是any,unknown extends any也是成立的,因此,如果提取出this是any,也会默认this没有被提取出来,没有提取出this,那么就会原类型返回,反之,就会推断他的参数和返回值,这里需要注意的是,如果是推断this,需要显式注明this,而参数的话,直接用Rest来接收参数即可,并不会附带this,如果推断成功,那么就重新构建一个函数类型, 构建出来的函数虽然跟extends的推断相同,但却由于Rest的接收参数不接收this的行为,过滤掉了this
Partial、Required、Readonly
这几个我们会经常用到,第一个Partial,可以把映射类型的索引作为可选
T为待处理的类型,通过将T的索引和索引值遍历出来,统统加上?。所得到的新类型中的所以都变成了可选索引。
既然可以变为可选,同样也可以去掉可选,也就是Required。
我们可以用-?语法,将所有的可选类型都变为必选。
那么readonly也具有同等的思路,将每个值遍历出来,然后都加上readonly修饰符,那么每一个值都变成只读的了。
Pick、Record
pick是经常使用的内置工具类型,他用于将某一个索引类型中,取出确定索引和索引值,然后组成一个新的索引类型。
而Record,也跟pick类似,但不是从已有的索引类型获取,而是根据传入的索引,返回固定类型的索引类型。这些类型将有传入的第二个值确定。
这里用了一个技巧,因为我们是从头生成一个索引类型,但索引的类型我们虽然可以写死在源码中,但之后不好维护,因此这里用了keyof any,生成了当前有效索引类型的联合类型。比如当前有效的类型是string | number | symbol。那么 keyof any 就是string | number | symbol,从而构建合法的索引类型。
Exclude、Extract、Omit
Exclude用于将一个联合类型去掉一部分类型。
还记得我们之前的学习吗,如果泛型出现在extends左边,那么就是被分布式传入,因此我们可以利用这个特性,去掉联合类型中的某些类型。
源码很简单,我们来分析一下,T是目标联合类型,U是需要去掉的类型,可以是联合类型,T每次都会进行分布式计算,如果T的分布式类型存在U之中的类型,就返回never,反之就返回分布式类型。举个例子。
如果T是 'a' | 'b' | 'c' | 'd',U是 'a' | 'b',我么想去除a,b剩下 c,d,那么在分布计算中,会首先判断'a' extends 'a' | 'b' ? never : 'a',答案肯定是never,然后分别遍历剩下的几个,然后结果组成联合类型,结果最后是never | never | 'c' | 'd'最后,整理,答案是'c' | 'd',完成了去除的需求。
既然有了过滤,那么就有了保留,Extract,保留的思路和过滤的类似,都是利用分布式计算,只不过结果调换了一下,之前需要去掉的留下,不去掉的改为never。
让我们接着发散一下,我们上文说了可以过滤联合类型,留下剩下的联合类型,那么我们能不能过滤索引类型呢?答案是肯定的,如果我们配合pick的话。
根据源码,我们通过T传入原始索引类型,然后k是要过滤掉的类型,然后通过Exclude取出应该留下的索引,然后通过Pick从原始索引类型中取出对应的索引值,从而组成一个新的索引类型。这样就实现了删除的目的。
Awaited
还记的我们之前通过递归获取Promise的返回值吗,实际上这个已经被内置到工具类型中了,他的源码处理的边际情况较多。
如果传入的类型是null或者undefined,就原路返回,如果,里面存在then方法,那么尝试推断他的参数,也就是成功的回调函数,如果提取失败就原路返回最开始判断类型,提取成功那么就看这个成功函数的第一个参数,如果没有参数,就范湖never,如果存在参数就进入递归——即使可以在这里判断是否是Promise,他们并没有这么判断,而是交给递归中进行逻辑判断。
NonNullable
判断是不是非空类型,如果不是null或者undefined就原路返回,如果是的话就返回never,这个实现在4.8版本改动过,以最新的为例。
我们之前讲过,{}几乎是所有原始类型的父类型,为什么是几乎呢?因为还有null和undefined
那么什么类型实现了自己,还实现了{}?那么就是除了null和undefined的原始类型,如果非要一个类型,实现null或者undefined以及{}呢?那就是一种特殊的类型来代替了——never,没错,就是预期返回值。这样就可以巧妙的过滤出null和undefined。
那么?特殊类型呢?我们还记的有any和unknown,以及void,他们比较特殊。
any是万物的父类型,又是万物的子类型,因此any会原路返回any,这很合理。
unknown会返回{},也就是说,在4.8一个比较重要的改动,unknown会被视为{} | null | undefined,我们看一下4.8之前的实现。
这看起来没问题,但如果我写一个这样的函数,就会报错。
报错信息是类型 T 不能赋值给 NonNullable 的类型。
这是因为编辑器不知道value的是不是非空, NonNullable的结果有可能是T,也有可能是never, NonNullable的结果并非是联合类型,而是一个确定的类型,而返回值T理应完全符合 NonNullable,但显而易见,T并不能完全符合,因为 NonNullable可能得出不是T类型的类型。
如果更换为T和{}的联合类型,那么这个NonNullable的返回类型依然包含了T,将类型 T 与空对象类型 {} 进行交叉操作时,它实际上不会改变 T 的类型。交叉类型 T & {} 本质上与类型 T 是相同的。因此上面的函数不会报错。
这样也增强了typeScript 的类型控制流,如果一个值是unknown,那么if true的时候,类型将会是{},在4.8之前,那么if true的结果依然是unknown
void比较神奇,他没有一个合理的逻辑,他的返回值是void & {},因此在代码中,尽可能减少void类型运算。
工程能力
命名空间
在很在之前,我们提到过js实现的命名空间,而js的实现方式是全局挂载一个对象,然后对象上挂载不同的方法,而ts的实现也是如此,不过ts的使用却有着专门的方法
当然,命名空间还可以进行嵌套,也就是
导入的时候也需要多写一层,因为他们的实现方式是实际上就是js的往对象上挂载方法、甚至另一个对象,或者说命名空间。
后来,ts支持了module,也就是如下代码,我们可以为某个没有类型定义的npm包,进行手动声明类型,添加类型提示
但实际上他们的使用上并没有任何区别,甚至他们的AST都是一样的。
类型声明
上文我们使用了declare,这是一个类型声明语法,仔细想想,我们在其他的地方会见打量见到这个语法,那就是X.d.ts,也就是类型声明文件。
比如我们存在这么一段ts代码
那么我们生成的d.ts就是这种的
那么生成他有什么用呢?这些声明就如同ts的标注一样,会存特定的类型信息,并且他们不具备实际的逻辑,因此我们可以使用他们来进行类型的兼容比较、工具类型的声明、以及测试。
在typescript下有个lib包,里面是ts内置的类型声明。里面声明了es和dom类型。
不过,node环境并没有进行标准化,因此他的变化是相当快速的,ts依然可读取到他的声明,他们是怎么做到的呢?
那是因为可以通过@types/xxx包的形式。
在工程中,typescript会首先加载内置的lib声明,然后再查找@types下的声明。
三斜线指令
以上我们说的是typescript自动进行类型的导入,那么有没有办法进行手动呢?答案是有的,我们可以通过三斜线指令,来声明当前文件依赖的其他类型声明。他本质是一个自闭合的XML标签,需要我们把他放到顶部
可以看到,我们在这里使用了三种方法来引入声明类型。
path
path的实现是一个相对路径,指向项目其他声明文件,当然,其他声明文件依然也可以使用三斜线指令,然后引入其他的依赖,因此编译的时候typescript会根据path层层深入,最深的那个就是没有依赖其他生命文件的,那个声明文件会被优先加载。
types
types实现是一个包名,也就是上文的@types/XXX包,比如上面的例子,是@types/node包的依赖。如果ts文件中声明了某个包的类型导入,那么在生成的d.ts文件,会自动引用那个包对应的@types包。
lib
lib实现类似@types,但导入的并非@types包中的类型声明,而是typescript内置lib下的类型声明。
类型合并
我们知道type的类型是不合并的,后面的type或者interface被覆盖,但如果是命名空间或者declare,就会实现interface相同的效果,他们会进行合并,而非覆盖,因此我们可以利用命名空间或者declare,来给全局或者部分其他包增加类型。
那么还会产生一个问题,如果我们前面的某个文件声明了一个类型,后面不小心声明一个相同的类型,岂不是要覆盖或者合并了?在这里,typescript有个设计,在d.ts中,如果没有import、export语法,那么所有类型声明就是全局的,否则,就是模块内的。
比如我们在a.d.ts声明一个函数
在任意一个文件里面输入A('1')是有提示的,因为此时是A是一个全局类型,但如果增加任何一个引用或者导出。
那么此时之前的A('1')就会报错,提示没有对应类型,因为此时a.d.ts是一个模块内类型,而非全局类型。
因此,我们可以使用reference,也就是三斜线指令,引入外部类型,从而不用担心使用了import,把当前模块变为内部模块。
那么,如果我使用了import,但我依然想要某个类型是全局类型呢?那么我们只需要把声明的类型挂载到global下即可。
上文说到,命名空间和module会导致类型合并,因此不建议用这两个类型声明,而是采用es module。
而es module还兼并导入实际逻辑的功能,那么如果我只想导入type呢?
很简单,我们只需要加一个import type语法,即可单独引入类型。
我们即可导入FooType类型。
装饰器
为什么装饰器要最后讲呢?因为历史原因,装饰器从提案开始就相当坎坷,因此js的装饰器和ts的装饰器是不同的两个东西,在js的提案中过去了数年,在语法、作用、运行机制等迭代了四个版本。终于在222年进入stage 3,但ts和babel的装饰器是根据第一版提案实现的,其中一个原因是存在ts的超集语言Atscript,他支持了装饰器语法,为了避免社区分裂,ts引入装饰器,而Atscript放弃维护。
在ts中,装饰器本质是一个函数,只不过参数是提前确定好的。在ts中,目前装饰器只能在类或者类成员上使用。不过可以分为几种类别。
类装饰器
类装饰器是直接作用在类上的装饰器,它在执行时的入参只有一个,那就是这个类本身(而不是类的原型对象)。因此,我们可以通过类装饰器来覆盖类的属性与方法,如果你在类装饰器中返回一个新的类,它甚至可以篡改掉整个类的实现。
在这里,我们使用使用了两个类装饰器,分别是AddProperty、AddMethod,给类增加了两个方法和一个属性,因为js中,class本质就是原型链的语法糖,因此我们往原型上挂东西是可以被class继承和使用的。
实际上,装饰器并不仅仅是修改,还有篡改。
在这里,我们直接用装饰器返回了一个新的class,我们发现Bar变成了我们返回的新的class。
方法装饰器
方法装饰器的入参包括类的原型、方法名、以及方法的属性描述符,而我们可以通过属性描述符控制方法的内部实现甚至是可变性。
这里需要注意,target是类的原型,而非当前的类。
访问符装饰器
这个装饰器不常见,但其实他就是get 、set的装饰器,访问符装饰器只能同时应用在一对getter/setter其中一个,因为不论装饰哪一个,装饰器的入参都包括setter和getter方法。
在这个例子中,我们通过装饰器劫持了 setter ,在执行原本的 setter 方法修改了其参数。同时,我们也可以在这里去劫持 getter(descriptor.get),这样一来在读取这个值时,会直接返回一个我们固定好的值,而非其实际的值(如被 setter 更新过的)。
属性装饰器
属性装饰器在独立使用时能力非常有限,它的入参只有类的原型与属性名称,返回值会被忽略,但你仍然可以通过直接在类的原型上赋值来修改属性
参数装饰器
参数装饰器包括了构造函数的参数装饰器与方法的参数装饰器,它的入参包括类的原型、参数所在的方法名与参数在函数参数中的索引值(即第几个参数),如果只是单独使用,它的作用同样非常有限。
执行顺序
我们有这些装饰器,但这些装饰器部分功能是冲突的,比如类装饰器可以覆写整个类,那么他们的执行机制是怎样的呢?
我们直接用以下代试一下
从打印结果来书说,我们发现,实例方法和实例属性的执行、应用顺序取决于他们是否在前,而方法内部顺序是方法装饰器先执行、方法参数装饰器后执行、然后应用,最后方法装饰器结束应用。之后执行类的装饰器,构造函数作为类的参数,执行了他的参数装饰器,然后进行应用,最后,类的装饰器进行应用。
由此,我们可以总结为(属性 执行、应用 -》 \ 方法执行 -》 参数执行 -》 参数应用 -》 方法应用)-》 类执行 -》 构造函数参数执行 -》构造函数参数应用 -》 类应用
但是一般来说,一个类会有很多相同的装饰器,他们的执行顺序呢?
我们看到,他们类似洋葱模型一样,一层层进入,然后一层层出来,先进后出,也就是后面参数的装饰器的逻辑会先执行,但我们通常不会在同种装饰器中进行存在依赖关系的操作。 对于属性、参数装饰器来说,我们通常只进行信息注册,委托别人处理。对于方法装饰器来说,我们最多只进行方法执行前后的逻辑注入。而这些过程都应当是彼此独立的。
那么,我们在Nest经常看到他们使用装饰器来管理依赖关系,那么他们是怎么实现的呢?答案是用到了ES6的Reflect。
Reflect
Reflect默认大家都了解了,但正因为如此,大家可能会疑惑,Reflect中并没有实现装饰器的功能,答案是Reflect Metadata,反射原数据,一个很早的提案,却没有成为ES的一部分,因此所有基于Reflect Metadata的操作都是import "reflect-metadata"第三方包来解决的。实际上,反射元数据正是我们实现属性装饰器中提到的“委托”能力的基础。我们在属性装饰器中去注册一个元数据,然后在真正实例化这个类时,就可以拿到类原型上的元数据,以此对实例化完毕的类再进行额外操作。
@Reflect.metadata 装饰器会基于应用的位置进行实际的逻辑调用,如在类上装饰时以类作为 target 进行注册,而在静态成员与实例成员中分别使用构造函数、构造函数原型。
运行代码可在控制台看到 Aprop type: string。除能获取属性类型外,通过 Reflect.getMetadata("design:paramtypes", target, key) 和 Reflect.getMetadata("design:returntype", target, key) 可以分别获取函数参数类型和返回值类型。
小结
这一节我们关于typescript的内容就学习完了。我们这一节了解到了最后一些收尾知识,包括内置类型、工程能力以及最后的一大块知识点——装饰器。
不过不要忘了,我们这依然是入门知识,当我们学会如何熟练使用他们的时候,我们才迈过入门的坎,达到了解的程度。
回顾之前,这系列笔记确实花费不少精力,但是都是值得的,不管怎么说,这并非我们的终点,而是我们新的起点。