基于Vue3+Typescript封装的useRequest网络请求hook封装背景 在如今前后端分离的开发模式中,我们
封装背景
在如今前后端分离的开发模式中,我们有很多需要通过异步请求获取数据的场景,在此过程中会可能存在有很多的处理,如 loading、错误捕获、数据处理、请求前校验等。
在一般的情况下,我们实现基本的异步请求逻辑,往往会包含数据、后端接口、错误处理等,如下代码所示:
const list1 = ref<Item1[]>([]);
const list2 = ref<Item2[]>([]);
const userInfo = ref<Record<string, any>>({});
async function getList1() {
try {
const result1 = await getList1Api();
list1.value = result1;
} catch (err) {
// ... 错误处理
}
}
async function getList2() {
try {
const result2 = await getList2Api();
list2.value = result2;
} catch (err) {
// ... 错误处理
}
}
async function getUserInfo() {
try {
const result3 = await getUserInfoApi({id:3});
userInfo.value = result3;
} catch (err) {
// ... 错误处理
}
}
getList1();
getList2();
getUserInfo();
这样写确实没什么问题,毕竟是能完成业务需求的(人和代码有一个能跑就行)。但是这样的代码看似没重复,但好像又全是重复,毕竟从宏观的角度上看,无非就是三件事
- 声明初始数据
- 声明请求函数
- 调用函数发起请求,更新数据
基本流程
作为一名(cv战士)爱敲代码的前端,而且用的是vue3
+ Typescript
,我还是想结合响应式 API
封装成一个 hook
进行使用,以达到减少编写重复代码的目的
// 接口函数
type ApiFn = (...params: any[]) => Promise<any>;
/**
* 用于网络请求的hook
* @param apiFn 获取数据的接口
* @returns [接口的数据, 异步请求函数]
*/
function useRequest(apiFn: ApiFn): [Ref<any>, (params?: any) => Promise<any>] {
const data: Ref<any> = ref();
async function request(params?: any) {
try {
const result = await apiFn(params);
data.value = result;
} catch (error) {}
}
// 以数组的形式返回
return [data, request];
}
那么现在就可以在 .vue
文件中进行使用了, 使用方式如下所示:
import useRequest from './hooks/useRequest';
// 这是使用数组是因为可以防止命名冲突
const [list1, getList1] = useRequest(getList1Api);
const [list2, getList2] = useRequest(getList2Api);
const [userInfo, getUserInfo] = useRequest(getUserInfoApi);
getList1();
getList2();
getUserInfo({id:3});
现在的代码看起来就简洁多了,而且每个数据以及该数据相关的请求函数都组合在了一起。
初始化数据
目前 data 的初始值的 undefined
,如果我直接将 data 放在 <template>
中使用,在某些情况下会出问题
<template>
<el-table v-if="list1.length > 0" :data="list1">
<el-table-column></el-table-column>
<!-- ... -->
</el-table>
</template>
例如上面这种情况,需要访问 list1.length 属性时,由于 list1 初始时是 undefined
,这就会造成访问了undefined.length,毫无疑问肯定是会报错的
基于这种情况,我给 useRequest
这个 hook
假如第二个参数 options,其中包含一项是 defaultData,用于确定数据的初始值
// useRequest的返回值类型
type UseRequestReturn = [Ref<any>, (params?: any) => Promise<any>];
// useRequest的配置项
interface UseRequestOption {
defaultData?: any;
}
function useRequest(
apiFn: ApiFn,
{ defaultData }: UseRequestOption = {}
): UseRequestReturn {
// 将传入的defaultData赋值给data
const data = ref<any>(defaultData as any) as Ref<any>;
...
return [data, request];
}
在使用 hook
时传入初始化的数据,这样直接在模板使用时访问其方法就不会出现报错了
const [list1, getList1] = useRequest(getList1Api, { defaultData: [] });
const [list2, getList2] = useRequest(getList2Api, { defaultData: [] });
const [userInfo, getUserInfo] = useRequest(getUserInfoApi, { defaultData: {} });
getList1();
getList2();
getUserInfo({id:3});
这里可能有人会问怎么全使用 any
,因为我( 使用 anyScript )想先把功能实现,所有先使用 any
避免类型报错,在我后面会补全这些类型。
自动执行以及默认参数
目前请求的基本流程已经实现了,接下来需要对其功能进行扩展,那么扩展基本是围绕着 hook
中的 request
函数来进行
首先我想在调用 hook
时,可以在其内部自动调用一次 request
函数,那么在组件中就不需要再手动调用,而是等到某个事件(如 click,change 等)触发时再手动调用
那么我在 options 配置中加入一个属性 automatic ,如果该属性为 true,则在 hook 内部调用一次
function useRequest(
apiFn: ApiFn,
{ defaultData, automatic }: UseRequestOption = {}
): UseRequestReturn {
const data = ref<any>(defaultData as any) as Ref<any>;
async function request(params?: any) {
...
}
// 这里如果automatic为true,则调用一次request
if (automatic) {
request();
}
return [data, request];
}
那么首次调用时需要携带参数该怎么办?例如上面的 getUserInfo 函数,类似这种情况,我在配置项中多加一个defaultParams 属性,用于第一次调用时传递的参数
function useRequest(
apiFn: ApiFn,
{ automatic, defaultData, defaultParams }: UseRequestOption = {}
): UseRequestReturn {
const data = ref<any>(defaultData as any) as Ref<any>;
async function request(params?: any) {
...
}
if (automatic) {
request(defaultParams);
}
return [data, request];
}
在使用该 hook 时,可以通过配置进行首次自动调用以及传参
const [list1, getList1] = useRequest(getList1Api, { defaultData: [], automatic: true });
const [list2, getList2] = useRequest(getList2Api, { defaultData: [], automatic: true });
const [userInfo, getUserInfo] = useRequest(getUserInfoApi, {
defaultData: {},
automatic: true,
defaultParams: { id: 3 }
});
数据处理配置
在大多数的情况下,我们从接口拿到的数据是不能直接放到页面上使用的,需要对接口返回的数据做进一步处理
// 拿到的数组需要对某项进行单独处理:
async function getList1() {
const result1 = await getList1Api();
list1.value =
result1?.map((item) => ({
...item,
total: item.count1 + item.count2
})) || [];
}
// 需要的数据在接口返回数据的某一项中
async function getList2() {
const result2 = await getList2Api();
list2.value = result2.list || []
// ...
}
在这种情况下,我给 useRequest
这个 hooks
加入一个配置项 formatter,该配置项是一个函数,并把从接口拿到的数据以参数形式传入,在 formatter 函数中处理完成后进行 return
interface UseRequestOption {
defaultData?: any;
automatic?: boolean;
defaultParams?: any;
formatter?: (data: any) => any; // 处理数据的配置项
}
function useRequest(
apiFn: ApiFn,
{ automatic, defaultData, defaultParams, formatter }: UseRequestOption = {}
): UseRequestReturn {
const data = ref<any>(defaultData as any) as Ref<any>;
async function request(params?: any) {
try {
const result = await apiFn(params);
// 这里如果有formatter函数,则调用,并把接口数据当参数传入
// 在formatter中处理完成后,将处理好的数据返回
// 这样就可以将处理后的数据赋值给data
data.value = formatter?.(result) ?? result;
} catch (error) {}
}
if (automatic) {
request(defaultParams);
}
return [data, request];
}
例如上述的 list1 和 list2 需要额外处理,那么通过 formatter 配置项进行处理即可
const [list1, getList1] = useRequest(getList1Api, {
defaultData: [],
automatic: true,
formatter: (result: any) =>
result.map((item) => ({ ...item, total: item.count1 + item.count2 }))
});
const [list2, getList2] = useRequest(getList2Api, {
defaultData: [],
automatic: true,
formatter: (result: any) => result.list
});
加入 loading
在一些表格或者某些特定区域的数据请求中,在请求未完成之前,往往需要展示一个 loading
效果,这个loading效果可能是全屏的,有可能是局部的。
那么我就在这个 hook
中多加一个记录 loading
状态的数据进行暴露,并且在配置中决定是展示全屏 loading
还是局部 loading
type UseRequestReturn = [Ref<any>, (params?: any) => Promise<any>, Ref<boolean>];
interface UseRequestOption {
defaultData?: any;
automatic?: boolean;
defaultParams?: any;
fullScreenLoading?: boolean; // 控制是全屏loading还是局部loading
formatter?: (data: any) => any;
}
function useRequest(
apiFn: ApiFn,
{ automatic, defaultData, defaultParams, formatter, fullScreenLoading }: UseRequestOption = {}
): UseRequestReturn {
// useLoading是基于element封装的一个loading Hook
const [showLoading, closeLoading] = useLoading();
const data = ref<any>(defaultData as any) as Ref<any>;
// 多加一个loading状态
const loading = ref(false);
async function request(params?: any) {
// 如果fullScreenLoading为true,则开启全屏loading
// 否则使用局部loading
fullScreenLoading ? showLoading() : (loading.value = true);
try {
const result = await apiFn(params);
data.value = formatter?.(result) || result;
} catch (error) {
} finally {
// 重置loading状态
fullScreenLoading ? closeLoading() : (loading.value = false);
}
}
if (automatic) {
request(defaultParams);
}
return [data, request, loading];
}
请求周期钩子
一个基本的请求一般包含两个阶段:
- 请求前数据校验
- 请求成功/失败后的处理
在这个 hook
中,我依然通过配置项传入对应的处理函数,如 beforeRequest、onSuccess、onError
- 请求前有可能需要进行某些校验,如参数校验,所以在 beforeRequest 中会将参数传入
- 在请求成功后,可能需要拿到接口的的响应信息作进一步逻辑处理,或者拿到处理后的数据作处理,所以在 onSuccess 中会有两个参数:原始数据和处理后的数据
interface UseRequestOption {
defaultData?: any;
automatic?: boolean;
defaultParams?: any;
fullScreenLoading?: boolean;
formatter?: (data: any) => any;
beforeRequest?: (params?:any) => boolean | void; // 请求前的钩子
onSuccess?: (data: any, formatData:any) => void; // 请求成功的钩子
onError?: (err: any) => void; // 请求失败的钩子
}
function useRequest(
apiFn: ApiFn,
{
automatic,
defaultData,
defaultParams,
formatter,
fullScreenLoading,
beforeRequest,
onSuccess,
onError
}: UseRequestOption = {}
): UseRequestReturn {
...
async function request(params?: any) {
fullScreenLoading ? showLoading() : (loading.value = true);
// 请求前的处理,如果传入了beforeRequest配置并且返回false
// 那么该请求就不发出
const isRequest = beforeRequest?.(params) ?? true;
if (!isRequest) return;
try {
const result = await apiFn(params);
data.value = formatter?.(result) || result;
// 调用传入的onSuccess函数
// 并把数据通过参数形式传入
onSuccess?.(result, data.value);
} catch (error: any) {
// 错误处理
onError?.(error);
} finally {
fullScreenLoading ? closeLoading() : (loading.value = false);
}
}
...
return [data, request, loading];
}
下面是使用该 hook
的方式
const [list, getList] = useRequest(getListApi, {
defaultData: [],
automatic: true,
beforeRequest: (params:any) => {
if (xxx) {
// 其他处理
// 返回false,请求不会继续进行
return false;
}
},
formatter: (result: any) => result.list,
onSuccess: (result: any, formatResult: any) => {
/* 成功处理 */
},
onError: (err: any) => {
/* 错误处理 */
}
});
现在看起来使用封装的 hook
进行异步请求,就能很明确请求的每个阶段该处理的事情,而不是把所有代码都糅合到一个函数里,可能看起来就更直观。
加入类型校验
先前为了把功能实现,于是 hook
内部的类型都使用了 any
进行替代,那么既然用上了 typescript
,那就尽量避免 any
的存在,让其他人使用 hook
时具有对应的类型提示和检查。
在整个请求过程,其实就是往接口传入一些参数,得到服务器返回的数据,那么则有以下公式
data = await fetch(params)
那么我的想法是:首先通过传入的 Api 函数,自动推断出需要传入的参数类型和返回值类型,所以我定义了 3 个泛型。
- A:传入的
Api
函数类型 - D:需要的数据类型,默认是接口响应的数据类型
- P:传参类型,默认是接口的参数类型
现在函数签名如下:
// 我们约定Api函数的参数使用对象形式传递,所以只有一个参数
type ApiFn = (params?: any) => Promise<any>;
type ApiReturn<A extends ApiFn> = Awaited<ReturnType<A>>;
type UseRequestReturn<D, P> = [Ref<D>, (params?: P) => Promise<any>, Ref<boolean>];
interface UseRequestOption<A extends ApiFn, D, P> {
defaultData?: D; // 默认的数据类型为D
automatic?: boolean;
defaultParams?: P; // 默认的参数类型为P
fullScreenLoading?: boolean;
formatter?: (data: ApiReturn<A>) => D; // 通过接口响应的数据,处理成指定的数据类型
beforeRequest?: (params?: P) => boolean | void;
// 第一个参数是接口响应的数据,第二个参数处理后的数据
onSuccess?: (data: ApiReturn<A>, formatData: D) => void;
onError?: (err: any) => void;
}
function useRequest<A extends ApiFn, D = ApiReturn<A>, P = Parameters<A>[0]>(
apiFn: A,
options: UseRequestOption<A, D, P> = {}
): UseRequestReturn<D, P> {}
- 那么当使用这个 hook,并传入 Api 函数时,会自动推导类型
export function getUserInfoApi(params: GetUserInfoParams) {
return http.post<LoginResponse>({ url: Api.GetUserInfo, data: params });
}
- hook中返回的数据和包装后的请求函数也会自动推导类型
- 当传入默认数据类型时,也会自动推导 info 的类型;传入默认参数时,也会自动推导 params 的类型
- 同理,通过 formatter 配置项处理后返回的数据也可以自动推导出 info 的数据类型,并且有相应的类型提示
- 如果需要限制数据的类型,而不是根据推导,则需要显示传入泛型进行限制
interface UserInfo {
id: number;
name: string;
}
type GetUserInfoApi = typeof getUserInfoApi;
const [info, getUserInfo] = useRequest<GetUserInfoApi, UserInfo, GetUserInfoParams>(getUserInfoApi);
加入防抖和节流
在日常的开发中,对某个按钮加入防抖,或者某个功能加上节流还是很常见的,所以我在该 hook 中继续加上了防抖和节流的配置项
interface UseRequestOption<A extends ApiFn, D, P> {
throttleTime?: number; // 节流时间 ms
debounceTime?: number; // 防抖时间 ms
...
}
function useRequest<A extends ApiFn, D = ApiReturn<A>, P = Parameters<A>[0]>(
apiFn: A,
{
throttleTime,
debounceTime,
...
}: UseRequestOption<A, D, P> = {}
): UseRequestReturn<D, P> {
async function request(params?: P) {
...
}
let run = request;
// 如果传入了节流时间,则对request进行节流
if (throttleTime) {
const throttleRun = throttle(request, throttleTime);
run = async (params?: P) => throttleRun(params);
}
// 如果传入了防抖时间,则对request进行防抖
if (debounceTime) {
const debounceRun = debounce(request, debounceTime);
run = async (params?: P) => debounceRun(params);
}
...
return [data, run, loading];
}
总结
因为这是我根据日常开发所封装的 hook,所以在功能方面还有不足,但已经能满足开发中的大部分场景,如果后续遇到相应的场景进行扩展,目前已经在项目中使用,并没有什么问题。
封装该 hook 主要是想进一步掌握 typescript 的使用,并且提高自身的编程思维,并且省去重复的逻辑编写,令请求函数的每个部分更加直观。
这里有不足之处就是,这个 hook 已经在使用,如果后面要扩展功能,还有考虑兼容原有的逻辑。而且所有代码在一个 hook 中,随着功能的扩展会导致代码越来越多。
同时也恳请各位大佬可以留言给个建议,因为在 typescript 类型这方面,我总觉得有更好的方式去做类型限制,但目前想到的就是这种方式,谢谢!
参考
源码
import { throttle, debounce } from "lodash-es";
import { ref, reactive, type Ref } from "vue";
// 这个全屏loading是基于elementPlus封装
import useLoading from "./useLoading";
type ApiFn = (params?: any) => Promise<any>;
type ApiReturn<A extends ApiFn> = Awaited<ReturnType<A>>;
interface UseRequestOption<A extends ApiFn, D, P> {
defaultData?: D;
automatic?: boolean;
defaultParams?: P;
fullScreenLoading?: boolean;
throttleTime?: number;
debounceTime?: number;
formatter?: (data: ApiReturn<A>) => D;
beforeRequest?: (params?: P) => boolean | void;
onSuccess?: (data: ApiReturn<A>, formatData: D) => void;
onError?: (err: any) => void;
}
type UseRequestReturn<D, P> = [Ref<D>, (params?: P) => Promise<any>, Ref<boolean>];
/**
* @description 用于网络请求的hook
*/
function useRequest<A extends ApiFn, D = ApiReturn<A>, P = Parameters<A>[0]>(
apiFn: A,
{
automatic,
defaultData,
defaultParams,
throttleTime,
debounceTime,
formatter,
fullScreenLoading,
beforeRequest,
onSuccess,
onError
}: UseRequestOption<A, D, P> = {}
): UseRequestReturn<D, P> {
const { showLoading, closeLoading } = useLoading();
const data: Ref<D> = ref(defaultData) as Ref<D>;
const loading = ref<boolean>(false);
async function request(params?: P) {
fullScreenLoading ? showLoading() : (loading.value = true);
const isRequest = beforeRequest?.(params) ?? true;
if (!isRequest) return;
try {
const result = await apiFn(params);
data.value = formatter?.(result) ?? result;
onSuccess?.(result, data.value);
} catch (error) {
onError?.(error);
} finally {
fullScreenLoading ? closeLoading() : (loading.value = false);
}
}
let run = request;
if (throttleTime) {
const throttleRun = throttle(request, throttleTime);
run = async (params?: P) => throttleRun(params);
}
if (debounceTime) {
const debounceRun = debounce(request, debounceTime);
run = async (params?: P) => debounceRun(params);
}
if (automatic) {
request(defaultParams);
}
return [data, run, loading];
}
export default useRequest;
转载自:https://juejin.cn/post/7401408738514010149