前言
最近想寫一個(gè)可以適配多平臺(tái)的請(qǐng)求庫(kù)敷存,在研究 xhr 和 fetch 發(fā)現(xiàn)二者的參數(shù)、響應(yīng)堪伍、回調(diào)函數(shù)等差別很大锚烦。想到如果請(qǐng)求庫(kù)想要適配多平臺(tái),需要統(tǒng)一的傳參和響應(yīng)格式帝雇,那么勢(shì)必會(huì)在請(qǐng)求庫(kù)內(nèi)部做大量的判斷涮俄,這樣不但費(fèi)時(shí)費(fèi)力,還會(huì)屏蔽掉底層請(qǐng)求內(nèi)核差異尸闸。
閱讀 axios 和 umi-request 源碼時(shí)想到彻亲,請(qǐng)求庫(kù)其實(shí)基本都包含了攔截器、中間件和快捷請(qǐng)求等幾個(gè)通用的吮廉,與具體請(qǐng)求過(guò)程無(wú)關(guān)的功能苞尝。然后通過(guò)傳參,讓用戶接觸底層請(qǐng)求內(nèi)核宦芦。問(wèn)題在于宙址,請(qǐng)求庫(kù)內(nèi)置多個(gè)底層請(qǐng)求內(nèi)核,內(nèi)核支持的參數(shù)是不一樣的调卑,上層庫(kù)可能做一些處理抡砂,抹平一些參數(shù)的差異化,但對(duì)于底層內(nèi)核的特有的功能恬涧,要么放棄舀患,要么只能在參數(shù)列表中加入一些具體內(nèi)核的特有的參數(shù)。比如在 axios 中气破,它的請(qǐng)求配置參數(shù)列表中,羅列了一些 browser only的參數(shù)旷痕,那對(duì)于只需要在 node 環(huán)境中運(yùn)行的 axios 來(lái)說(shuō)欺抗,參數(shù)多少有些冗余绞呈,并且如果 axios 要支持其他請(qǐng)求內(nèi)核(比如小程序佃声、快應(yīng)用圾亏、華為鴻蒙等)志鹃,那么參數(shù)冗余也將越來(lái)越大,擴(kuò)展性也差捧杉。
換個(gè)思路來(lái)想淳玩,既然實(shí)現(xiàn)一個(gè)適配多平臺(tái)的統(tǒng)一的請(qǐng)求庫(kù)有這些問(wèn)題蜕着,那么是否可以從底向上的承匣,針對(duì)不同的請(qǐng)求內(nèi)核韧骗,提供一種方式可以很方便的為其賦予攔截器袍暴、中間件政模、快捷請(qǐng)求等幾個(gè)通用功能耗式,并且保留不同請(qǐng)求內(nèi)核的差異化刊咳?
設(shè)計(jì)實(shí)現(xiàn)
我們的請(qǐng)求庫(kù)要想與請(qǐng)求內(nèi)核無(wú)關(guān)娱挨,那么只能采用內(nèi)核與請(qǐng)求庫(kù)相分離的模式让蕾。使用時(shí),需要將請(qǐng)求內(nèi)核傳入笋婿,初始化一個(gè)實(shí)例足丢,再進(jìn)行使用斩跌∫唬或者基于請(qǐng)求庫(kù)袖订,傳入內(nèi)核洛姑,預(yù)置請(qǐng)求參數(shù)來(lái)進(jìn)行二次封裝楞艾。
基本架構(gòu)
首先實(shí)現(xiàn)一個(gè)基本的架構(gòu)
class PreQuest {
constructor(private adapter)
request(opt) {
return this.adapter(opt)
}
}
const adapter = (opt) => nativeRequestApi(opt)
// eg: const adapter = (opt) => fetch(opt).then(res => res.json())
// 創(chuàng)建實(shí)例
const prequest = new PreQuest(adapter)
// 這里實(shí)際調(diào)用的是 adapter 函數(shù)
prequest.request({ url: 'http://localhost:3000/api' })
可以看到昂勒,這里饒了個(gè)彎戈盈,通過(guò)實(shí)例方法調(diào)用了 adapter 函數(shù)塘娶。
這樣的話刁岸,為修改請(qǐng)求和響應(yīng)提供了想象空間。
class PreQuest {
// ...some code
async request(opt){
const options = modifyReqOpt(opt)
const res = await this.adapter(options)
return modifyRes(res)
}
// ...some code
}
中間件
可以采用 koa 的洋蔥模型酝碳,對(duì)請(qǐng)求進(jìn)行攔截和修改疏哗。
中間件調(diào)用示例:
const prequest = new PreQuest(adapter)
prequest.use(async (ctx, next) => {
ctx.request.path = '/perfix' + ctx.request.path
await next()
ctx.response.body = JSON.parse(ctx.response.body)
})
實(shí)現(xiàn)中間件基本模型返奉?
const compose = require('koa-compose')
class Middleware {
// 中間件列表
cbs = []
// 注冊(cè)中間件
use(cb) {
this.cbs.push(cb)
return this
}
// 執(zhí)行中間件
exec(ctx, next){
// 中間件執(zhí)行細(xì)節(jié)不是重點(diǎn),所以直接使用 koa-compose 庫(kù)
return compose(this.cbs)(ctx, next)
}
}
全局中間件弦讽,只需要添加一個(gè) use 和 exec 的靜態(tài)方法即可十厢。
PreQuest 繼承自 Middleware 類蛮放,即可在實(shí)例上注冊(cè)中間件包颁。
那么怎么在請(qǐng)求前調(diào)用中間件?
class PreQuest extends Middleware {
// ...some code
async request(opt) {
const ctx = {
request: opt,
response: {}
}
// 執(zhí)行中間件
async this.exec(ctx, async (ctx) => {
ctx.response = await this.adapter(ctx.request)
})
return ctx.response
}
// ...some code
}
中間件模型中娩嚼,前一個(gè)中間件的返回值是傳不到下一個(gè)中間件中佃迄,所以是通過(guò)一個(gè)對(duì)象在中間件中傳遞和賦值呵俏。
攔截器
攔截器是修改參數(shù)和響應(yīng)的另一種方式普碎。
首先看一下 axios 中攔截器是怎么用的麻车。
import axios from 'axios'
const instance = axios.create()
instance.interceptor.request.use(
(opt) => modifyOpt(opt),
(e) => handleError(e)
)
根據(jù)用法动猬,我們可以實(shí)現(xiàn)一個(gè)基本結(jié)構(gòu)
class Interceptor {
cbs = []
// 注冊(cè)攔截器
use(successHandler, errorHandler) {
this.cbs.push({ successHandler, errorHandler })
}
exec(opt) {
return this.cbs.reduce(
(t, c, idx) => t.then(c.successHandler, this.handles[idx - 1]?.errorHandler),
Promise.resolve(opt)
)
.catch(this.handles[this.handles.length - 1].errorHandler)
}
}
代碼很簡(jiǎn)單争占,有點(diǎn)難度的就是攔截器的執(zhí)行了臂痕。這里主要有兩個(gè)知識(shí)點(diǎn): Array.reduce 和 Promise.then 第二個(gè)參數(shù)的使用握童。
注冊(cè)攔截器時(shí),successHandler
與 errorHandler
是成對(duì)的稽揭, successHandler 中拋出的錯(cuò)誤溪掀,要在對(duì)應(yīng)的 errorHandler 中處理揪胃,所以 errorHandler 接收到的錯(cuò)誤喊递,是上一個(gè)攔截器中拋出的。
攔截器怎么使用呢?
class PreQuest {
// ... some code
interceptor = {
request: new Interceptor()
response: new Interceptor()
}
// ...some code
async request(opt){
// 執(zhí)行攔截器铐伴,修改請(qǐng)求參數(shù)
const options = await this.interceptor.request.exec(opt)
const res = await this.adapter(options)
// 執(zhí)行攔截器,修改響應(yīng)數(shù)據(jù)
const response = await this.interceptor.response.exec(res)
return response
}
}
攔截器中間件
攔截器也可以是一個(gè)中間件即供,可以通過(guò)注冊(cè)中間件來(lái)實(shí)現(xiàn)逗嫡。請(qǐng)求攔截器在 await next()
前執(zhí)行驱证,響應(yīng)攔截器在其后抹锄。
const instance = new Middleware()
instance.use(async (ctx, next) => {
// Promise 鏈?zhǔn)秸{(diào)用伙单,更改請(qǐng)求參數(shù)
await Promise.resolve().then(reqInterceptor1).then(reqInterceptor2)...
// 執(zhí)行下一個(gè)中間件吻育、或執(zhí)行到 this.adapter 函數(shù)
await next()
// Promise 鏈?zhǔn)秸{(diào)用布疼,更改響應(yīng)數(shù)據(jù)
await Promise.resolve().then(resInterceptor1).then(resInterceptor2)...
})
攔截器有請(qǐng)求攔截器和響應(yīng)攔截器兩類游两。
class InterceptorMiddleware {
request = new Interceptor()
response = new Interceptor()
// 注冊(cè)中間件
register: async (ctx, next) {
ctx.request = await this.request.exec(ctx.request)
await next()
ctx.response = await thie.response.exec(ctx.response)
}
}
使用
const instance = new Middleware()
const interceptor = new InterceptorMiddleware()
// 注冊(cè)攔截器
interceptor.request.use(
(opt) => modifyOpt(opt),
(e) => handleError(e)
)
// 注冊(cè)到中間中
instance.use(interceptor.register)
類型請(qǐng)求
這里我把類似 instance.get('/api')
這樣的請(qǐng)求叫做類型請(qǐng)求。庫(kù)中集成類型請(qǐng)求的話渐行,難免會(huì)對(duì)外部傳入的adapter 函數(shù)的參數(shù)進(jìn)行污染。因?yàn)樾枰獮檎?qǐng)求方式 get
和路徑 /api
分配鍵名粟害,并且將其混入到參數(shù)中悲幅,通常在中間件中會(huì)有修改路徑的需求汰具。
實(shí)現(xiàn)很簡(jiǎn)單留荔,只需要遍歷 HTTP 請(qǐng)求類型聚蝶,并將其掛在 this 下即可
class PreQuest {
constructor(private adapter) {
this.mount()
}
// 掛載所有類型的別名請(qǐng)求
mount() {
methods.forEach(method => {
this[method] = (path, opt) => {
// 混入 path 和 method 參數(shù)
return this.request({ path, method, ...opt })
}
})
}
// ...some code
request(opt) {
// ...some code
}
}
簡(jiǎn)單請(qǐng)求
axios 中,可以直接使用下面這種形式進(jìn)行調(diào)用
axios('http://localhost:3000/api').then(res => console.log(res))
我將這種請(qǐng)求方式稱之為簡(jiǎn)單請(qǐng)求验靡。
我們這里怎么實(shí)現(xiàn)這種寫法的請(qǐng)求方式呢雏节?
不使用 class 矾屯,使用傳統(tǒng)函數(shù)類寫法的話比較好實(shí)現(xiàn)件蚕,只需要判斷函數(shù)是否是 new 調(diào)用,然后在函數(shù)內(nèi)部執(zhí)行不同的邏輯即可妄痪。
demo 如下
function PreQuest() {
if(!(this instanceof PreQuest)) {
console.log('不是new 調(diào)用')
return // ...some code
}
console.log('new調(diào)用')
//... some code
}
// new 調(diào)用
const instance = new PreQuest(adapter)
instance.get('/api').then(res => console.log(res))
// 簡(jiǎn)單調(diào)用
PreQuest('/api').then(res => console.log(res))
class 寫法的話衫生,不能進(jìn)行函數(shù)調(diào)用罪针。我們可以在 class 實(shí)例上做文章泪酱。
首先初始化一個(gè)實(shí)例毡惜,看一下用法
const prequest = new PreQuest(adapter)
prequest.get('http://localhost:3000/api')
prequest('http://localhost:3000/api')
通過(guò) new 實(shí)例化出來(lái)的是一個(gè)對(duì)象经伙,對(duì)象是不能夠當(dāng)做函數(shù)來(lái)執(zhí)行橱乱,所以不能用 new 的形式來(lái)創(chuàng)建對(duì)象。
再看一下 axios 中生成實(shí)例的方法 axios.create
, 可以從中得到靈感危纫,如果 .create
方法返回的是一個(gè)函數(shù)种蝶,函數(shù)上掛上了所有 new 出來(lái)對(duì)象上的方法螃征,這樣的話盯滚,就可以實(shí)現(xiàn)我們的需求酗电。
簡(jiǎn)單設(shè)計(jì)一下:
方式一: 拷貝原型上的方法
class PreQuest {
static create(adapter) {
const instance = new PreQuest(adapter)
function inner(opt) {
return instance.request(opt)
}
for(let key in instance) {
inner[key] = instance[key]
}
return inner
}
}
注意: 在某些版本的 es 中魄藕,for in
循環(huán)遍歷不出 class 生成實(shí)例原型上的方法。
方式二: 還可以使用 Proxy 代理一個(gè)空函數(shù)撵术,來(lái)劫持訪問(wèn)背率。
class PreQuest {
// ...some code
static create(adapter) {
const instance = new PreQuest(adapter)
return new Proxy(function (){}, {
get(_, name) {
return Reflect.get(instance, name)
},
apply(_, __, args) {
return Reflect.apply(instance.request, instance, args)
},
})
}
}
上面兩種方法的缺點(diǎn)在于,通過(guò) create
方法返回的將不再是 PreQuest
的實(shí)例,即
const prequest = PreQuest.create(adapter)
prequest instanceof PreQuest // false
個(gè)人目前還沒(méi)有想到寝姿,判斷 prequest
是不是 PreQuest
實(shí)例有什么用交排,并且也沒(méi)有想到好的解決辦法。有解決方案的請(qǐng)?jiān)谠u(píng)論里告訴我饵筑。
使用 .create
創(chuàng)建 '實(shí)例' 的方式可能不符合直覺(jué)埃篓,我們還可以通過(guò) Proxy 劫持 new 操作嫂冻。
Demo如下:
class InnerPreQuest {
create() {
// ...some code
}
}
const PreQuest = new Proxy(InnerPreQuest, {
construct(_, args) {
return () => InnerPreQuest.create(...args)
}
})
在編寫代碼的過(guò)程中服傍,我選擇劫持了 adapter
函數(shù)灿椅,這產(chǎn)生了很多意想不到的效果。。。你可以思考幾分鐘杀迹,然后看一下這個(gè)文檔和源碼
實(shí)戰(zhàn)
以微信小程序?yàn)槔剐弧P〕绦蛑凶詭У?wx.request
并不好用徙邻。使用上面我們封裝的代碼帅容,可以很容易的打造出一個(gè)小程序請(qǐng)求庫(kù)麦乞。
封裝小程序原生請(qǐng)求
將原生小程序請(qǐng)求 Promise 化付翁,設(shè)計(jì)傳參 opt 對(duì)象
function adapter(opt) {
const { path, method, baseURL, ...options } = opt
const url = baseURL + path
return new Promise((resolve, reject) => {
wx.request({
...options,
url,
method,
success: resolve,
fail: reject,
})
})
}
調(diào)用
const instance = PreQuest.create(adapter)
// 中間件模式
instance.use(async (ctx, next) => {
// 修改請(qǐng)求參數(shù)
ctx.request.path = '/prefix' + ctx.request.path
await next()
// 修改響應(yīng)
ctx.response.body = JSON.parse(ctx.response.body)
})
// 攔截器模式
instance.interecptor.request.use(
(opt) => {
opt.path = '/prefix' + opt.path
return opt
}
)
instance.request({ path: '/api', baseURL: 'http://localhost:3000' })
instance.get('http://localhost:3000/api')
instance.post('/api', { baseURL: 'http://loclahost:3000' })
獲取原生請(qǐng)求實(shí)例
首先看一下在小程序中怎樣中斷請(qǐng)求
const request = wx.request({
// ...some code
})
request.abort()
使用我們封裝的這一層膨处,將拿不到原生請(qǐng)求實(shí)例测摔。
那么怎么辦呢挟纱?我們可以從傳參中入手
function adapter(opt) {
const { getWxInstance } = opt
return new Promise(() => {
getWxInstance(
wx.request(
// some code
)
)
})
}
用法如下:
const instance = PreQuest.create(adapter)
let nativeRequest
instance.post('http://localhost:3000/api', {
getWxInstance(instance) {
nativeRequest = instance
}
})
setTimeout(() => {
nativeRequest.abort()
})
需要注意的是:因?yàn)?wx.request
的執(zhí)行是在 n 個(gè)中間件撤师、攔截器之后執(zhí)行的衰伯,里面存在大量異步任務(wù)漱贱,所以通過(guò)上面拿到的 nativeRequest
只能在異步中執(zhí)行蚂且。
兼容多平臺(tái)小程序
查看了幾個(gè)小程序平臺(tái)和快應(yīng)用,發(fā)現(xiàn)請(qǐng)求方式都是小程序的那一套泞莉,那其實(shí)我們完全可以將 wx.request
拿出來(lái)疫剃,創(chuàng)建實(shí)例的時(shí)候再傳進(jìn)去您炉。
結(jié)語(yǔ)
上面的內(nèi)容中,我們基本實(shí)現(xiàn)了一個(gè)與請(qǐng)求內(nèi)核無(wú)關(guān)的請(qǐng)求庫(kù)熬北,并且設(shè)計(jì)了兩種攔截請(qǐng)求和響應(yīng)的方式炉峰,我們可以根據(jù)自己的需求和喜好自由選擇。
這種內(nèi)核裝卸的方式非常容易擴(kuò)展贴浙。當(dāng)面對(duì)一個(gè) axios 不支持的平臺(tái)時(shí)囱修,也不用費(fèi)勁的去找開源好用的請(qǐng)求庫(kù)了。我相信很多人在開發(fā)小程序的時(shí)候臂拓,基本都有去找 axios-miniprogram 的解決方案胶惰。通過(guò)我們的 PreQuest 項(xiàng)目鸯匹,可以體驗(yàn)到類似 axios 的能力杏糙。
PreQuest 項(xiàng)目中蕉堰,除了上面提到的內(nèi)容,還提供了全局配置、全局中間件挤土、別名請(qǐng)求等功能。項(xiàng)目中也有基于 PreQuest
封裝的請(qǐng)求庫(kù)知纷,@prequest/miniprogram,@prequest/fetch...也針對(duì)一些使用原生 xhr壤圃、fetch 等 API 的項(xiàng)目,提供了一種不侵入的方式來(lái)賦予 PreQuest的能力 @prequest/wrapper