Type script 入门实战笔记 - 快手资深前端技术专家,快手轻雀协作前端负责人 - 拉勾教育

最近几年,作为一名前端面试官,每次提问候选人时,我会经常提问 TypeScirpt 的这两个知识点:一个是如何使用 TypeScript 实现与 call(或者 apply) 功能类似的函数,重在考察候选人对泛型的应用;另一个是什么是泛型?泛型的作用是什么?重在考察候选人对泛型的理解。

如今,TypeScript 可谓是前端必须掌握的基本技能之一,而泛型则是 TypeScript 中非常基本、非常精华(有挑战)的特性,属于 TypeScript 入门(重在基础知识)和进阶(重在应用实践)之间衔接和升华的内容。因此,我把泛型的相关知识放在了 10 讲进行讲解。

在我看来,掌握了泛型后,就可以随心所欲地使用 TypeScript 了。

学习建议:使用 VS Code 新建一个 10.ts 文件,并尝试这一讲中所有的示例。

什么是泛型?

关于什么是泛型这个问题不是太好回答,比如在面试中,如果有候选人反过来问我这个问题,可能我也给不出一个特别标准的答案。

不过,我们可以借用 Java 中泛型的释义来回答这个问题:泛型指的是类型参数化,即将原来某种具体的类型进行参数化。和定义函数参数一样,我们可以给泛型定义若干个类型参数,并在调用时给泛型传入明确的类型参数。设计泛型的目的在于有效约束类型成员之间的关系,比如函数参数和返回值、类或者接口成员和方法之间的关系。

Tips:
请一定要记住这个答案哦~ 在某天面试时,如果碰到的面试官恰好是我,那么你至少已经答对了一半 TypeScript 的面试题;至于另外一半(实现 call)就作为这一讲的课后思考题,学完后,你自然能回答得上(思路)来。

泛型类型参数

泛型最常用的场景是用来约束函数参数的类型,我们可以给函数定义若干个被调用时才会传入明确类型的参数。

比如以下定义的一个 reflect 函数 ,它可以接收一个任意类型的参数,并原封不动地返回参数的值和类型,那我们该如何描述这个函数呢?好像得用上 unknown 了(其实我想说的是 any,因为 any is 魔鬼,所以还是用 unknown 吧)。

  1. function reflect(param: unknown) {
  2. return param;
  3. }
  4. const str = reflect('string');
  5. const num = reflect(1);

此时,reflect 函数虽然可以接收一个任意类型的参数并原封不动地返回参数的值,不过返回值类型不符合我们的预期。因为我们希望返回值类型与入参类型一一对应(比如 number 对 number、string 对 string),而不是无论入参是什么类型,返回值一律是 unknown。

此时,泛型正好可以满足这样的诉求,那如何定义一个泛型参数呢?首先,我们把参数 param 的类型定义为一个(类型层面的)参数、变量,而不是一个明确的类型,等到函数调用时再传入明确的类型。

比如我们可以通过尖括号 <> 语法给函数定义一个泛型参数 P,并指定 param 参数的类型为 P ,如下代码所示:

  1. function reflect<P>(param: P) {
  2. return param;
  3. }

这里我们可以看到,尖括号中的 P 表示泛型参数的定义,param 后的 P 表示参数的类型是泛型 P(即类型受 P 约束)。

我们也可以使用泛型显式地注解返回值的类型,虽然没有这个必要(因为返回值的类型可以基于上下文推断出来)。比如调用如下所示的 reflect 时,我们可以通过尖括号 <> 语法给泛型参数 P 显式地传入一个明确的类型。

  1. function reflect<P>(param: P):P {
  2. return param;
  3. }

然后在调用函数时,我们也通过 <> 语法指定了如下所示的 string、number 类型入参,相应地,reflectStr 的类型是 string,reflectNum 的类型是 number。

  1. const reflectStr = reflect<string>('string');
  2. const reflectNum = reflect<number>(1);

另外,如果调用泛型函数时受泛型约束的参数有传值,泛型参数的入参可以从参数的类型中进行推断,而无须再显式指定类型(可缺省),因此上边的示例可以简写为如下示例:

  1. const reflectStr2 = reflect('string');
  2. const reflectNum2 = reflect(1);

泛型不仅可以约束函数整个参数的类型,还可以约束参数属性、成员的类型,比如参数的类型可以是数组、对象,如下示例:

  1. function reflectArray<P>(param: P[]) {
  2. return param;
  3. }
  4. const reflectArr = reflectArray([1, '1']);

这里我们约束了 param 的类型是数组,数组的元素类型是泛型入参。

通过泛型,我们可以约束函数参数和返回值的类型关系。举一个我们比较熟悉的实际场景 React Hooks useState 为例,如下示例中,第 2 行 return 的元组(因为 useState 返回的是长度为 2、元素类型固定的数组)的第一个元素的类型就是泛型 S,第二个函数类型元素的参数类型也是泛型 S。

  1. function useState<S>(state: S, initialValue?: S) {
  2. return [state, (s: S) => void 0] as unknown as [S, (s: S) => void];
  3. }

注意:函数的泛型入参必须和参数 / 参数成员建立有效的约束关系才有实际意义。 比如在下面示例中,我们定义了一个仅约束返回值类型的泛型,它是没有任何意义的。

  1. function uselessGenerics<P>(): P {
  2. return void 0 as unknown as P;
  3. }

我们可以给函数定义任何个数的泛型入参,如下代码所示:

  1. function reflectExtraParams<P, Q>(p1: P, p2: Q): [P, Q] {
  2. return [p1, p2];
  3. }

在上述代码中,我们定义了一个拥有两个泛型入参(P 和 Q)的函数 reflectExtraParams,并通过 P 和 Q 约束函数参数 p1、p2 和返回值的类型。

泛型类

在类的定义中,我们还可以使用泛型用来约束构造函数、属性、方法的类型,如下代码所示:

  1. class Memory<S> {
  2. store: S;
  3. constructor(store: S) {
  4. this.store = store;
  5. }
  6. set(store: S) {
  7. this.store = store;
  8. }
  9. get() {
  10. return this.store;
  11. }
  12. }
  13. const numMemory = new Memory<number>(1);
  14. const getNumMemory = numMemory.get();
  15. numMemory.set(2);
  16. const strMemory = new Memory('');
  17. const getStrMemory = strMemory.get();
  18. strMemory.set('string');

首先,我们定义了一个支持读写的寄存器类 Memory,并使用泛型约束了 Memory 类的构造器函数、set 和 get 方法形参的类型,最后实例化了泛型入参分别是 number 和 string 类型的两种寄存器。

泛型类和泛型函数类似的地方在于,在创建类实例时,如果受泛型约束的参数传入了明确值,则泛型入参(确切地说是传入的类型)可缺省,比如第 14 行、第 18 行,<number><string> 泛型入参就是可以缺省的。

小贴士:对于 React 开发者而言,组件也支持泛型,如下代码所示。

  1. function GenericCom<P>(props: { prop1: string }) {
  2. return <></>;
  3. };
  4. <GenericCom<{ name: string; }> prop1="1" ... />

在第 1 行~ 第 3 行,我们定义了一个泛型组件 GenericCom,它接收了一个类型入参 P。在第 4 行,通过 JSX 语法创建组件元素的同时,我们还显式指定了接口类型 {name: string} 作为入参。

注意:这块的语法稍微有些奇怪,我们记住就可以了。

泛型类型

02 讲中我们提到,我们可以使用 Array <类型> 的语法来定义数组类型,这里的 Array 本身就是一种类型。

在 TypeScript 中,类型本身就可以被定义为拥有不明确的类型参数的泛型,并且可以接收明确类型作为入参,从而衍生出更具体的类型,如下代码所示:

  1. const reflectFn: <P>(param: P) => P = reflect;

这里我们为变量 reflectFn 显式添加了泛型类型注解,并将 reflect 函数作为值赋给了它。

我们也可以把 reflectFn 的类型注解提取为一个能被复用的类型别名或者接口,如下代码所示:

  1. type ReflectFuncton = <P>(param: P) => P;
  2. interface IReflectFuncton {
  3. <P>(param: P): P
  4. }
  5. const reflectFn2: ReflectFuncton = reflect;
  6. const reflectFn3: IReflectFuncton = reflect;

将类型入参的定义移动到类型别名或接口名称后,此时定义的一个接收具体类型入参后返回一个新类型的类型就是泛型类型。

如下示例中,我们定义了两个可以接收入参 P 的泛型类型(GenericReflectFunction 和 IGenericReflectFunction )。

  1. type GenericReflectFunction<P> = (param: P) => P;
  2. interface IGenericReflectFunction<P> {
  3. (param: P): P;
  4. }
  5. const reflectFn4: GenericReflectFunction<string> = reflect;
  6. const reflectFn5: IGenericReflectFunction<number> = reflect;
  7. const reflectFn3Return = reflectFn4('string');
  8. const reflectFn4Return = reflectFn5(1);

在泛型定义中,我们甚至可以使用一些类型操作符进行运算表达,使得泛型可以根据入参的类型衍生出各异的类型,如下代码所示:

  1. type StringOrNumberArray<E> = E extends string | number ? E[] : E;
  2. type StringArray = StringOrNumberArray<string>;
  3. type NumberArray = StringOrNumberArray<number>;
  4. type NeverGot = StringOrNumberArray<boolean>;

这里我们定义了一个泛型,如果入参是 number | string 就会生成一个数组类型,否则就生成入参类型。而且,我们还使用了与 JavaScript 三元表达式完全一致的语法来表达类型运算的逻辑关系,15 讲中会更详细地介绍。

发散一下,如果我们给上面这个泛型传入了一个 string | boolean 联合类型作为入参,将会得到什么类型呢?且看如下所示示例:

  1. type BooleanOrString = string | boolean;
  2. type WhatIsThis = StringOrNumberArray<BooleanOrString>;
  3. type BooleanOrStringGot = BooleanOrString extends string | number ? BooleanOrString[] : BooleanOrString;

嗯?难道不是 boolean | string ?

还真不是,如果你使用 VS Code 尝试了这个示例,并 hover 类型别名 WhatIsThis ,那么你会发现显示的类型将是 boolean | string[]。

BooleanOrStringGot 和 WhatIsThis 这两个类型别名的类型居然不一样,这 TM 是什么逻辑?这个就是所谓的分配条件类型(Distributive Conditional Types)。

关于分配条件类型这个概念,官方的释义:在条件类型判断的情况下(比如上边示例中出现的 extends),如果入参是联合类型,则会被拆解成一个个独立的(原子)类型(成员)进行类型运算。

比如上边示例中的 string | boolean 入参,先被拆解成 string 和 boolean 这两个独立类型,再分别判断是否是 string | number 类型的子集。因为 string 是子集而 boolean 不是,所以最终我们得到的 WhatIsThis 的类型是 boolean | string[]。

能接受入参的泛型类型和函数一样,都可以对入参类型进行计算并返回新的类型,像是在做类型运算。

利用泛型,我们可以抽象封装出很多有用、复杂的类型约束。比如在 Redux Model 中约束 State 和 Reducers 的类型定义关系,我们可以通过如下所示代码定义了一个既能接受 State 类型入参,又包含 state 和 reducers 这两个属性的接口类型泛型,并通过 State 入参约束了泛型的 state 属性和 reducers 属性下 action 索引属性的类型关系。

  1. interface ReduxModel<State> {
  2. state: State,
  3. reducers: {
  4. [action: string]: (state: State, action: any) => State
  5. }
  6. }

然后根据实际需要,我们传入了一个具体的 State 类型具象化 ReduxModel,并约束了一个实际的 model,如下代码所示:

  1. type ModelInterface = { id: number; name: string };
  2. const model: ReduxModel<ModelInterface> = {
  3. state: { id: 1, name: '乾元' },
  4. reducers: {
  5. setId: (state, action: { payload: number }) => ({
  6. ...state,
  7. id: action.payload
  8. }),
  9. setName: (state, action: { payload: string }) => ({
  10. ...state,
  11. name: action.payload
  12. })
  13. }
  14. }

在上述示例中,model 对象的 state 属性、reducers 属性的 setId、setName 方法的第一个参数 state 的类型都受到 ReduxModel 泛型入参 ModelInterface 的约束。

注意:枚举类型不支持泛型。

泛型约束

前面提到了泛型就像是类型的函数,它可以抽象、封装并接收(类型)入参,而泛型的入参也拥有类似函数入参的特性。因此,我们可以把泛型入参限定在一个相对更明确的集合内,以便对入参进行约束。

比如最前边提到的原封不动返回参数的 reflect 函数,我们希望把接收参数的类型限定在几种原始类型的集合中,此时就可以使用 “泛型入参名 extends 类型” 语法达到这个目的,如下代码所示:

  1. function reflectSpecified<P extends number | string | boolean>(param: P):P {
  2. return param;
  3. }
  4. reflectSpecified('string');
  5. reflectSpecified(1);
  6. reflectSpecified(true);
  7. reflectSpecified(null);

在上述示例中,我们限定了泛型入参只能是 number | string | boolean 的子集。

同样,我们也可以把接口泛型入参约束在特定的范围内,如下代码所示:

  1. interface ReduxModelSpecified<State extends { id: number; name: string }> {
  2. state: State
  3. }
  4. type ComputedReduxModel1 = ReduxModelSpecified<{ id: number; name: string; }>;
  5. type ComputedReduxModel2 = ReduxModelSpecified<{ id: number; name: string; age: number; }>;
  6. type ComputedReduxModel3 = ReduxModelSpecified<{ id: string; name: number; }>;
  7. type ComputedReduxModel4 = ReduxModelSpecified<{ id: number;}>;

在上述示例中,ReduxModelSpecified 泛型仅接收 {id: number; name: string} 接口类型的子类型作为入参。

我们还可以在多个不同的泛型入参之间设置约束关系,如下代码所示:

  1. interface ObjSetter {
  2. <O extends {}, K extends keyof O, V extends O[K]>(obj: O, key: K, value: V): V;
  3. }
  4. const setValueOfObj: ObjSetter = (obj, key, value) => (obj[key] = value);
  5. setValueOfObj({ id: 1, name: 'name' }, 'id', 2);
  6. setValueOfObj({ id: 1, name: 'name' }, 'name', 'new name');
  7. setValueOfObj({ id: 1, name: 'name' }, 'age', 2);
  8. setValueOfObj({ id: 1, name: 'name' }, 'id', '2');

在设置对象属性值的函数类型时,它拥有 3 个泛型入参:第 1 个是对象,第 2 个是第 1 个入参属性名集合的子集,第 3 个是指定属性类型的子类型(这里使用了 keyof 操作符,15 讲中我们会详细介绍 TypeScript 类型相关的操作符)。

另外,泛型入参与函数入参还有一个相似的地方在于,它也可以给泛型入参指定默认值(默认类型),且语法和指定函数默认参数完全一致,如下代码所示:

  1. interface ReduxModelSpecified2<State = { id: number; name: string }> {
  2. state: State
  3. }
  4. type ComputedReduxModel5 = ReduxModelSpecified2;
  5. type ComputedReduxModel6 = ReduxModelSpecified2<{ id: number; name: string; }>;
  6. type ComputedReduxModel7 = ReduxModelSpecified;

在上述示例中,我们定义了入参有默认类型的泛型 ReduxModelSpecified2,因此使用 ReduxModelSpecified2 时类型入参可缺省。而 ReduxModelSpecified 的入参没有默认值,所以缺省入参时会提示一个类型错误。

泛型入参的约束与默认值还可以组合使用,如下代码所示:

  1. interface ReduxModelMixed<State extends {} = { id: number; name: string }> {
  2. state: State
  3. }

这里我们限定了泛型 ReduxModelMixed 入参 State 必须是 {} 类型的子类型,同时也指定了入参缺省时的默认类型是接口类型 { id: number; name: string;}。

小结与预告

这一讲关于泛型的知识点看起来不是太多,不过难点在于如何理解泛型。

我们可以试着将泛型理解为类型中的函数,并通过抽象、封装类型运算逻辑实现类型可复用,以便更好地掌握泛型。

这里插播一道思考题:如何使用 TypeScript 实现 call?欢迎你在留言区与我互动、交流。

提示:因为仅凭本讲的内容可能无法回答这个问题,所以这里我提示几个关键词 Parameters、ReturnType。这些关键词与 Array 一样,都是 TypeScript 内封装好的泛型(第 14 讲里会介绍官方内置泛型),你可以在谷歌、百度搜索引擎或者官方文档中搜索一下 “TypeScript + 关键词” 提前获取更多信息。

11 讲我们将进入进阶课程的学习,敬请期待。

另外,如果你觉得本专栏有价值,欢迎分享给更多好友哦~