这是我参与8月更文挑战的第1天,活动详情查看: 8月更文挑战
前言
如题,本文想要实现一个完整的,可在大型项目中实际应用的一个 useFetchData Hooks
,网上有很多类似的实现,但是都没有找到最合适自己的(一千个读者就有一千个哈姆雷特,并非人家实现的不好),所以干脆不如自己撸一个,在此记录一下过程,希望能对大家有点帮助。
【实现的功能如下】:
- 自定义 hooks,使用方式统一,内部捕获错误,返回数据和
loading
- 基于 Typescript 封装了 fetch,实现了 cancel 请求,前端超时等功能
先看demo: awesome-use-fetch-data_Demo
Demo 仓库地址 -> awesome-use-fetch-data_Repo
本文大体上分为如下几个步骤讲解实现过程:
1 - 为什么要封装一个 useFetchData Hooks
2 - 使用 Typescript 封装一个前端超时取消、页面销毁/路由跳转取消、带 loading 的 fetch 库
3 - 结合二者,实现一个完整版的 useFetchData
为什么要封装一个 useFetchData Hooks
首先,就来说说为什么要实现这样一个 Custom Hooks 呢?原因有两点:
- 第一,如果你项目里大量使用
Hooks
开发代码,就避免不了封装高复用的Custom Hooks
如果你还是一直在复制粘贴代码,那么看完本篇文章,相信对你还是有些许裨益的。
- 第二,在业务场景里,基本上每个页面大部分组件都会请求数据接口渲染,所以对请求进行高复用封装能提高开发效率
具体为什么能提高开发效率,我这边简单写了两个伪代码,参考如下:
- 封装前
// page1.jsx
import { useState, useEffect } from 'react';
import axios from 'axios';
export default function Page1() {
const [data, setData] = useState([]);
const [loading, setLoading] = useState(false);
// 组件加载完毕,请求数据
useEffect(() => {
setLoading(true);
axios.get('/user/list', { params: { ID: 12345 } })
// 更新数据
.then(function(res) {
setData(res.data);
setLoading(false);
})
.catch(function(error) {
console.log(error);
setLoading(false);
});
}, [])
return (
<Table loading={loading} data={data} columns={columns} />
)
}
// page2.jsx
import { useState, useEffect } from 'react';
import axios from 'axios';
export default function Page2() {
const [list, setList] = useState([]);
const [loading, setLoading] = useState(false);
// 组件加载完毕,请求数据
useEffect(() => {
setLoading(true);
axios.post('/article/list', { page: 'Fred', pageSize: 'Flintstone'})
.then(function (res) {
setList(res.data);
setLoading(false);
})
.catch(function (error) {
console.log(error);
setLoading(false);
});
}, [])
return (
<Table loading={loading} data={list} columns={columns} />
)
}
- 封装后
// page1.jsx
// page1.jsx
import { useState, useEffect } from 'react';
import useFetchData from 'use-fetch-data';
export default function Page1() {
const [data, setData] = useState([]);
const options = { params: { ID: 12345 } };
const { data, loading } = useFetchData('/user/list', options);
return (
<Table loading={loading} data={data} columns={columns} />
)
}
// page2.jsx
import { useState, useEffect } from 'react';
import useFetchData from 'use-fetch-data';
export default function Page2() {
const options = { method: 'POST', data: { page: 'Fred', pageSize: 'Flintstone'}};
const { data, loading } = useFetchData('/article/list', options);
return (
<Table data={data} columns={columns} />
)
}
从上面两段代码,可以非常清晰的看出来封装前后的代码对比,封装前,每个业务组件在内部处理请求的同时,还要处理数据和 loading 状态,那么一个组件两个组件还好,当项目庞大起来,几十个接口几百个组件都进行请求,那么项目就有了大量的 CV 操作冗余代码。
由此可见一个 useFetchData Hooks
可以大大精简我们的业务代码,合理的将一个常用的组件或者方法抽象成 Custom Hooks
真的是一个非常好的选择,希望大家也能在平时自己用起来,构建自己的 react-use
之类的 Hooks Utils
。
介绍完封装一个 useFetchData Hooks
的充分理由,接下来就是正式进行代码封装了,既然是封装请求库,那么就必须选择一个请求库,我这里以个人比较喜欢的 fetch
为例,此 hooks 核心其实不是请求库,大家只要选择自己擅长的请求库就可以了。
基于 Typescript 封装一个多功能 fetch
上面提到了本文核心其实也不在封装 fetch
这里,因为 fetch
这个库并不是很多人喜欢用,可能大家很多人喜欢用 axios
,也没关系,反正做的事情都是差不多的,到时候各位进行自己请求库的替换就可以了,我只是比较喜欢 fetch
而已,ts-fetch
实现了如下功能:
-
1 - 内部处理异常(需要和后端约定好返回)
-
2 - 前端自超时,当请求响应时间超过一定阈值,前端认为超时(可以通过传参覆盖)
-
3 - 利用
AbortController
取消请求(页面销毁/路由跳转)
TS-Fetch 代码地址 -> useful-kit,个人 TypeScript 用的一般,如果有大佬们有更好的封装方法,可以留言或者仓库直接共建,非常感谢。
下面就直接贴封装的请求库代码,写文章,就只是简单的进行封装一下,大家在业务里还可以根据业务特性,再次进行改造到适合自己项目的程度,比如路径参数 api 这种也是可以支持的:
// 这个是同构 fetch,既能在服务端,又能在客户端
import fetch from 'isomorphic-unfetch';
// query 格式化的插件,其实可以自己实现
import qs from 'query-string';
// 捕获异常内部处理的一个提示,和你项目用的 ui 库一致就可以
import { message } from 'antd';
function filterObject(o: Record<string, string>, filter: Function) {
const res: Record<string, string> = {};
Object.keys(o).forEach(k => {
if (filter(o[k], k)) {
res[k] = o[k];
}
});
return res;
};
export enum EHttpMethods {
GET = 'GET',
POST = 'POST',
PUT = 'PUT',
PATCH = 'PATCH',
DELETE = 'DELETE'
}
type ICustomRequestError = {
status: number;
statusText: string;
url: string;
}
function dealErrToast(err: Error & ICustomRequestError, abortController?: AbortController) {
switch(err.status) {
case 408: {
abortController && abortController.abort();
(typeof window !== 'undefined') && message.error(err.statusText);
break;
}
default: {
console.log(err);
break;
}
}
}
/**
* @description: 声明请求头header的类型
*/
interface IHeaderConfig {
Accept?: string;
'Content-Type': string;
[propName: string]: any;
}
export interface IResponseData {
code: number;
data: any;
message: string;
}
interface IAnyMap {
[propName: string]: any;
}
export interface IRequestOptions {
headers?: IHeaderConfig;
signal?: AbortSignal;
method?: EHttpMethods;
query?: IAnyMap;
params?: IAnyMap;
data?: IAnyMap;
body?: string;
timeout?: number;
credentials?: 'include' | 'same-origin';
mode?: 'cors' | 'same-origin';
cache?: 'no-cache' | 'default' | 'force-cache';
}
/**
* Http request
* @param url request URL
* @param options request options
*/
interface IHttpInterface {
request<T = IResponseData>(url: string, options?: IRequestOptions): Promise<T>;
}
const CAN_SEND_METHOD = ['POST', 'PUT', 'PATCH', 'DELETE'];
class Http implements IHttpInterface {
public async request<T>(url: string, options?: IRequestOptions, abortController?: AbortController): Promise<T> {
const opts: IRequestOptions = Object.assign({
method: 'GET',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json'
},
credentials: 'include',
timeout: 10000,
mode: 'cors',
cache: 'no-cache'
}, options);
abortController && (opts.signal = abortController.signal);
if (opts && opts.query) {
url += `${url.includes('?') ? '&' : '?'}${qs.stringify(
filterObject(opts.query, Boolean),
)}`;
}
const canSend = opts && opts.method && CAN_SEND_METHOD.includes(opts.method);
if (canSend && opts.data) {
opts.body = JSON.stringify(filterObject(opts.data, Boolean));
opts.headers && Reflect.set(opts.headers, 'Content-Type', 'application/json');
}
console.log('Request Opts: ', opts);
try {
const res = await Promise.race([
fetch(url, opts),
new Promise<any>((_, reject) => {
setTimeout(() => {
return reject({ status: 408, statusText: '请求超时,请稍后重试', url });
}, opts.timeout);
}),
]);
const result = await res.json();
return result;
} catch (e) {
dealErrToast(e, abortController);
return e;
}
}
}
const { request } = new Http();
export { request as default };
高阶完整版的 useFetchData
上面已经封装完了一个基于 fetch
的请求库,接下来就是利用它写一个 useFetchData Hooks
。这里思考如下:
- 1 - 使用起来要简单,统一,尽可能的减少请求库逻辑代码在业务组件内
- 2 - 在
hooks
内部进行错误处理,组件业务层级无需关心处理 - 3 - 返回响应数据、异常错误和
loading
有个上面三点目标,接下来就是实现了,具体代码如下:
/**
* /hooks/useFetchData.tsx
*/
import { useState, useEffect, useRef } from 'react';
import request, { IRequestOptions, IResponseData } from '../utils/request';
interface IFetchResData {
data: T | undefined;
loading: boolean;
error: any;
}
function useFetchData<T = any>(url: string, options?: IRequestOptions): IFetchResData {
// 如果是一个通用的 fetchData,那么使用any是没办法的,如果只是针对list,any可以替换为对应的数据范型
const [data, setData] = useState<T>();
const [loading, setLoading] = useState<boolean>(false);
const [error, setError] = useState<any>(null);
/**
* 超时或者页面销毁/路由跳转,取消请求
*/
const abortControllerRef = useRef<AbortController>();
function destory() {
setData(undefined);
setLoading(false);
setError(null);
abortControllerRef.current && abortControllerRef.current.abort();
}
useEffect(() => {
setLoading(true);
abortControllerRef.current = new AbortController();
request(url, options || {}, abortControllerRef.current).then(res => {
const { code, message, data } = res as IResponseData;
if (code !== 0) {
console.log('Error Msg: ', message);
throw new Error(message);
}
setData(data);
setLoading(false);
}).catch(err => {
setError(err);
}).finally(() => {
setLoading(false);
});
return () => destory();
}, [url, JSON.stringify(options)]);
return { loading, data, error };
}
export default useFetchData;
代码就是上面那个样子,我们来看看是不是满足上面四点目标:
1 - 使用起来简单、统一,减少请求逻辑在业务组件里
// 1 - 基础使用 GET 无参数
const { loading, data } = useFetchData('/user/list');
// 2 - 进阶使用 GET 有参数
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number | undefined>(10);
const options = { query: { page, pageSize } };
const { loading, data } = useFetchData(getUserList, options);
// 3 - 进阶使用 POST
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number | undefined>(10);
const options = useMemo(() => ({
method: EHttpMethods.POST,
data: { page, pageSize }
}), [page, pageSize]);
const { loading, data } = useFetchData(postUserList, options);
可以看到,使用上应该可以算是清晰简单,你只需要传递对应请求的参数和 api 地址即可获取到数据以及 loading
状态。并且请求的逻辑在业务组件里只有必要的 api url 和参数,这两个是无法减少的,至于其他逻辑,完全封装到了 hooks
内部。
这里需要注意的是,因为 options 也就是 hooks 的第二个参数 是一个对象,因此会存在一个问题,就是组件重复渲染的时候,hooks 会重复发请求,因为每一次 options 都是一个新对象,即使没有改变也是一个新的内存地址,所以为了避免这种情况,有两种解决方案。
- 第一种:hooks 层解决,我这边使用了
useEffect + JSON.stringify
来确保参数发生变化才重新请求。- 第二种:业务层解决,业务代码使用
useMemo
来进行处理。 个人而言更倾向第二种,目前因为是 Demo 阶段,所以两种方法我里面都用了,大家按需使用即可。
2 - hooks 内部进行错误处理
错误处理集中在请求库和 hooks
内部,业务组件不需要关心以及处理错误,大大减少了业务代码复杂度,核心代码如下:
// 请求层错误处理
/**
* 错误处理
* @param err
* @param abortController
*/
function dealErrToast(err: Error & ICustomRequestError, abortController?: AbortController) {
switch(err.status) {
case 408: {
abortController && abortController.abort();
(typeof window !== 'undefined') && message.error(err.statusText);
break;
}
default: {
console.log(err);
break;
}
}
}
// hooks 层错误处理
const { code, message, data } = res as IResponseData;
if (code !== 0) {
// do something
console.log('Error Msg: ', message);
throw new Error(message);
}
返回响应数据和 loading + TS 增强数据提示
这个看代码就很简单了,每一个请求都返回了响应的数据以及 loading
状态,同样的大大程度精简了业务代码,非常简便。
这里可以说明一下,为了清晰明了,我在
hooks
内部增设了loading
这个state
,其实可以通过data
和error
两个字段聚合出loading
状态,这里大家可以参考swr
。不过我觉得增加一个loading
更清晰,所以就这么写了。
接下来就是 TS 的好处了,如果你在数据模型里设置里返回的数据类型,那么你获取到的 data
就是一个带提示的数据,在业务开发里非常的好用,只需要在编辑器里 .
一下,就能看到返回的这个数据对象的各种属性,开发简直不要太爽,再也不用边开发边查阅 API 文档了!效果如下图:
具体代码如下:
// 数据接口层
export interface IUserStruct {
id: number;
name: string;
age: number;
}
export interface IUserListResData {
list: IUserStruct[];
total: number
}
// 业务代码层
const { loading, data } = useFetchData<IUserListResData>(getLimitUserList, options);
最后你的 data
就如上图所示了~
总结
这篇文章也算是个人想总结想写很久的一篇文章了,毕竟我真的很喜欢用 fetch
,但是在公司里很少有人用,所以意难平吧。本文说技术含量其实也没啥技术含量,更多的应该是经验分享吧,代码较多,建议大家 Clone 仓库自己跑一跑,希望对大家有用处。