axios傳遞參數傳不過去,axios二次封裝及調用,一篇拒絕低級封裝axios的文章
目前掘金上已經有很多關于axios封裝的文章。自己在初次閱讀這些文章中,見識到很多封裝思路,但在付諸實踐時一直有疑問:這些看似高級的二次封裝,是否會把axios的調用方式弄得更加復雜? 優秀的二次封裝,有以下特點:
能改善原生框架上的不足:明確原生(sheng)框架(jia)的缺點,且在(zai)二(er)次封裝(zhuang)后能徹底杜絕(jue)這些缺點,與此同時不(bu)會引入新的缺點。
保持原有的功能:當進行二次封(feng)裝時(shi),新框架的(de) API 可能會更改原生(sheng)框架的(de) API 的(de)調(diao)用方(fang)式(例如傳參方(fang)式),但我們要保(bao)證能通(tong)過新 API 調(diao)用原生(sheng) API 上的(de)所有功能。
理解成本低:有原生框(kuang)(kuang)架(jia)使用經驗(yan)的開發者在面對二(er)次(ci)封裝的框(kuang)(kuang)架(jia)和 API 時能(neng)迅速理解且上手。
但目前我見過(guo),或者我接收過(guo)的(de)(de)(de)項目里眾多的(de)(de)(de)axios二次封裝中(zhong),并不具備(bei)上述原(yuan)則,我們接下盤點一些常見的(de)(de)(de)低級(ji)的(de)(de)(de)二次封裝的(de)(de)(de)手法。
盤點那些低級的axios二次封裝方式
1. 對特定 method 封裝成新的 API,卻暴露極少的參數
例如以下代碼:
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]); }); }); };
上(shang)面的(de)代碼(ma)中對method為(wei)post的(de)請(qing)求(qiu)方法進(jin)行封裝(zhuang),用于解決原(yuan)生 API 中在處理報(bao)錯時(shi)(shi)需要用try~catch包裹。但這(zhe)種封裝(zhuang)有(you)一(yi)個(ge)缺點:整(zheng)個(ge)post方法只暴露(lu)了(le)url,data,params三個(ge)參數(shu),通常這(zhe)三個(ge)參數(shu)可(ke)以滿足(zu)大多數(shu)簡單請(qing)求(qiu)。但是,如果我們遇(yu)到一(yi)個(ge)特殊(shu)的(de)post接(jie)口(kou),它的(de)響應時(shi)(shi)間(jian)較(jiao)慢,需要設(she)置較(jiao)長的(de)超時(shi)(shi)時(shi)(shi)間(jian),那上(shang)面的(de)post方法就立馬嗝(ge)屁了(le)。
此時用原生的axios.post方法可(ke)以輕松搞定上述特殊場景,如下所示:
// 針對此次請求把超時時間設置為15saxios.post("/submit", form, { timeout: 15000 });
類似(si)的(de)特殊(shu)場(chang)景(jing)還有很多,例如:
需要上傳表單(dan),表單(dan)中不僅含數據還(huan)有文(wen)件(jian),那(nei)只能設置(zhi)headers["Content-Type"]為"multipart/form-data"進(jin)行請求,如果要顯示上傳文(wen)件(jian)的進(jin)度條,則(ze)還(huan)要設置(zhi)onUploadProgress屬性(xing)。
存在需(xu)要(yao)防止數據競(jing)態的接口,那(nei)只能設置cancelToken或(huo)(huo)signal。有(you)人說可以在通過攔截(jie)器interceptors統一(yi)處理以避免(mian)競(jing)態并發,對此我舉個(ge)(ge)用(yong)以反對的場景(jing):如(ru)果同(tong)一(yi)個(ge)(ge)頁(ye)面中(zhong)(zhong)有(you)兩個(ge)(ge)或(huo)(huo)多個(ge)(ge)下(xia)拉(la)(la)框,兩個(ge)(ge)下(xia)拉(la)(la)框都會調用(yong)同(tong)一(yi)個(ge)(ge)接口獲取下(xia)拉(la)(la)選項(xiang),那(nei)你這個(ge)(ge)用(yong)攔截(jie)器實現(xian)(xian)的避免(mian)數據競(jing)態的機制就會出現(xian)(xian)問題,因為會導致這些下(xia)拉(la)(la)框中(zhong)(zhong)只有(you)一(yi)個(ge)(ge)請求(qiu)不會被中(zhong)(zhong)斷。
有些開發者會說不會出現這種接口,已經約定好的所有post接口只需這三種參數就行。對此我想反駁:一個有潛力的項目總會不斷地加入更多的需求,如果你覺得你的項目是沒有潛力的,那當我沒說。但如果你不敢肯定你的項目之后是否會加入更多特性,不敢保證是否會遇到這類特殊場景,那請你在二次封裝時,盡可能地保持與原生API對齊,以保證原生API中一切能做到的,二次封裝后的新API也能做到。以避免在(zai)遇到(dao)上述(shu)的(de)特(te)殊情況(kuang)時,你只(zhi)能(neng)尷尬地(di)修改新API,而且還會出現(xian)為了兼容因而改得(de)特(te)別(bie)難(nan)看那種寫法。
2. 封裝創建axios實例的方法,或者封裝自定義axios類
例如以下代碼:
// 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. 多個配置下的處理方式
如果(guo)有兩(liang)種(zhong)或以上不同(tong)的配(pei)置(zhi),這些配(pei)置(zhi)各被一(yi)部分接口使用。那么(me)就(jiu)應(ying)該聲明對(dui)應(ying)不同(tong)配(pei)置(zhi)的常量(liang),然后(hou)在調用axios時傳入對(dui)應(ying)的配(pei)置(zhi)常量(liang),如下所(suo)示:
// 聲明含不同配置項的常量configA和configBconst configA = { // ....};const configB = { // ....};// 在需要這些配置的接口里把對應的常量傳進去axios.get("api1", configA); axios.get("api2", configB);
對比起多個(ge)不同配置的axios實(shi)例,上述的寫(xie)法更加直(zhi)觀,能讓閱讀代(dai)碼的人(ren)直(zhi)接看出區別。
2. 多個攔截器下的處理方式
如果有兩種或以上不同的攔截器,這些攔截器中各被一部分接口使用。那么,我們可以把這些攔截器都掛載到全局唯一的axios實例上,然后(hou)通過以下兩種方式來讓攔截器選擇性執行:
1、推薦:在(zai)config中(zhong)新加一個自(zi)定(ding)義屬性以決定(ding)攔截器是否執行(xing),代碼(ma)如下所示(shi):
調用請求時,寫法如下(xia)所示:
instance.get("/api", { //新增自定義參數enableIcp來決定是否執行攔截器 enableIcp: true, });
在(zai)攔截器中,我們這么編寫邏輯
// 請求攔截器寫法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塞自(zi)定(ding)義屬(shu)性,同時在(zai)編寫攔(lan)(lan)截(jie)器(qi)時配合(he),就可以(yi)完美的控制(zhi)單(dan)個(ge)或多個(ge)攔(lan)(lan)截(jie)器(qi)的執(zhi)行與否。
2、次要推薦:使用axios官方提供的runWhen屬性來決定攔截器是否執行,注意該屬性只能決定請求攔截器的執行與否,不能決定響應攔截器的執行與否。用法如下所示:
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 } );
本章總結
當我們進行二次封裝時,切勿為了封裝而封裝,首先要分析原有框架的缺點,下面我們來分析一下axios目前有什么缺點。
盤點axios目前的缺點
1. 不能智能推導params
在axios的類型文件中,config變量對應的類型AxiosRequestConfig如下所示:
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包裹著,如下所示:
try { const response = await axios("/get"); } catch (err) { // ...處理錯誤的邏輯}
如果每次都要用(yong)try~catch代碼塊(kuai)去包裹調用(yong)接口的代碼行(xing),會很繁(fan)瑣(suo)。
3. 不支持路徑參數替換
目前大多數后端暴露的接口格式都遵循RESTful風格,而RESTful風格的url中需要把參數值嵌到路徑中,例如存在RESTful風格的接口,url為/api/member/{member_id},用于獲取成員的信息,調用中我們需要把member_id替換成實際值,如下所示:
axios(`/api/member/${member_id}`);
如果把其(qi)封裝成一個請(qing)求資源方法,就要(yao)額外(wai)暴露對應路(lu)徑參數的形參。非常不(bu)美(mei)觀(guan),如下所示:
function getMember(member_id, config) { axios(`/api/member/${member_id}`, config); }
針(zhen)對上述缺點,下(xia)(xia)面我(wo)分享一下(xia)(xia)自己精(jing)簡的二次封裝。
應該如何精簡地進行二次封裝
在本節中我會結合Typescript來展示如何精簡地進行二次封裝以解決上述axios的缺點。注意在這次封裝中我不會寫任何涉及到業務上的場景例如鑒權登錄和錯誤碼映射。下面(mian)先展示(shi)一下二次封裝后的使用方式。
使用方式以及效果
使用方式如下所示:
apis[method][url](config);
method對應接口的請求方法;url為接口路徑;config則是AxiosConfig,也就是配置。
返回結果的數據類型為:
{ // 當請求報錯時,data為null值 data: null | T; // 當請求報錯時,err為AxiosError類型的錯誤實例 err: AxiosError | null; // 當請求報錯時,response為null值 response: AxiosResponse<T> | null;}
下面來展示一(yi)下使用(yong)效(xiao)果(guo):
1、支持url智能推導,且根據輸入的url推導出需要的params、data。在缺寫或寫錯請求參數時,會出現ts錯誤提示
舉兩個接口做例子:
路徑為/register,方法為post,data數據類型為{ username: string; password: string }
路徑為/password,方法為put,data數據類型為{ password: string },params數據類型為{ username: string }
調用效果如下所示:
通(tong)過這(zhe)種方(fang)式,我們(men)無(wu)需再通(tong)過一個函數來執(zhi)行請求(qiu)(qiu)接(jie)口邏(luo)輯(ji),而是可以直接(jie)通(tong)過調用api來執(zhi)行請求(qiu)(qiu)接(jie)口邏(luo)輯(ji)。如(ru)下所(suo)示(shi):
// ------------以前的方式-------------// 需要用一個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"]({ username, password }); //... 響應結束后處理邏輯 }; return <button onClick={registerAccount}>注冊賬號</button>; };
以往我們如果想在組件里調用一個已寫在前端代碼里的接口,則需要先知道接口的url(如上面的/register),再去通過url在前端代碼里找到該接口對應的請求函數(如上面的register)。而如果用本文這種做法,我們只需要知道url就可以。
這么做(zuo)還有(you)一個好處是防止重復記錄接口。
2、支(zhi)持(chi)返(fan)回(hui)結果的智能(neng)推導
舉一個接口為例子:
路徑為/admin,方法為get,返回結果的數據類型為{admins: string[]}
調用效果如下所示:
3、支持錯誤捕捉,無需寫try~catch包裹處理
調用時寫法如下所(suo)示:
const getAdmins = async () => { const { err, data } = await apis.get['/admins'](); // 判斷如果err不為空,則代表請求出錯 if (err) { //.. 處理錯誤的邏輯 // 最后return跳出,避免執行下面的邏輯 return }; // 如果err為空,代表請求正常,此時需要用!強制聲明data不為null setAdmins(data!.admins); };
4、支持路徑參數,且路徑參數也是會智能推導的
舉一個接口為例子:
路徑為/account/{username},方法為get,需要username路徑參數
寫法如下所示:
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); };
實現方式
先展示二次(ci)封裝(zhuang)后的 API 層(ceng)目錄
我們先看/apis.index.ts的代碼
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;
邏輯上(shang)很簡單,只負責(ze)導出包含所有請(qing)求的apis對象。接下來看看/apis/get.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屬性如下所示:
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", }, }), };
這樣我們每次在開發中調用apis.get['/longtime']時就不需要再定義timeout了。
額外說一種情況,如果請求里的body需要放入FormData類型(xing)的表單數據,則可以用下(xia)面的情況處理:
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", }, }), };
下面來看看定義makeRequest方法的/api/request/index.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<T = any> { data: null | T; err: AxiosError | null; response: AxiosResponse<T> | null; }// 重新定義RequestConfig,在AxiosRequestConfig基礎上再加args數據export interface RequestConfig extends AxiosRequestConfig { args?: Record<string, any>; }/** * 允許定義四個可選的泛型參數: * Payload: 用于定義響應結果的數據類型 * Data:用于定義data的數據類型 * Params:用于定義parmas的數據類型 * Args:用于定義存放路徑參數的屬性args的數據類型 */// 這里的定義中重點處理上述四個泛型在缺省和定義下的四種不同情況interface MakeRequest { <Payload = any>(config: RequestConfig): ( requestConfig?: Partial<RequestConfig> ) => Promise<ResultFormat<Payload>>; <Payload, Data>(config: RequestConfig): ( requestConfig: Partial<Omit<RequestConfig, "data">> & { data: Data } ) => Promise<ResultFormat<Payload>>; <Payload, Data, Params>(config: RequestConfig): ( requestConfig: Partial<Omit<RequestConfig, "data" | "params">> & (Data extends undefined ? { data?: undefined } : { data: Data }) & { params: Params; } ) => Promise<ResultFormat<Payload>>; <Payload, Data, Params, Args>(config: RequestConfig): ( requestConfig: Partial<Omit<RequestConfig, "data" | "params" | "args">> & (Data extends undefined ? { data?: undefined } : { data: Data }) & (Params extends undefined ? { params?: undefined } : { params: Params }) & { args: Args; } ) => Promise<ResultFormat<Payload>>; }const makeRequest: MakeRequest = <T>(config: RequestConfig) => { return async (requestConfig?: Partial<RequestConfig>) => { // 合并在service中定義的config和調用時從外部傳入的config const mergedConfig: RequestConfig = { ...config, ...requestConfig, headers: { ...config.headers, ...requestConfig?.headers, }, }; // 統一處理返回類型 try { const response: AxiosResponse<T, RequestConfig> = await instance.request<T>(mergedConfig); const { data } = response; return { err: null, data, response }; } catch (err: any) { return { err, data: null, response: null }; } }; };export default makeRequest;
上面代碼中重點在于MakeRequest類(lei)型中對泛型的(de)處理,其余邏輯都很簡單。
最后展示一下支持路徑參數替換的攔截器urlArgs對應的代碼:
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; }, }, };
已上就是整個二(er)次封裝的過程了(le),如果有(you)不懂的可(ke)以直接查看項目 enhance-axios-frame里的代碼或在評論(lun)(lun)區討論(lun)(lun)。
作者:村上小樹
鏈接:
//juejin.cn/post/7173670666326474783