原文地址:monine.github.io/#/article/2…
最近工作賊忙,這篇文章按說應該兩個月之前就產出,可是每天的精力基本都用在工作上,一寫文章就犯迷糊,斷斷續續的每次要重新屢邏輯,以後再也不這樣了。這篇文章是我司後臺專案中遇到的一個基礎需求,自己設計了一個實現方案,感覺還不錯。
需求
後端介面響應,根據與後端約定的狀態碼(非 http 狀態碼)判定介面是否異常,我司的約定是 status !== 0
則表示介面異常。一旦介面處於異常狀態,先讓業務端(呼叫者)處理異常,再由業務端決定是否執行介面異常統一處理(目前我司的統一處理內容就是彈出個 element-ui message 提示訊息 ?)。
這個流程有一個難點,當介面響應後處於異常狀態,先交由業務端處理,再由業務端決定是否執行統一處理?
API 層我司使用的是第三方庫 axios,介面響應後會先走響應攔截器,再走業務端程式碼。 正常的介面異常統一處理流程,是在響應攔截器內判定,與後端約定的響應狀態碼是否為異常狀態碼。如果是,則先執行統一處理邏輯,再交由到業務端處理。那現在的需求是將介面異常處理的流程逆轉,介面響應狀態異常之後,先交由業務端執行異常處理,再由業務端決定是否執行介面異常狀態統一處理。
如上所說,如果介面處於異常狀態,需要判定是否要執行統一處理,分兩種情況:
- 業務端沒有處理異常,必然要執行統一處理。
- 業務端已經處理異常,並且主動宣告是否繼續執行統一處理。(主動宣告該如何設計?)
問題來了,介面異常統一處理的程式碼應該寫在哪裡?如何保證它在狀態異常情況下,先交由業務端處理,再根據業務端的宣告判定是否執行統一處理。
歷史解決方案之 mixin
我司之前已經有過處理方案,不過是隻針對 vue 框架下的處理,通過 mixin 將 methods 內所有方法進行覆寫,補丁函式內對源函式執行完成之後獲取的返回結果進行判定,如果返回結果為 Promise 型別,則繼續進行相關異常處理操作。這樣確實能夠達到實現效果,但總覺得很不優雅:
- API 層的處理與框架深度繫結,這本身就不合理。
- methods 內函式全部被覆寫,大量無用開銷;如果進行函式名稱約定,加入覆寫篩選,這又增加約定成本。
- 只能應用於 vue 框架,無法再其它專案下直接使用,很侷限。
當時我瞭解到上述的處理方案後第一反應是 API 層的任何操作都不應該與框架本身進行任何關聯繫結,如同當年 vue 從全家桶中移除 vue-resource 一樣。
我的解決方案
經過一些思考,大致確定了一個思路:利用 Promise 狀態的穩定,以介面名稱作為唯一標識,表示當前介面是否還需要執行統一處理。。
我是這樣設計的,介面呼叫時使用 url 作為唯一標識,以狀態的形式儲存在陣列內 const unhandleAPI = []
。
介面返回後進入響應攔截器,在此對介面響應狀態進行判斷,如果屬於異常狀態,則使用 setTimeout
將介面異常統一處理函式設定為 macro task,作為一個非同步任務推遲到下一輪 Event Loop 再執行,並返回 Promise.reject
。然後會進入業務端介面呼叫程式碼的 catch 回撥函式內,執行完業務異常處理後,如果沒有返回值,則表示無需再執行統一處理。相反,返回非 undefined
值,則表示還需要執行統一處理。
執行介面異常統一處理之前,先判定 url 標識是否存在於 unhandleAPI
內,如存在,則執行統一處理。
以上是一個大致的設計思路,具體到實現還需要解決一些實際問題:
- 如何確定介面的唯一性?因為同一個介面可能毫秒內被多次呼叫。
- 介面的異常狀態以什麼樣的形式進行儲存?
- 如何在適當的時候移除介面異常狀態?比如業務端處理了異常不想再執行統一處理。
具體實現
-
介面異常處理狀態儲存
使用一個陣列物件
const unhandleAPI = []
儲存所有已經呼叫但暫未響應的介面唯一標識,介面異常統一處理函以此判定判定是否需要執行。另外對外暴露一些操作unhandleAPI
的介面。const unhandleAPI = []; if (process.env.NODE_ENV !== 'production') { window.unhandleAPI = unhandleAPI; } export function matchUnhandleAPI(id) { return unhandleAPI.find(apiUid => apiUid === id); } export function addUnhandleAPI(id) { unhandleAPI.push(id); } export function removeUnhandleAPI(id) { const index = unhandleAPI.findIndex(apiUid => apiUid === id); if (process.env.NODE_ENV === 'production') { unhandleAPI.splice(index, 1); } else { // 方便非 production 環境檢視介面處理情況 unhandleAPI[index] += '#removed'; } } 複製程式碼
-
傳送介面請求
通過檢視 axios 原始碼,知道 axios 真正呼叫介面的方法是
axios.Axios.prototype.request
,所以需要對其進行覆寫。將當前呼叫介面的唯一標識新增到unhandleAPI
陣列物件內,同時也要新增到axios.Axios.prototype.request
方法所返回的 Promise 例項物件當中(介面響應後的處理會使用到)。let uid = 0; const axiosRequest = axios.Axios.prototype.request; axios.Axios.prototype.request = function(config) { uid += 1; const apiUid = `${config.url}?uid=${uid}`; // 介面呼叫的唯一標識 config.apiUid = apiUid; // 響應攔截器內需要使用到 apiUid,所以新增為 config 屬性 addUnhandleAPI(apiUid); // 新增到介面異常處理狀態儲存的陣列物件 const p = axiosRequest.call(this, config); // 觸發 axios 介面呼叫 p.apiUid = apiUid; // 在當前介面呼叫所返回的 Promise 例項中新增唯一標識屬性 return p; }; 複製程式碼
-
介面響應進入響應攔截器
在響應攔截器內判定介面狀態,如果正常,則從介面狀態儲存的陣列物件中移除當前響應介面的唯一標識。如果異常,則
setTimeout
延遲執行介面狀態異常統一處理函式,並返回Promise.reject()
給到業務端。service.interceptors.response.use( ({ data, config }) => { const { status, msg, data: result } = data; // 判斷介面狀態是否異常 if (status !== 0) { const pr = Promise.reject(data); pr.apiUid = config.apiUid; // Promise 例項中新增當前介面的唯一標識屬性 setTimeout(handleAPIStatusError, 0, pr, msg); // 異常先交由業務端處理,延遲執行統一處理函式 return pr; } // 介面狀態正常 removeUnhandleAPI(config.apiUid); // 從介面異常處理狀態儲存的陣列物件中移除當前響應介面的唯一標識 return result; }, error => { Message.error(error.message); return Promise.reject(error); } ); 複製程式碼
-
業務端處理
現在假設介面狀態屬於異常情況,經過響應攔截器之後,程式碼執行到業務端,先看看業務端介面呼叫程式碼:
callAPIMethod().catch(error => { // 業務端處理異常 }); 複製程式碼
以上是 Promise catch 的常規語法,此時如果 callAPIMethod 返回的 Promise 狀態為 rejected,則會執行 catch 函式的回撥函式。
還記得上文提到的流程上的難點嗎?
業務端決定是否執行介面異常統一處理函式,因此需要在此進行設計,catch 函式的回撥函式如何進行宣告?其實上文已經提到 宣告 的設計方案,利用 catch 函式的回撥函式的返回值。
設計方案 OK,落實到具體實現該如何進行程式碼編寫?無疑,需要針對 catch 函式進行覆寫:
Promise.prototype.catch = function(onRejected) { function $onRejected(...args) { const catchResult = onRejected(...args); if (catchResult === undefined && this.apiUid) { removeUnhandleAPI(this.apiUid); } } return this.then(null, $onRejected.bind(this)); }; 複製程式碼
catch 方法本身其實只是語法糖,將 catch 函式的回撥函式進行包裝,在包裝後的函式內,先執行業務端 catch 的回撥函式,獲取到函式執行結果。接著,如果當前 promise 物件上有 apiUid 屬性,則表示當前 promise 是 API 層的 promise。如果 catch 的回撥函式執行完畢之後的返回結果是
undefined
,則表示不再需要執行介面異常狀態統一處理函式,相應的,需要從之前定義的unhandleAPI
陣列內移除當前介面的唯一標識。 -
then 方法返回新 promise
以上業務端處理看似正常,然而大多數情況下,業務端程式碼在介面呼叫之後不會直接鏈式呼叫 catch 方法,而是先呼叫 then 方法,再呼叫 catch 方法,如下:
callAPIMethod() .then(response => { // ... }) .catch(error => { // ... }); 複製程式碼
callAPIMethod()
的執行結果返回的是個 promise 物件,並且這個 promise 物件上會有apiUid
屬性,表示當前 promise 是 API 層介面。然後鏈式呼叫 then 方法和 catch 方法,就因為中間插入了 then 方法的呼叫,導致 catch 的覆寫函式內this
物件的屬性上沒有了apiUid
屬性,也就無法判定當前 promise 是 API 層介面的返回物件。原因是 then 方法執行完後返回了新的 Promise 例項,所以同樣需要對 then 方法進行覆寫。const promiseThen = Promise.prototype.then; Promise.prototype.then = function(onFulfilled, onRejected) { // 獲取 then 方法返回的新 Promise 例項物件 const p = promiseThen.call(this, onFulfilled, onRejected); // 在 promise 物件上有 apiUid 的情況下,表示是介面層的 Promise // 則給 then 方法返回的 Promise 例項物件也加上 apiUid if (this.apiUid) p.apiUid = this.apiUid; return p; }; 複製程式碼
then 方法的覆寫函式內,先執行原生的 then 方法,獲取返回結果,再判斷當前呼叫者 promise 物件是否有
apiUid
屬性。如果有,則表示是 API 層的 Promise,從而需要給當前 then 方法返回的 Promise 例項也新增上apiUid
屬性。 -
執行介面異常狀態統一處理函式
介面異常狀態情況下,如果業務端主動宣告需要執行介面異常狀態統一處理(業務端 catch 回撥函式返回非
undefined
值),則在執行響應攔截器內setTimeout
延遲執行的函式handleAPIStatusError
時只要介面響應狀態為異常,都會執行介面異常狀態統一處理函式,內部會進行判定
function handleAPIStatusError(pr, msg) { const index = unhandleAPI.findIndex(apiUid => apiUid === pr.apiUid); if (index >= 0) { pr.catch(() => { Message.error({ message: msg, duration: 5e3 }); }); } } 複製程式碼
如果
unhandleAPI
陣列物件內能夠找到pr.apiUid
,則表示需要執行介面異常狀態統一處理。
可能存在的問題
如果專案是由 vue-cli 搭建的 webpack 模板專案,在沒有修改 .babelrc 檔案配置的情況下,此方案在 Firefox 瀏覽器下是無效的。介面狀態異常的情況下,總是會執行統一處理,不會先交由業務端處理異常,再判定是否執行統一處理。
Firefox 下無效的原因和解決方案我會在下一篇文章講解。
自我評價
個人認為這樣的設計還是很優雅的,認知成本非常小,對小夥伴的常規開發沒有任何汙染;對框架沒有任何依賴,可移植到任何框架專案下。
另外
能力有限,哪位小夥伴有更加優雅合適的方案還望不吝賜教。