如何構建通用 api 中間層
零、問題的由來
開門見山地說,這篇文章是一篇安利軟文~,安利的物件就是最近搞的 tua-api。
顧名思義,這就是一款輔助獲取介面資料的工具。
發請求相關的工具辣麼多,那我為啥要用你呢?
理想狀態下,專案中應該有一個 api 中間層。各種介面在這裡定義,業務側不應該手動編寫介面地址,而應該呼叫介面層匯出的函式。
import { fooApi } from '@/apis/' fooApi .bar({ a: '1', b: '2' }) // 發起請求,a、b 是請求引數 .then(console.log) // 收到響應 .catch(console.error) // 處理錯誤
那麼如何組織實現這個 api 中間層呢?這裡涉及兩方面:
- 如何發請求,即“武器”部分
- 如何組織管理 api 地址
讓我們先回顧一下有關發請求的歷史。
一、如何發請求
1.1.原生 XHR (XMLHttpRequest)
說到發請求,最經典的方式莫過於呼叫瀏覽器原生的 XHR。在此不贅述,有興趣可以看看MDN 上的文件。
var xhr = window.XMLHttpRequest ? new XMLHttpRequest() // 在萬惡的 IE 上可能還沒有 XMLHttpRequest 這物件 : new ActiveXObject('Microsoft.XMLHTTP') xhr.open('GET', 'some url') xhr.responseType = 'json' // 傳統使用 onreadystatechange xhr.onreadystatechange = function () { if (xhr.readyState === 4 && xhr.status === 200) { console.log(xhr.responseText) } } // 或者直接使用 onload 事件 xhr.onload = function () { console.log(xhr.response) } // 處理出錯 xhr.onerror = console.error xhr.send()
這程式碼都不用看,想想就頭皮發麻...
1.2.jQuery 封裝的 ajax
由於原生 XHR 寫起來太繁瑣,再加上當時 jQuery 如日中天。日常開發中用的比較多的還是 jQuery 提供的 ajax 方法。jQuery ajax 文件點這裡
var params = {
url: 'some url',
data: { name: 'Steve', location: 'Beijing' },
}
$.ajax(params)
.done(console.log)
.fail(console.error)
jQuery 不僅封裝了 XHR,還十分貼心地提供跨域的 jsonp 功能。
$.ajax({
url: 'some url',
data: { name: 'Steve', location: 'Beijing' },
dataType: 'jsonp',
success: console.log,
error: console.error,
})
講道理,jQuery 的 ajax 已經很好用了。然而隨著 Vue、React、Angular 的興起,連 jQuery 本身都被革命了。新專案為了發個請求還引入巨大的 jQuery 肯定不合理,當然後面這些替代方案也功不可沒...
1.3.現代瀏覽器的原生 fetch
XHR 是一個設計粗糙的 API。記得當年筆試某部門的實習生的時候就有手寫 XHR 的題目,我反正記不住 api,並沒有寫出來...
fetch api 基於 Promise 設計,呼叫起來比 XHR 方便多了。
fetch(url)
.then(res => res.json())
.then(console.log)
.catch(console.error)
async/await 自然也能使用
try {
const data = await fetch(url).then(res => res.json())
console.log(data)
} catch (e) {
console.error(e)
}
當然 fetch 也有不少的問題
- 相容性問題
- 使用繁瑣,詳見參考文獻之 fetch 沒有你想象的那麼美
- 不支援 jsonp(雖然理論上不應該支援,但實際上日常還是需要使用的)
- 只對網路請求報錯,對400,500都當做成功的請求,需要二次封裝
- 預設不會帶 cookie,需要新增配置項
- 不支援 abort,不支援超時控制,使用 setTimeout 及 Promise.race 的實現的超時控制並不能阻止請求過程繼續在後臺執行,造成了流量的浪費
- 沒有辦法原生監測請求的進度,而 XHR 可以
1.4.基於 Promise 的 axios
axios 算是請求框架中的明星專案了。目前 github 5w+ 的 star...
先來看看有什麼特性吧~
- 同時支援瀏覽器端和服務端的請求。(XMLHttpRequests、http)
- 支援 Promise
- 支援請求和和資料返回的攔截
- 轉換請求返回資料,自動轉換JSON資料
- 支援取消請求
- 客戶端防止 xsrf 攻擊
嗯,看起來確實是居家旅行全棧開發必備好庫,但是 axios 並不支援 jsonp...
1.5.不得不用的 jsonp
在伺服器端不方便配置跨域頭的情況下,採用 jsonp 的方式發起跨域請求是一種常規操作。
在此不探究具體的實現,原理上來說就是
- 由於 script 標籤可以設定跨域的來源,所以首先動態插入一個 script,將 src 設定為目標地址
- 服務端收到請求後,根據回撥函式名(可自己約定,或作為引數傳遞)將 json 資料填入(即 json padding,所以叫 jsonp...)。例如
callback({ "foo": "bar" })
。 - 瀏覽器端收到響應後自然會執行該 script 即呼叫該函式,那麼回撥函式就收到了服務端填入的 json 資料了。
上面講到新專案一般都棄用 jQuery 了,那麼跨域請求還是得發呀。所以可能你還需要一個傳送 jsonp 的庫。(實踐中選了 fetch-jsonp
,當然其他庫也可以)
綜上,日常開發在框架的使用上以 axios
為主,實在不得不發 jsonp 請求時,就用 fetch-jsonp
。這就是我們中間層的基礎,即“武器”部分。
1.6.小程式場景
在小程式場景沒得選,只能使用官方的 wx.request
函式...
二、構建介面層基礎功能
對於簡單的頁面,直接裸寫請求地址也沒毛病。但是一旦專案變大,頁面數量也上去了,直接在頁面,或是元件中裸寫介面的話,會帶來以下問題
- 程式碼冗餘:很多介面請求都是類似的程式碼,有許多相同的邏輯
- 不同的庫和場景下的介面寫法不同(ajax、jsonp、小程式...)
- 不方便切換測試域名
- 不方便編寫介面註釋
- 沒法實現統一攔截器、甚至中介軟體功能
如何封裝這些介面呢?
2.1.介面地址劃分
首先我們來分析一下介面地址的組成
https://example-base.com/foo/create
https://example-base.com/foo/modify
https://example-base.com/foo/delete
對於以上地址,在 tua-api
中一般將其分為3部分
- host:
'https://example-base.com/'
- prefix:
'foo'
- pathList:
[ 'create', 'modify', 'delete' ]
2.2.檔案結構
apis/
一般是這樣的檔案結構:
.
└── apis
├── prefix-1.js
├── prefix-2.js
├── foo.js // <-- 以上的 api 地址會放在這裡
└── index.js
index.js
作為介面層的入口,會匯入並生成各個 api 然後再匯出。
2.3.基礎配置內容
所以以上的示例介面地址可以這麼寫
// src/apis/foo.js
export default {
// 請求的公用伺服器地址。
host: 'http://example-base.com/',
// 請求的中間路徑,建議與檔案同名,以便後期維護。
prefix: 'foo',
// 介面地址陣列
pathList: [
{ path: 'create' },
{ path: 'modify' },
{ path: 'delete' },
],
}
這時如果想修改伺服器地址,只需要修改 host 即可。甚至還能這麼玩
// src/apis/foo.js
// 某個獲取頁面地址引數的函式
const getUrlParams = () => {...}
export default {
// 根據 NODE_ENV 採用不同的伺服器
host: process.env.NODE_ENV === 'test'
? 'http://example-test.com/'
: 'http://example-base.com/',
// 根據頁面引數採用不同的伺服器,即頁面地址帶 ?test=1 則走測試地址
host: getUrlParams().test
? 'http://example-test.com/'
: 'http://example-base.com/',
// ...
}
2.4.配置匯出
下面來看一下 apis/index.js
該怎麼寫:
import TuaApi from 'tua-api'
// 小程式端這樣匯入
// import TuaApi from 'tua-api/dist/mp'
// 初始化
const tuaApi = new TuaApi({ ... })
// 匯出
export const fooApi = tuaApi.getApi(require('./foo').default)
這樣我們就把介面地址封裝了起來,業務側不需要關心介面的邏輯,而後期介面的修改和升級時只需要修改這裡的配置即可。
2.5.介面引數與介面型別
示例的介面地址太理想化了,如果有引數如何傳遞?
假設以上介面新增 id、from 和 foo 引數。並且增加以下邏輯:
- foo 引數預設填
bar
- from 引數預設填
index-page
- delete 介面使用 jsonp 的方式,from 引數預設填
delete-page
- modify 介面使用 post 的方式,from 引數不需要填
哎~,別急著死,暫且看看怎麼用 tua-api
來抽象這些邏輯?
// src/apis/foo.js
export default {
// ...
// 公共引數,將會合併到後面的各個介面引數中
commonParams: {
foo: 'bar',
from: 'index-page',
},
pathList: [
{
path: 'create',
params: {
// 類似 Vue 中 props 的型別檢查
id: { required: true },
},
},
{
path: 'modify',
// 使用 post 的方式
type: 'post',
params: {
// 寫成 isRequired 也行
id: { isRequired: true },
// 介面不合並公共引數,即不傳 from 引數
commonParams: null,
},
},
{
path: 'delete',
// 使用 jsonp 的方式(不填則預設使用 axios)
reqType: 'jsonp',
params: {
id: { required: true },
// 這裡填寫的 from 會覆蓋 commonParams 中的同名屬性
from: 'delete-page',
},
},
],
}
現在來看看業務側程式碼有什麼變化。
import { fooApi } from '@/apis/'
// 直接呼叫將會報錯,因為沒有傳遞 id 引數
await fooApi.create()
// 請求引數使用傳入的 from:id=1&foo=bar&from=foo-page
await fooApi.create({ id: 1, from: 'foo-page' })
// 請求引數將只有 id:id=1
await fooApi.modify({ id: 1 })
// 請求引數將使用自身的 from:id=1&foo=bar&from=delete-page
await fooApi.delete({ id: 1 })
2.6.介面重新命名
假設現在後臺又添加了以下兩個新介面,咱們該怎麼寫配置呢?
remove/all
add-array
首先,把後臺同學砍死...2333
這什麼鬼介面地址,直接填的話會業務側就會寫成這樣。
fooApi['remove/all']
fooApi['add-array']
這程式碼簡直無法直視...讓我們用 name
屬性,將介面重新命名一下。
// src/apis/foo.js
export default {
// ...
pathList: [
// ...
{ path: 'remove/all', name: 'removeAll' },
{ path: 'add-array', name: 'addArray' },
],
}
三、高階功能
一個介面層僅僅只能發 api 請求是遠遠不夠的,在日常使用中往往還有以下需求
- 發起請求時展示 loading,收到響應後隱藏
- 出錯時展示錯誤資訊,例如彈一個 toast
- 介面上報:包括效能和錯誤
- 新增特技:如介面引數加密、校驗
3.1.小程式端的 loading 展示
小程式端由於原生自帶 UI 元件,所以框架內建了該功能。主要包括以下引數
- isShowLoading
- showLoadingFn
- hideLoadingFn
顧名思義,就是開關和具體的顯示、隱藏的方法,詳情參閱這裡
3.2.基礎鉤子函式
最簡單的鉤子函式就是 beforeFn/afterFn
這倆函數了。
beforeFn 是在請求發起前執行的函式(例如小程式可以通過返回 header 傳遞 cookie),因為是通過 beforeFn().then(...)
呼叫,所以注意要返回 Promise。
afterFn 是在收到響應後執行的函式,可以不用返回 Promise。
注意接收的引數是一個【陣列】[ res.data, ctx ]
所以預設值是
const afterFn = ([x]) => x
,即返回介面資料到業務側
- 第一個引數是介面返回的資料物件
{ code, data, msg }
- 第二個引數是請求相關引數的物件,例如有請求的 host、type、params、fullPath、reqTime、startTime、endTime 等等
3.3.middleware 中介軟體
鉤子函式有時不太夠用,並且程式碼一長不太好維護。所以 tua-api 還引入了中介軟體功能,用法上和 koa 的中介軟體很像(其實底層直接用了 koa-compose
)。
export default {
middleware: [ fn1, fn2, fn3 ],
}
首先說下中介軟體執行順序,koa 中介軟體的執行順序和 redux 的正好相反,例如以上寫法會以以下順序執行:
請求引數 -> fn1 -> fn2 -> fn3 -> 響應資料 -> fn3 -> fn2 -> fn1
介面說下中介軟體的寫法,分為兩種
- 普通函式:注意一定要
return next()
否則Promise
鏈就斷了! - async 函式:注意一定要
await next()
!
// 普通函式,注意一定要 return next()
function (ctx, next) {
ctx.req // 請求的各種配置
ctx.res // 響應,但這時還未發起請求,所以是 undefined!
ctx.startTime // 發起請求的時間
// 傳遞控制權給下一個中介軟體
return next().then(() => {
// 注意這裡才有響應!
ctx.res // 響應物件
ctx.res.data // 響應的資料
ctx.reqTime // 請求花費的時間
ctx.endTime // 收到響應的時間
})
}
// async/await
async function (ctx, next) {
ctx.req // 請求的各種配置
// 傳遞控制權給下一個中介軟體
await next()
// 注意這裡才有響應響應!
ctx.res // 響應物件
}
四、小結
這篇安利文,先是從前端發請求的歷史出發。一步步介紹瞭如何構建以及使用 api 中間層,來統一管理介面地址,最後還介紹了下中介軟體等高階功能。話說回來,這麼好用的 tua-api 各位開發者老爺們不來了解一下麼?
參考文獻
原文地址:https://segmentfault.com/a/1190000016966523