一篇拒絕低階封裝axios的文章
theme: devui-blue highlight: androidstudio
為什麼要寫這篇文章
事前提醒:閱讀本文存在不同想法時,可以在評論中表達,但請勿使用過激的措辭。
目前掘金上已經有很多關於axios
封裝的文章。自己在初次閱讀這些文章中,見識到很多封裝思路,但在付諸實踐時一直有疑問:這些看似高階的二次封裝,是否會把axios
的呼叫方式弄得更加複雜? 優秀的二次封裝,有以下特點:
- 能改善原生框架上的不足:明確原生框架的缺點,且在二次封裝後能徹底杜絕這些缺點,與此同時不會引入新的缺點。
- 保持原有的功能:當進行二次封裝時,新框架的 API 可能會更改原生框架的 API 的呼叫方式(例如傳參方式),但我們要保證能通過新 API 呼叫原生 API 上的所有功能。
- 理解成本低:有原生框架使用經驗的開發者在面對二次封裝的框架和 API 時能迅速理解且上手。
但目前我見過,或者我接收過的專案裡眾多的axios
二次封裝中,並不具備上述原則,我們接下盤點一些常見的低階的二次封裝的手法。
盤點那些低階的axios
二次封裝方式
1. 對特定 method 封裝成新的 API,卻暴露極少的引數
例如以下程式碼:
js
export const post = (url, data, params) => {
return new Promise((resolve) => {
axios
.post(url, data, { params })
.then((result) => {
resolve([null, result.data]);
})
.catch((err) => {
resolve([err, undefined]);
});
});
};
上面的程式碼中對method
為post
的請求方法進行封裝,用於解決原生 API 中在處理報錯時需要用try~catch
包裹。但這種封裝有一個缺點:整個post
方法只暴露了url
,data
,params
三個引數,通常這三個引數可以滿足大多數簡單請求。但是,如果我們遇到一個特殊的post
介面,它的響應時間較慢,需要設定較長的超時時間,那上面的post
方法就立馬嗝屁了。
此時用原生的axios.post
方法可以輕鬆搞定上述特殊場景,如下所示:
js
// 針對此次請求把超時時間設定為15s
axios.post("/submit", form, { timeout: 15000 });
類似的特殊場景還有很多,例如:
- 需要上傳表單,表單中不僅含資料還有檔案,那隻能設定
headers["Content-Type"]
為"multipart/form-data"
進行請求,如果要顯示上傳檔案的進度條,則還要設定onUploadProgress
屬性。 - 存在需要防止資料競態的介面,那隻能設定
cancelToken
或signal
。有人說可以在通過攔截器interceptors
統一處理以避免競態併發,對此我舉個用以反對的場景:如果同一個頁面中有兩個或多個下拉框,兩個下拉框都會呼叫同一個介面獲取下拉選項,那你這個用攔截器實現的避免資料競態的機制就會出現問題,因為會導致這些下拉框中只有一個請求不會被中斷。
有些開發者會說不會出現這種介面,已經約定好的所有post
介面只需這三種引數就行。對此我想反駁:一個有潛力的專案總會不斷地加入更多的需求,如果你覺得你的專案是沒有潛力的,那當我沒說。但如果你不敢肯定你的專案之後是否會加入更多特性,不敢保證是否會遇到這類特殊場景,那請你在二次封裝時,儘可能地保持與原生API
對齊,以保證原生API
中一切能做到的,二次封裝後的新API
也能做到。以避免在遇到上述的特殊情況時,你只能尷尬地修改新API
,而且還會出現為了相容因而改得特別難看那種寫法。
2. 封裝建立axios
例項的方法,或者封裝自定義axios
類
例如以下程式碼:
``js
// 1. 封裝建立
axios`例項的方法
const createAxiosByinterceptors = (config) => {
const instance = axios.create({
timeout: 1000,
withCredentials: true,
...config,
});
instance.interceptors.request.use(xxx, xxx); instance.interceptors.response.use(xxx, xxx); return instance; };
// 2. 封裝自定義axios
類
class Request {
instance: AxiosInstance
interceptorsObj?: RequestInterceptors
constructor(config: RequestConfig) { this.instance = axios.create(config) this.interceptorsObj = config.interceptors
this.instance.interceptors.request.use(
this.interceptorsObj?.requestInterceptors,
this.interceptorsObj?.requestInterceptorsCatch,
)
this.instance.interceptors.response.use(
this.interceptorsObj?.responseInterceptors,
this.interceptorsObj?.responseInterceptorsCatch,
)
} } ```
上面的兩種寫法都是用於建立多個不同配置和不同攔截器的axios
例項以應付多個場景。對此我想表明自己的觀點:一個前端專案中,只能存在一個axios
例項。多個axios
例項會增加程式碼理解成本,讓參與或者接手專案的開發者花更多的時間去思考和接受每個axios
例項的用途和場景,就好比一個專案多個Vuex
或Redux
一樣雞肋。
那麼有開發者會問如果有相當數量的介面需要用到不同的配置和攔截器,那要怎麼辦?下面我來分多個配置和多個攔截器兩種場景進行分析:
1. 多個配置下的處理方式
如果有兩種或以上不同的配置,這些配置各被一部分介面使用。那麼就應該宣告對應不同配置的常量,然後在呼叫axios
時傳入對應的配置常量,如下所示:
```js // 宣告含不同配置項的常量configA和configB const configA = { // .... };
const configB = { // .... };
// 在需要這些配置的接口裡把對應的常量傳進去 axios.get("api1", configA); axios.get("api2", configB); ```
對比起多個不同配置的axios
例項,上述的寫法更加直觀,能讓閱讀程式碼的人直接看出區別。
2. 多個攔截器下的處理方式
如果有兩種或以上不同的攔截器,這些攔截器中各被一部分介面使用。那麼,我們可以把這些攔截器都掛載到全域性唯一的axios
例項上,然後通過以下兩種方式來讓攔截器選擇性執行:
-
推薦:在
config
中新加一個自定義屬性以決定攔截器是否執行,程式碼如下所示:呼叫請求時,寫法如下所示:
js instance.get("/api", { //新增自定義引數enableIcp來決定是否執行攔截器 enableIcp: true, });
在攔截器中,我們這麼編寫邏輯
```js // 請求攔截器寫法 instance.interceptors.request.use( // onFulfilled寫法 (config: RequestConfig) => { // 從config取出enableIcp const { enableIcp } = config; if (enableIcp) { //...執行邏輯 } return config; },
// onRejected寫法 (error) => { // 從error中取出config配置 const { config } = error; // 從config取出enableIcp const { enableIcp } = config; if (enableIcp) { //...執行邏輯 } return error; } );
// 響應攔截器寫法 instance.interceptors.response.use( // onFulfilled寫法 (response) => { // 從response中取出config配置 const { config } = response; // 從config取出enableIcp const { enableIcp } = config; if (enableIcp) { //...執行邏輯 } return response; },
// onRejected寫法 (error) => { // 從error中取出config配置 const { config } = error; // 從config取出enableIcp const { enableIcp } = config; if (enableIcp) { //...執行邏輯 } return error; } ); ```
通過以上寫法,我們就可以通過
config.enableIcp
來決定所註冊攔截器的攔截器是否執行。舉一反三來說,我們可以通過往config
塞自定義屬性,同時在編寫攔截器時配合,就可以完美的控制單個或多個攔截器的執行與否。 -
次要推薦:使用
axios
官方提供的runWhen
屬性來決定攔截器是否執行,注意該屬性只能決定請求攔截器的執行與否,不能決定響應攔截器的執行與否。用法如下所示:js function onGetCall(config) { return config.method === "get"; } axios.interceptors.request.use( function (config) { config.headers.test = "special get headers"; return config; }, null, // onGetCall的執行結果為false時,表示不執行該攔截器 { runWhen: onGetCall } );
關於
runWhen
更多用法可看axios#interceptors
本章總結
當我們進行二次封裝時,切勿為了封裝而封裝,首先要分析原有框架的缺點,下面我們來分析一下axios
目前有什麼缺點。
盤點axios
目前的缺點
1. 不能智慧推導params
在axios
的型別檔案中,config
變數對應的型別AxiosRequestConfig
如下所示:
ts
export interface AxiosRequestConfig<D = any> {
url?: string;
method?: Method | string;
baseURL?: string;
transformRequest?: AxiosRequestTransformer | AxiosRequestTransformer[];
transformResponse?: AxiosResponseTransformer | AxiosResponseTransformer[];
headers?: AxiosRequestHeaders;
params?: any;
paramsSerializer?: (params: any) => string;
data?: D;
timeout?: number;
// ...其餘屬性省略
}
可看出我們可以通過泛型定義data
的型別,但params
被寫死成any
型別因此無法定義。
2. 處理錯誤時需要用try~catch
這個應該是很多axios
二次封裝都會解決的問題。當請求報錯時,axios
會直接丟擲錯誤。需要開發者用try~catch
包裹著,如下所示:
js
try {
const response = await axios("/get");
} catch (err) {
// ...處理錯誤的邏輯
}
如果每次都要用try~catch
程式碼塊去包裹呼叫介面的程式碼行,會很繁瑣。
3. 不支援路徑引數替換
目前大多數後端暴露的介面格式都遵循RESTful
風格,而RESTful
風格的url
中需要把引數值嵌到路徑中,例如存在RESTful
風格的介面,url
為/api/member/{member_id}
,用於獲取成員的資訊,呼叫中我們需要把member_id
替換成實際值,如下所示:
js
axios(`/api/member/${member_id}`);
如果把其封裝成一個請求資源方法,就要額外暴露對應路徑引數的形參。非常不美觀,如下所示:
js
function getMember(member_id, config) {
axios(`/api/member/${member_id}`, config);
}
針對上述缺點,下面我分享一下自己精簡的二次封裝。
應該如何精簡地進行二次封裝
在本節中我會結合Typescript
來展示如何精簡地進行二次封裝以解決上述axios
的缺點。注意在這次封裝中我不會寫任何涉及到業務上的場景例如鑑權登入和錯誤碼對映。下面先展示一下二次封裝後的使用方式。
本次二次封裝後的所有程式碼可在enhance-axios-frame中檢視。
使用方式以及效果
使用方式如下所示:
js
apis[method][url](config);
method
對應介面的請求方法;url
為介面路徑;config
則是AxiosConfig
,也就是配置。
返回結果的資料型別為:
js
{
// 當請求報錯時,data為null值
data: null | T;
// 當請求報錯時,err為AxiosError型別的錯誤例項
err: AxiosError | null;
// 當請求報錯時,response為null值
response: AxiosResponse<T> | null;
}
下面來展示一下使用效果:
- 支援
url
智慧推導,且根據輸入的url
推匯出需要的params
、data
。在缺寫或寫錯請求引數時,會出現ts
錯誤提示
舉兩個介面做例子:
- 路徑為
/register
,方法為post
,data
資料型別為{ username: string; password: string }
- 路徑為
/password
,方法為put
,data
資料型別為{ password: string }
,params
資料型別為{ username: string }
呼叫效果如下所示:
通過這種方式,我們無需再通過一個函式來執行請求介面邏輯,而是可以直接通過呼叫api
來執行請求介面邏輯。如下所示:
```tsx // ------------以前的方式------------- // 需要用一個registerAccount函式來包裹著請求程式碼行 function register( data: { username: string; password: string }, config: AxiosConfig ) { return instance.post("/register", data, config); }
const App = () => { const registerAccount = async (username, password) => { const response = await register({ username, password }); //... 響應結束後處理邏輯 };
return <button onClick={registerAccount}>註冊賬號</button>;
};
// ------------現在的方式------------- const App = () => { const registerAccount = async (username, password) => { // 直接呼叫apis const response = await apis.post"/register"; //... 響應結束後處理邏輯 };
return <button onClick={registerAccount}>註冊賬號</button>;
}; ```
以往我們如果想在元件裡呼叫一個已寫在前端程式碼裡的介面,則需要先知道介面的url
(如上面的/register
),再去通過url
在前端程式碼裡找到該介面對應的請求函式(如上面的register
)。而如果用本文這種做法,我們只需要知道url
就可以。
這麼做還有一個好處是防止重複記錄介面。
- 支援返回結果的智慧推導
舉一個介面為例子:
- 路徑為
/admin
,方法為get
,返回結果的資料型別為{admins: string[]}
呼叫效果如下所示:
- 支援錯誤捕捉,無需寫
try~catch
包裹處理
呼叫時寫法如下所示:
```js const getAdmins = async () => { const { err, data } = await apis.get'/admins'; // 判斷如果err不為空,則代表請求出錯 if (err) { //.. 處理錯誤的邏輯
// 最後return跳出,避免執行下面的邏輯
return
};
// 如果err為空,代表請求正常,此時需要用!強制宣告data不為null
setAdmins(data!.admins);
}; ```
- 支援路徑引數,且路徑引數也是會智慧推導的
舉一個介面為例子:
- 路徑為
/account/{username}
,方法為get
,需要username
路徑引數
寫法如下所示:
js
const getAccount = async () => {
const { err, data } = await apis.get["/account/{username}"]({
// config新增args屬性,且在裡面定義username的值。最終url會被替換為/account/123
args: {
username: "123",
},
});
if (err) return;
setAccount(data);
};
實現方式
先展示二次封裝後的 API 層目錄
我們先看/apis.index.ts
的程式碼
```js import deleteApis from "./apis/delete"; import get from "./apis/get"; import post from "./apis/post"; import put from "./apis/put";
// 每一個屬性中會包含同名的請求方法下所有介面的請求函式 const apis = { get, post, put, delete: deleteApis, };
export default apis; ```
邏輯上很簡單,只負責匯出包含所有請求的apis
物件。接下來看看/apis/get.ts
```ts import makeRequest from "../request";
export default { "/admins": makeRequest<{ admins: string[] }>({ url: "/admins", }), "/delay": makeRequest({ url: "/delay", }), "/500-error": makeRequest({ url: "/500-error", }), // makeRequest用於生成支援智慧推導,路徑替換,捕獲錯誤的請求函式 // 其形參的型別為RequestConfig,該型別在繼承AxiosConfig上加了些自定義屬性,例如存放路徑引數的屬性args // makeRequest帶有四個可選泛型,分別為: // - Payload: 用於定義響應結果的資料型別,若沒有則可定義為undefined,下面的變數也一樣 // - Data:用於定義data的資料型別 // - Params:用於定義parmas的資料型別 // - Args:用於定義存放路徑引數的屬性args的資料型別 "/account/{username}": makeRequest< { id: string; name: string; role: string }, undefined, undefined, { username: string }
({ url: "/account/{username}", }), }; ```
一切的重點在於makeRequest
,其作用我再註釋裡已經說了,就不再重複了。值得一提的是,我們在呼叫apis.get['xx'](config1)
中的config1
是配置,這裡生成請求函式的makeRequest(config2)
的config2
也是配置,這兩個配置在最後會合並在一起。這麼設計的好處就是,如果有一個介面需要特殊配置,例如需要更長的timeout
,可以直接在makeRequest
這裡就加上timeout
屬性如下所示:
js
{
// 這是一個耗時較長的介面
'/longtime': makeRequest({
url: '/longtime',
// 設定超時時間
timeout: 15000
}),
}
這樣我們每次在開發中呼叫apis.get['/longtime']
時就不需要再定義timeout
了。
額外說一種情況,如果請求裡的body
需要放入FormData
型別的表單資料,則可以用下面的情況處理:
ts
export default {
"/register": makeRequest<null, { username: string; password: string }>({
url: "/register",
method,
// 把Content-Type設為multipart/form-data後,axios內部會自動把{ username: string; password: string }物件轉換為待同屬性的FormData型別的變數
headers: {
"Content-Type": "multipart/form-data",
},
}),
};
關於上述詳情可看axios#-automatic-serialization-to-formdata。
下面來看看定義makeRequest
方法的/api/request/index.ts
檔案:
```ts import urlArgs from "./interceptor/url-args";
const instance = axios.create({ timeout: 10000, baseURL: "/api", }); // 通過攔截器實現路徑引數替換機制,之後會放出urlArgs程式碼 instance.interceptors.request.use(urlArgs.request.onFulfilled, undefined);
// 定義返回結果的資料型別
export interface ResultFormat
// 重新定義RequestConfig,在AxiosRequestConfig基礎上再加args資料
export interface RequestConfig extends AxiosRequestConfig {
args?: Record
/*
* 允許定義四個可選的泛型引數:
* Payload: 用於定義響應結果的資料型別
* Data:用於定義data的資料型別
* Params:用於定義parmas的資料型別
* Args:用於定義存放路徑引數的屬性args的資料型別
/
// 這裡的定義中重點處理上述四個泛型在預設和定義下的四種不同情況
interface MakeRequest {
const makeRequest: MakeRequest =
export default makeRequest; ```
上面程式碼中重點在於MakeRequest
型別中對泛型的處理,其餘邏輯都很簡單。
最後展示一下支援路徑引數替換的攔截器urlArgs
對應的程式碼:
ts
const urlArgsHandler = {
request: {
onFulfilled: (config: AxiosRequestConfig) => {
const { url, args } = config as RequestConfig;
// 檢查config中是否有args屬性,沒有則跳過以下程式碼邏輯
if (args) {
const lostParams: string[] = [];
// 使用String.prototype.replace和正則表示式進行匹配替換
const replacedUrl = url!.replace(/\{([^}]+)\}/g, (res, arg: string) => {
if (!args[arg]) {
lostParams.push(arg);
}
return args[arg] as string;
});
// 如果url存在未替換的路徑引數,則會直接報錯
if (lostParams.length) {
return Promise.reject(new Error("在args中找不到對應的路徑引數"));
}
return { ...config, url: replacedUrl };
}
return config;
},
},
};
已上就是整個二次封裝的過程了,如果有不懂的可以直接檢視專案 enhance-axios-frame裡的程式碼或在評論區討論。
進階:
我在之前的文章裡有介紹如何給axios
附加更多高階功能,如果有興趣的可以點選以下連結看看:
後記
這篇文章寫到這裡就結束了,如果覺得有用可以點贊收藏,如果有疑問可以直接在評論留言,歡迎交流 👏👏👏。