基于 Fetch 的请求封装方案
技术DX, TypeScript

基于 Fetch 的请求封装方案

Published On
| 更新于
Updated On
19分钟阅读概述:分享了基于 Fetch API 的请求封装方案,实现更加友好的调用。

最前

前端开发的日常工作中,还原页面 UI 只是基础环节,真实的业务开发中,必然离开不了对请求、以及响应返回数据的处理,这个环节才是让 UI 页面呈现出真实且有现实意义的数据。

另一方面,为何有必要对请求封装——首先,基础的两大关于请求的 API: AJAX(XHR)Fetch API ,如果场景很简单(比如就是一个 demo 演示、代码片段),那么使用原生 API 就足够。

比如如下的简易演示代码:

mini-demo.jsx

封装带来的好处,其一是提升 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 原生,而会对其适当封装。本文具体的步骤演示了如何封装得当,从而在后续对封装类调用时明显提升开发体验

正文

调用效果直观例

先来直观的看,当我们后续封装完善后,对封装方法的实际调用效果

(场景假定:传统增删改查场景,这里演示的两个调用例子主要通过请求后端接口,分别处理如下数据表:用户角色表,以及用户创建的帖子的表:) (如下的方法字段名有着很好的语义化,所以不再冗余解释)

  1. 对用户角色的增删改查
UserService.ts
  1. 对用户“帖子”的增删改查
PostService.ts

而对于如上的导出的 NameService 实例,我们可以在组件中直接调用 NameService#Func,获取接口数据等;或者在以 redux / mobx 为例的状态库中去调用 NameService#Func 以初始化全局状态。

封装 RestException 错误类

我们先定义这样一个错误类型,它服务于后续我们在 Fetch 中处理 HTTP 响应状态码时,对于特定的状态码,会手动抛出指定错误:

RestException.ts

封装 RestCore 以及 RestRequestWrapper

封装之前,先明白基本使用场景下的几个需求,或者换个说法:常规调用 Fetch 方法时,我们会初始化哪些参数。初步的,我们容易想到这么些:

  1. 请求的完整 URL 路径;
  2. 请求头的字段指定;
  3. 请求方法的指定;(比如使用 get / post / put / patch / delete)
  4. 请求参数如何携带:(比如是 a. /user/42 (/user/[uid]);b. /user?id=42&name=kim;c.req.body: {id: 42, name: "kim"}。这么三种形式)
  5. ...

基于上面的需求,吾辈最终如下封装得到 RestCore 类和 RestRequestWrapper 类,

RestCore.ts
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 方法。

RestRequestWrapper.ts
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,
    );
  }
}

这里关注方法 AddParameterAddBodyAddHeader 的返回结果: return this;,才允许我们链式调用,连续初始化请求头,以及“不同位置”的请求参数。

处理 HTTP 状态码函数:

RestCore.ts

细节处:最终只对外导出 RestCore 以供外部调用;另外,如何确定 RestRequestWrapper 的定义和扮演角色,吾辈暂时也体会不深刻,因为也是基于公司前辈的代码优化而来,或许也涉及到什么设计模式(?存疑),但是目前也并不分明。

吾辈只能揣摩一下,抽离定义 RestRequestWrapper 的作用在于:不想让类 RestCore 显得“过重”,从而把 AddParameterAddBodyAddHeader 这样易链式多次调用的方法,解耦单独封装到 RestRequestWrapper 类中。

当然,上面的封装只能面对实际业务的简单场景,更进一步的,对于请求,开发还可能需要处理的情况有:

  • 请求的 Loading 状态;
  • 请求返回结果的是否缓存;
  • 请求的中断允许;
  • 多个请求间的竞态问题;
  • ...等等

那么还需进一步拓展上面的封装类。

延伸

为何是基于 class 类的封装

class 关键字实际在 JS 中并不常用,函数式开发是当下的趋势,同时函数式定义,还有一个明显的好处,利于摇树优化的分析(即定义了但未调用的函数,最终不会参与到打包体积当中)。

显然的,上面的基于 class 类对 Fetch API 的封装,同样可以通过普通函数定义加上闭包特性来实现。

而本文中如此实现,原因很简单,大致如下:

其一,优秀的公司前辈最早是基于某 C# 开发的项目中的请求封装,而后复用到了前端 React 项目中,显然的,传统语言中,类封装是通用选择;而吾辈如上的二次复用优化,实际也是有相当程度的路径依赖;

其次的,吾辈并不排斥 class 类关键字的定义和使用风格,同时感觉相较于使用一群“离散”的函数来实现对 Fetch API 同上的封装风格,使用 class 也许有个更好的“聚拢”效果。

再其次的,吾辈在个人的实际项目中是对 Fetch API 和 Axios API 作了同样的封装风格处理(当然一个项目中同时出现两类请求方案,是无必要的,吾辈也仅是在个人项目中如此体验而已),那么这个时候,如果我们使用的是类封装风格,那么额外的,再定义一个可被继承的抽象类就能非常方便的服务于子类 RestCore 的具体实现,所以真实项目代码里,吾辈有着如此定义:

对 Fetch API 的封装:

RestCore.ts
class RestCore extends RestCoreAbs {/* ... */}

对 Axios API 的封装:

RestCoreVerAxios.ts
class RestCoreVerAxios extends RestCoreAbs {/* ... */}

同个调用风格 - 对 Axios 的简易封装

具体代码示例可见另一篇博文:基于 Axios 的请求封装风格

源码初始版本

具体代码示例可见另一篇博文:Fetch API 请求封装的最初实现

THX

强烈的爱与支持来自瓷器:

基于 Fetch 的请求封装方案

https://blog.ninoh.cc/blog/a5-req-encapsulation[Copy]
本文作者
ninohx96
创建/发布于
Published On
更新/发布于
Updated On
许可协议
CC BY-NC-SA 4.0

转载或引用本文时请遵守“署名-非商业性使用-相同方式共享 4.0 国际”许可协议,注明出处、不得用于商业用途!分发衍生作品时必须采用相同的许可协议。