TypeScript - 泛型

·

8 min read

泛型(Generics)是 TypeScript 的一个重要特性,它允许我们在编写代码时定义可复用的组件或函数,这些组件或函数能够适用于多种数据类型。泛型的主要目的是提高代码的灵活性和可重用性,同时保持类型安全。

声明函数(function) 类(Class) 类型(type) 接口(interface)等,

不预先指定具体的参数类型,在使用时进行声明,或者编辑器进行推导

这样可以让类型更加宽泛,更加灵活。

泛型可以理解成,把类型当做参数,去约束代码中的函数,类型,接口的类型。

1.没有使用泛型可能出现的问题

假设我们要实现一个创建数组的函数,createList。

入参为:

length:number,代表需要创建的数组的长度

item:any,代表需要填充的数据

出参为:

list:any[] 创建的数组

我们可能会写出这样的代码:

function createList(length:number,item:any) {
    const list:any[] = []; // const list: never[]
    for(let index =0;index<length;index++) {
        list.push(item);
    }
    return list;
}

// 创建一个数组
const list = createList(3,{name:"张三"})

// 对第一项进行修改
list[0].name = '李四' // 没问题
list[0].naem = '李四' // 假设我们拼写出错,但也没问题,编辑器不会提示类型错误

由于我们的list实际上类型为any[],会跳过代码的类型检查,所以对数据的操作都不会提示类型错误。

这样可能会导致出现bug。

2.使用泛型实现

基本使用

我们实际上需要约束的类型,只有item的类型。所以我们的函数定义中使用了一个泛型T。

代表函数调用时,传入的类型参数。将item的类型约定为T,返回的list自然是T[]。

function createList<T>(length:number,item:T) {
    const list:T[] = [];
    for(let index =0;index<length;index++) {
        list.push(item);
    }
    return list;
}

// 编辑器自动推导 T 的类型为 {name:string}
const list = createList(3,{name:"张三"}) 

// 通过手动指定泛型的类型
// const list = createList<{name:string}>(3,{name:"张三"}) 

list[0].name = '李四' // ok
list[0].naem = "李四" // 类型“{ name: string; }”上不存在属性“naem”。ts(2339)

通过泛型的类型约束,对入参和出参做了约束。所以修改的操作会在编辑器进行类型校验。

这样可以让我们写类型更健全的代码,减少失误。

2.2 箭头函数的泛型

ES6的箭头函数(Arrow Function)也可以使用泛型去定义。

我个人还是偏好使用function去定义函数,写起来更顺手。具体使用什么函数的形式,看个人喜好。

const createList = <T>(length:number,item:T) => {
    const list:T[] = []
    for(let i = 0;i<length;i++){
        list.push(item)
    }
    return list
}

2.3 多个泛型

我们可以把泛型理解成函数的入参,入参可以多个,当然可以使用多个泛型

多个泛型的情况下,一般可能用T,U,P,K等描述泛型

我个人喜欢用比较语义化的单词去描述泛型,避免自己写多了忘记这个泛型是什么

比如:实现一个swap函数,交换数组的两项。

function swap<First,Last>(list: [First,Last]) {
    const [first,last] = list;

    return [last,first];
}

// 编辑器类型推导:
// function swap<string, number>(list: [string, number]): (string | number)[]
swap(["hello",1])

2.4 对泛型进行类型的约束 (extends关键字)

我想实现一个getLength方法,用于获取入参的长度

入参可能的类型:数组,字符串,对象

我们可能会这样写:

function getLength<T>(item:T) {
    return item.length; // 类型“T”上不存在属性“length”。ts(2339)
}

这个类型错误,实际上是因为泛型T的类型太宽泛了。

比如:T可能是一个number类型,但number没有length属性。

所以我们可以使用 extends 关键字 对泛型T进行约束。我们希望T是有length属性的

interface WithLength {
  length: number;
}

// 约束泛型 T 是一个有length属性的类型
function getLength<T extends WithLength>(item: T) {
  return item.length; // 编辑器不会有类型提示错误
}

// T为number[]
const arrLength = getLength([1,2]) 

// T为string
const strLength = getLength('hello') 

//T为{length: number;}
const objLength = getLength({length: 10}) 

// T为{0: string;1: string;length: number;}
const arrayLikeLength = getLength({0:"0",1:"1",length: 10})

2.5 泛型的其他使用

除了在函数之外,我们也在 类(Class) 类型(type) 接口(interface)的类型描述中使用

2.5.1 类(Class)使用泛型的示例

class Service<Config extends Record<string,any>> {
    config:Config;
    constructor(config:Config) {
        if (config) {
            this.config = config;
        }
    }
}

// 约定Config泛型的类型
interface ApiConfig {
    baseUrl:string;
    onSuccess:() => void
}

// 类型“{ baseUrl: string; }”的参数不能赋给类型“ApiConfig”的参数。
// 类型 "{ baseUrl: string; }" 中缺少属性 "onSuccess"
// 但类型 "ApiConfig" 中需要该属性。
const instance = new Service<ApiConfig>({baseUrl:"https://xxxx"})

2.5.2 类型(type)使用泛型的示例

type TableColumn<T> = { name: T; title: string };

type TableProps<T extends string = any> = {
  columns: TableColumn<T>;
};

const columns: TableColumn<"id" | "name">[] = [
  {
    name: "name",
    title: "名称",
  },
  {
    name: "age", // 不能将类型“"age"”分配给类型“"id" | "name"”。ts(2322)
    title: "张三",
  },
];

2.5.3 接口(interface)使用泛型的示例

改成接口(interface)则是:

type TableColumn<T> = { name: T; title: string };

interface TableProps<T extends string = any> {
  columns: TableColumn<T>;
}

const columns: TableColumn<"id" | "name">[] = [
  {
    name: "name",
    title: "名称",
  },
  {
    name: "age",
    title: "张三",
  },
];

2.6 源码中泛型的使用

2.6.1 ahooks useSetState

文档地址:https://ahooks.gitee.io/zh-CN/hooks/use-set-state

const[state, setState]=useSetState<T>(initialState);

state:T 当前状态

setState 设置当前状态 (state: Partial<T> | null) => void | ((prevState: T) => Partial<T> | null) => void

这个hook的使用方式类似于类组件(Class Component)的this.setState

interface State {
  name: string;
  description: string;
}

const App = () => {
  const [state, setState] = useSetState<State>({
    name: "zhangsan",
    age: 18,
  });

  const handleChange = (key: keyof State, value: string) => {
    setState({
      [key]: value,
    });
  };
  return (
    <div>
      <div>
        <input
          value={state.name}
          onChange={(e) => handleChange("name", e.target.value)}
        />
        <input
          value={state.age}
          onChange={(e) => handleChange("description", e.target.value)}
        />
      </div>
    </div>
  );
};

我们可以根据这个类型提示,简单实现这个hook:

功能很简单,其实就是setState方法做一次合并Object的操作,以及setState的类型做区分处理(setState的参数可以是一个state,也可以是一个回调函数)

但类型写起来有点繁杂,推荐大家可以尝试一下练手

// 对象的key支持的类型
type RecordKey = string | number | symbol;

// 对象的类型
type ObjState = Record<RecordKey, any>;

// setState方法的callback参数
type SetStateCallbackArg<State> = (
  prevState: State
) => Partial<State> | void | null;

// setState方法的类型(参数可能是一个新的值,或者一个函数)
type StateSetter<State> = (
  nextState: Partial<State> | SetStateCallbackArg<State>
) => void;

// hook的返回值
type SetStateReturn<State> = [State, StateSetter<State>];

function useSetState<State extends ObjState>(
  initState: State | (() => State)
): SetStateReturn<State> {
  const [state, setState] = useState(initState);

  // setter的参数可能是一个值,也可能是一个函数
  const setter = (nextState: Partial<State> | SetStateCallbackArg<State>) => {
    let computedState;
    if (typeof nextState === "function") {
      computedState = nextState(state); // 计算新的state
    } else {
      computedState = nextState;
    }
    // 判断是否为空,为空不做更新操作
    if (typeof computedState === "undefined" || computedState === null) return;
    setState({
      ...state,
      ...computedState,
    });
  };

  return [state, setter];
}

ahooks的类型实现,以及功能逻辑更加简洁,推荐学习,可以参考:https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useSetState/index.ts

import { useCallback, useState } from 'react';
import { isFunction } from '../utils';

export type SetState<S extends Record<string, any>> = <K extends keyof S>(
  state: Pick<S, K> | null | ((prevState: Readonly<S>) => Pick<S, K> | S | null),
) => void;

const useSetState = <S extends Record<string, any>>(
  initialState: S | (() => S),
): [S, SetState<S>] => {
  const [state, setState] = useState<S>(initialState);

  const setMergeState = useCallback((patch) => {
    setState((prevState) => {
      const newState = isFunction(patch) ? patch(prevState) : patch;
      return newState ? { ...prevState, ...newState } : prevState;
    });
  }, []);

  return [state, setMergeState];
};

export default useSetState;

我们把重点 SetState 这个类型的实现,拆解一下

type SetState<S extends Record<string, any>> = <K extends keyof S>(
  state: Pick<S, K> | null | ((prevState: Readonly<S>) => Pick<S, K> | S | null),
) => void

遇到这种比较长的类型,可读性比较差,可以尝试拆出来(我自己的实现就是把类型拆出来),方便理解

type Callback<S, K extends keyof S> = (
  prevState: Readonly<S>
) => Pick<S, K> | S | null;

export type SetState<S extends Record<string, any>> = <K extends keyof S>(
  state: Pick<S, K> | null | Callback<S, K>
) => void;

// SetState类型是一个函数 () => void
// 参数的类型: 一个对象,或者一个回调函数,回调函数可能返回新的对象

// Pick<S,K> 的类型描述相对于Partial<S>,更加严谨,因为Pick<S,K>的属性一定是S的属性
// Partial<S>的属性不一定是S的属性

type Obj = {
  name: string;
  age: number;
};

type Girl = {
  name: string;
  age: number;
  sex: string;
};

type Result = Girl extends Partial<Obj> ? true : false; // true

ahooks的源码类型定义比我想的更加严谨。

首先是Pick<S, K>的用法,Pick是内置的工具类型,用于将泛型里面的S和K生成对应的属性的类型。而Partial<S>只是将S的最外层的键值对设置为可选,可能有派生的状态。

其次是Readonly这个内置的工具类型,确保写代码的过程中,如果对原本的state进行修改,编辑器的静态类型提示会报错,告诉你state是只读的。

2.6.2 useRequest

useRequest也是我觉得写的很棒的代码,感兴趣可以阅读

https://github.com/alibaba/hooks/blob/master/packages/hooks/src/useRequest/src/useRequest.ts

我们不关注代码细节,看下类型是如何定义的

function useRequest<TData, TParams extends any[]>(
  service: Service<TData, TParams>,
  options?: Options<TData, TParams>,
  plugins?: Plugin<TData, TParams>[],
){
  return useRequestImplement<TData, TParams>(service, options, [
    ...(plugins || []),
    useDebouncePlugin,
    useLoadingDelayPlugin,
    usePollingPlugin,
    useRefreshOnWindowFocusPlugin,
    useThrottlePlugin,
    useAutoRunPlugin,
    useCachePlugin,
    useRetryPlugin,
  ] as Plugin<TData, TParams>[]);
}

开发者可以通过指定 TData 和 TParams 的泛型类型定义,去描述异步请求返参和请求入参的类型

3.常用的泛型

3.1 ts 提供的泛型工具

你可以理解成是官方提供的类型工具函数。熟练掌握就可以避免类型提示方面要造轮子了。

3.1.1 Record

Record用来描述键值对,比起Object类型,更友好


import {useEffect, useState} from 'react'

function App() {
  // useState根据入参推导,userInfo的类型是{}
  const [userInfo,setUserInfo] = useState({}) 

  useEffect(() => {
  // 发起请求,更新userInfo
  },[])

  // 类型“{}”上不存在属性“name”。ts(2339)
  return (
    <div>
      <div>{userInfo.name}</div>
    </div>
  )
}

解法:使用inferface定义UserInfoState接口,给useState做泛型的类型定义

import {useEffect, useState} from 'react'

interface UserInfoState {
  name?:string, // 方便编辑器提示
  [key:string]?:any
}

function App() {
  const [userInfo,setUserInfo] = useState<UserInfoState>({})

  useEffect(() => {
  // 发起请求,更新userInfo
  },[])

  return (
    <div>
      <div>{userInfo.name}</div>
    </div>
  )
}

解法:使用Record定义UserInfoState,代表Record是一个有键值对的对象

type UserInfoState = Record<string,any>

// 如果你想更严谨的定义
type UserInfoState = Partial<Record<"name",any>>

function App() {
  const [userInfo,setUserInfo] = useState<UserInfoState>({})

  useEffect(() => {
  // 发起请求,更新userInfo
  },[])

  return (
    <div>
      <div>{userInfo.name}</div>
    </div>
  )
}

顺带一提,如果是将一些js项目技术重构到ts项目,为了避免前期静态类型检查飘红过多,可以先给把泛型指定any,后续重构过程再约定更严谨的类型。

3.1.2 Partial

表示可选,将键值对改成可选的

type UserInfoState = Partial<Record<"name",any>>

// 实际上等同于:
// type UserInfoState = {name?:any}

但partial的类型仅调整第一层


type State = {
  obj:{
    innerObj:{
      name:string
    }
  }
}

type PartialState = Partial<State>

// 等同于 type PartialState = {
//     obj?: {
//         innerObj: {
//             name: string;
//         };
//     } | undefined;
// }

感兴趣可以去看Partial的实现

3.1.3 Readonly

将一个对象的表层键值对设置为只读,不允许修改

但运行时还是需要靠Object.freeze以及Proxy,ts只是类型校验的工具

function freeze(data:Readonly<Record<string,any>>) {
  data.a = 1; // 类型“Readonly<Record<string, any>>”中的索引签名仅允许读取。
  return data;
}

3.1.4 Pick
一般用于获取一个类型的某些键值对,用于派生一个类型

interface User {
  name:string,
  age:number,
  avatar:string,
  userId:number
}

type Profile = Pick<User,"name"|"avatar">

// 等同于
// type Profile = {
//     name: string;
//     avatar: string;
// }

和Pick相反的是Omit,用于剔除某些键值对。比如上述的写法,我们可以用Omit这样实现:

interface User {
  name:string,
  age:number,
  avatar:string,
  userId:number
}

// type Profile = Pick<User,"name"|"avatar">
type Profile = Omit<User,"age"|"userId">

// 等同于
// type Profile = {
//     name: string;
//     avatar: string;
// }

3.1.4 Parameters

用于获取参数的类型

function swap<T,U>(list:[T,U]) {
  const [first,last] =list;
  return [last,first];
}

type SwapFn = typeof swap; // 获取swap的类型
type SwapArgType = Parameters<SwapFn> // 获取swap参数的类型

与之相似的还有ConstructorParameters,用于获取构造函数的参数类型


class Person {
  name:string;
  age:number;
  constructor(name:string,age:number) {
  this.age = age;
  this.name = name;
  }
}

type PersonConstructorType = ConstructorParameters<typeof Person>
// 等同于:type PersonConstructorType = [name: string, age: number]

函数还可以通过ReturnType去获取返参的类型

function sum(...args: number[]) {
  return args.reduce((acc, cur) => acc + cur, 0);
}

type SumReturnType = ReturnType<typeof sum>
// 等同于 type SumReturnType = ReturnType<typeof sum>

4.项目中的泛型实践

4.1 react hooks

useState和useRef 指定泛型

我们简单实现了一个Input组件,通过 forwardRef 以及 useImperativeHandle 将Input的getWidth方法暴露出去,父级的组件可以通过ref去调用Input组件的方法

inputRef的类型定义,需要为useRef指定类型为HTMLInputElement,否则有类型提示错误

由于我们想让Input组件支持一些原生html input的属性,比如:placeholder,value,onChange等。

用到 InputHTMLAttributes<HTMLInputElement> 去获取原生input的属性。

同时,我们还可以导出一个InputActionRef接口给外部,帮助外部直接使用,避免手写类型声明

import { useRef, forwardRef, useImperativeHandle, useState } from "react";
import { type InputHTMLAttributes } from "react";

// 获取input的所有原本属性
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {}

// 组件提供的方法
interface InputActionRef {
  getWidth: () => number;
}

const Input = forwardRef((props: InputProps, ref) => {
  const inputRef = useRef<HTMLInputElement>(null);
  // 也可以使用 as断言去做类型的定义
  //  const inputRef= useRef(null) as unknown as MutableRefObject<HTMLInputElement>

  // 暴露getWidth方法
  useImperativeHandle(ref, () => {
    return {
      // 获取宽度
      getWidth: () => {
        return inputRef.current?.offsetWidth;
      },
    };
  });
  return (
    <div>
      <input {...props} ref={inputRef} />
    </div>
  );
});

使用

const App = () => {
  const [value, setValue] = useState("");
  const inputRef = useRef<InputActionRef>(null);

  const handleChange: InputProps["onChange"] = (event) => {
    setValue(event.currentTarget.value);
  };

  const handleGetWidth = () => {
    // 通过 InputActionRef,能获取对应类型提示和代码补全提示
    const width = inputRef.current?.getWidth();
    console.log("width>>",width); 
  };

  return (
    <div>
      <Input
        ref={inputRef}
        style={{ width: "200px" }}
        value={value}
        onChange={handleChange}
        placeholder="请输入"
      />
      <button onClick={handleGetWidth}>Get Width Test</button>
    </div>
  );
};

export default App;

4.2 Promise指定泛型

一般用在Service层比较多,比如发起请求的函数,通过泛型的指定,可以让开发者明确知道请求返回的数据格式,这样在代码的编写过程中,有对应的类型提示和补全提示,更严谨。

以ice的request为例

泛型T是请求返参(响应数据)的类型

泛型D是请求入参(请求数据)的类型

const request = async function <T = any, D = any>(options: RequestConfig<D>): Promise<T> {

} as Request;

我们根据这样的类型定义,可以简单包一层

import axios from "axios";

// 实际上的RequestConfig更多,可以参考 https://github.com/axios/axios/blob/v1.x/index.d.ts
interface RequestConfig<D> {
  data: D;
  method: string;
  url: string;
}

function request<T = any, D = any>(options: RequestConfig<D>): Promise<T> {
  return new Promise((resolve, reject) => {
    const { url, method, data } = options;
    const requestConfig: RequestConfig<D> = {
      url,
      method,
      data,
    };
    axios<T>(requestConfig)
      .then((res) => {
        resolve(res.data);
      })
      .catch((err) => {
        reject(err);
      });
  });
}

调用的话

// 定义响应数据的类型
type UserList = {
    total:number,
    current:number,
    data: Array<{name:string,id:number,desc:string}>
}

// 写一个请求
const getUserList = async () => {
    return request<UserList>({
      url: "/api/user/list",
      method: "get",
      data: {},
    });
}

// 调用
getUserList().then(res=>{
    console.log(res); // 有类型和代码补全提示:(parameter) res: UserList
})

5.泛型的痛点

5.1 加大前端维护的工作量

上面service的例子,实际上是由前端去维护响应返回数据的类型。

这意味着:如果接口返参有变动,比如:加了字段,调整了类型,前端都需要去手动调整。

解法:

如果后端使用swagger有良好文档习惯,可以考虑使用命令行工具,去生成请求层的类型代码:
https://github.com/acacode/swagger-typescript-api

如果后端没有swagger,直接给出json格式的数据。使用提效工具,避免手动维护

https://xiets.gitee.io/json-to-any-web/

5.2 泛型可读性