基于 Fetch 的请求封装方案
- Published On
- Updated On
最前
前端开发的日常工作中,还原页面 UI 只是基础环节,真实的业务开发中,必然离开不了对请求、以及响应返回数据的处理,这个环节才是让 UI 页面呈现出真实且有现实意义的数据。
另一方面,为何有必要对请求封装——首先,基础的两大关于请求的 API: AJAX(XHR) 和 Fetch API ,如果场景很简单(比如就是一个 demo 演示、代码片段),那么使用原生 API 就足够。
比如如下的简易演示代码:
封装带来的好处,其一是提升 DX 开发体验,因为对于原生 API 的调用,一些 API 的调用形式可能并不是那么友好,写得累赘,凭记忆下也容易疏漏。
(上面的原因是从原生 API 调用不友好的角度考虑,但对于已经高度封装的库的 API 呢,其本身调用已经足够友好,还有二次封装的必要吗,这里也就引入第二点原因,如下)
其二,也是吾辈认为更重要的一点,就是抹平底层实现的区别,比如,虽然社区里有种意见: 对于像 axios 这样已经是基于 XHR 的高度封装库,不应再对其封装,但是试想,
如果以后项目中需要更换请求库,比如全面拥抱 Fetch API 而更换另一请求封装库,如果原本的,请求库 API 被调用在项目代码各处,那么更换库的代价是繁重的。
而如果我们预先的,对第三方库作了封装,有着自己约定风格 API 的对外暴露,那么之后切换底层依赖的请求库时,就会轻松很多。
(因此吾辈在自己的项目实践中,按着基于 Fetch API 封装类的调用风格,对 Axios 作了同样处理,从而在业务代码中请求数据时,两者调用形式保持一致,可以在 Axios 和 Fetch API 中无感切换)
所以综合以上两点原因,封装都是必要的。(所以不意外的,经常见到这样一些博文标题:“如何对 axios 优雅/二次封装”)
(本文无意拓展 XHR 或者 FetchAPI 的细节,MDN 能够给以详尽参考。本文主要内容仅作一个基于 Fetch API 封装方案的分享)
背景
社区中偶尔出现这样的问题:“我应该在项目中使用 Fetch 还是 Axios ?”,这样的问题有一个显著错误,即将不在同一个维度的概念进行对比,并以此对比来作为自己的选择依据,但是前文提及:Fetch API 是 Web API 原生支持,是相较于古早的 XHR 更新的技术标准;而后者 Axios 是当前流行框架,底层基于 XHR (XMLHttpRequest) 封装,所以显然的,后者已经是一个成熟且高度的封装。
所以这样提出问题或许更加适当:“我应该在项目中使用 Fetch 还是 XHR 作为请求的底层实现",或者是:“我应该在项目中使用 基于 FetchAPI 的封装库还是 Axios 这样当下流行的请求封装库”
一般的,除去简单的代码片段演示例子,我们不会直接调用 Fetch API 原生,而会对其适当封装。本文具体的步骤演示了如何封装得当,从而在后续对封装类调用时明显提升开发体验。
正文
调用效果直观例
先来直观的看,当我们后续封装完善后,对封装方法的实际调用效果:
(场景假定:传统增删改查场景,这里演示的两个调用例子主要通过请求后端接口,分别处理如下数据表:用户角色表,以及用户创建的帖子的表:) (如下的方法字段名有着很好的语义化,所以不再冗余解释)
- 对用户角色的增删改查
- 对用户“帖子”的增删改查
而对于如上的导出的 NameService 实例,我们可以在组件中直接调用 NameService#Func
,获取接口数据等;或者在以 redux / mobx 为例的状态库中去调用 NameService#Func
以初始化全局状态。
封装 RestException 错误类
我们先定义这样一个错误类型,它服务于后续我们在 Fetch 中处理 HTTP 响应状态码时,对于特定的状态码,会手动抛出指定错误:
封装 RestCore 以及 RestRequestWrapper
封装之前,先明白基本使用场景下的几个需求,或者换个说法:常规调用 Fetch 方法时,我们会初始化哪些参数。初步的,我们容易想到这么些:
- 请求的完整 URL 路径;
- 请求头的字段指定;
- 请求方法的指定;(比如使用 get / post / put / patch / delete)
- 请求参数如何携带:(比如是 a.
/user/42
(/user/[uid]
);b./user?id=42&name=kim
;c.req.body:{id: 42, name: "kim"}
。这么三种形式) - ...
基于上面的需求,吾辈最终如下封装得到 RestCore 类和 RestRequestWrapper 类,
enum ReqMethod {
GET = "GET",
POST = "POST",
PUT = "PUT",
PATCH = "PATCH",
DELETE = "DELETE",
}
const headersPlainObj = {
"response-format": "json",
Accept: "*/*",
"Accept-Encoding": "gzip, deflate",
"Content-Type": "application/json;charset=utf-8",
};
const headersInitPrev = new Headers(headersPlainObj);
class RestCore {
private reLoginHandler?: () => void;
constructor(
private readonly urlHeadPartial?: string,
private readonly assignBaseUrl = "/api",
) {
RestCore.urlPartialValidator(urlHeadPartial);
this.urlHeadPartial = urlHeadPartial || "";
this.assignBaseUrl = assignBaseUrl;
}
private static urlPartialValidator(urlPartial?: string) {
/* 一般的,我们可以借助 `url-join` 等第三方库对 url 作合法拼贴,
不过这里,我们强制约定传入路径必须以 "/" 开头,
否则抛出自定义 `RestCoreInitException` 错误 (这里不再冗余写明 RestCoreInitException 定义)*/
if (urlPartial && !urlPartial.startsWith("/")) {
throw new RestCoreInitException(/* some err msg */);
}
}
private static CreateRequestInit(method: ReqMethod): RequestInit {
return {
method,
headers: headersInitPrev,
// credentials: "include",
// mode: "same-origin",
mode: "cors",
credentials: "same-origin",
};
}
private bridgeToRestRequestWrpper(method: ReqMethod, url: string) {
if (/^[.|/]{0,1}$/.test(url)) {
url = ""; // 🎈 规避请求的重定向,如 `api/post/` 重定向到 `/api/post`
}
RestCore.urlPartialValidator(url);
// 🎈 解耦到单独类,进一步初始化请求头以及请求参数
return new RestRequestWrapper(
url,
this,
RestCore.CreateRequestInit(method),
);
}
// 基于 put patch delete 请求方法的,同下相同定义即可
public Get(url: string) {
return this.bridgeToRestRequestWrpper(ReqMethod.GET, url);
}
public Post(url: string) {
return this.bridgeToRestRequestWrpper(ReqMethod.POST, url);
}
// 🎈 实际调用 Fetch API 的地方
private async GetResponseInternal(url: string, requestInit: RequestInit) {
const curBaseUrl = this.assignBaseUrl + this.urlHeadPartial;
const response = await fetch(curBaseUrl + url, requestInit);
// 🎈 该函数主要处理 HTTP 响应状态码,并在"状态码 >= 400" 情况,手动抛出自定义 `RestException` 错误
await hdlFetchResponseStatusCondition(response, this.reLoginHandler);
return response;
}
public async GetResponse(url: string, requestInit: RequestInit) {
return this.GetResponseInternal(url, requestInit);
}
public async GetTResponse<T>(url: string, requestInit: RequestInit) {
const response = await this.GetResponse(url, requestInit);
const text = await response.text();
if (text != null && text.length !== 0) {
return JSON.parse(text) as T;
}
return null;
}
public SetReLoginHandler(handler: () => void) {
this.reLoginHandler = handler;
}
}
export RestCore;
类 RestRequestWrapper 的作用有二:进一步初始化请求头以及请求参数;实例暴露的 GetTResponseAsync
方法,内部重新关联指回 RestCore 的 GetTResponse
方法。
const RestDateTimeFormat = "YYYY-MM-DD HH:mm:ss";
class RestRequestWrapper {
private parameters = "";
constructor(
private readonly url: string,
private readonly restCore: RestCore,
private readonly requestInit: RequestInit,
) {
this.url = url;
this.restCore = restCore;
this.requestInit = requestInit;
}
public AddParameter(
key: string,
value: string | number | dayjs.Dayjs | boolean,
) {
if (dayjs.isDayjs(value)) {
value = value.format(RestDateTimeFormat);
}
if (this.parameters.length === 0) {
this.parameters += "?";
} else {
this.parameters += "&";
}
this.parameters += `${key}=${value}`;
return this;
}
public AddParameterArray(
key: string,
values: (string | number | dayjs.Dayjs)[],
) {
if (values == null || values.length === 0) return this;
for (const value of values) {
this.AddParameter(key, value);
}
return this;
}
public AddBody(body: object) {
this.requestInit.body = JSON.stringify(body);
return this;
}
public AddHeader(name: string, value: string) {
(this.requestInit.headers as Headers).append(name, value);
return this;
}
/** 🎈 备用 API | 返回 fetch raw response, 无后续取值,自行决定 response.text() 或者 response.json() 或者其他对 response body 取值方式 */
public GetResponseAsync() {
return this.restCore.GetResponse(
this.url + this.parameters,
this.requestInit,
);
}
/** 🎈 主要 API | 依赖泛型,供调用时预先指定请求返回的数据结构 */
public GetTResponseAsync<T>() {
return this.restCore.GetTResponse<T>(
this.url + this.parameters,
this.requestInit,
);
}
}
这里关注方法 AddParameter
, AddBody
, AddHeader
的返回结果: return this;
,才允许我们链式调用,连续初始化请求头,以及“不同位置”的请求参数。
处理 HTTP 状态码函数:
细节处:最终只对外导出 RestCore 以供外部调用;另外,如何确定 RestRequestWrapper 的定义和扮演角色,吾辈暂时也体会不深刻,因为也是基于公司前辈的代码优化而来,或许也涉及到什么设计模式(?存疑),但是目前也并不分明。
吾辈只能揣摩一下,抽离定义 RestRequestWrapper 的作用在于:不想让类 RestCore 显得“过重”,从而把 AddParameter
, AddBody
, AddHeader
这样易链式多次调用的方法,解耦单独封装到 RestRequestWrapper 类中。
当然,上面的封装只能面对实际业务的简单场景,更进一步的,对于请求,开发还可能需要处理的情况有:
- 请求的 Loading 状态;
- 请求返回结果的是否缓存;
- 请求的中断允许;
- 多个请求间的竞态问题;
- ...等等
那么还需进一步拓展上面的封装类。
延伸
为何是基于 class 类的封装
class
关键字实际在 JS 中并不常用,函数式开发是当下的趋势,同时函数式定义,还有一个明显的好处,利于摇树优化的分析(即定义了但未调用的函数,最终不会参与到打包体积当中)。
显然的,上面的基于 class 类对 Fetch API 的封装,同样可以通过普通函数定义加上闭包特性来实现。
而本文中如此实现,原因很简单,大致如下:
其一,优秀的公司前辈最早是基于某 C# 开发的项目中的请求封装,而后复用到了前端 React 项目中,显然的,传统语言中,类封装是通用选择;而吾辈如上的二次复用优化,实际也是有相当程度的路径依赖;
其次的,吾辈并不排斥 class 类关键字的定义和使用风格,同时感觉相较于使用一群“离散”的函数来实现对 Fetch API 同上的封装风格,使用 class 也许有个更好的“聚拢”效果。
再其次的,吾辈在个人的实际项目中是对 Fetch API 和 Axios API 作了同样的封装风格处理(当然一个项目中同时出现两类请求方案,是无必要的,吾辈也仅是在个人项目中如此体验而已),那么这个时候,如果我们使用的是类封装风格,那么额外的,再定义一个可被继承的抽象类就能非常方便的服务于子类 RestCore 的具体实现,所以真实项目代码里,吾辈有着如此定义:
对 Fetch API 的封装:
class RestCore extends RestCoreAbs {/* ... */}
对 Axios API 的封装:
class RestCoreVerAxios extends RestCoreAbs {/* ... */}
同个调用风格 - 对 Axios 的简易封装
具体代码示例可见另一篇博文:基于 Axios 的请求封装风格
源码初始版本
具体代码示例可见另一篇博文:Fetch API 请求封装的最初实现
THX
强烈的爱与支持来自瓷器:
基于 Fetch 的请求封装方案
https://blog.ninoh.cc/blog/a5-req-encapsulation[Copy]转载或引用本文时请遵守“署名-非商业性使用-相同方式共享 4.0 国际”许可协议,注明出处、不得用于商业用途!分发衍生作品时必须采用相同的许可协议。