##記一次封裝Axios的經(jīng)歷

記一次封裝Axios的經(jīng)歷 - 掘金
https://juejin.im/post/5a52c9a4f265da3e2a0d6b74

前言

前端開發(fā)中,如果頁(yè)面需要與后臺(tái)接口交互别垮,并且無(wú)刷新頁(yè)面结榄,那么需要借助一下Ajax的http庫(kù)來(lái)完成與后臺(tái)數(shù)據(jù)接口的對(duì)接工作。在jQuery很盛行的時(shí)候但两,我們會(huì)使用$.ajax()舆声,現(xiàn)在,可選擇的就更多渔嚷,例如:SuperAgent进鸠、AxiosFetch…等等形病。有了這些http庫(kù)客年,我們不在需要關(guān)注太多與ajax底層相關(guān)的細(xì)節(jié)的問題。很多時(shí)候和場(chǎng)景下漠吻,只需要關(guān)注如何構(gòu)建一個(gè)request以及如何處理一個(gè)response即可搀罢,但即便這些http庫(kù)已經(jīng)在一定程度上簡(jiǎn)化了我們的開發(fā)工作,我們?nèi)匀恍枰槍?duì)項(xiàng)目的實(shí)際需要侥猩,團(tuán)隊(duì)內(nèi)部技術(shù)規(guī)范對(duì)這些http庫(kù)進(jìn)行封裝榔至,進(jìn)而優(yōu)化我們的開發(fā)效率。

本文將結(jié)合我們團(tuán)隊(duì)使用的一個(gè)http庫(kù)Axios和我們團(tuán)隊(duì)開發(fā)工程的一些場(chǎng)景欺劳,分享我們前端團(tuán)隊(duì)對(duì)http庫(kù)進(jìn)行封裝的經(jīng)歷唧取。

對(duì)http庫(kù)進(jìn)行基本的封裝

服務(wù)端URL接口的定義

以用戶管理模塊為例。對(duì)于用戶管理模塊划提,服務(wù)端通常會(huì)定義如下接口:

  • GET /users?page=0&size=20 - 獲取用戶信息的分頁(yè)列表
  • GET /users/all - 獲取所有的用戶信息列表
  • GET /users/:id - 獲取指定id的用戶信息
  • POST /users application/x-www-form-urlencoded - 創(chuàng)建用戶
  • PUT /users/:id application/x-www-form-urlencoded - 更新指定id的用戶信息
  • DELETE /users/:id 刪除指定id的用戶信息

通過(guò)以上定義枫弟,不難發(fā)現(xiàn)這些都是基于RESTful標(biāo)準(zhǔn)進(jìn)行定義的接口。

將接口進(jìn)行模塊化封裝

針對(duì)這樣一個(gè)用戶管理模塊鹏往,我們首先需要做的就是定義一個(gè)用戶管理模塊類淡诗。

// UserManager.js
import axios from 'axios'

class UserManager {
  constructor() {
    this.$http = axios.create({
      baseUrl: 'https://api.forcs.com'  // 當(dāng)然,這個(gè)地址是虛擬的
    })
    // 修改POST和PUT請(qǐng)求默認(rèn)的Content-Type伊履,根據(jù)自己項(xiàng)目后端的定義而定韩容,不一定需要
    this.dataMethodDefaults = {
      headers: {
        'Content-Type': 'application/x-www-form-urlencoded'
      },
      transformRequest: [function (data) {
        return qs.stringify(data)
      }]
    }
  }
}

export default new UserManager()  // 單例模塊

UserManager的構(gòu)造函數(shù)中,我們?cè)O(shè)置了一些請(qǐng)求的公共參數(shù)唐瀑,比如接口的baseUrl群凶,這樣后面在發(fā)起請(qǐng)求的時(shí)候,URL只需要使用相對(duì)路徑即可哄辣。與此同時(shí)请梢,我們還調(diào)整了POST請(qǐng)求和PUT請(qǐng)求默認(rèn)的Content-TypeAxios默認(rèn)是application/json力穗,我們根據(jù)后端接口的定義毅弧,將其調(diào)整成了表單類型application/x-www-form-urlencoded。最后当窗,借助ES6模塊化的特性够坐,我們將UserManager單例化。

實(shí)際的場(chǎng)景中,一套符合行業(yè)標(biāo)準(zhǔn)的后端接口規(guī)范要比這復(fù)雜得多咆霜。由于這些內(nèi)容不是本文討論的重點(diǎn)邓馒,所以簡(jiǎn)化了。

接著蛾坯,給UserManager添加調(diào)用接口的方法光酣。

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})無(wú)效`))
    }
    return this.$http.get(`/users/${id}`)
  }

  createUser (data = {}) {
    if (!data || !Object.keys(data).length) {
      return Promise.reject(new Error('createUser:提交的數(shù)據(jù)無(wú)效'))
    }
    return this.$http.post('/users', data, { ...this.dataMethodDefaults })
  }

  updateUser (id, update = {}) {
    if (!update || !Object.keys(update).length) {
      return Promise.reject(new Error('updateUser:提交的數(shù)據(jù)無(wú)效'))
    }
    return this.$http.put(`/users/${id}`, update, { ...this.dataMethodDefaults })
  }

  deleteUser (id) {
    if (!id) {
      return Promise.reject(new Error(`deleteUser:id(${id})無(wú)效`))
    }
    return this.$http.delete(`/users/${id}`)
  }
}

export default new UserManager()

新增的方法沒有什么特別的地方,一目了然脉课,就是通過(guò)Axios執(zhí)行http請(qǐng)求調(diào)用服務(wù)端的接口救军。值得注意的是,在getUser()倘零、createUser()唱遭、updateUser()deleteUser()這四個(gè)方法中呈驶,我們對(duì)參數(shù)進(jìn)行了簡(jiǎn)單的驗(yàn)證拷泽,當(dāng)然,實(shí)際的場(chǎng)景會(huì)比范例代碼的更加復(fù)雜些袖瞻,其實(shí)參數(shù)驗(yàn)證不是重點(diǎn)司致,關(guān)鍵在于驗(yàn)證的if語(yǔ)句塊中,return的是一個(gè)Promise對(duì)象聋迎,這是為了和Axios的API保持一致脂矫。

前端調(diào)用封裝的方法

經(jīng)過(guò)這樣封裝后,前端頁(yè)面與服務(wù)端交互就變得簡(jiǎn)單多了霉晕。下面以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 () {      // 加載頁(yè)面的時(shí)候庭再,獲取用戶列表      this._getUserList()    },    handleCreateUser () {      // 提交創(chuàng)建用戶的表單      userManager.createUser({ ...this.formData })      .then(response => {        // 刷新列表        this._getUserList()      }).catch(err => {        console.error(err.message)      })    }  }</script>

當(dāng)然,類似的js代碼在React版本的前端頁(yè)面上也是適用的牺堰。

// 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

為了節(jié)省篇幅拄轻,后面就不再展示前端頁(yè)面上調(diào)用封裝模塊的代碼了。

ok萌焰,接口用起來(lái)很方便哺眯,封裝到這一步感覺似乎沒啥毛病“歉可是,一個(gè)APP怎么可能就這么些接口呢一疯,它會(huì)涉及到若干個(gè)接口撼玄,而不同的接口可能歸類在不同的模塊。就拿我們的后臺(tái)項(xiàng)目來(lái)說(shuō)墩邀,內(nèi)容管理模塊就分為單片管理和劇集管理掌猛,劇集管理即包括劇集實(shí)體自身的管理,也包括對(duì)單片進(jìn)行打包的管理,所以荔茬,后臺(tái)對(duì)內(nèi)容管理模塊的接口定義如下:

單片管理

  • 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

篇幅關(guān)系废膘,就不列出所有的接口了∧轿担可以看到接口依然是按照RESTful標(biāo)準(zhǔn)來(lái)定義的丐黄。按照之前說(shuō)的做法,我們可以立即對(duì)這些接口進(jìn)行封裝孔飒。

定義一個(gè)單品管理的模塊類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})無(wú)效`))
    }
    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})無(wú)效`))
    }
    return this.$http.get(`/episodes/${id}`)
  }

  // ... 篇幅原因,后面的接口省略
}

export default new EpisodeManager()

發(fā)現(xiàn)問題了嗎坏瞄?存在重復(fù)的代碼桂对,會(huì)給后期的維護(hù)埋下隱患。編程原則中鸠匀,有一個(gè)很著名的原則:DRY蕉斜,翻譯過(guò)來(lái)就是要盡可能的避免重復(fù)的代碼。在靈活的前端開發(fā)中,要更加留意這條原則蚓让,重復(fù)的代碼越多沉删,維護(hù)的成本越大,靈活度和健壯性也隨之降低诽凌。想想要是大型的APP涉及到的模塊有數(shù)十個(gè)以上,每個(gè)模塊都擼一遍這樣的代碼坦敌,如果后期公共屬性有啥調(diào)整的話侣诵,這樣的改動(dòng)簡(jiǎn)直就是個(gè)災(zāi)難!

為了提升代碼的復(fù)用性狱窘,靈活度杜顺,減少重復(fù)的代碼,應(yīng)該怎么做呢蘸炸?如果了解OOP的話躬络,你應(yīng)該可以很快想出對(duì)——定義一個(gè)父類,抽離公共部分搭儒。

讓封裝的模塊更具備復(fù)用性

使用繼承的方式進(jìn)行重構(gòu)

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

定義一個(gè)父類BaseModule穷当,將代碼公共的部分都放到這個(gè)父類中。

// 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

然后讓UserManager淹禾、VideoManager馁菜、EpisodeManager都繼承自這個(gè)BaseModule,移除重復(fù)的代碼铃岔。

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})無(wú)效`))
    }
+    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})無(wú)效`))
    }
+    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})無(wú)效`))
    }
+    return this.get(`/episodes/${id}`)
-    return this.$http.get(`/episodes/${id}`)
  }

  // ... 篇幅原因汪疮,后面的接口省略
}

export default new EpisodeManager()

利用OOP的繼承特性,將公共代碼抽離到父類中,使得封裝模塊接口的代碼得到一定程度的簡(jiǎn)化智嚷,以后如果接口的公共部分的默認(rèn)屬性有何變動(dòng)卖丸,只需要維護(hù)BaseModule即可。如果你對(duì)BaseModule有留意的話盏道,應(yīng)該會(huì)注意到,BaseModule也不完全將公共部分隱藏在自身當(dāng)中粹湃。同時(shí)泉坐,BaseModule還對(duì)Axios對(duì)象的代理方法(axios.get()axios.post()孤钦、axios.put()axios.delete())進(jìn)行了包裝纯丸,從而將Axios內(nèi)聚在自身內(nèi)部,減少子類的依賴層級(jí)俊扭。對(duì)于子類,不再需要關(guān)心Axios對(duì)象仇矾,只需要關(guān)心父類提供的方法和部分屬性即可姐仅。這樣做掏膏,一方面提升了父類的復(fù)用性壤追,另一方面也使得子類可以更加好對(duì)父類進(jìn)行擴(kuò)展,同時(shí)又不影響到其他子類伶丐。

對(duì)于一般場(chǎng)景哗魂,封裝到這里录别,此役也算是可以告捷组题,終于可以去沖杯咖啡小歇一會(huì)咯崔列。不過(guò)盈咳,公司還沒跨鱼响,事情怎么可能完呢……

BaseModule的問題

過(guò)了一周后丈积,新項(xiàng)目啟動(dòng),這個(gè)項(xiàng)目對(duì)接的是另一個(gè)后端團(tuán)隊(duì)的接口牙寞。大體上還好间雀,接口命名風(fēng)格依然基本跟著RESTful的標(biāo)準(zhǔn)走,可是连锯,請(qǐng)求地址的域名換了运怖,請(qǐng)求頭的Content-Type也和之前團(tuán)隊(duì)定義的不一樣吻氧,這個(gè)后端團(tuán)隊(duì)用的是application/json盯孙。

當(dāng)然振惰,實(shí)際上不同的后端團(tuán)隊(duì)定義的接口,差異未必會(huì)這么小:(

面對(duì)這種場(chǎng)景透罢,我們的第一反應(yīng)可能是:好擼羽圃,把之前項(xiàng)目的BaseModule復(fù)制到現(xiàn)在的項(xiàng)目中朽寞,調(diào)整一下就好了脑融。

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默認(rèn)POST和PUT請(qǐng)求Header的Content-Typeapplication/json,所以只需要將之前設(shè)置Content-Type的代碼移除即可。接著匣沼,就可以喝著咖啡释涛,聽著歌殉农,愉快的封裝接口對(duì)接數(shù)據(jù)了!

認(rèn)真回想一下耀态,這樣做其實(shí)又了我們之前提到一個(gè)問題:重復(fù)的代碼。你可能認(rèn)為仙逻,反正不是一個(gè)項(xiàng)目的,代碼獨(dú)立維護(hù)缺亮,所以這樣也不打緊萌踱。我從客觀的角度認(rèn)為,對(duì)于一些小項(xiàng)目或者小團(tuán)隊(duì)园担,這樣做的確沒啥毛病,但如果蝙泼,我是說(shuō)如果汤踏,項(xiàng)目越來(lái)越多了搂擦,這樣每個(gè)項(xiàng)目復(fù)制一套代碼真的好嗎瀑踢?假如哪天后端團(tuán)隊(duì)做了統(tǒng)一規(guī)范,所有接口的請(qǐng)求頭都按照一套規(guī)范來(lái)設(shè)置棘劣,其實(shí)之前的代碼都得逐一調(diào)整?我的天糙俗,這得多大工作量⊥欤總之粉臊,重復(fù)的代碼就是個(gè)坑!

應(yīng)對(duì)這種情況屠凶,怎么破?

讓封裝的模塊更具備通用性

在面向?qū)ο缶幊痰脑瓌t中唉韭,有這么一條:開閉原則女器。即對(duì)擴(kuò)展開發(fā),對(duì)修改關(guān)閉俏拱。根據(jù)這條原則,我想到的一個(gè)方案,就是給封裝的BaseModule提供對(duì)外設(shè)置的選項(xiàng)远搪,就像jQuery的大多數(shù)插件那樣,工廠方法中都會(huì)提供一個(gè)options對(duì)象參數(shù)倘潜,方便外層調(diào)整插件的部分屬性。我們也可以對(duì)BaseModule進(jìn)行一些改造养泡,讓它更靈活,更易于擴(kuò)展肩榕。

對(duì)BaseModule進(jìn)行重構(gòu)

接下來(lái)需要對(duì)之前的BaseModule進(jìn)行重構(gòu)橘荠,讓它更具備通用性。

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
}

// 組合請(qǐng)求方法的headers
// headers = default <= common <= method <= extra
function resolveHeaders (method, defaults = {}, extras = {}) {
  method = method && method.toLowerCase()
  // check method參數(shù)的合法性
  if (!/^(get|post|put|delete|patch|options|head)$/.test(method)) {
    throw new Error(`method:${method}不是合法的請(qǐng)求方法`)
  }

  const headers = { ...defaults }
  const commonHeaders = headers.common || {}
  const headersForMethod = headers[method] || {}

  return _clearUpHeaders({
    ...headers,
    ...commonHeaders,
    ...headersForMethod,
    ...extras
  })
}

// 組合請(qǐng)求方法的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') {
          // 針對(duì)application/x-www-form-urlencoded對(duì)data進(jìn)行序列化
          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)))
    })
  }
}

// 導(dǎo)出工廠方法
export function createHttpClient (options, defaults) {
  return new HttpClientModule(options, defaults)
}

// 默認(rèn)導(dǎo)出模塊對(duì)象
export default HttpClientModule  // import

經(jīng)過(guò)重構(gòu)的BaseModule已經(jīng)面目全非优训,模塊的名稱也換成了更加通用的叫法:HttpClientModule抡医。HttpClientModule的構(gòu)造函數(shù)提供了一個(gè)options參數(shù),為了減少模塊的學(xué)習(xí)成本水孩,options基本沿用了AxiosRequest Config定義的結(jié)構(gòu)體。唯獨(dú)有一點(diǎn)不同宙刘,就是對(duì)optionsheaders屬性處理。

這里需要多說(shuō)一下玉罐,看似完美的Axios存在一個(gè)比較嚴(yán)重,但至今還沒修復(fù)的bug,就是通過(guò)defaults屬性設(shè)置headers是不起作用的扭屁,必須在執(zhí)行請(qǐng)求操作(調(diào)用request()然眼、get()高每、post()…等請(qǐng)求方法)時(shí),通過(guò)方法的config參數(shù)設(shè)置header才會(huì)生效。為了規(guī)避這個(gè)特性的bug洪囤,我在HttpClientModule這個(gè)模塊中喇完,按照Axios的API設(shè)計(jì)不脯,自己手動(dòng)實(shí)現(xiàn)了類似的features。既可以通過(guò)common屬性設(shè)置公共的header复局,也可以以請(qǐng)求方法名(get、post角钩、put…等)作為屬性名來(lái)給特定請(qǐng)求方法的請(qǐng)求設(shè)置默認(rèn)的header惨险。大概像下面這樣:

const options = {
  // ...
  headers: {
    // 設(shè)置公共的header
    common: {
      Authorization: AUTH_TOKEN
    },
    // 為post和put請(qǐng)求設(shè)置請(qǐng)求時(shí)的Content-Type
    post: {
      'Content-Type': 'application/x-www-form-urlencoded'
    },
    put: {
      'Content-Type': 'application/x-www-form-urlencoded'
    }
  }
}

const httpClient = new HttpClientModule(options)

獨(dú)立發(fā)布重構(gòu)的封裝模塊

我們可以為HttpClientModule單獨(dú)創(chuàng)建一個(gè)npm項(xiàng)目,給它取一個(gè)名詞,例如httpclient-module冀墨。取名前最好先上npmjs上查一下名稱是否已經(jīng)被其它模塊使用了弟翘,盡量保持名稱的唯一性稀余。然后通過(guò)webpack盒蟆、rollupparcel等構(gòu)建工具進(jìn)行打包,發(fā)布到npmjs上。當(dāng)然要出,如果代碼中涉及到私有的配置信息或颊,也可以自己搭建一個(gè)npm私服倉(cāng)庫(kù),然后布到私服上平挑。這樣,就可以通過(guò)npm install命令直接將模塊安裝到我們的項(xiàng)目中來(lái)使用了。安裝模塊可以通過(guò)如下命令:

npm install httpclient-module --save
# or
npm i httpclient-module -S

對(duì)業(yè)務(wù)接口層的模塊進(jìn)行調(diào)整

還記得前面針對(duì)業(yè)務(wù)層定義的UserManager赏枚、VideoManager以及EpisodeManager嗎,他們都繼承自BaseModule晓猛,但為了讓父類BaseModule更具通用性饿幅,我們以及將它進(jìn)行了重構(gòu),并且換了個(gè)名稱進(jìn)行了獨(dú)立發(fā)布戒职,那么這幾個(gè)業(yè)務(wù)層的manager模塊應(yīng)該如何使用這個(gè)經(jīng)過(guò)重構(gòu)的模塊HttpClientModule呢?

因?yàn)槟切﹎anager模塊都繼承自父類BaseModule洪燥,我們只需要對(duì)BaseModule進(jìn)行調(diào)整即可摄凡。

- 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

本質(zhì)上就是用自己封裝的httpclient-module替換了原來(lái)的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可以認(rèn)為是Axios與業(yè)務(wù)接口層之間的適配器。將Axios封裝到httpclient-module钦扭,降低了前端項(xiàng)目對(duì)第三方庫(kù)的依賴纫版。前面有提到Axios是存在一些比較明顯的bug的,經(jīng)過(guò)這層封裝客情,我們可以降低bug對(duì)項(xiàng)目的影響其弊,只需要維護(hù)httpclient-module,就可以規(guī)避掉第三方bug帶來(lái)的影響膀斋。如果以后發(fā)現(xiàn)有更好的http庫(kù)梭伐,需要替換掉Axios,只需要升級(jí)httpclient-module就可以了仰担。對(duì)于業(yè)務(wù)層糊识,不需要做太大的調(diào)整。

有了httpclient-module這層適配器,也給團(tuán)隊(duì)做技術(shù)統(tǒng)一化規(guī)范帶來(lái)方便赂苗。假如以后團(tuán)隊(duì)的接口規(guī)范做了調(diào)整愉耙,比如接口域名切換到https,請(qǐng)求頭認(rèn)證做統(tǒng)一調(diào)整拌滋,或者請(qǐng)求頭需要增減其他參數(shù)朴沿,也只需要更新httpclient-module就好。如果不是團(tuán)隊(duì)做統(tǒng)一調(diào)整败砂,而是個(gè)別項(xiàng)目赌渣,也只需要調(diào)整BaseModule,修改一下傳遞給httpclient-moduleoptions參數(shù)即可昌犹。

讓封裝的模塊提高我們開發(fā)效率

httpclient-module愉快的工作了一段時(shí)間后坚芜,我們又遇到了新的問題。

隨著項(xiàng)目迭代祭隔,前端加入的業(yè)務(wù)功能越來(lái)越多货岭,需要對(duì)接后臺(tái)的業(yè)務(wù)接口也逐漸增多。比如新增一個(gè)內(nèi)容供應(yīng)商管理模塊疾渴,我們就需要為此創(chuàng)建一個(gè)CPManager千贯,然后添加調(diào)用接口請(qǐng)求的方法,新增一個(gè)內(nèi)容標(biāo)簽管理模塊搞坝,就需要定義一個(gè)TagManager搔谴,然后添加調(diào)用接口請(qǐng)求的方法。像下面這樣的代碼桩撮。

新增的內(nèi)容供應(yīng)商管理模塊:

// CPManager.js
// ...

class CPManager extends BaseModule {
  constructor () { /* ... */ }

  createCp (data) { /* ... */ }
  getCpPageableList (page = 0, size = 20) { /* ... */ }
  getCpFullList () { /* ... */ }
  getCp (id) { /* ... */ }
  updateCp (id, update) { /* ... */ }
  deleteCp (id) { /* ... */ }

  // ...
}

內(nèi)容標(biāo)簽管理模塊:

// TagManager.js
// ...

class TagManager extends BaseModule {
  constructor () { /* ... */ }

  createTag (data) { /* ... */ }
  getTagPageableList (page = 0, size = 20) { /* ... */ }
  getTagFullList () { /* ... */ }
  getTag (id) { /* ... */ }
  updateTag (id, update) { /* ... */ }
  deleteTag (id) { /* ... */ }

  // ...
}

新增的模塊遠(yuǎn)不止這些敦第,我們發(fā)現(xiàn),代碼中存在很多重復(fù)的地方店量,比如createXXX()芜果、getXXX()updateXXX()融师、deleteXXX()右钾,分別對(duì)應(yīng)的都是模塊下的CRUD接口,而且如果業(yè)務(wù)接口沒有太特殊的場(chǎng)景時(shí)旱爆,定義一個(gè)接口舀射,僅僅就是為了封裝一個(gè)調(diào)用。

// ...

class TagManager extends BaseModule {

  // ...

  createTag (data) {
    // 定義createTag()方法怀伦,就是為了簡(jiǎn)化/tags的POST請(qǐng)求
    return this.$http.post('/tags', data)
  }

  // ...
}

我們覺得這些重復(fù)的工作是可以簡(jiǎn)化掉的脆烟。根據(jù)方法語(yǔ)義化命名的習(xí)慣,創(chuàng)建資源的方法我們會(huì)以create作為前綴房待,對(duì)應(yīng)執(zhí)行POST請(qǐng)求邢羔。更新資源使用update作為方法名的前綴驼抹,對(duì)應(yīng)執(zhí)行PUT請(qǐng)求。獲取資源或者資源列表张抄,方法名以get開頭砂蔽,對(duì)應(yīng)GET請(qǐng)求。刪除資源署惯,則用delete開頭左驾,對(duì)應(yīng)DELETE請(qǐng)求。如下表所示:

方法名前綴 功能 請(qǐng)求方法 接口
create 創(chuàng)建資源 POST /resources
get 獲取資源 GET /resources/:id极谊、/resources诡右、/resources/all
update 更新資源 PUT /resources/:id
delete 刪除資源 DELETE /resources/:id

按照這個(gè)約定,我們團(tuán)隊(duì)想轻猖,既然方法的前綴逼争、請(qǐng)求方法和URL接口三者可以存在一一對(duì)應(yīng)的關(guān)系尤揣,那么能不能通過(guò)Key -> Value的方式自動(dòng)化的生成與URL請(qǐng)求綁定好了的方法呢?

例如TagManager,我們希望通過(guò)類似下面的代碼進(jìn)行創(chuàng)建观话。

// 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層可以直接調(diào)用創(chuàng)建好的模塊方法胆数。

// 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>

這樣在前端定義一個(gè)業(yè)務(wù)接口的模塊是不是方便多了:)而且怒允,有沒有注意到砰嘁,我們對(duì)接口的傳參也做了調(diào)整。無(wú)論是URL的路徑變量還是查詢參數(shù)市殷,我們都可以通過(guò)對(duì)象化的方式進(jìn)行傳遞愕撰。這種統(tǒng)一參數(shù)類型的調(diào)整,簡(jiǎn)化了接口的學(xué)習(xí)成本醋寝,自動(dòng)生成的方法都是通過(guò)對(duì)象化的方式將參數(shù)綁定到接口當(dāng)中搞挣。

在RESTful標(biāo)準(zhǔn)的接口中,接口的URL可能會(huì)存在兩種參數(shù)音羞,路徑變量(Path Variables)和查詢參數(shù)(Query Argument)囱桨。

  • 路徑變量:就是URL中映射到指定資源所涉及的變量,比如/resources/:id嗅绰,這里的:id蝇摸,指的就是資源id,操作不同的資源時(shí)办陷,URL中:id這段路徑也會(huì)不同。/resources/1律歼,/resources/2…等
  • 查詢參數(shù):指的是URL中的query參數(shù)民镜,通常就是GET請(qǐng)求或者DELETE請(qǐng)求的URL中問號(hào)后面那段,比如/resources?page=0&size=20险毁,page和size就是查詢參數(shù)

先來(lái)一波實(shí)現(xiàn)的思路

首先對(duì)自動(dòng)生成的與URL綁定的模塊方法進(jìn)行設(shè)計(jì)制圈。

// 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表示路徑參數(shù)對(duì)象,querys表示GET或者DELETE請(qǐng)求的查詢參數(shù)對(duì)象鲸鹦,data表示POST或者PUT請(qǐng)求提交的數(shù)據(jù)對(duì)象慧库,大概要傳達(dá)的意思是:

  • 自動(dòng)生成的方法,會(huì)接受3個(gè)類型為Plain Object的參數(shù)馋嗜,參數(shù)都是可選的齐板,返回一個(gè)Promise對(duì)象。
  • 當(dāng)給方法傳遞三個(gè)參數(shù)對(duì)象的時(shí)候葛菇,參數(shù)依次是路徑變量對(duì)象甘磨,查詢參數(shù)對(duì)象或者數(shù)據(jù)對(duì)象,兼容AxiosAPI的config對(duì)象眯停。

下面用一個(gè)GET請(qǐng)求和一個(gè)PUT請(qǐng)求進(jìn)行圖解示意济舆,先看看GET請(qǐng)求

<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請(qǐng)求:

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

  • 當(dāng)傳遞兩個(gè)參數(shù)時(shí),如果URL接口不帶路徑變量莺债,那么第一個(gè)參數(shù)是查詢參數(shù)對(duì)象(GET方法或者DELETE方法)或者數(shù)據(jù)對(duì)象(POST方法或者PUT方法)滋觉,第二個(gè)是config對(duì)象。如果URL接口帶有路徑變量齐邦,那么第一個(gè)參數(shù)就表示路徑變量對(duì)象椎侠,第二個(gè)參數(shù)是查詢參數(shù)對(duì)象或者數(shù)據(jù)對(duì)象。

比如下面兩個(gè)GET方法的URL接口侄旬,左邊這個(gè)不帶路徑變量肺蔚,右邊的帶有路徑變量:id。左邊的儡羔,假設(shè)與URL接口綁定的方法名是getTagPageableList宣羊,當(dāng)我們調(diào)用方式只穿兩個(gè)參數(shù),那么第一個(gè)參數(shù)會(huì)轉(zhuǎn)換成查詢參數(shù)的格式key1=value1&key2=value2&...&keyn=valuen汰蜘,第二個(gè)參數(shù)則相當(dāng)于Axiosconfig對(duì)象仇冯。右邊的,因?yàn)閁RL接口中帶有路徑變量:id族操,那么調(diào)用綁定URL接口的方法getTagById并傳了兩個(gè)參數(shù)時(shí)苛坚,第一個(gè)參數(shù)對(duì)象被根據(jù)key替換掉URL接口中的路徑變量,第二個(gè)參數(shù)則會(huì)被作為查詢參數(shù)使用色难。

<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方法的請(qǐng)求也是類似泼舱,只是將查詢參數(shù)替換成了提交的數(shù)據(jù)。

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

  • 當(dāng)只傳遞一個(gè)參數(shù)時(shí)枷莉,如果接口URL不帶路徑變量娇昙,那么這個(gè)參數(shù)就是查詢參數(shù)對(duì)象或者數(shù)據(jù)對(duì)象,如果接口URL帶有路徑變量笤妙,那么這個(gè)參數(shù)對(duì)象就會(huì)映射到路徑變量中冒掌。

兩個(gè)GET請(qǐng)求:

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

一個(gè)POST請(qǐng)求和一個(gè)PUT請(qǐng)求:

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

將思路轉(zhuǎn)換成實(shí)現(xiàn)的代碼

httpclient-module中實(shí)現(xiàn)功能噪裕。

// ...

/* 請(qǐng)求方法與模塊方法名的映射關(guān)系對(duì)象
 * key -> 請(qǐng)求方法
 * value -> pattern:方法名的正則表達(dá)式,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+$' }
}

// 輔助方法膳音,判斷是否是函數(shù)
const isFunc = function (o) {
  return typeof o === 'function'
}

// 輔助方法,判斷是否是plain object
// 這個(gè)方法相對(duì)簡(jiǎn)單铃诬,如果想看更加嚴(yán)謹(jǐn)?shù)膶?shí)現(xiàn)祭陷,可以參考lodash的源碼
const isObject = function (o) {
  return Object.prototype.toString.call(o) === '[object Object]'
}

/* 
 * 將http請(qǐng)求綁定到模塊方法中
 *
 * @param method 請(qǐng)求方法
 * @param moduleInstance 模塊實(shí)例對(duì)象或者模塊類的原型對(duì)象
 * @param shouldSendData 表示是否是POST,或者PUT這類請(qǐng)求方法
 *
 * @return Axios請(qǐng)求api返回的Promise對(duì)象
 */
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))
    })
  }
}

/*
 * 根據(jù)定義的模塊方法名稱氧急,通過(guò)methodPatternMapper轉(zhuǎn)換成綁定URL的模塊方法
 *
 * @param moduleInstance 模塊實(shí)例對(duì)象或者模塊類的原型對(duì)象
 * @param name 模塊方法名稱
 *
 * @return Function 綁定的模塊方法
 * @throw 方法名稱和請(qǐng)求方法必須一一匹配
 *        如果發(fā)現(xiàn)匹配到的方法不止1個(gè)或者沒有颗胡,則會(huì)拋出異常
 */
function resolveMethodByName(moduleInstance, name) {
  let requestMethod = Object.keys(metherPatternMapper).filter(key => {
    const { pattern } = methodPatternMapper[key]
    if (!(pattern instanceof RegExp)) {
      // methodPatternMapper每個(gè)屬性的value的pattern
      // 既可以是正則表達(dá)式字符串,也可是是正則類型的對(duì)象
      pattern = new RegExp(pattern)
    }
    return pattern.test(name)
  })

  if (requestMethod.length !== 1) {
    throw `
      解析${name}異常吩坝,解析得到的方法有且只能有1個(gè)毒姨,
      但實(shí)際解析到的方法個(gè)數(shù)是:${requestMethod.length}
    `
  }

  requestMethod = requestMethod[0]
  return bindModuleMethod(requestMethod, moduleInstance,
                          methodPatternMapper[requestMethod].sendData)
}

/*
 * 將參數(shù)映射到路徑變量
 * 
 * @param url
 * @param params 被映射到路徑變量的參數(shù)
 * 
 * @return 將路徑變量替換好的URL
 */
function mapParamsToPathVariables(url, params) {
  if (!url || typeof url !== 'string') {
    throw new Error(`url ${url} 應(yīng)該是URL字符串`)
  }
  return url.replace(/:(\w+)/ig, (_, key) => params[key])
}

export function bindUrls (urls = {}) {
  // 為什么返回一個(gè)函數(shù)對(duì)象?后面會(huì)給大家解釋
  return module => {
    const keys = Object.keys(urls)
    if (!keys.length) {
      console.warn('urls對(duì)象為空钉寝,無(wú)法完成URL的映射')
      return
    }

    const instance = module.prototype || module

    keys.forEach(name => {
      const url = urls[name]

      if (!url) {
        throw new Error(`${name}()的地址無(wú)效`)
      }
      // 根據(jù)urls對(duì)象動(dòng)態(tài)定義模塊方法
      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)
      })
    })
  }
}

為了閱讀方便弧呐,我把關(guān)鍵的幾個(gè)地方都放到了一起,但在實(shí)際項(xiàng)目當(dāng)中嵌纲,建議適當(dāng)?shù)牟鸱忠幌麓a俘枫,以便維護(hù)和測(cè)試。

我們實(shí)現(xiàn)了一個(gè)將URL請(qǐng)求與模塊實(shí)例方法進(jìn)行綁定的函數(shù)bindUrls()逮走,并通過(guò)httpclient-module導(dǎo)出鸠蚪。bundUrls()的實(shí)現(xiàn)并不復(fù)雜。urls是一個(gè)以方法名作為key师溅,URL作為value的對(duì)象茅信。對(duì)urls對(duì)象進(jìn)行遍歷,遍歷過(guò)程中墓臭,先用對(duì)象的key進(jìn)行正則匹配蘸鲸,從而得到是相應(yīng)的請(qǐng)求方法(見methodPatternMapper),并將請(qǐng)求綁定到一個(gè)函數(shù)中(見resolveMethodByName()bindModuleMethod())窿锉。然后通過(guò)Object.defineProperty()方法給模塊的實(shí)例(或者原型)對(duì)象添加方法酌摇,方法的名稱就是urlskey。被動(dòng)態(tài)添加到模塊實(shí)例對(duì)象的方法在被調(diào)用時(shí)嗡载,先判斷與方法綁定的URL是否有路徑變量窑多,如果有,則通過(guò)mapParamsToPathVariables()進(jìn)行轉(zhuǎn)換洼滚,然后在執(zhí)行之前通過(guò)resolveMethodByName()得到的已經(jīng)和請(qǐng)求綁定好的函數(shù)埂息。

我們用bindUrls()對(duì)之前的TagManager進(jìn)行改造。

// 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()要返回一個(gè)函數(shù),通過(guò)返回的函數(shù)處理module這個(gè)參數(shù)耿芹,而不是將module作為bindUrls的第二個(gè)參數(shù)進(jìn)行處理呢?

這樣做的目的在于考慮兼容ES7裝飾器@decorator的寫法挪哄。在ES7的環(huán)境中吧秕,我們還可以用裝飾器來(lái)將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 {
  /* ... */
}

這樣迹炼,我們可以通過(guò)bindUrls()砸彬,方便的給模塊添加一系列可以執(zhí)行URL請(qǐng)求的實(shí)例方法。

提升bindUrls()的靈活度

bindUrls()靈活度還有提升的空間∷谷耄現(xiàn)在的版本對(duì)urls這個(gè)參數(shù)只能支持字符串類型的value砂碉,我們覺得urlsvalue除了可以是字符串外,還可以是其他類型刻两,比如plain object增蹭。同時(shí),key的前綴只能是create磅摹、update滋迈、getdelete四個(gè)户誓,感覺有些死板饼灿,我們想可以支持更多的前綴,或者說(shuō)方法的名稱不一定要局限于某種格式帝美,可以自由的給方法命名碍彭。

我們對(duì)現(xiàn)在的版本進(jìn)行一些小改動(dòng),提升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(`未知的請(qǐng)求方法: ${requestMethod}`)
+   }
+ }

export function mapUrls (urls = {}) {
  return module => {
    const keys = Object.keys(urls)
    if (!keys.length) {
      console.warn('urls對(duì)象為空庇忌,無(wú)法完成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}()的地址無(wú)效`)
      }

+      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)
      })
    })
  }
}

經(jīng)過(guò)調(diào)整的bindUrls()對(duì)urls支持plain object類型的valueplain object類型的value可以有兩個(gè)key女责,一個(gè)是url漆枚,就是接口的URL,另一個(gè)是method抵知,可以指定請(qǐng)求方法墙基。如果設(shè)置了method,那么就不需要根據(jù)urlskey的前綴推導(dǎo)請(qǐng)求方法了刷喜,這樣可以使得配置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

現(xiàn)在,我們只需要通過(guò)bindUrls()掖疮,簡(jiǎn)單的定義一個(gè)對(duì)象初茶,就可以給一個(gè)模塊添加請(qǐng)求接口的方法了。

總結(jié)

回顧一些我們對(duì)Axios這個(gè)http庫(kù)封裝的幾個(gè)階段

  • 定義一個(gè)模塊浊闪,比如UserManager恼布,然后給模塊添加一些調(diào)用URL接口的方法螺戳,規(guī)定好參數(shù),然后在界面層可以通過(guò)模塊的方法來(lái)調(diào)用URL接口與后臺(tái)進(jìn)行數(shù)據(jù)通信折汞,簡(jiǎn)化了調(diào)用http庫(kù)API的流程倔幼。
  • 假如項(xiàng)目中,接口越來(lái)越多爽待,那么會(huì)導(dǎo)致相應(yīng)的模塊也越來(lái)越多损同,比如VideoManagerEpisodeManager鸟款、CPManager等膏燃。隨著模塊模塊逐漸增多,我們發(fā)現(xiàn)重復(fù)的代碼也在增多何什,需要提升代碼的復(fù)用性组哩,那么,可以給這些Manager模塊定義一個(gè)基類BaseModule富俄,然后將http庫(kù)相關(guān)的代碼轉(zhuǎn)移到BaseModule中禁炒,從而子類中調(diào)用URL接口的方法。
  • 后來(lái)發(fā)現(xiàn)霍比,即使有了BaseModule消除了重復(fù)的代碼幕袱,但還是存在重復(fù)的工作,比如手寫那些CRUD方法悠瞬,于是们豌,我們將BaseModule獨(dú)立成一個(gè)單獨(dú)的項(xiàng)目httpclient-module,從之前的繼承關(guān)系轉(zhuǎn)為組合關(guān)系浅妆,并設(shè)計(jì)了一個(gè)APIbindUrls()望迎。通過(guò)這個(gè)API,我們可以以key -> value這種配置項(xiàng)的方式凌外,動(dòng)態(tài)的給一個(gè)模塊添加執(zhí)行URL接口請(qǐng)求的方法辩尊,從而進(jìn)一步的簡(jiǎn)化我們的代碼,提升我們開發(fā)的效率康辑。
  • 最后摄欲,還給bindUrls()做了靈活性的提升工作。

在整個(gè)http封裝過(guò)程中疮薇,我們進(jìn)行了一些思考胸墙,比如復(fù)用性,通用性按咒,靈活性迟隅。其最終的目的是為了提升我們開發(fā)過(guò)程的效率,減少重復(fù)工作。但回過(guò)頭來(lái)看智袭,對(duì)于http庫(kù)的封裝其實(shí)并非一定要做到最后這一步的樣子奔缠。我們也是根據(jù)實(shí)際情況一步一步迭代過(guò)來(lái)的,所以吼野,具體需要封裝到哪一程度添坊,并沒有確切的答案,得從實(shí)際的場(chǎng)景出發(fā)箫锤,綜合考慮后,選擇最合適的方式雨女。

另外的谚攒,其實(shí)整個(gè)過(guò)程的思考(不是代碼),不僅僅適用于Axios庫(kù)氛堕,也可以用于其他的http庫(kù)馏臭,比如SuperAgent或者fetch,也不僅僅適用于http庫(kù)的封裝讼稚,對(duì)于其他類型的模塊的封裝也同樣適用括儒,不過(guò)需要觸類旁通。

以上是我們團(tuán)隊(duì)封裝Axios的開發(fā)經(jīng)歷锐想,希望對(duì)大家有幫助和啟發(fā)帮寻。文中有不當(dāng)?shù)牡胤剑瑲g迎批評(píng)和討論赠摇。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末固逗,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子藕帜,更是在濱河造成了極大的恐慌烫罩,老刑警劉巖,帶你破解...
    沈念sama閱讀 212,454評(píng)論 6 493
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件洽故,死亡現(xiàn)場(chǎng)離奇詭異贝攒,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī)时甚,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,553評(píng)論 3 385
  • 文/潘曉璐 我一進(jìn)店門隘弊,熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人撞秋,你說(shuō)我怎么就攤上這事长捧。” “怎么了吻贿?”我有些...
    開封第一講書人閱讀 157,921評(píng)論 0 348
  • 文/不壞的土叔 我叫張陵串结,是天一觀的道長(zhǎng)。 經(jīng)常有香客問我,道長(zhǎng)肌割,這世上最難降的妖魔是什么卧蜓? 我笑而不...
    開封第一講書人閱讀 56,648評(píng)論 1 284
  • 正文 為了忘掉前任,我火速辦了婚禮把敞,結(jié)果婚禮上弥奸,老公的妹妹穿的比我還像新娘。我一直安慰自己奋早,他們只是感情好盛霎,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,770評(píng)論 6 386
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著耽装,像睡著了一般愤炸。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上掉奄,一...
    開封第一講書人閱讀 49,950評(píng)論 1 291
  • 那天规个,我揣著相機(jī)與錄音,去河邊找鬼姓建。 笑死诞仓,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的速兔。 我是一名探鬼主播墅拭,決...
    沈念sama閱讀 39,090評(píng)論 3 410
  • 文/蒼蘭香墨 我猛地睜開眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼涣狗!你這毒婦竟也來(lái)了帜矾?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 37,817評(píng)論 0 268
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤屑柔,失蹤者是張志新(化名)和其女友劉穎屡萤,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體掸宛,經(jīng)...
    沈念sama閱讀 44,275評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡死陆,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,592評(píng)論 2 327
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了唧瘾。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片措译。...
    茶點(diǎn)故事閱讀 38,724評(píng)論 1 341
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖饰序,靈堂內(nèi)的尸體忽然破棺而出领虹,到底是詐尸還是另有隱情,我是刑警寧澤求豫,帶...
    沈念sama閱讀 34,409評(píng)論 4 333
  • 正文 年R本政府宣布塌衰,位于F島的核電站诉稍,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏最疆。R本人自食惡果不足惜杯巨,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 40,052評(píng)論 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望努酸。 院中可真熱鬧服爷,春花似錦、人聲如沸获诈。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,815評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)舔涎。三九已至镜会,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間终抽,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,043評(píng)論 1 266
  • 我被黑心中介騙來(lái)泰國(guó)打工桶至, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留昼伴,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 46,503評(píng)論 2 361
  • 正文 我出身青樓镣屹,卻偏偏與公主長(zhǎng)得像圃郊,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子亚侠,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,627評(píng)論 2 350