在 Javascript 中,变量名,键的命名风格一般都是 camelCase 的,而在许多接口规范中,则都是接受 PascalCase 风格的 JSON Object。完成大驼峰参数到小驼峰参数的转换,这在 Javascript 中是较容易实现的。可是在 Typescript 中,我们进行了这样的转换后,就会丢失类型的信息。这导致我们每次进行转换后,都必须重写类型,或是放弃后续的类型检查。
-
忽略后续类型违反了重构 Typescript 的初衷 这导致后续的代码都失去了类型检查的保护,在后续业务变更新增后续代码,更会导致更多的问题
-
重写类型导致需要进行小驼峰类型 <-> 大驼峰类型的重复编写工作 在编程中,可怕的不仅仅是重复工作导致的工作量增加,更严重的是,重复可能会带来错误:某个重复中的错误会导致错误难以排查,而多个重复中共有的错误,则会导致错误难以修正 这也就是违反了编程中我们常说的 DRY 原则 (Don't Repeat Yourself)
遗憾的是,这个问题在 Typescript 4.1 以前,是简单的没有解决方法的,不过幸运的是,4.1 版本已经进入了 stable 阶段,它带来的字符串模版类型 template literal type
可以让我们修改字符串常量的类型,进行各种强大的操作,例如,通过下面的 PascalCasedPros<T>
,我们就可以将小驼峰的接口类型中的每个键转为大驼峰:
type PascalCase<T> = T extends string
? T extends `${infer A}${infer B}`
? `${Uppercase<A>}${B}`
: T
: T;
type PascalCasedProps<T> = T extends Function
? T
: T extends Array<infer U>
? Array<PascalCasedProps<U>>
: {
[K in keyof T as PascalCase<K>]: PascalCasedProps<T[K]>;
};
咋一看其中内容是非常复杂且混乱的,但让我们以具体的代码为例子,具体的一步步来解析一下:
PascalCase<T>
将字符串常量类型 T
转换为对应的大驼峰版本
首先,我们需要一个能将一个字符串字面量 (string literal
) 类型转换为对应的大驼峰字符串字面量类型的泛型类型别名 generic type alias
。
而我们要的泛型类型别名,就是如下的 PascalCase<T>
:可以看到,我们传入一个字符串字面量类型,它就可以完成我们需要的转换。
type PascalCase<T> = T extends string
? T extends `${infer A}${infer B}`
? `${Uppercase<A>}${B}`
: T
: T;
type CheckMe = PascalCase<"HelloWorld">;
// type CheckMe = "helloWorld"
但它是如何工作的呢?让我们来逐步解析一下:
最外部是 T extends string ? (...) : T
这样的结构,这有点像三目运算符,实际上,它起的正是这样的作用:
- 首先,
A extends B
是判断A
类型是否扩展了B
类型(比如一个类实现了另一个接口),在这里,T extends string
也就是判断T
类型是否是string
- 如果不是 string,我们进入
: T
部分,很简单,即不做转换,直接返回T
类型
中间的部分则要复杂一些,涉及到字符串模板类型,让我们下面来看一下:
T extends `${infer A}${infer B}`
? `${Uppercase<A>}${B}`
: T
-
首先,这部分和之前一样,也是判断 T 是否扩展了
${infer A}${infer B}
类型T extends `${infer A}${infer B}`
-
这里我们需要讲解一下
infer
在 typescript 中的作用,它代表让 TypeScript 的类型系统推测一个类型,也就是说,让编译器自动根据字符串字面量T
的结构,自动推出A
和B
对应的部分。 -
理解了上面的内容,我们回过头来看完整的中间部分,就没有那么难以理解了
T extends `${infer A}${infer B}` ? `${Uppercase<A>}${B}` : T
可以看到,在编辑器工作时,将
A
推断为第一个字符,B
推断为后续部分。(当不影响后续匹配时,每个推断类型会匹配尽量少且不为空串的子字符串,所以A
只匹配第一个字符)。例如,对于字符串"helloWorld"
的匹配,A
将被推断为"h"
,B
将被推断为后面的"elloWorld"
。推断完成后,我们用
${Uppercase<A>}${<B>}
将首字母小写的源串拼接回来,即完成了字符串字面量类型首字母大写的转换。
PascalCasedProps<T>
递归的将接口 T
中的所有键转换为大驼峰
type PascalCasedProps<T> = T extends Function
? T
: T extends Array<infer U>
? Array<PascalCasedProps<U>>
: {
[K in keyof T as PascalCase<K>]: PascalCasedProps<T[K]>;
};
最后,通过这个 PascalCasedProps
的泛型类型别名,我们就可以实现我们的目的了。但是,它看起来还要更复杂,但其实基本原理还都是一样的,我们还是具体的来解析一下吧:
-
这部分,是判断
T
是否是一个函数,若是,我们需要原样返回类型T
,否则继续进行判断:T extends Function : T
-
这部分,则是判断我们传入的
T
类型是否是数组类型T extends Array<infer U>
- 如果是的话,我们不能对它直接应用
PascalCasedProps<T>
,(为什么不能,我们稍后进行解释)。- 我们要对数组项的类型做转换,也就是编译器帮助我们推导出来的
U
类型,最终的写法也就是
T extends Array<infer U> ? Array<PascalCasedProps<U>>
- 我们要对数组项的类型做转换,也就是编译器帮助我们推导出来的
- 如果是的话,我们不能对它直接应用
-
而如果
T
类型不是数组,比如是一个对象,我们要怎么处理呢?具体的,在这部分:{ [K in keyof T as PascalCase<K>]: PascalCasedProps<T[K]>; };
-
首先,在左边的
[K in keyof T as PascalCase<K>]
中,keyof T
即是指类型T
的所有键。举一个例子:// type keys = 'keyA' | 'keyB' type keys = keyof { keyA: string; keyB: number; };
而
K in keyof T
是将一个接口的键,对应到另一个接口上的写法。K
一一对应的代表了T
中每一个键的类型。 -
后面的
as PascalCase<K>
则是对每个键的数值映射到对应的小驼峰风格,我们则通过T[K]
取出每个键对应的值的类型,并将它递归的转为小驼峰。至此,我们的转换就顺利完成咯。
-
补充内容:为什么对数组和函数特殊处理?
不过,为什么前面需要对数组和函数进行特判处理,而其他值不用呢?可以首先试一下,为什么要对数组进行判断:
type PascalCase<T> = T extends string
? T extends `${infer A}${infer B}`
? `${Uppercase<A>}${B}`
: T
: T;
type PascalCasedProps<T> = T extends Function
? T
: // : T extends Array<infer U>
// ? Array<PascalCasedProps<U>>
{
[K in keyof T as PascalCase<K>]: PascalCasedProps<T[K]>;
};
type A = PascalCasedProps<string[]>;
如果注释掉判断数组的部分,我们转换后的类型 A
在编辑器中可以看到,变成了:
type A = {
[x: number]: string;
Length: number;
ToString: () => string;
ToLocaleString: () => string;
Pop: () => string | undefined;
Push: (...items: string[]) => number;
Concat: {
(...items: ConcatArray<string>[]): string[];
(...items: (string | ConcatArray<...>)[]): string[];
};
... 15 more ...;
ReduceRight: {
...;
};
}·
这是因为 JavasScript 中数组也是一个对象,它的全部内置方法(例如 .Push()
)也被转换为大驼峰类型,而这显然不是我们想要的。
- 而函数在转换后,则变成了一个空 Object
{}
:这应当是函数本身也被认为是对象,且没有任何属性的缘故吧:
// type A = {}
type A = PascalCasedProps<() => {}>;
为什么会这样呢?我做了一个小的试验:
let foo = 1;
foo.bar = 2;
console.log(foo); // 1
console.log(foo.bar); // undefined
let bar = () => {};
bar.foo = 3;
console.log(bar); // [Function: bar]
console.log(bar.foo); // 3
可以看到,JavaScript 中的函数确实能作为一个对象,为它的属性赋值,而非引用类型则不可以(虽然不会报错,但值并不会保存下来)
而对于其他任何非引用类型(如 Number
, String
等),经过我的试验,Typescript 由于不认为他们是对象,会直接原样返回他们的类型。
总结
在利用 TypeScript 进行接口风格转换的过程中,我可以说第一次认识到了 TypeScript 4.1 类型系统的强大之处。在 4.1 版本刚刚发布时,网上有许多声音认为 TypeScript 已经越来越复杂,变得和 Java,C++ 一样了,我个人看着 Template Literal Type 也是摸不着头脑,一时并没有想到这个功能的 use case。但没想到,在一次项目的 TypeScript 重构中,我就体验到了这个功能不仅强大,更及具意义。
程序员间有一个经典的说法:程序编写时永恒的两个问题,分别是变量命名和缓存一致性。如今,随着业务变得越来越复杂,我们调用的,来自不同服务的接口的命名规范,也变得越来越多样。而作为前端工程师,不论是编写页面还是所谓的 BFF 层,如何高效的完成各类 API 的调用和聚合,在我们的开发工作中也是至关重要的。
TypeScript 中可以说实现了应该是目前编程语言中最为强大的类型系统,掌握好其中的高级特性,我们就可以完成对许多类型的管理和转换,高效地写出严谨对应业务数据形式的代码,提高代码的可知性,尽可能在编译时避免绝大部分的问题。
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!