记一次封装Axios的经历

前言

前端开发中,若是页面须要与后台接口交互,而且无刷新页面,那么须要借助一下Ajax的http库来完成与后台数据接口的对接工做。在jQuery很盛行的时候,咱们会使用$.ajax(),如今,可选择的就更多,例如:SuperAgentAxiosFetch…等等。有了这些http库,咱们不在须要关注太多与ajax底层相关的细节的问题。不少时候和场景下,只须要关注如何构建一个request以及如何处理一个response便可,但即使这些http库已经在必定程度上简化了咱们的开发工做,咱们仍然须要针对项目的实际须要,团队内部技术规范对这些http库进行封装,进而优化咱们的开发效率。javascript

本文将结合咱们团队使用的一个http库Axios和咱们团队开发工程的一些场景,分享咱们前端团队对http库进行封装的经历。前端

对http库进行基本的封装

服务端URL接口的定义

以用户管理模块为例。对于用户管理模块,服务端一般会定义以下接口:vue

  • GET /users?page=0&size=20 - 获取用户信息的分页列表
  • GET /users/all - 获取全部的用户信息列表
  • GET /users/:id - 获取指定id的用户信息
  • POST /users application/x-www-form-urlencoded - 建立用户
  • PUT /users/:id application/x-www-form-urlencoded - 更新指定id的用户信息
  • DELETE /users/:id 删除指定id的用户信息

经过以上定义,不难发现这些都是基于RESTful标准进行定义的接口。java

将接口进行模块化封装

针对这样一个用户管理模块,咱们首先须要作的就是定义一个用户管理模块类。react

// UserManager.js
import axios from 'axios'

class UserManager {
  constructor() {
    this.$http = axios.create({
      baseUrl: 'https://api.forcs.com'  // 固然,这个地址是虚拟的
    })
    // 修改POST和PUT请求默认的Content-Type,根据本身项目后端的定义而定,不必定须要
    this.dataMethodDefaults = {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      transformRequest: [function (data) {
        return qs.stringify(data)
      }]
    }
  }
}

export default new UserManager()  // 单例模块
复制代码

UserManager的构造函数中,咱们设置了一些请求的公共参数,好比接口的baseUrl,这样后面在发起请求的时候,URL只须要使用相对路径便可。与此同时,咱们还调整了POST请求和PUT请求默认的Content-TypeAxios默认是application/json,咱们根据后端接口的定义,将其调整成了表单类型application/x-www-form-urlencoded。最后,借助ES6模块化的特性,咱们将UserManager单例化。webpack

实际的场景中,一套符合行业标准的后端接口规范要比这复杂得多。因为这些内容不是本文讨论的重点,因此简化了。ios

接着,给UserManager添加调用接口的方法。git

import axios from 'axios'
import qs from 'query-string'

class UserManager {
  constructor() {
    this.$http = axios.create({
      baseUrl: 'https://api.forcs.com'
    })
    this.dataMethodDefaults = {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      transformRequest: [function (data) {
        return qs.stringify(data)
      }]
    }
  }
  
  getUsersPageableList (page = 0, size = 20) {
    return this.$http.get(`/users?page=${page}&size=${size}`)
  }
  
  getUsersFullList () {
    return this.$http.get('/users/all')
  }
  
  getUser (id) {
    if (!id) {
      return Promise.reject(new Error(`getUser:id(${id})无效`))
    }
    return this.$http.get(`/users/${id}`)
  }
  
  createUser (data = {}) {
    if (!data || !Object.keys(data).length) {
      return Promise.reject(new Error('createUser:提交的数据无效'))
    }
    return this.$http.post('/users', data, { ...this.dataMethodDefaults })
  }
  
  updateUser (id, update = {}) {
    if (!update || !Object.keys(update).length) {
      return Promise.reject(new Error('updateUser:提交的数据无效'))
    }
    return this.$http.put(`/users/${id}`, update, { ...this.dataMethodDefaults })
  }
  
  deleteUser (id) {
    if (!id) {
      return Promise.reject(new Error(`deleteUser:id(${id})无效`))
    }
    return this.$http.delete(`/users/${id}`)
  }
}

export default new UserManager()
复制代码

新增的方法没有什么特别的地方,一目了然,就是经过Axios执行http请求调用服务端的接口。值得注意的是,在getUser()createUser()updateUser()deleteUser()这四个方法中,咱们对参数进行了简单的验证,固然,实际的场景会比范例代码的更加复杂些,其实参数验证不是重点,关键在于验证的if语句块中,return的是一个Promise对象,这是为了和Axios的API保持一致。es6

前端调用封装的方法

通过这样封装后,前端页面与服务端交互就变得简单多了。下面以Vue版本的前端代码为例github

<!-- src/components/UserManager.vue -->
<template>
  <!-- 模板代码能够忽略 -->
</template>

<script>
  import userManager from '../services/UserManager'
  export default {
    data () {
      return {
        userList: [],
        currentPage: 0,
        currentPageSize: 20,
        formData: {
          account: '',
          nickname: '',
          email: ''
        }
      }
    },
    _getUserList () {
      userManager.getUser(this.currentPage, this.currentPageSize)
      .then(response => {
        this.userList = response.data
      }).catch(err => {
        console.error(err.message)
      })
    },
    mounted () {
      // 加载页面的时候,获取用户列表
      this._getUserList()
    },
    handleCreateUser () {
      // 提交建立用户的表单
      userManager.createUser({ ...this.formData })
      .then(response => {
        // 刷新列表
        this._getUserList()
      }).catch(err => {
        console.error(err.message)
      })
    }
  }
</script>
复制代码

固然,相似的js代码在React版本的前端页面上也是适用的。

// src/components/UserList.js
import React from 'react'
import userManager from '../servers/UserManager'

class UserManager extends React.Compnent {
  constructor (props) {
    super(props)
    this.state.userList = []
    this.handleCreateUser = this.handleCreateUser.bind(this)
  }
  
  _getUserList () {
    userManager.getUser(this.currentPage, this.currentPageSize)
    .then(response => {
      this.setState({ userList: userList = response.data })
    }).catch(err => {
      console.error(err.message)
    })
  }
  
  componentDidMount () {
    this._getUserList()
  }
  
  handleCreateUser (data) {
    userManager.createUser({ ...data })
    .then(response => {
      this._getUserList()
    }).catch(err => {
      console.error(err.message)
    })
  }
  
  render () {
    // 模板代码就能够忽略了
    return (/* ...... */)
  }
}
            
export default UserManager
复制代码

为了节省篇幅,后面就再也不展现前端页面上调用封装模块的代码了。

ok,接口用起来很方便,封装到这一步感受彷佛没啥毛病。但是,一个APP怎么可能就这么些接口呢,它会涉及到若干个接口,而不一样的接口可能归类在不一样的模块。就拿咱们的后台项目来讲,内容管理模块就分为单片管理和剧集管理,剧集管理即包括剧集实体自身的管理,也包括对单片进行打包的管理,因此,后台对内容管理模块的接口定义以下:

单片管理

  • GET /videos?page=0&size=20
  • GET /videos/all
  • GET /videos/:id
  • POST /videos application/x-www-form-urlencoded
  • PUT /videos/:id application/x-www-form-urlencoded
  • DELETE /videos/:id

剧集管理:

  • GET /episodes?page=0&size=20
  • GET /episodes/all
  • GET /episodes/:id
  • POST /episodes application/x-www-form-urlencoded
  • PUT /episodes/:id application/x-www-form-urlencoded
  • DELETE /episodes/:id

篇幅关系,就不列出全部的接口了。能够看到接口依然是按照RESTful标准来定义的。按照以前说的作法,咱们能够当即对这些接口进行封装。

定义一个单品管理的模块类VideoManager

// VideoManager.js
import axios from 'axios'
import qs from 'query-string'

class VideoManager {
  constructor () {
    this.$http = axios.create({
      baseUrl: 'https://api.forcs.com'
    })
    this.dataMethodDefaults = {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      transformRequest: [function (data) {
        return qs.stringify(data)
      }]
    }
  }
  
  getVideosPageableList (page = 0, size = 20) {
    return this.$http.get(`/videos?page=${page}&size=${size}`)
  }
  
  getVideosFullList () {
    return this.$http.get('/videos/all')
  }
  
  getVideo (id) {
    if (!id) {
      return Promise.reject(new Error(`getVideo:id(${id})无效`))
    }
    return this.$http.get(`/videos/${id}`)
  }
  
  // ... 篇幅缘由,后面的接口省略
}

export default new VideoManager()

复制代码

以及剧集管理的模块类EpisodeManager.js

//EpisodeManager.js
import axios from 'axios'
import qs from 'query-string'

class EpisodeManager {
  constructor () {
    this.$http = axios.create({
      baseUrl: 'https://api.forcs.com'
    })
    this.dataMethodDefaults = {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      transformRequest: [function (data) {
        return qs.stringify(data)
      }]
    }
  }
  
  getEpisodesPageableList (page = 0, size = 20) {
    return this.$http.get(`/episodes?page=${page}&size=${size}`)
  }
  
  getEpisodesFullList () {
    return this.$http.get('/episodes/all')
  }
  
  getEpisode (id) {
    if (!id) {
      return Promise.reject(new Error(`getEpisode:id(${id})无效`))
    }
    return this.$http.get(`/episodes/${id}`)
  }
  
  // ... 篇幅缘由,后面的接口省略
}

export default new EpisodeManager()

复制代码

发现问题了吗?存在重复的代码,会给后期的维护埋下隐患。编程原则中,有一个很著名的原则:DRY,翻译过来就是要尽量的避免重复的代码。在灵活的前端开发中,要更加留意这条原则,重复的代码越多,维护的成本越大,灵活度和健壮性也随之下降。想一想要是大型的APP涉及到的模块有数十个以上,每一个模块都撸一遍这样的代码,若是后期公共属性有啥调整的话,这样的改动简直就是个灾难!

为了提高代码的复用性,灵活度,减小重复的代码,应该怎么作呢?若是了解OOP的话,你应该能够很快想出对——定义一个父类,抽离公共部分。

让封装的模块更具有复用性

使用继承的方式进行重构

定义一个父类BaseModule,将代码公共的部分都放到这个父类中。

// BaseModule.js
import axios from 'axios'
import qs from 'query-string'

class BaseModule {
  constructor () {
    this.$http = axios.create({
      baseUrl: 'https://api.forcs.com'
    })
    this.dataMethodDefaults = {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      transformRequest: [function (data) {
        return qs.stringify(data)
      }]
    }
  }
  
  get (url, config = {}) {
    return this.$http.get(url, config)
  }
  
  post (url, data = undefined, config = {}) {
    return this.$http.post(url, data, { ...this.dataMethodDefaults, ...config })
  }
  
  put (url, data = undefined, config = {}) {
    return this.$http.put(url, data, { ...this.dataMethodDefaults, ...config })
  }
  
  delete (url, config = {}) {
    return this.$http.delete(url, config)
  }
}

export default BaseModule

复制代码

而后让UserManagerVideoManagerEpisodeManager都继承自这个BaseModule,移除重复的代码。

UserManager.js

+ import BaseModule from './BaseModule'
- import axios from 'axios'
- import qs from 'query-string'

+ class UserManager extends BaseModule {
- class UserManager {
    constructor() {
+ super()
- this.$http = axios.create({
- baseUrl: 'https://api.forcs.com'
- })
- this.dataMethodDefaults = {
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- transformRequest: [function (data) {
- return qs.stringify(data)
- }]
- }
  }
  
  getUsersPageableList (page = 0, size = 20) {
+ return this.get(`/users?page=${page}&size=${size}`)
- return this.$http.get(`/users?page=${page}&size=${size}`)
  }
  
  getUsersFullList () {
+ return this.get('/users/all')
- return this.$http.get('/users/all')
  }
  
  getUser (id) {
    if (!id) {
      return Promise.reject(new Error(`getUser:id(${id})无效`))
    }
+ return this.get(`/users/${id}`)
- return this.$http.get(`/users/${id}`)
  }
  
  // ......
}

export default new UserManager()
复制代码

VideoManager.js

+ import BaseModule from './BaseModule'
- import axios from 'axios'
- import qs from 'query-string'

+ class VideoManager extends BaseModule {
- class VideoManager {
  constructor () {
+ super()
- this.$http = axios.create({
- baseUrl: 'https://api.forcs.com'
- })
- this.dataMethodDefaults = {
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- transformRequest: [function (data) {
- return qs.stringify(data)
- }]
- }
  }
  
  getVideosPageableList (page = 0, size = 20) {
+ return this.get(`/videos?page=${page}&size=${size}`)
- return this.$http.get(`/videos?page=${page}&size=${size}`)
  }
  
  getVideosFullList () {
+ return this.get('/videos/all')
- return this.$http.get('/videos/all')
  }
  
  getVideo (id) {
    if (!id) {
      return Promise.reject(new Error(`getVideo:id(${id})无效`))
    }
+ return this.get(`/videos/${id}`)
- return this.$http.get(`/videos/${id}`)
  }
  
  // ......
}

export default new VideoManager()
复制代码

EpisodeManager.js

+ import BaseModule from './BaseModule'
- import axios from 'axios'
- import qs from 'query-string'

+ class EpisodeManager extends BaseModule {
- class EpisodeManager {
  constructor () {
+ super()
- this.$http = axios.create({
- baseUrl: 'https://api.forcs.com'
- })
- this.dataMethodDefaults = {
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- transformRequest: [function (data) {
- return qs.stringify(data)
- }]
- }
  }
  
  getEpisodesPageableList (page = 0, size = 20) {
+ return this.get(`/episodes?page=${page}&size=${size}`)
- return this.$http.get(`/episodes?page=${page}&size=${size}`)
  }
  
  getEpisodesFullList () {
+ return this.get('/episodes/all')
- return this.$http.get('/episodes/all')
  }
  
  getEpisode (id) {
    if (!id) {
      return Promise.reject(new Error(`getEpisode:id(${id})无效`))
    }
+ return this.get(`/episodes/${id}`)
- return this.$http.get(`/episodes/${id}`)
  }
  
  // ... 篇幅缘由,后面的接口省略
}

export default new EpisodeManager()
复制代码

利用OOP的继承特性,将公共代码抽离到父类中,使得封装模块接口的代码获得必定程度的简化,之后若是接口的公共部分的默认属性有何变更,只须要维护BaseModule便可。若是你对BaseModule有留意的话,应该会注意到,BaseModule也不彻底将公共部分隐藏在自身当中。同时,BaseModule还对Axios对象的代理方法(axios.get()axios.post()axios.put()axios.delete())进行了包装,从而将Axios内聚在自身内部,减小子类的依赖层级。对于子类,再也不须要关心Axios对象,只须要关心父类提供的方法和部分属性便可。这样作,一方面提高了父类的复用性,另外一方面也使得子类能够更加好对父类进行扩展,同时又不影响到其余子类。

对于通常场景,封装到这里,此役也算是能够告捷,终于能够去冲杯咖啡小歇一会咯。不过,公司还没跨,事情怎么可能完呢……

BaseModule的问题

过了一周后,新项目启动,这个项目对接的是另外一个后端团队的接口。大致上还好,接口命名风格依然基本跟着RESTful的标准走,但是,请求地址的域名换了,请求头的Content-Type也和以前团队定义的不同,这个后端团队用的是application/json

固然,实际上不一样的后端团队定义的接口,差别未必会这么小:(

面对这种场景,咱们的第一反应多是:好撸,把以前项目的BaseModule复制到如今的项目中,调整一下就行了。

import axios from 'axios'
import qs from 'query-string'

class BaseModule {
  constructor () {
    this.$http = axios.create({
- baseUrl: 'https://api.forcs.com'
+ baseUrl: 'https://api2.forcs.com'
    })
- this.dataMethodDefaults = {
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- transformRequest: [function (data) {
- return qs.stringify(data)
- }]
- }
  }
  
  get (url, config = {}) {
    return this.$http.get(url, config)
  }
  
  post (url, data = undefined, config = {}) {
- return this.$http.post(url, data, { ...this.dataMethodDefaults, ...config })
+ return this.$http.post(url, data, config)
  }
  
  put (url, data = undefined, config = {}) {
- return this.$http.post(url, data, { ...this.dataMethodDefaults, ...config })
+ return this.$http.put(url, data, config)
  }
  
  delete (url, config = {}) {
    return this.$http.delete(url, config)
  }
}

export default BaseModule

复制代码

因为Axios默认POST和PUT请求Header的Content-Typeapplication/json,因此只须要将以前设置Content-Type的代码移除便可。接着,就能够喝着咖啡,听着歌,愉快的封装接口对接数据了!

认真回想一下,这样作其实又了咱们以前提到一个问题:重复的代码。你可能认为,反正不是一个项目的,代码独立维护,因此这样也不打紧。我从客观的角度认为,对于一些小项目或者小团队,这样作的确没啥毛病,但若是,我是说若是,项目愈来愈多了,这样每一个项目复制一套代码真的好吗?假如哪天后端团队作了统一规范,全部接口的请求头都按照一套规范来设置,其实以前的代码都得逐一调整?个人天,这得多大工做量。总之,重复的代码就是个坑!

应对这种状况,怎么破?

让封装的模块更具有通用性

在面向对象编程的原则中,有这么一条:开闭原则。即对扩展开发,对修改关闭。根据这条原则,我想到的一个方案,就是给封装的BaseModule提供对外设置的选项,就像jQuery的大多数插件那样,工厂方法中都会提供一个options对象参数,方便外层调整插件的部分属性。咱们也能够对BaseModule进行一些改造,让它更灵活,更易于扩展。

对BaseModule进行重构

接下来须要对以前的BaseModule进行重构,让它更具有通用性。

import axios from 'axios'
import qs from 'query-string'

function isEmptyObject (obj) {
  return !obj || !Object.keys(obj).length
}

// 清理headers中不须要的属性
function clearUpHeaders (headers) {
  [
    'common',
    'get',
    'post',
    'put',
    'delete',
    'patch',
    'options',
    'head'
  ].forEach(prop => headers[prop] && delete headers[prop])
  return headers
}

// 组合请求方法的headers
// headers = default <= common <= method <= extra
function resolveHeaders (method, defaults = {}, extras = {}) {
  method = method && method.toLowerCase()
  // check method参数的合法性
  if (!/^(get|post|put|delete|patch|options|head)$/.test(method)) {
    throw new Error(`method:${method}不是合法的请求方法`)
  }
  
  const headers = { ...defaults }
  const commonHeaders = headers.common || {}
  const headersForMethod = headers[method] || {}
  
  return _clearUpHeaders({
    ...headers,
    ...commonHeaders,
    ...headersForMethod,
    ...extras
  })
}

// 组合请求方法的config
// config = default <= extra
function resolveConfig (method, defaults = {}, extras = {}) {
  if (isEmptyObject(defaults) && isEmptyObject(extras)) {
    return {}
  }
  
  return {
    ...defaults,
    ...extras,
    resolveHeaders(method, defaults.headers, extras.headers)
  }
}

class HttpClientModule {
  constructor (options = {}) {
    const defaultHeaders = options.headers || {}
    if (options.headers) {
      delete options.headers
    }
    
    const defaultOptions = {
      baseUrl: 'https://api.forcs.com',
      transformRequest: [function (data, headers) {
        if (headers['Content-Type'] === 'application/x-www-form-urlencoded') {
          // 针对application/x-www-form-urlencoded对data进行序列化
          return qs.stringify(data)
        } else {
          return data
        }
      }]
    }
    
    this.defaultConfig = {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded',
      	...defaultHeaders
      }
    }
    
    this.$http = axios.create({ ...defaultOptions, ...options })
  }
  
  get (url, config = {}) {
    return new Promise((resolve) => {
      resolve(this.$http.get(url, resolveConfig(
        'get', this.defaultConfig, config)))
    })
  }
  
  post (url, data = undefined, config = {}) {
    return new Promise((resolve) => {
      resolve(this.$http.post(url, data, resolveConfig(
        'post', this.defaultConfig, config)))
    })
  }
  
  put (url, data = undefined, config = {}) {
    return new Promise((resolve) => {
      resolve(this.$http.put(url, data, resolveConfig(
        'put', this.defaultConfig, config)))
    })
  }
  
  delete (url, config = {}) {
    return new Promise((resolve) => {
      resolve(this.$http.delete(url, resolveConfig(
        'delete', this.defaultConfig, config)))
    })
  }
}

// 导出工厂方法
export function createHttpClient (options, defaults) {
  return new HttpClientModule(options, defaults)
}

// 默认导出模块对象
export default HttpClientModule  // import

复制代码

通过重构的BaseModule已经面目全非,模块的名称也换成了更加通用的叫法:HttpClientModuleHttpClientModule的构造函数提供了一个options参数,为了减小模块的学习成本,options基本沿用了AxiosRequest Config定义的结构体。惟独有一点不一样,就是对optionsheaders属性处理。

这里须要多说一下,看似完美的Axios存在一个比较严重,但至今还没修复的bug,就是经过defaults属性设置headers是不起做用的,必须在执行请求操做(调用request()get()post()…等请求方法)时,经过方法的config参数设置header才会生效。为了规避这个特性的bug,我在HttpClientModule这个模块中,按照Axios的API设计,本身手动实现了相似的features。既能够经过common属性设置公共的header,也能够以请求方法名(get、post、put…等)做为属性名来给特定请求方法的请求设置默认的header。大概像下面这样:

const options = {
  // ...
  headers: {
    // 设置公共的header
    common: {
      Authorization: AUTH_TOKEN
    },
    // 为post和put请求设置请求时的Content-Type
    post: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    put: {
      'Content-Type': 'application/x-www-form-urlencoded'
    }
  }
}

const httpClient = new HttpClientModule(options)

复制代码

独立发布重构的封装模块

咱们能够为HttpClientModule单首创建一个npm项目,给它取一个名词,例如httpclient-module。取名前最好先上npmjs上查一下名称是否已经被其它模块使用了,尽可能保持名称的惟一性。而后经过webpackrollupparcel等构建工具进行打包,发布到npmjs上。固然,若是代码中涉及到私有的配置信息,也能够本身搭建一个npm私服仓库,而后布到私服上。这样,就能够经过npm install命令直接将模块安装到咱们的项目中来使用了。安装模块能够经过以下命令:

npm install httpclient-module --save
# or
npm i httpclient-module -S
复制代码

对业务接口层的模块进行调整

还记得前面针对业务层定义的UserManagerVideoManager以及EpisodeManager吗,他们都继承自BaseModule,但为了让父类BaseModule更具通用性,咱们以及将它进行了重构,而且换了个名称进行了独立发布,那么这几个业务层的manager模块应该如何使用这个通过重构的模块HttpClientModule呢?

由于那些manager模块都继承自父类BaseModule,咱们只须要对BaseModule进行调整便可。

- import axios from 'axios'
- import qs from 'query-string'
+ import { createHttpClient } from 'httpclient-module'

+ const P_CONTENT_TYPE = 'application/x-www-form-urlencoded'
class BaseModule {
  constructor () {
- this.$http = axios.create({
- baseUrl: 'https://api.forcs.com'
- })
- this.dataMethodDefaults = {
- headers: {
- 'Content-Type': 'application/x-www-form-urlencoded'
- },
- transformRequest: [function (data) {
- return qs.stringify(data)
- }]
- }
+ this.$http = createHttpClient({
+ headers: {
+ post: { 'Content-Type': P_CONTENT_TYPE },
+ put: { 'Content-Type': P_CONTENT_TYPE }
+ }
+ })
  }
  
  get (url, config = {}) {
    return this.$http.get(url, config)
  }
  
  post (url, data = undefined, config = {}) {
- return this.$http.post(url, data, { ...this.dataMethodDefaults, ...config })
+ return this.$http.post(url, data, config)
  }
  
  put (url, data = undefined, config = {}) {
- return this.$http.put(url, data, { ...this.dataMethodDefaults, ...config })
+ return this.$http.put(url, data, config)
  }
  
  delete (url, config = {}) {
    return this.$http.delete(url, config)
  }
}

export default BaseModule
复制代码

本质上就是用本身封装的httpclient-module替换了原来的Axios。这样有什么好处呢?

httpclient-module能够认为是Axios与业务接口层之间的适配器。将Axios封装到httpclient-module,下降了前端项目对第三方库的依赖。前面有提到Axios是存在一些比较明显的bug的,通过这层封装,咱们能够下降bug对项目的影响,只须要维护httpclient-module,就能够规避掉第三方bug带来的影响。若是之后发现有更好的http库,须要替换掉Axios,只须要升级httpclient-module就能够了。对于业务层,不须要作太大的调整。

有了httpclient-module这层适配器,也给团队作技术统一化规范带来方便。假如之后团队的接口规范作了调整,好比接口域名切换到https,请求头认证作统一调整,或者请求头须要增减其余参数,也只须要更新httpclient-module就好。若是不是团队作统一调整,而是个别项目,也只须要调整BaseModule,修改一下传递给httpclient-moduleoptions参数便可。

让封装的模块提升咱们开发效率

httpclient-module愉快的工做了一段时间后,咱们又遇到了新的问题。

随着项目迭代,前端加入的业务功能愈来愈多,须要对接后台的业务接口也逐渐增多。好比新增一个内容供应商管理模块,咱们就须要为此建立一个CPManager,而后添加调用接口请求的方法,新增一个内容标签管理模块,就须要定义一个TagManager,而后添加调用接口请求的方法。像下面这样的代码。

新增的内容供应商管理模块:

// CPManager.js
// ...

class CPManager extends BaseModule {
  constructor () { /* ... */ }
  
  createCp (data) { /* ... */ }
  getCpPageableList (page = 0, size = 20) { /* ... */ }
  getCpFullList () { /* ... */ }
  getCp (id) { /* ... */ }
  updateCp (id, update) { /* ... */ }
  deleteCp (id) { /* ... */ }
  
  // ...
}
复制代码

内容标签管理模块:

// TagManager.js
// ...

class TagManager extends BaseModule {
  constructor () { /* ... */ }
  
  createTag (data) { /* ... */ }
  getTagPageableList (page = 0, size = 20) { /* ... */ }
  getTagFullList () { /* ... */ }
  getTag (id) { /* ... */ }
  updateTag (id, update) { /* ... */ }
  deleteTag (id) { /* ... */ }
  
  // ...
}
复制代码

新增的模块远不止这些,咱们发现,代码中存在不少重复的地方,好比createXXX()getXXX()updateXXX()deleteXXX(),分别对应的都是模块下的CRUD接口,并且若是业务接口没有太特殊的场景时,定义一个接口,仅仅就是为了封装一个调用。

// ...

class TagManager extends BaseModule {
  
  // ...
  
  createTag (data) {
    // 定义createTag()方法,就是为了简化/tags的POST请求
    return this.$http.post('/tags', data)
  }
  
  // ...
}
复制代码

咱们以为这些重复的工做是能够简化掉的。根据方法语义化命名的习惯,建立资源的方法咱们会以create做为前缀,对应执行POST请求。更新资源使用update做为方法名的前缀,对应执行PUT请求。获取资源或者资源列表,方法名以get开头,对应GET请求。删除资源,则用delete开头,对应DELETE请求。以下表所示:

方法名前缀 功能 请求方法 接口
create 建立资源 POST /resources
get 获取资源 GET /resources/:id、/resources、/resources/all
update 更新资源 PUT /resources/:id
delete 删除资源 DELETE /resources/:id

按照这个约定,咱们团队想,既然方法的前缀、请求方法和URL接口三者能够存在一一对应的关系,那么能不能经过Key -> Value的方式自动化的生成与URL请求绑定好了的方法呢?

例如TagManager,咱们但愿经过相似下面的代码进行建立。

// TagManager.js

const urls = {
  createTag: '/tags',
  updateTag: '/tags/:id',
  getTag: '/tags/:id',
  getTagPageableList: '/tags',
  getTagFullList: '/tags/all',
  deleteTag: '/tags/:id'
}

export default moduleCreator(urls)
复制代码

而后在UI层能够直接调用建立好的模块方法。

// TagManager.vue

<script>
  import tagManager from './service/TagManager.js'
  // ...
  
  export default {
    data () {
      return {
        tagList: [],
        page: 0,
        size: 20,
        // ...
      }
    },
    // ...
    _refresh () {
      const { page, size } = this
      // GET /tags?page=[page]&size=[size]
      tagManager.getTagPageableList({ page, size })
        .then(resolved => this.tagList = resolved.data)
    },
    mounted () {
      this._refresh()
    },
    handleCreate (data) {
      // POST /tags
      tagManager.createTag({ ...data })
        .then(_ => this._refresh())
        .catch(err => console.error(err.message))
    },
    handleUpdate (id, update) {
      // PUT /tags/:id
      tagManager.updateTag({ id }, { ...update })
        .then(_ => this._refresh())
        .catch(err => console.error(err.message))
    },
    handleDelete (id) {
      // DELETE /tags/:id
      tagManager.deleteTag({ id })
        .then(_ => this._refresh())
        .catch(err => console.error(err.message))
    },
    // ...
  }
</script>
复制代码

这样在前端定义一个业务接口的模块是否是方便多了:)并且,有没有注意到,咱们对接口的传参也作了调整。不管是URL的路径变量仍是查询参数,咱们均可以经过对象化的方式进行传递。这种统一参数类型的调整,简化了接口的学习成本,自动生成的方法都是经过对象化的方式将参数绑定到接口当中。

在RESTful标准的接口中,接口的URL可能会存在两种参数,路径变量(Path Variables)和查询参数(Query Argument)。

  • 路径变量:就是URL中映射到指定资源所涉及的变量,好比/resources/:id,这里的:id,指的就是资源id,操做不一样的资源时,URL中:id这段路径也会不一样。/resources/1,/resources/2…等
  • 查询参数:指的是URL中的query参数,一般就是GET请求或者DELETE请求的URL中问号后面那段,好比/resources?page=0&size=20,page和size就是查询参数

先来一波实现的思路

首先对自动生成的与URL绑定的模块方法进行设计。

// GET, DELETE
methodName ([params|querys:PlainObject, [querys|config:PlainObject, [config:PlainObject]]]) => :Promise
// POST, PUT
methodName ([params|data:PlainObject, [data|config:PlainObject, [config:PlainObject]]]) => :Promise
复制代码

这是一段伪代码。params表示路径参数对象,querys表示GET或者DELETE请求的查询参数对象,data表示POST或者PUT请求提交的数据对象,大概要传达的意思是:

  • 自动生成的方法,会接受3个类型为Plain Object的参数,参数都是可选的,返回一个Promise对象。
  • 当给方法传递三个参数对象的时候,参数依次是路径变量对象,查询参数对象或者数据对象,兼容AxiosAPI的config对象。

下面用一个GET请求和一个PUT请求进行图解示意,先看看GET请求

下面是PUT请求:

  • 当传递两个参数时,若是URL接口不带路径变量,那么第一个参数是查询参数对象(GET方法或者DELETE方法)或者数据对象(POST方法或者PUT方法),第二个是config对象。若是URL接口带有路径变量,那么第一个参数就表示路径变量对象,第二个参数是查询参数对象或者数据对象。

好比下面两个GET方法的URL接口,左边这个不带路径变量,右边的带有路径变量:id。左边的,假设与URL接口绑定的方法名是getTagPageableList,当咱们调用方式只穿两个参数,那么第一个参数会转换成查询参数的格式key1=value1&key2=value2&...&keyn=valuen,第二个参数则至关于Axiosconfig对象。右边的,由于URL接口中带有路径变量:id,那么调用绑定URL接口的方法getTagById并传了两个参数时,第一个参数对象被根据key替换掉URL接口中的路径变量,第二个参数则会被做为查询参数使用。

POST方法和PUT方法的请求也是相似,只是将查询参数替换成了提交的数据。

  • 当只传递一个参数时,若是接口URL不带路径变量,那么这个参数就是查询参数对象或者数据对象,若是接口URL带有路径变量,那么这个参数对象就会映射到路径变量中。

两个GET请求:

一个POST请求和一个PUT请求:

将思路转换成实现的代码

httpclient-module中实现功能。

// ...

/* 请求方法与模块方法名的映射关系对象 * key -> 请求方法 * value -> pattern:方法名的正则表达式,sendData:表示是不是POST,PUT或者PATCH方法 */
const methodPatternMapper = {
  get: { pattern: '^(get)\\w+$' },
  post: { pattern: '^(create)\\w+$', sendData: true },
  put: { pattern: '^(update)\\w+$', sendData: true },
  delete: { pattern: '^(delete)\\w+$' }
}

// 辅助方法,判断是不是函数
const isFunc = function (o) {
  return typeof o === 'function'
}

// 辅助方法,判断是不是plain object
// 这个方法相对简单,若是想看更加严谨的实现,能够参考lodash的源码
const isObject = function (o) {
  return Object.prototype.toString.call(o) === '[object Object]'
}

/* * 将http请求绑定到模块方法中 * * @param method 请求方法 * @param moduleInstance 模块实例对象或者模块类的原型对象 * @param shouldSendData 表示是不是POST,或者PUT这类请求方法 * * @return Axios请求api返回的Promise对象 */
function bindModuleMethod(method, moduleInstance, shouldSendData) {
  return function (url, args, config = {}) {
    return new Promise(function (resolve, reject) {
      let p = undefined
      config = { ...config, url, method }
      if (args) {
        shouldSendData ?
          config.data = args :
          config.url = `${config.url}?${qs.stringify(args)}`
      }
      moduleInstance.$http.request(config)
        .then(response => resolve(response))
        .catch((error) => reject(error))
    })
  }
}

/* * 根据定义的模块方法名称,经过methodPatternMapper转换成绑定URL的模块方法 * * @param moduleInstance 模块实例对象或者模块类的原型对象 * @param name 模块方法名称 * * @return Function 绑定的模块方法 * @throw 方法名称和请求方法必须一一匹配 * 若是发现匹配到的方法不止1个或者没有,则会抛出异常 */
function resolveMethodByName(moduleInstance, name) {
  let requestMethod = Object.keys(metherPatternMapper).filter(key => {
    const { pattern } = methodPatternMapper[key]
    if (!(pattern instanceof RegExp)) {
      // methodPatternMapper每一个属性的value的pattern
      // 既能够是正则表达式字符串,也但是是正则类型的对象
      pattern = new RegExp(pattern)
    }
    return pattern.test(name)
  })
  
  if (requestMethod.length !== 1) {
    throw ` 解析${name}异常,解析获得的方法有且只能有1个, 但实际解析到的方法个数是:${requestMethod.length} `
  }
  
  requestMethod = requestMethod[0]
  return bindModuleMethod(requestMethod, moduleInstance,
                          methodPatternMapper[requestMethod].sendData)
}

/* * 将参数映射到路径变量 * * @param url * @param params 被映射到路径变量的参数 * * @return 将路径变量替换好的URL */
function mapParamsToPathVariables(url, params) {
  if (!url || typeof url !== 'string') {
    throw new Error(`url ${url} 应该是URL字符串`)
  }
  return url.replace(/:(\w+)/ig, (_, key) => params[key])
}

export function bindUrls (urls = {}) {
  // 为何返回一个函数对象?后面会给你们解释
  return module => {
    const keys = Object.keys(urls)
    if (!keys.length) {
      console.warn('urls对象为空,没法完成URL的映射')
      return
    }
    
    const instance = module.prototype || module
    
    keys.forEach(name => {
      const url = urls[name]
      
      if (!url) {
        throw new Error(`${name}()的地址无效`)
      }
      // 根据urls对象动态定义模块方法
      Object.defineProperty(instance, name, {
        configurable: true,
        writable: true,
        enumerable: true,
        value: ((url, func, thisArg) => () => {
          let args = Array.prototype.slice.call(arguments)
          if (args.length > 0 && url.indexOf('/:') >= 0) {
            if (isObject(args[0])) {
              const params = args[0]
              args = args.slice(1)
              url = mapParamsToPathVariables(url, params)
            }
          }
          return func && func.apply(thisArg, [ url ].concat(args))
        })(url, resolveMethodByName(instance, name), instance)
      })
    })
  }
}
复制代码

为了阅读方便,我把关键的几个地方都放到了一块儿,但在实际项目当中,建议适当的拆分一下代码,以便维护和测试。

咱们实现了一个将URL请求与模块实例方法进行绑定的函数bindUrls(),并经过httpclient-module导出。bundUrls()的实现并不复杂。urls是一个以方法名做为key,URL做为value的对象。对urls对象进行遍历,遍历过程当中,先用对象的key进行正则匹配,从而获得是相应的请求方法(见methodPatternMapper),并将请求绑定到一个函数中(见resolveMethodByName()bindModuleMethod())。而后经过Object.defineProperty()方法给模块的实例(或者原型)对象添加方法,方法的名称就是urlskey。被动态添加到模块实例对象的方法在被调用时,先判断与方法绑定的URL是否有路径变量,若是有,则经过mapParamsToPathVariables()进行转换,而后在执行以前经过resolveMethodByName()获得的已经和请求绑定好的函数。

咱们用bindUrls()对以前的TagManager进行改造。

// TagManager.js
// ...
+ import { bindUrls } from 'httpclient-module'

class TagManager extends BaseModule {
  constructor () {
    /* ... */
+ bindUrls({
+ createTag: '/tags',
+ getTagPageableList: '/tags',
+ getTagFullList: '/tags/all',
+ getTag: '/tags/:id',
+ updateTag: '/tags/:id',
+ deleteTag: '/tags/:id'
+ })(this)
  }
  
- createTag (data) { /* ... */ }
- getTagPageableList (page = 0, size = 20) { /* ... */ }
- getTagFullList () { /* ... */ }
- getTag (id) { /* ... */ }
- updateTag (id, update) { /* ... */ }
- deleteTag (id) { /* ... */ }
  
  // ...
}
复制代码

为何bindUrls()要返回一个函数,经过返回的函数处理module这个参数,而不是将module做为bindUrls的第二个参数进行处理呢?

这样作的目的在于考虑兼容ES7装饰器@decorator的写法。在ES7的环境中,咱们还能够用装饰器来将URL绑定到模块方法中。

import { bindUrls } from 'httpclient-module'

@bindUrls({
  createTag: '/tags',
  getTagPageableList: '/tags',
  getTagFullList: '/tags/all',
  getTag: '/tags/:id',
  updateTag: '/tags/:id',
  deleteTag: '/tags/:id'
})
class TagManager extends BaseModule {
  /* ... */
}
复制代码

这样,咱们能够经过bindUrls(),方便的给模块添加一系列能够执行URL请求的实例方法。

提高bindUrls()的灵活度

bindUrls()灵活度还有提高的空间。如今的版本对urls这个参数只能支持字符串类型的value,咱们以为urlsvalue除了能够是字符串外,还能够是其余类型,好比plain object。同时,key的前缀只能是createupdategetdelete四个,感受有些死板,咱们想能够支持更多的前缀,或者说方法的名称不必定要局限于某种格式,能够自由的给方法命名。

咱们对如今的版本进行一些小改动,提高bindUrls()的灵活度。

// ...

// 支持更多的前缀
const methodPatternMapper = {
- get: { pattern: '^(get)\\w+$' },
+ get: { pattern: '^(get|load|query|fetch)\\w+$' },
- post: { pattern: '^(create)\\w+$', sendData: true },
+ post: { pattern: '^(create|new|post)\\w+$', sendData: true },
- put: { pattern: '^(update)\\w+$', sendData: true },
+ put: { pattern: '^(update|edit|modify|put)\\w+$', sendData: true },
- delete: { pattern: '^(delete)\\w+$' }
+ delete: { pattern: '^(delete|remove)\\w+$' }
}

/* ... */

+ function resolveMethodByRequestMethod(moduleInstance, requestMethod) {
+ if (/^(post|put)$/.test(requestMethod)) {
+ return bindModuleMethod(requestMethod, moduleInstance, true)
+ } else if (/^(delete|get)$/.test(requestMethod)) {
+ return bindModuleMethod(requestMethod, moduleInstance)
+ } else {
+ throw new Error(`未知的请求方法: ${requestMethod}`)
+ }
+ }

export function mapUrls (urls = {}) {
  return module => {
    const keys = Object.keys(urls)
    if (!keys.length) {
      console.warn('urls对象为空,没法完成URL的映射')
      return
    }
    
    const instance = module.prototype || module
    
    keys.forEach(name => {
      let url = urls[name]
+ let requestMethod = undefined
+ if (isObject(url)) {
+ requestMethod = url['method']
+ url = url['url']
+ }

      if (!url) {
        throw new Error(`${name}()的地址无效`)
      }
	  
+ let func = undefined
+ if (!requestMethod) {
+ func = resolveMethodByName(instance, name)
+ } else {
+ func = resolveMethodByRequestMethod(instance, requestMethod)
+ }
      
      Object.defineProperty(instance, name, {
        configurable: true,
        writable: true,
        enumerable: true,
        value: ((url, func, thisArg) => () => {
          let args = Array.prototype.slice.call(arguments)
          if (args.length > 0 && url.indexOf('/:') >= 0) {
          	if (isObject(args[0])) {
          	  const params = args[0]
          	  args = args.slice(1)
          	  url = mapParamsToUrlPattern(url, params)
            }
          }
          return func && func.apply(thisArg, [ url ].concat(args))
- })(url, resolveMethodByName(instance, name), instance)
+ })(url, func, instance)
      })
    })
  }
}
复制代码

通过调整的bindUrls()urls支持plain object类型的valueplain object类型的value能够有两个key,一个是url,就是接口的URL,另外一个是method,能够指定请求方法。若是设置了method,那么就不须要根据urlskey的前缀推导请求方法了,这样可使得配置urls更加灵活。

const urls = {
  loadUsers: '/users',
}
// or
const urls = {
  users: { url: '/users', method: 'get' }
}

bindUrls(urls)(module)

module.users({ page: 1, size: 20 }) // => GET /users?page=1&size=20
复制代码

如今,咱们只须要经过bindUrls(),简单的定义一个对象,就能够给一个模块添加请求接口的方法了。

总结

回顾一些咱们对Axios这个http库封装的几个阶段

  • 定义一个模块,好比UserManager,而后给模块添加一些调用URL接口的方法,规定好参数,而后在界面层能够经过模块的方法来调用URL接口与后台进行数据通讯,简化了调用http库API的流程。
  • 假如项目中,接口愈来愈多,那么会致使相应的模块也愈来愈多,好比VideoManagerEpisodeManagerCPManager等。随着模块模块逐渐增多,咱们发现重复的代码也在增多,须要提高代码的复用性,那么,能够给这些Manager模块定义一个基类BaseModule,而后将http库相关的代码转移到BaseModule中,从而子类中调用URL接口的方法。
  • 后来发现,即便有了BaseModule消除了重复的代码,但仍是存在重复的工做,好比手写那些CRUD方法,因而,咱们将BaseModule独立成一个单独的项目httpclient-module,从以前的继承关系转为组合关系,并设计了一个APIbindUrls()。经过这个API,咱们能够以key -> value这种配置项的方式,动态的给一个模块添加执行URL接口请求的方法,从而进一步的简化咱们的代码,提高咱们开发的效率。
  • 最后,还给bindUrls()作了灵活性的提高工做。

在整个http封装过程当中,咱们进行了一些思考,好比复用性,通用性,灵活性。其最终的目的是为了提高咱们开发过程的效率,减小重复工做。但回过头来看,对于http库的封装其实并不是必定要作到最后这一步的样子。咱们也是根据实际状况一步一步迭代过来的,因此,具体须要封装到哪一程度,并无确切的答案,得从实际的场景出发,综合考虑后,选择最合适的方式。

另外的,其实整个过程的思考(不是代码),不只仅适用于Axios库,也能够用于其余的http库,好比SuperAgent或者fetch,也不只仅适用于http库的封装,对于其余类型的模块的封装也一样适用,不过须要举一反三。

以上是咱们团队封装Axios的开发经历,但愿对你们有帮助和启发。文中有不当的地方,欢迎批评和讨论。

相关文章
相关标签/搜索