在前端开发中,接口请求是一个非常基本的需求。几乎每个项目都会针对自己的使用场景对接口请求操作进行一系列封装。今天我们也来一步步封装一个通用的请求工具。
使用效果
首先让我们来看看封装完后的使用效果吧。
首先我们将提供一个 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
中同样存在。在参数类型不正确时,不仅会有编辑器的错误提示,还能够在运行时抛出错误。具体的实现方式将在后文阐述。
接着是具体的请求方法,包括 get
,post
,delete
等,例如:
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 }>{} })
来说,其中没有 param
和 query
字段,所以 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/
;动态的部分,需要我们传入的是 12345
和 group_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
来实现上述功能,也可以自己封装 fetch
或 XMLHttpRequest
,使用 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
方法将响应体解析为对象。而 timeout
和 retry
就是超时和重试功能了。
得益于 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
中添加 beforeFetch
和 afterFetch
字段,并在 get
和 request
中进行处理。
最后,希望本文能够对读者有所启发。