在正式开始之前,先问题问题,你知道在 Typescript(后面简称 TS)中有一个关键字用于推断类型么?是的这个关键字和本文的核心是一样的:infer。意如其字,infer就是推断,这个关键字就是用于推断类型。
类型推断
类型推断在 TS 中其实无处不在,最简单的例子:
const num = 12; // typeof num === number
在这里我们没有明确通过const num: number指定其类型,但是 TS 通过我们赋予其的初始值就可以确定其类型是 number,这就是最基础的类型推断。
同样如果我们声明一个函数:
function fun(a: number, b: number) {
return a + b;
}
const num = fun(1, 2); // typeof num === number
因为我们的函数fun有明确的返回值a + b并且这两个参数都是number类型,可以明确确定函数的返回值就是number类型,所以即便我们没有明确写出fun的返回值fun(): number,但是 TS 会自动给我们推断出函数的返回类型是什么。
以上就是最基础的类型推断。
infer 关键字
那么既然 TS 已经这么强大能够推断出类型,那么为什么还需要infer关键字呢?答案就是,自动推断冉冉不够强大(废话)。毕竟编译器的能力仍然是有限的,不能指望所有情况都能帮助我们推断正确,尤其是 JS 这么灵活的语言,比如:
function logTuple(tup: {tuple: [string, string]}) {
console.log(tup.tuple[0], tup.tuple[0]);
}
const myTuple = {tuple: ['a', 'b']};
logTuple(myTuple);
TS 会给我们一个错误.
Argument of type '\{ tuple: string[]; \}' is not assignable to parameter of type 'A'.
这是因为在我们声明myTuple的时候,TS 就给我们推断了其类型,在 TS 眼中['a', 'b']对应的是string[]类型,而我们声明的A类型里面则对应的是[string, string]的tuple类型,所以这是无法对应上的。而直接logTuple({tuple: ['a', 'b']});则又是可以的,这里就存在着参数类型推断的使用,我们会在后面的篇章中详细说,我们还是先关注失败的这种写法以及infer关键字。
那么这时候我们可以怎么解决这个问题呢?最简单的当然是把类型从参数中提出来,然后在声明myTuple的时候指定其类型来主动告知 TS:
type T = {
tuple: [string, string];
};
const myTuple: T = {tuple: ['a', 'b']};
logTuple(myTuple);
但这种方式有一个缺陷就是,如果logTuple函数并不是我们自己定义的,我们没法去修改其定义,那怎么办呢?
先直接说方案:
type GetFirstArgument<T> = T extends (
first: infer FirstArgument,
...args: any[]
) => any
? FirstArgument
: never;
const myTuple: GetFirstArgument<typeof logTuple> = {tuple: ['a', 'b']};
logTuple(myTuple);
通过这个工具类型,我们可以获取到logTuple的参数类型,进而指定myTuple为这个类型来解决这个问题。而这里我们就用到了infer这个关键字。那么事情是怎么发生的呢?
type GetFirstArgument<T> = T extends (
first: infer FirstArgument,
...args: any[]
) => any
? FirstArgument
: never;
- 第一步:TS 判断我们赋予的 T 类型是否符合
extends限定的类型,在这里是一个至少有一个参数的函数 - 第二步:一旦符合,则把这个 T 这个函数的第一个参数推断出来并且赋值给
FirstArgument - 第三步:如果 T 符合该类型推断,则返回推断出来的
FirstArgument类型,否则返回never
点击查看原文,原文这里有更丰富的组件进行代码讲解,更易于理解。
这是一个极其典型的通过泛型来进行类型推断的例子,而infer关键字在这里的意义则是推断出T函数签名的第一个参数的类型,以及把这个类型赋值给FirstArgument以便后续使用。
需要注意的是,FirstArgument是有作用域的,他只会存在于GetFirstArgument的推断周期内,如果我们在其他地方定义了同名的FirstArgument,并不会影响这里的FirstArgument。
type FirstArgument= string
type GetFirstArgument<T> = T extends (first: infer FirstArgument, ... // 没有任何关系
如果你仍然不理解,你可以把GetFirstArgument看作一个函数:
T是他的参数- 我们在使用
GetFirstArgument类型时,其实就是调用了这个函数,然后传入了logTuple作为参数T - 然后得到的是
=后面得到的类型,这段代码非常像我们在 JS 中二元判断,条件判断内容是T是否符合某个类型,在这里的类型是(first: infer FirstArgument,...args: any[]) => any,也就是一个至少有一个参数的函数 - 只要我们在使用
GetFirstArgument时候给的是这样的一个函数,那么推断条件就成立,就会得到一个推断的FirstArgument类型然后作为结果返回,否则就返回二元判断的另外一个结果
而这个 TS 的函数则是在:const a: GetFirstArgument<(a: number) => void>时调用(不是真的如 JS 函数一样调用,只是进行类比)。
泛型参数类型推断
在开始讲参数推断之前,我们需要至少对 TS 的泛型(generics)有基本的理解,推荐大家至少去官网看一下文档:文档地址。
我这里做一个最简单直观的介绍,我们直接用官方文档的例子:
function wrapper(arg: number): {inner: number} {
return {
inner: arg,
};
}
比如这个函数,他帮助我们把传入的参数外包了一层,使之变成了一个对象。在这个定义里面,我们的入参只能是 number 类型的,但是从函数实现上来说,我们其实并没必要把这个类型限制这么死,我门希望这个函数可以支持更多的类型。这里有很多解决方案,比如我们可以把我们能想象到的类型写到定义上:
function wrapper(arg: number | string | Date): { inner: number | string | Date } {
但是这并不是很好,因为每次我们希望增加一种类型的支持,都需要重新修改函数的定义,并且我们得到的返回值的inner类型是不确定的,不论我们最终传入的是number还是string,你得到的返回值的inner都是定义中的所有可能性。
而泛型则就是用来简单地解决这个问题的,我们来看看泛型的用法:
function wrapper<T>(arg: T): {inner: T} {
return {
inner: arg,
};
}
在这里我们对于函数的定义上增加了<T>,而他就是泛型的关键。当我们使用这个函数的时候就会变成这样:
wrapper<number>(1);
这时候 TS 的编译器就会知道,你这次调用wrapper函数传入的是number类型,所以这次函数调用的返回值也是{inner: number}。
泛型就类似于针对 TS 定义的参数,通过在调用时指定类型,来动态地应用类型于函数定义。就像我们在 JS 中,函数的参数可以控制函数的运行结果一样,所以让我用一句话来定义就是:针对 TS 定义的参数。
OK,泛型的基础用法和理解就讲到这里,更多的用法还是去看官方文档吧。
参数类型推断(type argument inference)
那么接下去我们就来聊聊本文的另一个重点:参数类型推断。在 TS 中,类型推断时非常常见的,比如:
类型推断同样可是使用在函数的泛型定义中,比如上面的例子:
function wrapper<T>(arg: T): {inner: T} {
return {
inner: arg,
};
}
如若我们在使用时直接wrapper(1),这个函数也能正常使用,并且 TS 也能识别出返回的类型是{inner: number}。在这个过程中,TS 首先发现我们传递给wrapper函数的参数是1,是个 number 类型。继而发现wrapper函数接受泛型,因为整个函数只有一个泛型而且和入参的类型时一致的,所以 TS 可以反推出<T>就是number类型。而这个过程是在wrapper(1)调用的时候确定的,所以就很类似 JS 的函数。
参数类型推断非常有用,尤其是当入参的类型非常复杂的时候。典型的例子是 vue3 的defineComponent函数。
defineComponent用于定义一个组件,而组件定义的配置内容的类型时非常复杂的:
defineComponent({
props: {},
data() {},
setup() {},
mounted() {},
...
})
如果我们需要在使用该函数的时候把整个配置的内容都定义出来,那么我们的代码量可能需要增加一倍,并且整个学习难度也自然会上升一些,毕竟你需要记住这些参数的类型。
我们可以看一下defineComponeng的定义:
export function defineComponent<
PropsOptions extends Readonly<ComponentPropsOptions>,
RawBindings,
D,
C extends ComputedOptions = {},
M extends MethodOptions = {},
Mixin extends ComponentOptionsMixin = ComponentOptionsMixin,
Extends extends ComponentOptionsMixin = ComponentOptionsMixin,
E extends EmitsOptions = Record<string, any>,
EE extends string = string
>(
options: ComponentOptionsWithObjectProps<
PropsOptions,
RawBindings,
D,
C,
M,
Mixin,
Extends,
E,
EE
>
): DefineComponent<PropsOptions, RawBindings, D, C, M, Mixin, Extends, E, EE>;
这还仅仅是一种函数签名,vue3 的defineComponent函数签名有 4 种 overrides,其复杂程度可想而知。索性有参数类型推断,让我们避免了每次使用都要写一堆复杂定义的烦恼。
我们只需要:
const Comp = defineComponent({
props: {
name: String,
},
});
我们就知道Comp的 props 中有一个名为name的属性类型为String。
extends
我相信你已经注意到了在上面的defineComponents存在着非常多的extends。这又是 TS 中对于类型推断非常重要的一个字段。他的重要性体现在对鱼类型约束上。当你写function A<T>(opt: T): T的时候,这里的T可以是任意类型,所以你调用函数A(1)的时候,T的类型又完全交还给 TS 来判断,比如之前的数组例子:
function A<T>(opt: T): T {
return opt;
}
const tuple = A(['a', 'b']);
这时候tuple会是string[]类型,而不是我们期望的[string, string]类型。这时候我们就可以给T一个约束:
function A<T extends [string, ...string[]]>(opt: T): T {
return opt;
}
const tuple = A(['a', 'b']);
此时我们的tuple就会是[string, string],我们给T约束为string类型的 tuple,TS 就会在类型推断的时候有限匹配约束的类型,如果类型无法匹配则会报错。
类型约束在类型推断中是非常有用的,事实上你应该尽量在使用泛型时赋予一个约束,这能够帮助我们更精确的得到自己想要的类型,你可以回过头去看 vue3 的difineComponent,几乎所有的泛型都是用extends来进行类型约束。
实例讲解
最后我们通过一个例子再来展示一下参数类型推断的作用,假如有如下的场景:
function defineStore(options) {
return options; // 你创建的store
}
defineStore({
actions: {
a: (commits) => commits.a,
},
commits: {
a: (state, newName) => 'a',
},
state: {
name: 'jokcy',
},
});
我们想要创建一个定义store的函数,这个定义比较类似 vuex,我们分成state/actions/commits三部分,在每个commit函数中我们会接收state作为第一个参数,便于我们更新数据;在每个action中我们接受所有commits作为参数,这样我们在action中可以进行数据更新。
那么为了更好的体验,我们自然希望每个commit中拿到的state可以知道其类型,同样在每个action中也知道所有commit的函数签名,这样我们调用commit的时候就知道要传哪些参数,TS 也可以帮我们在代码检测帮我们定为错误。我们可以这么做:
type MyCommits = {...}
type MyState = {...}
a: (commits: MyCommits)
a: (state: MyState)
但这就要求我们对整个定义的类型做出声明,工作量非常大,而且未来你改一个函数,你还得改一遍类型,所有工作都 double 了,你愿意么?我肯定不愿意,那么怎么办呢?我希望defineStore来帮我完成这件事情,我希望我在调用他的时候自动帮我检测这些关键类型,在这里也就是state和commits,我们先来看state:
type Options<State> = {
actions: Record<
string,
(commits: Record<string, (...args: any) => void>) => void
>;
commits: Record<string, (state: State, ...args: any) => void>;
state: State;
};
function defineStore<State>(options: Options<State>) {
return options; // 你创建的store
}
我们为defineStore定义来一个State泛型,这样之后,你在定义commit的时候,你拿到的第一个参数state自动会推断出其类型,而这个类型的依据就是你传入的state的值。
注意: 你可以打开TS Playground把上面的代码复制进去看一下运行结果。
既然state可以这样,那么自然commits也可以,所以我们会得到以下的结果:
type Options<
State,
Commits extends Record<string, (state: State, ...args: any) => void>
> = {
actions: Record<string, (commits: Commits) => void>;
commits: Commits;
state: State;
};
function defineStore<
State,
Commits extends Record<string, (state: State, ...args: any) => void>
>(options: Options<State, Commits>) {
return options;
}
defineStore({
actions: {
a: (commits) => commits.a,
},
commits: {
a: (state, newName: string) => 'a',
},
state: {
name: 'jokcy',
},
});
但是你实际运行这段代码时却会发现在actions里面并拿不到真正的commits而是Record<string, ...>,这里有一个很奇怪的问题,我也正在咨询 TS 官方人员,在得到答案之后会再继续更新(如果同学们知道也可以在评论区回复哦,解决问题有惊喜)。这个问题时可以解决的,怎么解决呢?只需要手动声明每个commit中的state的类型就可以了:
type State = {
name: string;
};
commits: {
a: (state: State, newName: stirng) => 'a';
}
你可以在 playground 尝试一下。这是目前一个可以接受的方案,因为我也会推荐你声明一下你的state类型,因为自动推断数据的类型经常不准,比如你直接写: state: {arr: []},你会得到never[]导致你无法修改arr,所以你肯定是要声明具体每个数组项的类型的。不论如何,定义的大头,commits他肯定是帮我们省去了。
小结
本文中我尽力为大家解释清楚 TS 中的类型推断(type infer)是怎么回事,我相信你自己尝试去理解一下把 TS 泛型类比函数的方式,一定会帮助你更好地理解。类型推断是非常重要的,如果你对于这一块不够理解,阅读 TS 代码就会比较困难,尤其是一些开源项目的定义,那是极其复杂的,各种泛型套泛型,所以即便你可能自己不会去写这么复杂的定义,推荐你至少对类型推断有一个总体的认知,防止在遇到类型问题时一脸懵逼。
另外类型推断的熟练也是非常依赖练习的,建议各位在写 TS 代码时尽量不用any,尽量把类型定义地足够精确,当你尝试这么去做的时候你自然会接触到类型推断的使用,你可能会遇到很多问题,但是千万不要害怕,花时间一个个去解决,不要一解决不了就any,在解决问题之后你就会发现你的能力又提高了很多。
另外推荐大家可以用一下 Discord,TS 官方有一个讨论组在上面,并且有专门的 help channel,你可以去提问,会有专门的人来回复你(当然你的英语也得过关,以及你一定要学会好的提问方式)。频道链接。
最后的最后,TS 毫无疑问在前端的重要性会越来越大,作为希望长期从事前端同学,必须好好掌握,我也是最近才真正开始承认这一点的重要性,并且 TS 虽然不难也并没有那么简单,我考虑未来也会出 TS 基础方向的工具,以交互式的体验带给大家最好的学习体验,敬请期待吧。