一篇拒絕低級封裝axios的文章

語言: CN / TW / HK

theme: devui-blue highlight: androidstudio


為什麼要寫這篇文章

事前提醒:閲讀本文存在不同想法時,可以在評論中表達,但請勿使用過激的措辭。

目前掘金上已經有很多關於axios封裝的文章。自己在初次閲讀這些文章中,見識到很多封裝思路,但在付諸實踐時一直有疑問:這些看似高級的二次封裝,是否會把axios的調用方式弄得更加複雜? 優秀的二次封裝,有以下特點:

  1. 能改善原生框架上的不足:明確原生框架的缺點,且在二次封裝後能徹底杜絕這些缺點,與此同時不會引入新的缺點。
  2. 保持原有的功能:當進行二次封裝時,新框架的 API 可能會更改原生框架的 API 的調用方式(例如傳參方式),但我們要保證能通過新 API 調用原生 API 上的所有功能。
  3. 理解成本低:有原生框架使用經驗的開發者在面對二次封裝的框架和 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]); }); }); };

上面的代碼中對methodpost的請求方法進行封裝,用於解決原生 API 中在處理報錯時需要用try~catch包裹。但這種封裝有一個缺點:整個post方法只暴露了url,data,params三個參數,通常這三個參數可以滿足大多數簡單請求。但是,如果我們遇到一個特殊的post接口,它的響應時間較慢,需要設置較長的超時時間,那上面的post方法就立馬嗝屁了。

此時用原生的axios.post方法可以輕鬆搞定上述特殊場景,如下所示:

js // 針對此次請求把超時時間設置為15s axios.post("/submit", form, { timeout: 15000 });

類似的特殊場景還有很多,例如:

  1. 需要上傳表單,表單中不僅含數據還有文件,那隻能設置headers["Content-Type"]"multipart/form-data"進行請求,如果要顯示上傳文件的進度條,則還要設置onUploadProgress屬性。
  2. 存在需要防止數據競態的接口,那隻能設置cancelTokensignal。有人説可以在通過攔截器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實例的用途和場景,就好比一個項目多個VuexRedux一樣雞肋。

那麼有開發者會問如果有相當數量的接口需要用到不同的配置和攔截器,那要怎麼辦?下面我來分多個配置多個攔截器兩種場景進行分析:

1. 多個配置下的處理方式

如果有兩種或以上不同的配置,這些配置各被一部分接口使用。那麼就應該聲明對應不同配置的常量,然後在調用axios時傳入對應的配置常量,如下所示:

```js // 聲明含不同配置項的常量configA和configB const configA = { // .... };

const configB = { // .... };

// 在需要這些配置的接口裏把對應的常量傳進去 axios.get("api1", configA); axios.get("api2", configB); ```

對比起多個不同配置的axios實例,上述的寫法更加直觀,能讓閲讀代碼的人直接看出區別。

2. 多個攔截器下的處理方式

如果有兩種或以上不同的攔截器,這些攔截器中各被一部分接口使用。那麼,我們可以把這些攔截器都掛載到全局唯一的axios實例上,然後通過以下兩種方式來讓攔截器選擇性執行:

  1. 推薦:在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塞自定義屬性,同時在編寫攔截器時配合,就可以完美的控制單個或多個攔截器的執行與否。

  2. 次要推薦:使用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; }

下面來展示一下使用效果:

  1. 支持url智能推導,且根據輸入的url推導出需要的paramsdata。在缺寫或寫錯請求參數時,會出現ts錯誤提示

舉兩個接口做例子:

  • 路徑為/register,方法為postdata數據類型為{ username: string; password: string }
  • 路徑為/password,方法為putdata數據類型為{ password: string }params數據類型為{ username: string }

調用效果如下所示:

自動推導api1效果2.gif

自動推導api2效果1.gif

通過這種方式,我們無需再通過一個函數來執行請求接口邏輯,而是可以直接通過調用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就可以。

這麼做還有一個好處是防止重複記錄接口。

  1. 支持返回結果的智能推導

舉一個接口為例子:

  • 路徑為/admin,方法為get,返回結果的數據類型為{admins: string[]}

調用效果如下所示:

返回結果推導api效果.gif

  1. 支持錯誤捕捉,無需寫try~catch包裹處理

調用時寫法如下所示:

```js const getAdmins = async () => { const { err, data } = await apis.get'/admins'; // 判斷如果err不為空,則代表請求出錯 if (err) { //.. 處理錯誤的邏輯

  // 最後return跳出,避免執行下面的邏輯
  return
};
// 如果err為空,代表請求正常,此時需要用!強制聲明data不為null
setAdmins(data!.admins);

}; ```

  1. 支持路徑參數,且路徑參數也是會智能推導的

舉一個接口為例子:

  • 路徑為/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 層目錄

image.png

我們先看/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 { data: null | T; err: AxiosError | null; response: AxiosResponse | null; }

// 重新定義RequestConfig,在AxiosRequestConfig基礎上再加args數據 export interface RequestConfig extends AxiosRequestConfig { args?: Record; }

/* * 允許定義四個可選的泛型參數: * Payload: 用於定義響應結果的數據類型 * Data:用於定義data的數據類型 * Params:用於定義parmas的數據類型 * Args:用於定義存放路徑參數的屬性args的數據類型 / // 這裏的定義中重點處理上述四個泛型在缺省和定義下的四種不同情況 interface MakeRequest { (config: RequestConfig): ( requestConfig?: Partial ) => Promise>;

(config: RequestConfig): ( requestConfig: Partial> & { data: Data } ) => Promise>;

(config: RequestConfig): ( requestConfig: Partial> & (Data extends undefined ? { data?: undefined } : { data: Data }) & { params: Params; } ) => Promise>;

(config: RequestConfig): ( requestConfig: Partial> & (Data extends undefined ? { data?: undefined } : { data: Data }) & (Params extends undefined ? { params?: undefined } : { params: Params }) & { args: Args; } ) => Promise>; }

const makeRequest: MakeRequest = (config: RequestConfig) => { return async (requestConfig?: Partial) => { // 合併在service中定義的config和調用時從外部傳入的config const mergedConfig: RequestConfig = { ...config, ...requestConfig, headers: { ...config.headers, ...requestConfig?.headers, }, }; // 統一處理返回類型 try { const response: AxiosResponse = await instance.request(mergedConfig); const { data } = response; return { err: null, data, response }; } catch (err: any) { return { err, data: null, response: null }; } }; };

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附加更多高級功能,如果有興趣的可以點擊以下鏈接看看:

  1. 反饋請求結果

  2. 接口限流

  3. 數據緩存

  4. 錯誤自動重試

後記

這篇文章寫到這裏就結束了,如果覺得有用可以點贊收藏,如果有疑問可以直接在評論留言,歡迎交流 👏👏👏。