泛型(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 泛型可读性