美文网首页小程序
【小程序】一个通用request的封装

【小程序】一个通用request的封装

作者: Jack_Lo | 来源:发表于2018-04-26 23:02 被阅读0次

    小程序内置了wx.request,用于向后端发送请求,我们先来看看它的文档:

    wx.request(OBJECT)
    发起网络请求。使用前请先阅读说明

    OBJECT参数说明:

    参数名 类型 必填 默认值 说明 最低版本
    url String - 开发者服务器接口地址 -
    data Object/String/ArrayBuffer - 请求的参数 -
    header Object - 设置请求的 header,header 中不能设置 Referer。 -
    method String GET (需大写)有效值:OPTIONS, GET, HEAD, POST, PUT, DELETE, TRACE, CONNECT -
    dataType String json 如果设为json,会尝试对返回的数据做一次 JSON.parse -
    responseType String text 设置响应的数据类型。合法值:text、arraybuffer 1.7.0
    success Function - 收到开发者服务成功返回的回调函数 -
    fail Function - 接口调用失败的回调函数 -
    complete Function - 接口调用结束的回调函数(调用成功、失败都会执行) -

    success返回参数说明:

    参数 类型 说明 最低版本
    data Object/String/ArrayBuffer 开发者服务器返回的数据 -
    statusCode Number 开发者服务器返回的 HTTP 状态码 -
    header Object 开发者服务器返回的 HTTP Response Header 1.2.0

    这里我们主要看两点:

    1. 回调函数:success、fail、complete;
    2. success的返回参数:data、statusCode、header。

    相对于通过入参传回调函数的方式,我更喜欢promise的链式,这是我期望的第一个点;success的返回参数,在实际开发过程中,我只关心data部分,这里可以做一下处理,这是第二点。

    promisify

    小程序默认支持promise,所以这一点改造还是很简单的:

    /**
     * promise请求
     * 参数:参考wx.request
     * 返回值:[promise]res
     */
    function requestP(options = {}) {
      const {
        success,
        fail,
      } = options;
    
      return new Promise((res, rej) => {
        wx.request(Object.assign(
          {},
          options,
          {
            success: res,
            fail: rej,
          },
        ));
      });
    }
    

    这样一来我们就可以使用这个函数来代替wx.request,并且愉快地使用promise链式:

    requestP({
      url: '/api',
      data: {
        name: 'Jack'
      }
    })
      .then((res) => {
        console.log(res);
      })
      .catch((err) => {
        console.log(err);
      });
    

    注意,小程序的promise并没有实现finally,Promise.prototype.finally是undefined,所以complete不能用finally代替。

    精简返回值

    精简返回值也是很简单的事情,第一直觉是,当请求返回并丢给我一大堆数据时,我直接resolve我要的那一部分数据就好了嘛:

    return new Promise((res, rej) => {
      wx.request(Object.assign(
        {},
        options,
        {
          success(r) {
            res(r.data);  // 这里只取data
          },
          fail: rej,
        },
      ));
    });
    

    but!这里需要注意,我们仅仅取data部分,这时候默认所有success都是成功的,其实不然,wx.request是一个基础的api,fail只发生在系统和网络层面的失败情况,比如网络丢包、域名解析失败等等,而类似404、500之类的接口状态,依旧是调用success,并体现在statusCode上。

    从业务上讲,我只想处理json的内容,并对json当中的相关状态进行处理;如果一个接口返回的不是约定好的json,而是类似404、500之类的接口异常,我统一当成接口/网络错误来处理,就像jquery的ajax那样。

    也就是说,如果我不对statusCode进行区分,那么包括404、500在内的所有请求结果都会走requestP().then,而不是requestP().catch。这显然不是我们熟悉的使用方式。

    于是我从jquery的ajax那里抄来了一段代码。。。

    /**
     * 判断请求状态是否成功
     * 参数:http状态码
     * 返回值:[Boolen]
     */
    function isHttpSuccess(status) {
      return status >= 200 && status < 300 || status === 304;
    }
    

    isHttpSuccess用来决定一个http状态码是否判为成功,于是结合requestP,我们可以这么来用:

    return new Promise((res, rej) => {
      wx.request(Object.assign(
        {},
        options,
        {
          success(r) {
            const isSuccess = isHttpSuccess(r.statusCode);
            if (isSuccess) {  // 成功的请求状态
              res(r.data);
            } else {
              rej({
                msg: `网络错误:${r.statusCode}`,
                detail: r
              });
            }
          },
          fail: rej,
        },
      ));
    });
    

    这样我们就可以直接resolve返回结果中的data,而对于非成功的http状态码,我们则直接reject一个自定义的error对象,这样就是我们所熟悉的ajax用法了。

    登录

    我们经常需要识别发起请求的当前用户,在web中这通常是通过请求中携带的cookie实现的,而且对于前端开发者是无感知的;小程序中没有cookie,所以需要主动地去补充相关信息。

    首先要做的是:登录

    通过wx.login接口我们可以得到一个code,调用后端登录接口将code传给后端,后端再用code去调用微信的登录接口,换取sessionKey,最后生成一个sessionId返回给前端,这就完成了登录。

    api-login.jpg

    具体参考微信官方文档:wx.login

    const apiUrl = 'https://jack-lo.github.io';
    let sessionId = '';
    
    /**
     * 登录
     * 参数:undefined
     * 返回值:[promise]res
     */
    function login() {  
      return new Promise((res, rej) => {
        // 微信登录
        wx.login({
          success(r1) {
            if (r1.code) {
              // 获取sessionId
              requestP({
                url: `${apiUrl}/api/login`,
                data: {
                  code: r1.code,
                },
                method: 'POST'
              })
                .then((r2) => {
                  if (r2.rcode === 0) {
                    const { sessionId } = r2.data;
    
                    // 保存sessionId
                    sessionId = sessionId;
                    res(r2);
                  } else {
                    rej({
                      msg: '获取sessionId失败',
                      detail: r2
                    });
                  }
                })
                .catch((err) => {
                  rej(err);
                });
            } else {
              rej({
                msg: '获取code失败',
                detail: r1
              });
            }
          },
          fail: rej,
        });
      });
    }
    

    好的,我们做好了登录并且顺利获取到了sessionId,接下来是考虑怎么把sessionId通过请求带上去。

    sessionId

    为了将状态与数据区分开来,我们决定不通过data,而是通过header的方式来携带sessionId,我们对原本的requestP稍稍进行修改,使得它每次调用都自动在header里携带sessionId:

    function requestP(options = {}) {
      const {
        success,
        fail,
      } = options;
    
      // 统一注入约定的header
      let header = Object.assign({
        sessionId: sessionId
      }, options.header);
    
      return new Promise((res, rej) => {
        ...
      });
    }
    

    好的,现在请求会自动带上sessionId了;
    但是,革命尚未完成:

    • 我们什么时候去登录呢?或者说,我们什么时候去获取sessionId?
    • 假如还没登录就发起请求了怎么办呢?
    • 登录过期了怎么办呢?

    我设想有这样一个逻辑:

    1. 当我发起一个请求的时候,如果这个请求不需要sessionId,则直接发出;
    2. 如果这个请求需要携带sessionId,就去检查现在是否有sessionId,有的话直接携带,发起请求;
    3. 如果没有,自动去走登录的流程,登录成功,拿到sessionId,再去发送这个请求;
    4. 如果有,但是最后请求返回结果是sessionId过期了,那么程序自动走登录的流程,然后再发起一遍。

    其实上面的那么多逻辑,中心思想只有一个:都是为了拿到sessionId

    我们需要对请求做一层更高级的封装。

    首先我们需要一个函数专门去获取sessionId,它将解决上面提到的2、3点:

    /**
     * 获取sessionId
     * 参数:undefined
     * 返回值:[promise]sessionId
     */
    function getSessionId() {
      return new Promise((res, rej) => {
        // 本地sessionId缺失,重新登录
        if (!sessionId) {
          login()
            .then((r1) => {
              res(r1.data.sessionId);
            })
            .catch(rej);
          }
        } else {
          res(sessionId);
        }
      });
    }
    

    好的,接下来我们解决第1、4点,我们先假定:sessionId过期的时候,接口会返回code=401

    整合了getSessionId,得到一个更高级的request方法:

    /**
     * ajax高级封装
     * 参数:[Object]option = {},参考wx.request;
     * [Boolen]keepLogin = false
     * 返回值:[promise]res
     */
    function request(options = {}, keepLogin = true) {
      if (keepLogin) {
        return new Promise((res, rej) => {
          getSessionId()
            .then((r1) => {
              // 获取sessionId成功之后,发起请求
              requestP(options)
                .then((r2) => {
                  if (r2.rcode === 401) {
                    // 登录状态无效,则重新走一遍登录流程
                    // 销毁本地已失效的sessionId
                    sessionId = '';
    
                    getSessionId()
                      .then((r3) => {
                        requestP(options)
                          .then(res)
                          .catch(rej);
                      });
                  } else {
                    res(r2);
                  }
                })
                .catch(rej);
            })
            .catch(rej);
        });
      } else {
        // 不需要sessionId,直接发起请求
        return requestP(options);
      }
    }
    

    留意req的第二参数keepLogin,是为了适配有些接口不需要sessionId,但因为我的业务里大部分接口都需要登录状态,所以我默认值为true。

    这差不多就是我们封装request的最终形态了。

    并发处理

    这里其实我们还需要考虑一个问题,那就是并发。

    试想一下,当我们的小程序刚打开的时候,假设页面会同时发出5个请求,而此时没有sessionId,那么,这5个请求按照上面的逻辑,都会先去调用login去登录,于是乎,我们就会发现,登录接口被同步调用了5次!并且后面的调用将导致前面的登录返回的sessionId过期~

    这bug是很严重的,理论上来说,登录我们只需要调用一次,然后一直到过期为止,我们都不需要再去登录一遍了。

    ——那么也就是说,同一时间里的所有接口其实只需要登录一次就可以了。

    ——也就是说,当有登录的请求发出的时候,其他那些也需要登录状态的接口,不需要再去走登录的流程,而是等待这次登录回来即可,他们共享一次登录操作就可以了!

    解决这个问题,我们需要用到队列。

    我们修改一下getSessionId这里的逻辑:

    const loginQueue = [];
    let isLoginning = false;
    
    /**
     * 获取sessionId
     * 参数:undefined
     * 返回值:[promise]sessionId
     */
    function getSessionId() {
      return new Promise((res, rej) => {
        // 本地sessionId缺失,重新登录
        if (!sessionId) {
          loginQueue.push({ res, rej });
    
          if (!isLoginning) {
            isLoginning = true;
    
            login()
              .then((r1) => {
                isLoginning = false;
                while (loginQueue.length) {
                  loginQueue.shift().res(r1);
                }
              })
              .catch((err) => {
                isLoginning = false;
                while (loginQueue.length) {
                  loginQueue.shift().rej(r1);
                }
              });
          }
        } else {
          res(sessionId);
        }
      });
    }
    

    使用了isLoginning这个变量来充当锁的角色,锁的目的就是当登录正在进行中的时候,告诉程序“我已经在登录了,你先把回调都加队列里去吧”,当登录结束之后,回来将锁解开,把回调全部执行并清空队列。

    这样我们就解决了问题,同时提高了性能。

    封装

    在做完以上工作以后,我们都很清楚的封装结果就是request,所以我们把request暴露出去就好了:

    function request() {
      ...
    }
    
    module.exports = request;
    

    这般如此之后,我们使用起来就可以这样子:

    const request = require('request.js');
    
    Page({
      ready() {
        // 获取热门列表
        request({
          url: 'https://jack-lo.github.io/api/hotList',
          data: {
            page: 1
          }
        })
          .then((res) => {
            console.log(res);
          })
          .catch((err) => {
            console.log(err);
          });
    
        // 获取最新信息
        request({
          url: 'https://jack-lo.github.io/api/latestList',
          data: {
            page: 1
          }
        })
          .then((res) => {
            console.log(res);
          })
          .catch((err) => {
            console.log(err);
          });
      },
    });
    

    是不是很方便,可以用promise的方式,又不必关心登录的问题。

    然而可达鸭眉头一皱,发现事情并不简单,一个接口有可能在多个地方被多次调用,每次我们都去手写这么一串url参数,并不那么方便,有时候还不好找,并且容易出错。

    如果能有个地方专门记录这些url就好了;如果每次调用接口,都能像调用一个函数那么简单就好了。

    基于这个想法,我们还可以再做一层封装,我们可以把所有的后端接口,都封装成一个方法,调用接口就相对应调用这个方法:

    const apiUrl = 'https://jack-lo.github.io';
    
    const req = {
      // 获取热门列表
      getHotList(data) {
        const url = `${apiUrl}/api/hotList`
        return request({ url, data });
      },
      // 获取最新列表
      getLatestList(data) {
        const url = `${apiUrl}/api/latestList`
        return request({ url, data });
      }
    }
    
    module.exports = req;  // 注意这里暴露的已经不是request,而是req
    

    那么我们的调用方式就变成了:

    const req = require('request.js');
    
    Page({
      ready() {
        // 获取热门列表
        req.getHotList({
          page: 1
        })
          .then((res) => {
            console.log(res);
          })
          .catch((err) => {
            console.log(err);
          });
    
        // 获取最新信息
        req.getLatestList({
          page: 1
        })
          .then((res) => {
            console.log(res);
          })
          .catch((err) => {
            console.log(err);
          });
      }
    });
    

    这样一来就方便了很多,而且有一个很大的好处,那就是当某个接口的地址需要统一修改的时候,我们只需要对request.js进行修改,其他调用的地方都不需要动了。

    错误信息的提炼

    最后的最后,我们再补充一个可轻可重的点,那就是错误信息的提炼。

    当我们在封装这么一个req对象的时候,我们的promise曾经reject过很多的错误信息,这些错误信息有可能来自:

    1. wx.request的fail;
    2. 不符合isHttpSuccess的网络错误;
    3. getSessionId失败;
      ...

    等等的一切可能。

    这就导致了我们在提炼错误信息的时候陷入困境,到底catch到的会是哪种error对象?

    这么看你可能不觉得有问题,我们来看看下面的例子:

    req.getHotList({
      page: 1
    })
      .then((res) => {
        console.log(res);
      })
      .catch((err) => {
        console.log(err);
      });
    

    假如上面的例子中,我想要的不仅仅是console.log(err),而是想将对应的错误信息弹窗出来,我应该怎么做?

    我们只能将所有可能出现的错误都检查一遍:

    req.getHotList({
      page: 1
    })
      .then((res) => {
        if (res.code !== 0) {
          // 后端接口报错格式
          wx.showModal({
            content: res.msg
          });
        }
      })
      .catch((err) => {
        let msg = '未知错误';
    
        // 文本信息直接使用
        if (typeof err === 'string') {
          msg = err;
        }
    
        // 小程序接口报错
        if (err.errMsg) {
          msg = err.errMsg;
        }
    
        // 自定义接口的报错,比如网络错误
        if (err.detail && err.detail.errMsg) {
          msg = err.detail.errMsg;
        }
    
        // 未知错误
        wx.showModal({
          content: msg
        });
      });
    

    这就有点尴尬了,提炼错误信息的代码量都比业务还多几倍,而且还是每个接口调用都要写一遍~

    为了解决这个问题,我们需要封装一个方法来专门做提炼的工作:

    /**
     * 提炼错误信息
     * 参数:err
     * 返回值:[string]errMsg
     */
    function errPicker(err) {
      if (typeof err === 'string') {
        return err;
      }
    
      return err.msg || err.errMsg || (err.detail && err.detail.errMsg) || '未知错误';
    }
    

    那么过程会变成:

    req.getHotList({
      page: 1
    })
      .then((res) => {
        console.log(res);
      })
      .catch((err) => {
        const msg = req.errPicker(err);
    
        // 未知错误
        wx.showModal({
          content: msg
        });
      });
    

    好吧,我们再偷懒一下,把wx.showModal也省去了:

    /**
     * 错误弹窗
     */
    function showErr(err) {
      const msg = errPicker(err);
    
      console.log(err);
      wx.showModal({
        showCancel: false,
        content: msg
      });
    }
    

    最后就变成了:

    req.getHotList({
      page: 1
    })
      .then((res) => {
        console.log(res);
      })
      .catch(req.showErr);
    

    至此,一个简单的wx.request封装过程便完成了,封装过的req比起原来,使用上更加方便,扩展性和可维护性也更好。

    结尾

    以上内容其实是简化版的mp-req,介绍了mp-req这一工具的实现初衷以及思路,使用mp-req来管理接口会更加的便捷,同时mp-req也提供了更加丰富的功能,比如插件机制、接口的缓存,以及接口分类等,欢迎大家关注mp-req了解更多内容。

    以上最终代码可以在这里获取:req.js

    相关文章

      网友评论

        本文标题:【小程序】一个通用request的封装

        本文链接:https://www.haomeiwen.com/subject/pzmkcftx.html