在前端開發中,接口請求是一個非常基本的需求。幾乎每個項目都會針對自己的使用場景對接口請求操作進行一系列封裝。今天我們也來一步步封裝一個通用的請求工具。
使用效果
首先讓我們來看看封裝完後的使用效果吧。
首先我們將提供一個 defineApi 函數,用於定義接口的配置信息,包括 url,請求參數以及返回類型等,具體使用方法如下:
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 等,例如:
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 方法將只接受兩個參數。
count: defineApi('count/%(num)d', {
data: <{ num: number }>{},
param: object({ num: number() }),
})
而對於 count 接口,當接口的配置中有 param 字段時,get 方法將新增一個 param 參數,並且該參數不可省略。
具體實現
下面讓我們來一步步實現上面的功能。
defineApi
如何定義一個接口呢?其實接口有點類似於函數,我們只需要關心它的輸入輸出就行了。接口的輸入就是各種請求參數,接口的輸出就是響應的類型。當我們有接口如下時:
GET: https://example.com/api/user/12345?include=group_name
其中固定的部分是 https://example.com/api/user/;動態的部分,需要我們傳入的是 12345 和 group_name。我們將問號前,位於 URL 路徑中的變量稱為路徑參數(param),? 後的稱為查詢參數(query)。
那麼我們如何在代碼中定義一個接口呢?一個接口,需要包括 url,路徑參數,查詢參數,以及響應對象的類型。明白了這些,代碼實現並不困難:
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 函數的實現就呼之欲出了:
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 實現:
function request<T>(...args: RequestParam) {
const req = new Request(...args)
return lastValueFrom<T>(
of(req).pipe(
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(3)
)
)
}
其中我們使用 of(req) 開始了一個流(或觀察鏈),並使用 fromFetch 進行請求,將 Request 轉換為了 Response。在兩個 tap 中,我們將在響應狀態碼不是 200 和響應類型不是 json 時拋出錯誤。接着使用 Response.prototype.json 方法將響應體解析為對象。而 timeout 和 retry 就是超時和重試功能了。
得益於 rxjs 的管道機制,我們的代碼清晰易讀,便於拓展。例如,當我們需要為所有請求添加請求頭時:
of(req).pipe(
tap(r => {
r.headers.append('x-custom-header', 'header value')
}),
mergeMap(r => fromFetch(r)),
...
)
當我們需要在發起請求時展示全局加載動畫時:
of(req).pipe(
tap(() => showLoading()),
mergeMap(r => fromFetch(r)),
tap(() => hideLoading()),
...
)
甚至可以通過拋出錯誤來攔截請求:
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 中進行處理。
最後,希望本文能夠對讀者有所啓發。