最近想寫一個能夠適配多平臺的請求庫,在研究 xhr 和 fetch 發現兩者的參數、響應、回調函數等差異很大。想到若是請求庫想要適配多平臺,須要統一的傳參和響應格式,那麼勢必會在請求庫內部作大量的判斷,這樣不但費時費力,還會屏蔽掉底層請求內核差別。node
閱讀 axios 和 umi-request 源碼時想到,請求庫其實基本都包含了攔截器、中間件和快捷請求等幾個通用的,與具體請求過程無關的功能。而後經過傳參,讓用戶接觸底層請求內核。問題在於,請求庫內置多個底層請求內核,內核支持的參數是不同的,上層庫可能作一些處理,抹平一些參數的差別化,但對於底層內核的特有的功能,要麼放棄,要麼只能在參數列表中加入一些具體內核的特有的參數。好比在 axios 中,它的請求配置參數列表中,羅列了一些 browser only的參數,那對於只須要在 node 環境中運行的 axios 來講,參數多少有些冗餘,而且若是 axios 要支持其餘請求內核(好比小程序、快應用、華爲鴻蒙等),那麼參數冗餘也將愈來愈大,擴展性也差。ios
換個思路來想,既然實現一個適配多平臺的統一的請求庫有這些問題,那麼是否能夠從底向上的,針對不一樣的請求內核,提供一種方式能夠很方便的爲其賦予攔截器、中間件、快捷請求等幾個通用功能,而且保留不一樣請求內核的差別化?git
咱們的請求庫要想與請求內核無關,那麼只能採用內核與請求庫相分離的模式。使用時,須要將請求內核傳入,初始化一個實例,再進行使用。或者基於咱們這個請求庫,傳入內核,預置請求參數來進行二次封裝。github
首先實現一個基本的架構json
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())
// 建立實例
const prequest = new PreQuest(adapter)
// 這裏實際調用的是 adapter 函數
prequest.request({ url: 'http://localhost:3000/api' })
複製代碼
能夠看到,這裏饒了個彎,經過實例方法調用了 adapter 函數。axios
這樣的話,爲修改請求和響應提供了想象空間。小程序
class PreQuest {
// ...some code
async request(opt){
const options = modifyReqOpt(opt)
const res = await this.adapter(options)
return modifyRes(res)
}
// ...some code
}
複製代碼
能夠採用 koa 的洋蔥模型,對請求進行攔截和修改。微信小程序
中間件調用示例:api
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)
})
複製代碼
實現中間件基本模型?promise
const compose = require('koa-compose')
class Middleware {
// 中間件列表
cbs = []
// 註冊中間件
use(cb) {
this.cbs.push(cb)
return this
}
// 執行中間件
exec(ctx, next){
// 中間件執行細節不是重點,因此直接使用 koa-compose 庫
return compose(this.cbs)(ctx, next)
}
}
複製代碼
全局中間件,只須要添加一個 use 和 exec 的靜態方法便可。
PreQuest 繼承自 Middleware 類,便可在實例上註冊中間件。
那麼怎麼在請求前調用中間件?
class PreQuest extends Middleware {
// ...some code
async request(opt) {
const ctx = {
request: opt,
response: {}
}
// 執行中間件
async this.exec(ctx, async (ctx) => {
ctx.response = await this.adapter(ctx.request)
})
return ctx.response
}
// ...some code
}
複製代碼
中間件模型中,前一箇中間件的返回值是傳不到下一個中間件中,因此是經過一個對象在中間件中傳遞和賦值。
攔截器是修改參數和響應的另外一種方式。
首先看一下 axios 中攔截器是怎麼用的。
import axios from 'axios'
const instance = axios.create()
instance.interceptor.request.use(
(opt) => modifyOpt(opt),
(e) => handleError(e)
)
複製代碼
根據用法,咱們能夠實現一個基本結構
class Interceptor {
cbs = []
// 註冊攔截器
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)
}
}
複製代碼
代碼很簡單,有點難度的就是攔截器的執行了。這裏主要有兩個知識點: Array.reduce 和 Promise.then 第二個參數的使用。
註冊攔截器時,successHandler
與 errorHandler
是成對的, successHandler 中拋出的錯誤,要在對應的 errorHandler 中處理,因此 errorHandler 接收到的錯誤,是上一個攔截器中拋出的。
攔截器怎麼使用呢?
class PreQuest {
// ... some code
interceptor = {
request: new Interceptor()
response: new Interceptor()
}
// ...some code
async request(opt){
// 執行攔截器,修改請求參數
const options = await this.interceptor.request.exec(opt)
const res = await this.adapter(options)
// 執行攔截器,修改響應數據
const response = await this.interceptor.response.exec(res)
return response
}
}
複製代碼
攔截器也能夠是一箇中間件,能夠經過註冊中間件來實現。請求攔截器在 await next()
前執行,響應攔截器在其後。
const instance = new Middleware()
instance.use(async (ctx, next) => {
// Promise 鏈式調用,更改請求參數
await Promise.resolve().then(reqInterceptor1).then(reqInterceptor2)...
// 執行下一個中間件、或執行到 this.adapter 函數
await next()
// Promise 鏈式調用,更改響應數據
await Promise.resolve().then(resInterceptor1).then(resInterceptor2)...
})
複製代碼
攔截器有請求攔截器和響應攔截器兩類。
class InterceptorMiddleware {
request = new Interceptor()
response = new Interceptor()
// 註冊中間件
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()
// 註冊攔截器
interceptor.request.use(
(opt) => modifyOpt(opt),
(e) => handleError(e)
)
// 註冊到中間中
instance.use(interceptor.register)
複製代碼
這裏我把相似 instance.get('/api')
這樣的請求叫作類型請求。庫中集成類型請求的話,不免會對外部傳入的adapter 函數的參數進行污染。由於須要爲請求方式 get
和路徑 /api
分配鍵名,而且將其混入到參數中,一般在中間件中會有修改路徑的需求。
實現很簡單,只須要遍歷 HTTP 請求類型,並將其掛在 this 下便可
class PreQuest {
constructor(private adapter) {
this.mount()
}
// 掛載全部類型的別名請求
mount() {
methods.forEach(method => {
this[method] = (path, opt) => {
// 混入 path 和 method 參數
return this.request({ path, method, ...opt })
}
})
}
// ...some code
request(opt) {
// ...some code
}
}
複製代碼
axios 中,能夠直接使用下面這種形式進行調用
axios('http://localhost:3000/api').then(res => console.log(res))
複製代碼
我將這種請求方式稱之爲簡單請求。
咱們這裏怎麼實現這種寫法的請求方式呢?
不使用 class ,使用傳統函數類寫法的話比較好實現,只須要判斷函數是不是 new 調用,而後在函數內部執行不一樣的邏輯便可。
demo 以下
function PreQuest() {
if(!(this instanceof PreQuest)) {
console.log('不是new 調用')
return // ...some code
}
console.log('new調用')
//... some code
}
// new 調用
const instance = new PreQuest(adapter)
instance.get('/api').then(res => console.log(res))
// 簡單調用
PreQuest('/api').then(res => console.log(res))
複製代碼
class 寫法的話,不能進行函數調用。咱們能夠在 class 實例上作文章。
首先初始化一個實例,看一下用法
const prequest = new PreQuest(adapter)
prequest.get('http://localhost:3000/api')
prequest('http://localhost:3000/api')
複製代碼
經過 new 實例化出來的是一個對象,對象是不可以當作函數來執行,因此不能用 new 的形式來建立對象。
再看一下 axios 中生成實例的方法 axios.create
, 能夠從中獲得靈感,若是 .create
方法返回的是一個函數,函數上掛上了全部 new 出來對象上的方法,這樣的話,就能夠實現咱們的需求。
簡單設計一下:
方式一: 拷貝原型上的方法
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
循環遍歷不出 class 生成實例原型上的方法。
方式二: 還可使用 Proxy 代理一個空函數,來劫持訪問。
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)
},
})
}
}
複製代碼
上面兩種方法的缺點在於,經過 create
方法返回的將再也不是 PreQuest
的實例,即
const prequest = PreQuest.create(adapter)
prequest instanceof PreQuest // false
複製代碼
我的目前尚未想到,判斷 prequest
是否是 PreQuest
實例有什麼用,而且也沒有想到好的解決辦法。有解決方案的請在評論裏告訴我。
使用 .create
建立 '實例' 的方式可能不符合直覺,咱們還能夠經過 Proxy 劫持 new 操做。
Demo以下:
class InnerPreQuest {
create() {
// ...some code
}
}
const PreQuest = new Proxy(InnerPreQuest, {
construct(_, args) {
return () => InnerPreQuest.create(...args)
}
})
複製代碼
如何實如今請求接口前,先拿到 token 再去請求?
下面的例子中,頁面同時發起多個請求
const prequest = PreQuest.create(adapter)
prequest('/api/1').catch(e => e) // auth fail
prequest('/api/2').catch(e => e) // auth fail
prequest('/api/3').catch(e => e) // auth fail
複製代碼
首先很容易想到,咱們可使用中間件爲其添加 token
prequest.use(async (ctx, next) => {
ctx.request.headers['Authorization'] = `bearer ${token}`
await next()
})
複製代碼
但 token 值從何而來?token 須要請求接口得來,而且須要從新建立請求實例,以免從新走添加 token 的中間件的邏輯。
簡單實現一下
const tokenRequest = PreQuest.create(adapter)
let token = null
prequest.use(async (ctx, next) => {
if(!token) {
token = await tokenRequest('/token')
}
ctx.request.headers['Authorization'] = `bearer ${token}`
await next()
})
複製代碼
這裏使用了 token 變量,來避免每次請求接口,都去調接口拿 token。
代碼乍一看沒有問題,但仔細一想,當同時請求多個接口,tokenRequest 請求尚未獲得響應時,後面的請求又都走到這個中間件,此時 token 值爲空,會形成屢次調用 tokenRequest。那麼如何解決這個問題?
很容易想到,能夠加個鎖機制來實現
let token = null
let pending = false
prequest.use(async (ctx, next) => {
if(!token) {
if(pending) return
pending = true
token = await tokenRequest('/token')
pending = flase
}
ctx.request.headers['Authorization'] = `bearer ${token}`
await next()
})
複製代碼
這裏咱們加了 pending 來判斷 tokenRequest 的執行,成功解決了 tokenRequest 執行屢次的問題,但又引入了新的問題,在執行 tokenRequest 時,後面到來的請求應當怎麼處理?上面的代碼,直接 return 掉了,請求將被丟棄。實際上,咱們但願,請求能夠在這裏暫停,當拿到 token 時,再請求後面的中間件。
請求暫停,咱們也能夠很容想到使用 async、await 或者 promise 來實現。但在這裏如何用呢?
我從 axios 的 cancelToken 實現中獲得了靈感。axios 中,利用 promise 簡單實現了一個狀態機,將 Promise 中的 resolve 賦值到外部局部變量,實現對 promise 流程的控制。
簡單實現一下
let token = null
let pending = false
let resolvePromise
let promise = new Promise((resolve) => resolvePromise = resolve)
prequest.use(async (ctx, next) => {
if(!token) {
if(pending) {
// promise 控制流程
token = await promise
} else {
pending = true
token = await tokenRequest('/token')
// 調用 resolve,使得 promise 能夠執行剩餘的流程
resolvePromise(token)
pending = flase
}
}
ctx.request.headers['Authorization'] = `bearer ${token}`
await next()
})
複製代碼
當執行 tokenRequest 時,其他請求的接口,都會進入到一個 promise 控制的流程中,當 token 獲得後,經過外部 resolve, 控制 promise 繼續執行,以此設置請求頭,和執行剩餘中間件。
這種方式雖然實現了需求,但代碼醜陋不美觀。
咱們能夠將狀態都封裝到一個函數中。以實現相似下面這種調用。這樣的調用符合直覺且美觀。
prequest.use(async (ctx, next) => {
const token = await wrapper(tokenRequest)
ctx.request.headers['Authorization'] = `bearer ${token}`
await next()
})
複製代碼
怎麼實現這樣一個 wrapper 函數?
首先,狀態不能封裝到 wrapper 函數中,不然每次都會生成新的狀態,wrapper 將形同虛設。可使用閉包函數將狀態保存。
function createWrapper() {
let token = null
let pending = false
let resolvePromise
let promise = new Promise((resolve) => resolvePromise = resolve)
return function (fn) {
if(pending) return promise
if(token) return token
pending = true
token = await fn()
pending = false
resolvePromise(token)
return token
}
}
複製代碼
使用時,只須要利用 createWrapper
生成一個 wrapper
便可
const wrapper = createWrapper()
prequest.use(async (ctx, next) => {
const token = await wrapper(tokenRequest)
ctx.request.headers['Authorization'] = `bearer ${token}`
await next()
})
複製代碼
這樣的話,就能夠實現咱們的目的。
但,這裏的代碼還有問題,狀態封裝在 createWrapper 內部,當 token 失效後,咱們將無從處理。
比較好的作法是,將狀態從 createWrapper
參數中傳入。
代碼實現,請參考這裏
以微信小程序爲例。小程序中自帶的 wx.request
並很差用。使用上面咱們封裝的代碼,能夠很容易的打造出一個小程序請求庫。
將原生小程序請求 Promise 化,設計傳參 opt 對象
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,
})
})
}
複製代碼
const instance = PreQuest.create(adapter)
// 中間件模式
instance.use(async (ctx, next) => {
// 修改請求參數
ctx.request.path = '/prefix' + ctx.request.path
await next()
// 修改響應
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' })
複製代碼
首先看一下在小程序中怎樣中斷請求
const request = wx.request({
// ...some code
})
request.abort()
複製代碼
使用咱們封裝的這一層,將拿不到原生請求實例。
那麼怎麼辦呢?咱們能夠從傳參中入手
function adapter(opt) {
const { getNativeRequestInstance } = opt
let resolvePromise: any
getNativeRequestInstance(new Promise(resolve => (resolvePromise = resolve)))
return new Promise(() => {
const nativeInstance = wx.request(
// some code
)
resolvePromise(nativeInstance)
})
}
複製代碼
這裏參考了 axios 中 cancelToken 的實現方式,使用狀態機來實現獲取原生請求。
用法以下:
const instance = PreQuest.create(adapter)
instance.post('http://localhost:3000/api', {
getNativeRequestInstance(promise) {
promise.then(instance => {
instance.abort()
})
}
})
複製代碼
查看了幾個小程序平臺和快應用,發現請求方式都是小程序的那一套,那其實咱們徹底能夠將 wx.request
拿出來,建立實例的時候再傳進去。
上面的內容中,咱們基本實現了一個與請求內核無關的請求庫,而且設計了兩種攔截請求和響應的方式,咱們能夠根據本身的需求和喜愛自由選擇。
這種內核裝卸的方式很是容易擴展。當面對一個 axios 不支持的平臺時,也不用費勁的去找開源好用的請求庫了。我相信不少人在開發小程序的時候,基本都有去找 axios-miniprogram 的解決方案。經過咱們的 PreQuest 項目,能夠體驗到相似 axios 的能力。
PreQuest 項目中,除了上面提到的內容,還提供了全局配置、全局中間件、別名請求等功能。項目中也有基於 PreQuest
封裝的請求庫,@prequest/miniprogram,@prequest/fetch...也針對一些使用原生 xhr、fetch 等 API 的項目,提供了一種不侵入的方式來賦予 PreQuest的能力 @prequest/wrapper