preface

Recently, I wanted to write a request library that can adapt to multiple platforms. After studying XHR and FETCH, I found that the parameters, response and callback function of the two are very different. If the request library is to adapt to multiple platforms and needs a uniform format of input and response, then it must do a lot of judgment inside the request library, which is not only time-consuming and laborious, but also hides the underlying request kernel differences.

When reading the source code of AXIos and UMi-Request, I realized that the request library basically contains several common functions, such as interceptors, middleware and quick requests, which are independent of the specific request process. The user is then exposed to the underlying requesting kernel by passing parameters. The problem is that the request library has multiple low-level request kernels built in, and the parameters supported by the kernels are not the same. The superlibrary may do some processing to smooth out some parameter differences, but for the low-level kernel-specific functions, it either gives up or only adds some kernel-specific parameters to the parameter list. In AXIos, for example, the request configuration parameter list lists browser Only parameters, which are somewhat redundant for Axios that only needs to run in a Node environment. And if AXIOS were to support other requesting kernels (e.g., applets, fast applications, Huawei Hongmo, etc.), there would be more and more parameter redundancy and poor scalability.

Change the way of thinking to think, now that request in realization of unification of an adapter multi-platform library have these problems, so whether can you from the bottom up, according to different requests the kernel, provides a way can be very convenient for the interceptor, middleware, quick request a few common functions, such as different requests and retain the difference of the kernel?

Designed and implemented

In order for our request library to be independent of the request kernel, we can only adopt the mode of separating the kernel from the request library. To use it, you need to pass in the request kernel, initialize an instance, and then use it. Or based on our request library, pass in the kernel, preset request parameters for secondary encapsulation.

Basic architecture

Start by implementing a basic architecture

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())

// Create an instance
const prequest = new PreQuest(adapter)

// The adapter function is actually called here
prequest.request({ url: 'http://localhost:3000/api' })
Copy the code

As you can see, the adapter function is called through the instance method.

This leaves room for imagination to modify the request and response.

class PreQuest {
    / /... some code
    
    async request(opt){
        const options = modifyReqOpt(opt)
        const res = await this.adapter(options)
        return modifyRes(res)
    }

    / /... some code
}
Copy the code

The middleware

Koa’s Onion model can be used to intercept and modify requests.

Examples of middleware calls:

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)
})
Copy the code

Implement the basic middleware model?

const compose =  require('koa-compose')

class Middleware {
    // Middleware list
    cbs = []
    
    // Register middleware
    use(cb) {
       this.cbs.push(cb)
       return this
    }
    
    // Execute middleware
    exec(ctx, next){
        Middleware implementation details are not important, so use the KOa-compose library directly
        return compose(this.cbs)(ctx, next)
    }
}
Copy the code

Global middleware, just add a static method of use and exec.

PreQuest inherits from the Middleware class to register Middleware on instances.

So how do you call middleware before a request?

class PreQuest extends Middleware {
    / /... some code
     
    async request(opt) {
    
        const ctx = {
            request: opt,
            response: {}}// Execute middleware
        async this.exec(ctx, async (ctx) => {
            ctx.response = await this.adapter(ctx.request)
        })
        
        return ctx.response
    }
        
    / /... some code
}

Copy the code

In the middleware model, the return value of the previous middleware is not transmitted to the next middleware, so it is passed and assigned by an object in the middleware.

The interceptor

Interceptors are another way to modify parameters and responses.

First take a look at how interceptors are used in Axios.

import axios from 'axios'

const instance = axios.create()

instance.interceptor.request.use(
    (opt) = > modifyOpt(opt),
    (e) = > handleError(e)
)
Copy the code

Depending on usage, we can implement a basic structure

class Interceptor {
    cbs = []
    
    // Register interceptors
    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)
    }
}
Copy the code

The code is simple, but the execution of the interceptor is a bit more difficult. There are two main points: array. reduce and promise. then the use of the second parameter.

When registering an interceptor, successHandler is paired with errorHandler. Errors thrown from successHandler are handled in the corresponding errorHandler, so errors received by errorHandler, Is thrown in the last interceptor.

How does the interceptor work?

class PreQuest {
    // ... some code
    interceptor = {
        request: new Interceptor()
        response: new Interceptor()
    }
    
    / /... some code
    
    async request(opt){
        
        // Execute interceptor to modify request parameters
        const options = await this.interceptor.request.exec(opt)
        
        const res = await this.adapter(options)
        
        // Execute interceptor to modify response data
        const response = await this.interceptor.response.exec(res)
        
        return response
    }
    
}
Copy the code

Interceptor middleware

An interceptor can also be a middleware, which can be implemented by registering middleware. The request interceptor is executed before the await next() and the response interceptor after.

const instance = new Middleware()

instance.use(async (ctx, next) => {
    // Promise makes a chain call to change the request parameters
    await Promise.resolve().then(reqInterceptor1).then(reqInterceptor2)...
    // Execute the next middleware, or the this.adapter function
    await next()
    // Promise is a chain call that changes the response data
    await Promise.resolve().then(resInterceptor1).then(resInterceptor2)...
})
Copy the code

There are two types of interceptors: request interceptor and response interceptor.

class InterceptorMiddleware {
    request = new Interceptor()
    response = new Interceptor()
    
    // Register middleware
    register: async (ctx, next) {
        ctx.request = await this.request.exec(ctx.request)
        await next()
        ctx.response = await thie.response.exec(ctx.response)
    }
}
Copy the code

use

const instance = new Middleware()
const interceptor = new InterceptorMiddleware()

// Register interceptors
interceptor.request.use(
    (opt) = > modifyOpt(opt),
    (e) = > handleError(e)
)

// Register in the middle
instance.use(interceptor.register)
Copy the code

Type a request

Here I call requests like instance.get(‘/ API ‘) type requests. Integrating type requests into the library will inevitably contaminate the parameters of the External Adapter function. Because you need to assign key names to get and path/API requests and mix them into parameters, there is often a need to modify paths in middleware.

The implementation is simple, just iterate over the HTTP request type and hang it under this

class PreQuest {
    constructor(private adapter) {
        this.mount()
    }
    
    // Mount all types of alias requests
    mount() {
       methods.forEach(method= > {
           this[method] = (path, opt) = > {
             // Mix in the path and method arguments
             return this.request({ path, method, ... opt }) } }) }/ /... some code

    request(opt) {
        / /... some code}}Copy the code

A simple request

In AXIos, the call can be made directly using the following form

axios('http://localhost:3000/api').then(res= > console.log(res))
Copy the code

I call this a simple request.

How are we going to implement this request here?

Instead of using class, it is easier to write a function class in the traditional way. You just need to check whether the function is a new call and then execute a different logic inside the function.

The demo is as follows

function PreQuest() {
    if(! (this instanceof PreQuest)) {
        console.log('Not a new call')
        return / /... some code
    }
   
   console.log('the new call') 
   
   / /... some code
}

/ / the new call
const instance = new PreQuest(adapter)
instance.get('/api').then(res= > console.log(res))

// Simple call
PreQuest('/api').then(res= > console.log(res))
Copy the code

Class does not allow function calls. We can work with class instances.

First, initialize an instance to see how it works

const prequest = new PreQuest(adapter)

prequest.get('http://localhost:3000/api')

prequest('http://localhost:3000/api')
Copy the code

New instantiates an object. Objects cannot be executed as functions, so new cannot be used to create objects.

If the.create method returns a function that hangs all the methods on the new object, then we can do what we want.

Here’s a simple design:

Method 1: Copy the method from the prototype

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
    }
}
Copy the code

Note: In some versions of ES,for inThe loop does not iterate over the method on the class generation instance prototype.

Method 2: You can also use Proxy to Proxy an empty function to hijack access.

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)
          },
        })
    }
}
Copy the code

The disadvantage of the above two methods is that the create method will no longer return an instance of PreQuest, i.e

const prequest = PreQuest.create(adapter)

prequest instanceof PreQuest  // false
Copy the code

I have not yet thought of the use of determining whether prequest is a Prequest instance or not, and I have not yet thought of a good solution. If you have a solution, let me know in the comments.

While creating ‘instances’ using.create may not be intuitive, we can also hijack the new operation by Proxy.

The Demo is as follows:

class InnerPreQuest {
  create() {
     / /... some code}}const PreQuest = new Proxy(InnerPreQuest, {
    construct(_, args) {
        return () = >InnerPreQuest.create(... args) } })Copy the code

Request a lock

How to get the token before requesting the interface?

In the example below, the page makes multiple requests at the same time

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
Copy the code

First, it’s easy to imagine that we could add tokens to it using middleware

prequest.use(async (ctx, next) => {
    ctx.request.headers['Authorization'] = `bearer ${token}`
    await next()
})
Copy the code

But where do token values come from? The token needs to be retrieved from the request interface, and the request instance needs to be recreated to avoid retracing the logic of the middleware where the token was added.

So let’s just implement it briefly

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()
})
Copy the code

The token variable is used to avoid calling the interface for the token each time the interface is requested.

At first glance, there is no problem with the code, but after a careful thought, when multiple interfaces are requested at the same time and the tokenRequest request has not received a response, the subsequent requests all go to this middleware, and the token value is empty, resulting in multiple calls to tokenRequest. So how to solve this problem?

It’s easy to imagine a locking mechanism

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()
})
Copy the code

Here we add pending to judge the execution of tokenRequest, which successfully solves the problem that tokenRequest is executed multiple times, but also introduces a new problem: how should incoming requests be handled when tokenRequest is executed? If the code above simply returns, the request will be discarded. In fact, we want the request to pause here and request the middleware later when the token is in hand.

To request a pause, we can easily use async, await, or promise. But how do you use it here?

I took inspiration from Axios’ implementation of cancelToken. In AXIos, a state machine is simply implemented by using promise. Resolve in promise is assigned to external local variables to control the promise process.

So let’s just implement it briefly

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 controls the flow
           token = await promise
        } else {
            pending = true
            token = await tokenRequest('/token')
            // Call resolve so that promise can execute the rest of the process
            resolvePromise(token)
            pending = flase
        }
    } 

    ctx.request.headers['Authorization'] = `bearer ${token}`
    await next()
})
Copy the code

When tokenRequest is executed, the rest of the request’s interface goes into a promise-controlled flow. When the token is obtained, the promise is controlled to continue execution through external resolve, which sets the request header and executes the remaining middleware.

This approach fulfils the requirements, but the code is ugly.

We can encapsulate all the states in a function. To implement a call like the following. Such calls are intuitive and beautiful.

prequest.use(async (ctx, next) => {
  const token = await wrapper(tokenRequest)
  ctx.request.headers['Authorization'] = `bearer ${token}`
  await next()
})
Copy the code

How to implement such a wrapper function?

First, the state cannot be wrapped in the Wrapper function, otherwise a new state will be generated each time and the Wrapper will become null and void. You can use closure functions to save state.

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
    }
}
Copy the code

When used, just generate a wrapper using createWrapper

const wrapper = createWrapper()

prequest.use(async (ctx, next) => {
  const token = await wrapper(tokenRequest)
  ctx.request.headers['Authorization'] = `bearer ${token}`
  await next()
})
Copy the code

In this way, we can achieve our goal.

However, there is a problem with the code here. The state is wrapped inside createWrapper and when the token is invalid there is nothing we can do about it.

It is better to pass in the state from the createWrapper parameter.

Code implementation, please refer to here

In actual combat

Take wechat applets for example. The wx.request widget doesn’t work well. Using the code we encapsulated above, you can easily build a small program request library.

Encapsulate the applets native request

Make the native applet request Promise and design the opt object

function adapter(opt) {
  const{ path, method, baseURL, ... options } = optconst url = baseURL + path
  return new Promise((resolve, reject) = >{ wx.request({ ... options, url, method,success: resolve,
      fail: reject,
    })
  })
}

Copy the code

call

const instance = PreQuest.create(adapter)

// Middleware mode
instance.use(async (ctx, next) => {
    // Modify request parameters
    ctx.request.path = '/prefix' + ctx.request.path
    
    await next()
    
    // Modify the response
    ctx.response.body = JSON.parse(ctx.response.body)
})

// Interceptor mode
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' })
Copy the code

Gets the native request instance

Let’s first look at how to interrupt a request in a small program

const request = wx.request({
    / /... some code
})

request.abort()
Copy the code

With the layer we encapsulated, you will not get the native request instance.

So what to do? We can start with the transfer of references

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)
    })
}
Copy the code

This is a reference to the implementation of cancelToken in AXIos, which uses a state machine to fetch native requests.

Usage:

const instance = PreQuest.create(adapter)

instance.post('http://localhost:3000/api', {
    getNativeRequestInstance(promise) {
      promise.then(instance= > {
          instance.abort()
      })
    }
})
Copy the code

Compatible with multi-platform small programs

After checking several applets and quick applications, we found that the request mode is a set of applets. In fact, we can take out wx. Request and pass it in when creating the instance.

conclusion

In the above content, we basically implemented a request library independent of the request kernel, and designed two ways to intercept requests and responses, which we can choose according to our own needs and preferences.

This way of loading and unloading the kernel is easily extensible. When faced with a platform that Axios doesn’t support, don’t bother to find a good open source request library. I believe that many people in the development of small programs, basically have to find axios-miniprogram solution. With our PreQuest project, you can experience axios-like capabilities.

In the PreQuest project, in addition to the above mentioned, global configuration, global middleware, alias requests, and more are provided. There are also request libraries based on PreQuest encapsulation in the project,@prequest/ miniProgram,@prequest/fetch… It also provides a non-intrusive way to give PreQuest the ability to @prequest/wrapper for projects that use native XHR, FETCH, etc apis

reference

axios: github.com/axios/axios

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