前端 - 封装一个通用的接口请求工具

在前端开发中,接口请求是一个非常基本的需求。几乎每个项目都会针对自己的使用场景对接口请求操作进行一系列封装。今天我们也来一步步封装一个通用的请求工具。

使用效果

首先让我们来看看封装完后的使用效果吧。

首先我们将提供一个 defineApi 函数,用于定义接口的配置信息,包括 url,请求参数以及返回类型等,具体使用方法如下:

ts 复制代码
const BASE_URL = 'https://example.com/api/'

const Api = {
	hello: defineApi('hello', { data: <{ ok: boolean }>{} }),
	count: defineApi('count/%(num)d', {
		data: <{ num: number }>{},
		param: object({ num: number() }),
	}),
	info: defineApi('info', {
		data: <{ page: number; next: string }>{},
		query: object({ page: number().transform(n => `${n}`) }),
	}),
	user: defineApi('user/%(id)d', {
		data: <{ name: string; id: number; type: 'user' }>{},
		param: object({ id: number() }),
		query: object({ includes: array(string()).transform(a => a.join(',')) }),
	}),
}

在上述的用例中,我们通过 defineApi 方法定义了请求参数的类型和返回值的类型。这些类型信息不仅仅只存在于 TypeScript 的类型系统,在 JavaScript 中同样存在。在参数类型不正确时,不仅会有编辑器的错误提示,还能够在运行时抛出错误。具体的实现方式将在后文阐述。

接着是具体的请求方法,包括 getpostdelete 等,例如:

ts 复制代码
function get(api: Api, init?: RequestInit): ApiData;
function get(api: Api, data: Data, init?: RequestInit): ApiData;
function get(api: Api, query?: Query, init?: RequestInit): ApiData;
function get(api: Api, data: Data, query?: Query, init?: RequestInit): ApiData;

注意,这四个方法签名并不是 get 函数的重载,而是根据接口(Api)的类型来决定使用哪一个函数,例如:

对于接口 hello: defineApi('hello', { data: <{ ok: boolean }>{} }) 来说,其中没有 paramquery 字段,所以 get 方法将只接受两个参数。

ts 复制代码
count: defineApi('count/%(num)d', {
	data: <{ num: number }>{},
	param: object({ num: number() }),
})

而对于 count 接口,当接口的配置中有 param 字段时,get 方法将新增一个 param 参数,并且该参数不可省略。

具体实现

下面让我们来一步步实现上面的功能。

defineApi

如何定义一个接口呢?其实接口有点类似于函数,我们只需要关心它的输入输出就行了。接口的输入就是各种请求参数,接口的输出就是响应的类型。当我们有接口如下时:

http 复制代码
GET: https://example.com/api/user/12345?include=group_name

其中固定的部分是 https://example.com/api/user/;动态的部分,需要我们传入的是 12345group_name。我们将问号前,位于 URL 路径中的变量称为路径参数(param),? 后的称为查询参数(query)。

那么我们如何在代码中定义一个接口呢?一个接口,需要包括 url,路径参数,查询参数,以及响应对象的类型。明白了这些,代码实现并不困难:

ts 复制代码
type Query = ConstructorParameters<typeof URLSearchParams>['0']

function defineApi<
	Data,
	PInput = never,
	POutput = PInput,
	QInput = never,
	QOutput extends Query = never,
>(
	path: string,
	{
		data,
		param = never(),
		query = never(),
	}: {
		data: Data
		param?: ZodType<POutput, any, PInput>
		query?: ZodType<QOutput, any, QInput>
	}
) {
	return { url: `${BASE_URL}${path}`, data, param, query }
}

JS 部分只是简单的将函数参数包装为一个对象返回,重要的是 TS 部分。

为了拥有运行时类型信息,我们使用了 zod 这个模式验证库。zod 将对象的类型信息称为"模式",zod 能够使用"模式"在运行时验证对象的类型。一个模式包含输入类型和输出类型,这是因为 zod 允许我们在定义对象的类型的同时定义转换函数。具体可以参考 zod 的文档

可以看到,我们已经能够得到接口的类型信息了。在 get 方法的实现中,我们将使用这些类型信息。

你可能会注意到我们使用 %(id)d 来定义 url 中的变量,这并不是 JS 中的特殊语法,而是 sprintf-js 中字符串参数的写法。我们使用 sprintf-js 来格式化 url。

get

让我们来思考一下我们将在 get 函数中做什么。我们将在 get 函数中发起请求,就像它的名字一样。但在发起请求前,我们需要用路径参数和查询参数得到请求的 url。而在使用参数前,我们需要验证他们的类型。明白了这些,get 函数的实现就呼之欲出了:

ts 复制代码
async function get<Data, PInput, QInput, POutput, QOutput extends Query>(
  api: {
    data: Data;
    url: string;
    param: ZodType<POutput, any, PInput>;
    query: ZodType<QOutput, any, QInput>;
  },
  ...args: [
    ...([PInput] extends [never] ? [] : [param: PInput]),
    ...([QInput] extends [never] ? [] : [query?: Partial<QInput>]),
    init?: RequestInit
  ]
): Promise<Awaited<Data>> {
  const param =
      api.param instanceof ZodNever
        ? <POutput>{}
        : await api.param.parseAsync(args.shift()),
    query =
      api.query instanceof ZodNever
        ? <QOutput>{}
        : await (api.query instanceof ZodObject
            ? api.query.partial()
            : api.query
          ).parseAsync(args.shift()),
    init: RequestInit = <any>args[0],
    search = new URLSearchParams(query),
    search_str = search.size ? `?${search}` : "",
    url = sprintf(api.url, param);

  return await request(`${url}${search_str}`, init);
}

在这里,我们使用了一点点类型体操,以根据 API 的类型来判断参数个数和类型。

parseAsync 将异步验证和转换 param 参数,并在类型不正确时抛出错误。

可以看到函数实现中主要的代码都是在赋值,主要的工作其实就是将参数拼接为 url。

request

get 方法中我们其实并没有发出请求。而是调用 request 方法。在这里我们才将发送真实请求,并实现超时,重试,错误处理等功能。

我们可以使用一些已经封装好的请求工具例如 axios 来实现上述功能,也可以自己封装 fetchXMLHttpRequest,使用 setTimeout 配合 AbortSignal 实现超时取消,使用递归实现重试。而这里,我将另辟蹊径,使用 rxjs 实现:

ts 复制代码
function request<T>(...args: RequestParam) {
  const req = new Request(...args);

  return lastValueFrom<T>(
    of(req).pipe(
      map(import.meta.env.DEV ? dev_proxy : identity),
      mergeMap((r) => fromFetch(r)),
      tap((r) => {
        if (!r.ok) throw new Error("Response Failed");
      }),
      tap((r) => {
        if (r.headers.get("content-type")?.indexOf("application/json") === -1)
          throw new Error("Response is not JSON");
      }),
      mergeMap((r) => from(r.json())),
      timeout(3000),
      retry(0)
    )
  );
}

其中我们使用 of(req) 开始了一个流(或观察链),并使用 fromFetch 进行请求,将 Request 转换为了 Response。在两个 tap 中,我们将在响应状态码不是 200 和响应类型不是 json 时抛出错误。接着使用 Response.prototype.json 方法将响应体解析为对象。而 timeoutretry 就是超时和重试功能了。

得益于 rxjs 的管道机制,我们的代码清晰易读,便于拓展。例如,当我们需要为所有请求添加请求头时:

ts 复制代码
of(req).pipe(
	tap(r => {
		r.headers.append('x-custom-header', 'header value')
	}),
	mergeMap(r => fromFetch(r)),
	...
)

当我们需要在发起请求时展示全局加载动画时:

ts 复制代码
of(req).pipe(
	tap(() => showLoading()),
	mergeMap(r => fromFetch(r)),
	tap(() => hideLoading()),
	...
)

甚至可以通过抛出错误来拦截请求:

ts 复制代码
of(req).pipe(
	tap(r => {
		if( /** 条件 */ ) throw new Error( ... )
	},
	mergeMap(r => fromFetch(r)),
	...
)

对所有接口的请求或响应的全局处理,都可以通过管道的方式在这里编写。

如果你喜欢 go 风格的返回值,也可以将 lastValueFrom 替换为你自定义的函数,将 rxjs 的 Observable 转化为 Promise<[T, null] | [null, Error]>

结语

通过善用第三方库和工具,我们实现了对于接口请求的封装。其实还有很多可以拓展的地方。例如,针对单个接口的特殊处理还没有实现。

要实现它,我们可以将 init 参数的类型由 RequestInit更改为 RequestInit | (api, param, query) => RequestInit,使得我们可以在单个接口中动态构造请求。

我们也可以在 defineApi 中添加 beforeFetchafterFetch 字段,并在 getrequest 中进行处理。

最后,希望本文能够对读者有所启发。

相关推荐
速盾cdn4 分钟前
速盾:vue的cdn是干嘛的?
服务器·前端·网络
四喜花露水37 分钟前
Vue 自定义icon组件封装SVG图标
前端·javascript·vue.js
前端Hardy1 小时前
HTML&CSS: 实现可爱的冰墩墩
前端·javascript·css·html·css3
web Rookie1 小时前
JS类型检测大全:从零基础到高级应用
开发语言·前端·javascript
Au_ust1 小时前
css:基础
前端·css
帅帅哥的兜兜1 小时前
css基础:底部固定,导航栏浮动在顶部
前端·css·css3
yi碗汤园1 小时前
【一文了解】C#基础-集合
开发语言·前端·unity·c#
就是个名称1 小时前
购物车-多元素组合动画css
前端·css
编程一生2 小时前
回调数据丢了?
运维·服务器·前端
丶21362 小时前
【鉴权】深入了解 Cookie:Web 开发中的客户端存储小数据
前端·安全·web