設計一個可插拔的請求庫?

前言

最近想寫一個能夠適配多平臺的請求庫,在研究 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 第二個參數的使用。

註冊攔截器時,successHandlererrorHandler 是成對的, 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

參考

axios: github.com/axios/axios

umi-request:github.com/umijs/umi-r…

相關文章
相關標籤/搜索