記一次封裝Axios的經歷

張國鈺發表於2018-01-08

前言

前端開發中,如果頁面需要與後臺介面互動,並且無重新整理頁面,那麼需要藉助一下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.js
import React from 'react'
import userManager from '../servers/UserManager'

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

為了節省篇幅,後面就不再展示前端頁面上呼叫封裝模組的程式碼了。

ok,介面用起來很方便,封裝到這一步感覺似乎沒啥毛病。可是,一個APP怎麼可能就這麼些介面呢,它會涉及到若干個介面,而不同的介面可能歸類在不同的模組。就拿我們的後臺專案來說,內容管理模組就分為單片管理和劇集管理,劇集管理即包括劇集實體自身的管理,也包括對單片進行打包的管理,所以,後臺對內容管理模組的介面定義如下:

單片管理

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

劇集管理:

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

篇幅關係,就不列出所有的介面了。可以看到介面依然是按照RESTful標準來定義的。按照之前說的做法,我們可以立即對這些介面進行封裝。

定義一個單品管理的模組類VideoManager

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

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

export default new VideoManager()

複製程式碼

以及劇集管理的模組類EpisodeManager.js

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

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

export default new EpisodeManager()

複製程式碼

發現問題了嗎?存在重複的程式碼,會給後期的維護埋下隱患。程式設計原則中,有一個很著名的原則:DRY,翻譯過來就是要儘可能的避免重複的程式碼。在靈活的前端開發中,要更加留意這條原則,重複的程式碼越多,維護的成本越大,靈活度和健壯性也隨之降低。想想要是大型的APP涉及到的模組有數十個以上,每個模組都擼一遍這樣的程式碼,如果後期公共屬性有啥調整的話,這樣的改動簡直就是個災難!

為了提升程式碼的複用性,靈活度,減少重複的程式碼,應該怎麼做呢?如果瞭解OOP的話,你應該可以很快想出對——定義一個父類,抽離公共部分。

讓封裝的模組更具備複用性

使用繼承的方式進行重構

記一次封裝Axios的經歷

定義一個父類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。這樣有什麼好處呢?

記一次封裝Axios的經歷

httpclient-module可以認為是Axios與業務介面層之間的介面卡。將Axios封裝到httpclient-module,降低了前端專案對第三方庫的依賴。前面有提到Axios是存在一些比較明顯的bug的,經過這層封裝,我們可以降低bug對專案的影響,只需要維護httpclient-module,就可以規避掉第三方bug帶來的影響。如果以後發現有更好的http庫,需要替換掉Axios,只需要升級httpclient-module就可以了。對於業務層,不需要做太大的調整。

有了httpclient-module這層介面卡,也給團隊做技術統一化規範帶來方便。假如以後團隊的介面規範做了調整,比如介面域名切換到https,請求頭認證做統一調整,或者請求頭需要增減其他引數,也只需要更新httpclient-module就好。如果不是團隊做統一調整,而是個別專案,也只需要調整BaseModule,修改一下傳遞給httpclient-moduleoptions引數即可。

讓封裝的模組提高我們開發效率

httpclient-module愉快的工作了一段時間後,我們又遇到了新的問題。

隨著專案迭代,前端加入的業務功能越來越多,需要對接後臺的業務介面也逐漸增多。比如新增一個內容供應商管理模組,我們就需要為此建立一個CPManager,然後新增呼叫介面請求的方法,新增一個內容標籤管理模組,就需要定義一個TagManager,然後新增呼叫介面請求的方法。像下面這樣的程式碼。

新增的內容供應商管理模組:

// CPManager.js
// ...

class CPManager extends BaseModule {
  constructor () { /* ... */ }
  
  createCp (data) { /* ... */ }
  getCpPageableList (page = 0, size = 20) { /* ... */ }
  getCpFullList () { /* ... */ }
  getCp (id) { /* ... */ }
  updateCp (id, update) { /* ... */ }
  deleteCp (id) { /* ... */ }
  
  // ...
}
複製程式碼

內容標籤管理模組:

// TagManager.js
// ...

class TagManager extends BaseModule {
  constructor () { /* ... */ }
  
  createTag (data) { /* ... */ }
  getTagPageableList (page = 0, size = 20) { /* ... */ }
  getTagFullList () { /* ... */ }
  getTag (id) { /* ... */ }
  updateTag (id, update) { /* ... */ }
  deleteTag (id) { /* ... */ }
  
  // ...
}
複製程式碼

新增的模組遠不止這些,我們發現,程式碼中存在很多重複的地方,比如createXXX()getXXX()updateXXX()deleteXXX(),分別對應的都是模組下的CRUD介面,而且如果業務介面沒有太特殊的場景時,定義一個介面,僅僅就是為了封裝一個呼叫。

// ...

class TagManager extends BaseModule {
  
  // ...
  
  createTag (data) {
    // 定義createTag()方法,就是為了簡化/tags的POST請求
    return this.$http.post('/tags', data)
  }
  
  // ...
}
複製程式碼

我們覺得這些重複的工作是可以簡化掉的。根據方法語義化命名的習慣,建立資源的方法我們會以create作為字首,對應執行POST請求。更新資源使用update作為方法名的字首,對應執行PUT請求。獲取資源或者資源列表,方法名以get開頭,對應GET請求。刪除資源,則用delete開頭,對應DELETE請求。如下表所示:

方法名字首 功能 請求方法 介面
create 建立資源 POST /resources
get 獲取資源 GET /resources/:id、/resources、/resources/all
update 更新資源 PUT /resources/:id
delete 刪除資源 DELETE /resources/:id

按照這個約定,我們團隊想,既然方法的字首、請求方法和URL介面三者可以存在一一對應的關係,那麼能不能通過Key -> Value的方式自動化的生成與URL請求繫結好了的方法呢?

例如TagManager,我們希望通過類似下面的程式碼進行建立。

// TagManager.js

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

export default moduleCreator(urls)
複製程式碼

然後在UI層可以直接呼叫建立好的模組方法。

// TagManager.vue

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

這樣在前端定義一個業務介面的模組是不是方便多了:)而且,有沒有注意到,我們對介面的傳參也做了調整。無論是URL的路徑變數還是查詢引數,我們都可以通過物件化的方式進行傳遞。這種統一引數型別的調整,簡化了介面的學習成本,自動生成的方法都是通過物件化的方式將引數繫結到介面當中。

在RESTful標準的介面中,介面的URL可能會存在兩種引數,路徑變數(Path Variables)和查詢引數(Query Argument)。

  • 路徑變數:就是URL中對映到指定資源所涉及的變數,比如/resources/:id,這裡的:id,指的就是資源id,操作不同的資源時,URL中:id這段路徑也會不同。/resources/1,/resources/2…等
  • 查詢引數:指的是URL中的query引數,通常就是GET請求或者DELETE請求的URL中問號後面那段,比如/resources?page=0&size=20,page和size就是查詢引數

先來一波實現的思路

首先對自動生成的與URL繫結的模組方法進行設計。

// GET, DELETE
methodName ([params|querys:PlainObject, [querys|config:PlainObject, [config:PlainObject]]]) => :Promise
// POST, PUT
methodName ([params|data:PlainObject, [data|config:PlainObject, [config:PlainObject]]]) => :Promise
複製程式碼

這是一段虛擬碼。params表示路徑引數物件,querys表示GET或者DELETE請求的查詢引數物件,data表示POST或者PUT請求提交的資料物件,大概要傳達的意思是:

  • 自動生成的方法,會接受3個型別為Plain Object的引數,引數都是可選的,返回一個Promise物件。
  • 當給方法傳遞三個引數物件的時候,引數依次是路徑變數物件,查詢引數物件或者資料物件,相容AxiosAPI的config物件。

下面用一個GET請求和一個PUT請求進行圖解示意,先看看GET請求

記一次封裝Axios的經歷

下面是PUT請求:

記一次封裝Axios的經歷

  • 當傳遞兩個引數時,如果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介面中的路徑變數,第二個引數則會被作為查詢引數使用。

記一次封裝Axios的經歷

POST方法和PUT方法的請求也是類似,只是將查詢引數替換成了提交的資料。

記一次封裝Axios的經歷

  • 當只傳遞一個引數時,如果介面URL不帶路徑變數,那麼這個引數就是查詢引數物件或者資料物件,如果介面URL帶有路徑變數,那麼這個引數物件就會對映到路徑變數中。

兩個GET請求:

記一次封裝Axios的經歷

一個POST請求和一個PUT請求:

記一次封裝Axios的經歷

將思路轉換成實現的程式碼

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的開發經歷,希望對大家有幫助和啟發。文中有不當的地方,歡迎批評和討論。

相關文章