最近想写一个能够适配多平台的请求库,在研究 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