[聚合文章] 记一次封装Axios的经历

Ajax 2018-01-08 18 阅读

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

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

对http库进行基本的封装

服务端URL接口的定义

以用户管理模块为例。对于用户管理模块,服务端通常会定义如下接口:

  • 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标准进行定义的接口。

将接口进行模块化封装

针对这样一个用户管理模块,我们首先需要做的就是定义一个用户管理模块类。

// 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 单例化。

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

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

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保持一致。

前端调用封装的方法

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

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

<script>
  import userManager from '../services/UserManager'
  export default {
    data () {
      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上查一下名称是否已经被其它模块使用了,尽量保持名称的唯一性。然后通过webpack、 rollup 、parcel等构建工具进行打包,发布到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 对象。
  • 当给方法传递三个参数对象的时候,参数依次是路径变量对象,查询参数对象或者数据对象,兼容 Axios API的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)(this)

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 ,从之前的继承关系转为组合关系,并设计了一个API bindUrls() 。通过这个API,我们可以以 key -> value 这种配置项的方式,动态的给一个模块添加执行URL接口请求的方法,从而进一步的简化我们的代码,提升我们开发的效率。
  • 最后,还给 bindUrls() 做了灵活性的提升工作。

在整个http封装过程中,我们进行了一些思考,比如复用性,通用性,灵活性。其最终的目的是为了提升我们开发过程的效率,减少重复工作。但回过头来看,对于http库的封装其实并非一定要做到最后这一步的样子。我们也是根据实际情况一步一步迭代过来的,所以,具体需要封装到哪一程度,并没有确切的答案,得从实际的场景出发,综合考虑后,选择最合适的方式。

另外的,其实整个过程的思考(不是代码),不仅仅适用于 Axios 库,也可以用于其他的http库,比如 SuperAgent 或者 fetch ,也不仅仅适用于http库的封装,对于其他类型的模块的封装也同样适用,不过需要触类旁通。

以上是我们团队封装Axios的开发经历,希望对大家有帮助和启发。文中有不当的地方,欢迎批评和讨论。

注:本文内容来自互联网,旨在为开发者提供分享、交流的平台。如有涉及文章版权等事宜,请你联系站长进行处理。