美文网首页简书语录vue专题html
##记一次封装Axios的经历

##记一次封装Axios的经历

作者: 葡萄喃喃呓语 | 来源:发表于2018-06-12 16:15 被阅读604次

    记一次封装Axios的经历 - 掘金
    https://juejin.im/post/5a52c9a4f265da3e2a0d6b74

    前言

    前端开发中,如果页面需要与后台接口交互,并且无刷新页面,那么需要借助一下Ajax的http库来完成与后台数据接口的对接工作。在jQuery很盛行的时候,我们会使用$.ajax(),现在,可选择的就更多,例如:SuperAgentAxiosFetch…等等。有了这些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 () {      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.jsimport 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的话,你应该可以很快想出对——定义一个父类,抽离公共部分。

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

    使用继承的方式进行重构

    <figure style="user-select: text !important; display: block; margin: 22px auto; text-align: center; color: rgb(51, 51, 51); font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;">[图片上传中...(image-71456b-1528791284991-7)]

    <figcaption style="user-select: text !important; display: block; text-align: center; font-size: 1rem; line-height: 1.6; color: rgb(144, 144, 144); margin-top: 2px;"></figcaption>

    </figure>

    定义一个父类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。这样有什么好处呢?

    <figure style="user-select: text !important; display: block; margin: 22px auto; text-align: center; color: rgb(51, 51, 51); font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;">[图片上传中...(image-db2000-1528791284989-6)]

    <figcaption style="user-select: text !important; display: block; text-align: center; font-size: 1rem; line-height: 1.6; color: rgb(144, 144, 144); margin-top: 2px;"></figcaption>

    </figure>

    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请求

    <figure style="user-select: text !important; display: block; margin: 22px auto; text-align: center; color: rgb(51, 51, 51); font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;">[图片上传中...(image-fb509-1528791284988-5)]

    <figcaption style="user-select: text !important; display: block; text-align: center; font-size: 1rem; line-height: 1.6; color: rgb(144, 144, 144); margin-top: 2px;"></figcaption>

    </figure>

    下面是PUT请求:

    <figure style="user-select: text !important; display: block; margin: 22px auto; text-align: center; color: rgb(51, 51, 51); font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;">[图片上传中...(image-bf3ff0-1528791284988-4)]

    <figcaption style="user-select: text !important; display: block; text-align: center; font-size: 1rem; line-height: 1.6; color: rgb(144, 144, 144); margin-top: 2px;"></figcaption>

    </figure>

    • 当传递两个参数时,如果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接口中的路径变量,第二个参数则会被作为查询参数使用。

    <figure style="user-select: text !important; display: block; margin: 22px auto; text-align: center; color: rgb(51, 51, 51); font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;">[图片上传中...(image-837de1-1528791284988-3)]

    <figcaption style="user-select: text !important; display: block; text-align: center; font-size: 1rem; line-height: 1.6; color: rgb(144, 144, 144); margin-top: 2px;"></figcaption>

    </figure>

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

    <figure style="user-select: text !important; display: block; margin: 22px auto; text-align: center; color: rgb(51, 51, 51); font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;">[图片上传中...(image-913b91-1528791284988-2)]

    <figcaption style="user-select: text !important; display: block; text-align: center; font-size: 1rem; line-height: 1.6; color: rgb(144, 144, 144); margin-top: 2px;"></figcaption>

    </figure>

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

    两个GET请求:

    <figure style="user-select: text !important; display: block; margin: 22px auto; text-align: center; color: rgb(51, 51, 51); font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;">[图片上传中...(image-ba9bf-1528791284988-1)]

    <figcaption style="user-select: text !important; display: block; text-align: center; font-size: 1rem; line-height: 1.6; color: rgb(144, 144, 144); margin-top: 2px;"></figcaption>

    </figure>

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

    <figure style="user-select: text !important; display: block; margin: 22px auto; text-align: center; color: rgb(51, 51, 51); font-family: -apple-system, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: 2; text-indent: 0px; text-transform: none; white-space: normal; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; background-color: rgb(255, 255, 255); text-decoration-style: initial; text-decoration-color: initial;">[图片上传中...(image-2bed7-1528791284988-0)]

    <figcaption style="user-select: text !important; display: block; text-align: center; font-size: 1rem; line-height: 1.6; color: rgb(144, 144, 144); margin-top: 2px;"></figcaption>

    </figure>

    将思路转换成实现的代码

    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的开发经历,希望对大家有帮助和启发。文中有不当的地方,欢迎批评和讨论。

    相关文章

      网友评论

        本文标题:##记一次封装Axios的经历

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