对api请求封装的探索和总结

2,944 阅读7分钟

第一份工作的时候我们老大让我封装下请求,我当即就说:封装什么?为什么要封装,本身人家的库就已经进行封装了啊,只需要几个参数就可以调用了,封装的还是要传一些参数的。嗯~当时还是有点理直气壮的,正所谓无知者无谓😂当然最后我还是听老大的了,那时候我只是封装了几个默认参数吧🐶而后经过几年的历练,对api请求的封装也一直在升级,现在请陪着我来一起回顾下

为什么进行封装

  • 简化使用成本。不同于库,封装是针对项目的,我们可以给定请求的主域名、请求头等默认值,减少使用请求时的需要传的参数和其他配置等
  • 可以统一处理一些逻辑,例如对请求异常的提醒处理等
  • 可以对请求进行一些改造以满足使用习惯

怎么封装

回调 VS PROMISE

很明显,回调容易陷入回调地狱,所以无论请求还是其他场景我们目前的编程方式基本都是推荐使用promise的,尤其是新的async/await的引入更是让promise的编程方式更加优雅

总是被resolve

请求总是被resolve?为什么?如果不是,会怎样?

async function f() {
  try {
    await Promise.reject('出错了');
  } catch(e) {
    ...
  }
  ...
}

正如上面这段代码,如果我们不加catch的话会怎样?该f函数后面的所有的代码都不会被执行,也就是说如果我们要保证代码的健壮性则必须给async/await函数增try/catch容错
那我们不用async了呗,确实是个不错的主意,但我必须提醒async的几点好处:

  • 总是返回promise,这一点很有用,例如
if (check) {
  return true
} else {
  return apiPromise()
}

你是判断返回值的类型还是resolve true?而async则比较完美的解决了这类问题!

  • async的await能让异步代码看起来是同步的,代码的结构也更清爽,语义也更明确
    我更相信你已经在大量使用async,所以,如果使用了async/await那么try/catch千万别忘记哦

即便是简单场景下不需要使用async,promise被拒绝也会有一些小问题,例如

api().then(res=>{
  this.hideLoading()
  ...
}).catch(err=>{
  this.hideLoading()
  ...
})

无论是否被成功resolve,都要执行的一些代码需要在两处书写

所以,你想推介什么? 封装的api请求总是被resolve,这样是不是就没必要关心reject了?也就不用管刚才那一堆问题了,是不是很爽?😊不对啊,总是有异常情况的啊,难道不管了?也resolve啊!😊添加字段区分就行了啊,是不是很聪明?😁

确认请求是完全理想的正确

什么意思?我们先回想下自己是否曾大量写过这样的代码,如果没有请忽略

api().then(res=>{
  this.hideLoading()
  if (res.code !== 0) {
    ...
  }
  ...
})

因为现在很多后端因监控运行状态等原因都不直接返回异常的http状态码,而是以各种code来标示是否处理成功,所以200的请求不一定是真正的请求完成,那校验code就成为必须的了

像真正的api那样

api有点被乱用了,api请求的api是后台提供的业务服务接口,抛去这一种,我们脑中正常的api是什么样子的?是不是像这样array.push(1),是预先定义的函数,是不需要关心内部实现的,所以请把api请求也封装成像真正的api那样,简单好用,随处可用

至此,个人关于对封装api请求的思想基本都阐述了,我们来看看代码实现(基于小程序项目,供参考,核心代码用===============标示)

源码参考

先来看看最终你用的爽不爽

// 例如后台文档是这样的
// curl --request POST \
//  --url 'http://user-interaction.ylf.org/controller/fun' \
//  --header 'Content-Type: application/json' \
//  --data '{
//	"page":1
// }'

// 你只需要这样
api.controller.fun({page: 10}).then(res=>{
  this.hideLoaing()
  if (res.errType) {
    ... // 异常处理
  }
  ... // 正常逻辑
})

// async方式
async function() {
  const res = await api.controller.fun({page: 10})
  this.hideLoaing()
  if (res.errType) {
    ... // 异常处理
  }
  ... // 正常逻辑
}

目录结构

api
├── doRequest.js // 封装的请求方法
├── index.js     // 生成api和export请求方法等
├── inject.js    // 拦截器
├── renewToken.js // 重新获取token
└── serviceJson.js // 供生成API的配置

doRequest.js // 封装的请求方法

import _ from '../lib/tools'
import injectMap from './inject'
import {api as constApi} from '../constVar'
import renewToken from './renewToken'

const apiDomain = constApi.domain

let getTokenPromise = ''// 只能同时存在一个实例
let wxSessionValid = null // 微信session_key的有效性
const checkWxSession = function () {
  return new Promise(resolve => {
    wx.checkSession({
      success() {
        resolve(true) // session_key 未过期,并且在本生命周期一直有效
      },
      fail() {
        resolve(false) // session_key 已经失效,需要重新执行登录流程
      }
    })
  })
}
// 检查业务层是否也处理成功,参数为请求的返回值
const defaultCheckDoRequestSuccess = (res) => !res.data.error_code

export async function doRequestWithCheckSession(data = {}, opts) {
  const opt = Object.assign({needToken: true}, opts)
  if (typeof opt.needToken === 'function') { // 是否需要鉴权有一定逻辑性,则可以将needToken配置设置为返回布尔值的函数,无参
    opt.needToken = opt.needToken()
  }
  if (typeof wxSessionValid !== 'boolean') {
    wxSessionValid = await checkWxSession() // 检查微信session是否有效
  }
  let jwt = wx.getStorageSync('jwt')
  // 鉴权方式:业务侧的鉴权和对微信session有效性的鉴权
  if (opt.needToken && (!jwt || jwt.expire_after <= +new Date() || !wxSessionValid)) { // 需要授权,已过期,去续租
    let jwt = ''
    if (getTokenPromise) {
      jwt = await getTokenPromise
    } else {
      getTokenPromise = renewToken()
      jwt = await getTokenPromise
    }
    wxSessionValid = true
    getTokenPromise = ''
    wx.setStorageSync('jwt', jwt)
  }
  Object.assign(opt, opt.needToken ? {httpOpt: {header: {Authorization: jwt.token}}} : {})
  return doRequest(opt.url, data, opt)
}

============================================================================================
/**
 * 请求接口函数
 * @param url
 * @param data 请求body
 * @param opt 具体配置见该函数的参数
 * @returns {Promise<any>}
 *
 * !!! 总是被解决,永远不会被拒绝,不过你可以通过判断是否有errType值来判断是否请求OK
 * errType === 'http' 是请求出错
 * errType === 'server' 是服务端处理出错,需要checkDoRequestSuccess函数提供判断逻辑
 */
export function doRequest(url, data, {
  method = 'get', httpOpt = {}, needToken = true, needToast = true,
  checkDoRequestSuccess = defaultCheckDoRequestSuccess
} = {}) {
  return new Promise((resolve) => {
    wx.request({
      url,
      data,
      method,
      ...httpOpt,
      success: (res) => { // 请求成功
        if (checkDoRequestSuccess(res)) { // 服务端也处理成功
          injectMap.forEach((val, key) => { // 匹配拦截规则
            if (key.indexOf(url.replace(apiDomain, '')) !== -1) {
              val()
            }
          })
          resolve(res)
        } else { // 服务端处理失败
          needToast && wx.showToast({
            title: res.data.reason || '请求出错,请稍后重试',
            icon: 'none',
            duration: 2000
          })
          resolve(Object.assign({
            errType: 'server'
          }, res))
        }
      },
      fail: (err) => { // 请求失败
        resolve({
          errType: 'http',
          err
        })
        checkNetWorkAndSaveCurrentPath()
      }
    })
  })
}
============================================================================================

// 检查网络问题和记录当前页面的路径
function checkNetWorkAndSaveCurrentPath() {
  /* eslint-disable no-undef */
  const pages = getCurrentPages() // 获取当前的页面栈
  const page = pages[pages.length - 1] // 当前的页面
  // 避免多个请求失败造成多个弱网页面栈,影响回跳
  if (['pages/normal/network', 'pages/normal/load'].indexOf(page.route) !== -1) {
    return
  }
  wx.getNetworkType({
    success: function (res) {
      const pathParamsStrArr = [] // 记录当前页面的路径参数
      _.forOwn(page.options, (v, k) => {
        pathParamsStrArr.push(`${k}=${v}`)
      })
      const path = `${page.route}?${pathParamsStrArr.join('&')}`
      wx.setStorageSync('badNetPagePath', path) // 记录被弱网中断的页面完整路径
      if (res.networkType === 'none') { // 如果是没有网络环境
        wx.redirectTo({
          url: '/pages/normal/network'
        })
      } else { // 弱网环境和其他异常情况
        wx.redirectTo({
          url: '/pages/normal/load'
        })
      }
    }
  })
}

核心index.js // 生成api和export请求方法等

import serviceJson from './serviceJson'
import { doRequestWithCheckSession, doRequest } from './doRequest'
import _ from '../lib/tools'
import {api as constApi} from '../constVar'

const apiDomain = constApi.domain
const api = {}


serviceJson.forEach(obj => {
  const keys = obj.url.replace(/\//g, '.')
  obj.url = apiDomain + obj.url
  _.set(api, keys, function (data) {
    return doRequestWithCheckSession(data, obj)
  })
})

/**
 * 调用示例
 * api.controller.fun({page: 10})
 *
 * 同时暴露出两个封装好的请求方法
 */
export default {
  ...api,
  doRequest,
  doRequestWithCheckSession
}

核心serviceJson.js // 供生成API的配置

/**
 * 项目请求配置
 *
 * 参数请前往 ./doRequest.js 查看doRequest函数说明,一下参数可能会出现变动而导致不准确
 * needToken=true 是否需要token认证
 * method=get 请求方法
 * dataType=json dataType
 * check 函数,参数为请求的返回值,要求返回布尔值,true代表请求成功(后台处理成功),false反之
 */
export default [
  {'url': 'joke/content/list'}
]

inject.js // 拦截器

// 请求hooks,当请求被匹配则执行预设的回调
// map的key为 ./serviceJson.js 配置里的url,value为callback

// import _ from '../lib/tools'

const map = new Map()

export default map

renewToken.js // 重新获取token

import {doRequest} from './doRequest'
import _ from '../lib/tools'
import {api as constApi} from '../constVar'

const apiDomain = constApi.domain

function navToLogin(resolve) {
  /* eslint-disable no-undef */
  const pages = getCurrentPages()
  const page = pages[pages.length - 1]
  page.openLoginModal(resolve)
}

export default async function renewToken() {
  // 确保有用户信息
  // 虽然只要有code即可换取用户id,但通常我们都需要
  await new Promise(resolve => {
    wx.getSetting({
      success: (res) => {
        // 如果用户没有授权或者没有必要的用户信息
        if (!res.authSetting['scope.userInfo'] || !_.isRealTrue(wx.getStorageSync('userInfoRes').userInfo)) {
          wx.hideLoading()
          navToLogin(resolve)
        } else {
          resolve()
        }
      }
    })
  })
  return new Promise((resolve) => {
    wx.login({
      success: res => {
        login(res.code).then((jwt) => {
          resolve(jwt) // resolve jwt
        }) // 通过code进行登录
      },
      fail(err) {
        wx.showToast({
          title: err.errMsg,
          icon: 'none',
          duration: 2000
        })
      }
    })
  })
}

/**
 * 登陆,获取jwt
 * @param code
 * @returns {Promise<any>}
 */
function login(code) {
  return new Promise((resolve) => {
    // 模拟登录换取业务端的用户信息和登录信息,仅测试
    doRequest(apiDomain + 'test/getToken', {code}, { needToast: false }).then(res => {
      if (res.errType) {
        // resolve('loginerr')
        resolve({
          'token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9',
          'expire_after': +new Date() + 1000 * 360 * 24
        })
        return
      }
      resolve({
        'token': 'eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9',
        'expire_after': +new Date() + 1000 * 360 * 24
      })
    })
  })
}

虽然是对API的思考,不仅限小程序,但作为同期的思考和总结,来波系列链接😄
开发微信小程序必须要知道的事
微信小程序之登录态的探索

欢迎交流指正,谢谢