axios如何利用promise無痛重新整理token

fengxianqi發表於2019-08-27

需求

最近遇到個需求:前端登入後,後端返回tokentoken有效時間,當token過期時要求用舊token去獲取新的token,前端需要做到無痛重新整理token,即請求重新整理token時要做到使用者無感知。

需求解析

當使用者發起一個請求時,判斷token是否已過期,若已過期則先調refreshToken介面,拿到新的token後再繼續執行之前的請求。

這個問題的難點在於:當同時發起多個請求,而重新整理token的介面還沒返回,此時其他請求該如何處理?接下來會循序漸進地分享一下整個過程。

實現思路

由於後端返回了token的有效時間,可以有兩種方法:

方法一:

在請求發起前攔截每個請求,判斷token的有效時間是否已經過期,若已過期,則將請求掛起,先重新整理token後再繼續請求。

方法二:

不在請求前攔截,而是攔截返回後的資料。先發起請求,介面返回過期後,先重新整理token,再進行一次重試。

兩種方法對比

方法一

  • 優點: 在請求前攔截,能節省請求,省流量。
  • 缺點: 需要後端額外提供一個token過期時間的欄位;使用了本地時間判斷,若本地時間被篡改,特別是本地時間比伺服器時間慢時,攔截會失敗。

PS:token有效時間建議是時間段,類似快取的MaxAge,而不要是絕對時間。當伺服器和本地時間不一致時,絕對時間會有問題。

方法二

  • 優點:不需額外的token過期欄位,不需判斷時間。
  • 缺點: 會消耗多一次請求,耗流量。

綜上,方法一和二優缺點是互補的,方法一有校驗失敗的風險(本地時間被篡改時,當然一般沒有使用者閒的蛋疼去改本地時間的啦),方法二更簡單粗暴,等知道伺服器已經過期了再重試一次,只是會耗多一個請求。

在這裡博主選擇了 方法二

實現

這裡會使用axios來實現,方法一是請求前攔截,所以會使用axios.interceptors.request.use()這個方法;

而方法二是請求後攔截,所以會使用axios.interceptors.response.use()方法。

封裝axios基本骨架

首先說明一下,專案中的token是存在localStorage中的。request.js基本骨架:

import axios from 'axios'

// 從localStorage中獲取token
function getLocalToken () {
    const token = window.localStorage.getItem('token')
    return token
}


// 給例項新增一個setToken方法,用於登入後將最新token動態新增到header,同時將token儲存在localStorage中
instance.setToken = (token) => {
  instance.defaults.headers['X-Token'] = token
  window.localStorage.setItem('token', token)
}

// 建立一個axios例項
const instance = axios.create({
  baseURL: '/api',
  timeout: 300000,
  headers: {
    'Content-Type': 'application/json',
    'X-Token': getLocalToken() // headers塞token
  }
})

// 攔截返回的資料
instance.interceptors.response.use(response => {
  // 接下來會在這裡進行token過期的邏輯處理
  return response
}, error => {
  return Promise.reject(error)
})

export default instance
複製程式碼

這個是專案中一般的axios例項的封裝,建立例項時,將本地已有的token放進header,然後export出去供呼叫。接下來就是如何攔截返回的資料啦。

instance.interceptors.response.use攔截實現

後端介面一般會有一個約定好的資料結構,如:

{code: 1234, message: 'token過期', data: {}}
複製程式碼

如我這裡,後端約定當code === 1234時表示token過期了,此時就要求重新整理token。

instance.interceptors.response.use(response => {
  const { code } = response.data
  if (code === 1234) {
    // 說明token過期了,重新整理token
    return refreshToken().then(res => {
      // 重新整理token成功,將最新的token更新到header中,同時儲存在localStorage中
      const { token } = res.data
      instance.setToken(token)
      // 獲取當前失敗的請求
      const config = response.config
      // 重置一下配置
      config.headers['X-Token'] = token
      config.baseURL = '' // url已經帶上了/api,避免出現/api/api的情況
      // 重試當前請求並返回promise
      return instance(config)
    }).catch(res => {
      console.error('refreshtoken error =>', res)
      //重新整理token失敗,神仙也救不了了,跳轉到首頁重新登入吧
      window.location.href = '/'
    })
  }
  return response
}, error => {
  return Promise.reject(error)
})

function refreshToken () {
    // instance是當前request.js中已建立的axios例項
    return instance.post('/refreshtoken').then(res => res.data)
}
複製程式碼

這裡需要額外注意的是,response.config就是原請求的配置,但這個是已經處理過了的,config.url已經帶上了baseUrl,因此重試時需要去掉,同時token也是舊的,需要重新整理下。

以上就基本做到了無痛重新整理token,當token正常時,正常返回,當token已過期,則axios內部進行一次重新整理token和重試。對呼叫者來說,axios內部的重新整理token是一個黑盒,是無感知的,因此需求已經做到了。

問題和優化

上面的程式碼還是存在一些問題的,沒有考慮到多次請求的問題,因此需要進一步優化。

如何防止多次重新整理token

如果refreshToken介面還沒返回,此時再有一個過期的請求進來,上面的程式碼就會再一次執行refreshToken,這就會導致多次執行重新整理token的介面,因此需要防止這個問題。我們可以在request.js中用一個flag來標記當前是否正在重新整理token的狀態,如果正在重新整理則不再呼叫重新整理token的介面。

// 是否正在重新整理的標記
let isRefreshing = false
instance.interceptors.response.use(response => {
  const { code } = response.data
  if (code === 1234) {
    if (!isRefreshing) {
      isRefreshing = true
      return refreshToken().then(res => {
        const { token } = res.data
        instance.setToken(token)
        const config = response.config
        config.headers['X-Token'] = token
        config.baseURL = ''
        return instance(config)
      }).catch(res => {
        console.error('refreshtoken error =>', res)
        window.location.href = '/'
      }).finally(() => {
        isRefreshing = false
      })
    }
  }
  return response
}, error => {
  return Promise.reject(error)
})
複製程式碼

這樣子就可以避免在重新整理token時再進入方法了。但是這種做法是相當於把其他失敗的介面給捨棄了,假如同時發起兩個請求,且幾乎同時返回,第一個請求肯定是進入了refreshToken後再重試,而第二個請求則被丟棄了,仍是返回失敗,所以接下來還得解決其他介面的重試問題。

同時發起兩個或以上的請求時,其他介面如何重試

兩個介面幾乎同時發起和返回,第一個介面會進入重新整理token後重試的流程,而第二個介面需要先存起來,然後等重新整理token後再重試。同樣,如果同時發起三個請求,此時需要快取後兩個介面,等重新整理token後再重試。由於介面都是非同步的,處理起來會有點麻煩。

當第二個過期的請求進來,token正在重新整理,我們先將這個請求存到一個陣列佇列中,想辦法讓這個請求處於等待中,一直等到重新整理token後再逐個重試清空請求佇列。 那麼如何做到讓這個請求處於等待中呢?為了解決這個問題,我們得藉助Promise。將請求存進佇列中後,同時返回一個Promise,讓這個Promise一直處於Pending狀態(即不呼叫resolve),此時這個請求就會一直等啊等,只要我們不執行resolve,這個請求就會一直在等待。當重新整理請求的介面返回來後,我們再呼叫resolve,逐個重試。最終程式碼:

// 是否正在重新整理的標記
let isRefreshing = false
// 重試佇列,每一項將是一個待執行的函式形式
let requests = []

instance.interceptors.response.use(response => {
  const { code } = response.data
  if (code === 1234) {
    const config = response.config
    if (!isRefreshing) {
      isRefreshing = true
      return refreshToken().then(res => {
        const { token } = res.data
        instance.setToken(token)
        config.headers['X-Token'] = token
        config.baseURL = ''
        // 已經重新整理了token,將所有佇列中的請求進行重試
        requests.forEach(cb => cb(token))
        // 重試完了別忘了清空這個佇列(掘金評論區同學指點)
        requests = []
        return instance(config)
      }).catch(res => {
        console.error('refreshtoken error =>', res)
        window.location.href = '/'
      }).finally(() => {
        isRefreshing = false
      })
    } else {
      // 正在重新整理token,返回一個未執行resolve的promise
      return new Promise((resolve) => {
        // 將resolve放進佇列,用一個函式形式來儲存,等token重新整理後直接執行
        requests.push((token) => {
          config.baseURL = ''
          config.headers['X-Token'] = token
          resolve(instance(config))
        })
      })
    }
  }
  return response
}, error => {
  return Promise.reject(error)
})
複製程式碼

這裡可能比較難理解的是requests這個佇列中儲存的是一個函式,這是為了讓resolve不執行,先存起來,等重新整理token後更方便呼叫這個函式使得resolve執行。至此,問題應該都解決了。

最後完整程式碼

import axios from 'axios'

// 從localStorage中獲取token
function getLocalToken () {
    const token = window.localStorage.getItem('token')
    return token
}

// 給例項新增一個setToken方法,用於登入後將最新token動態新增到header,同時將token儲存在localStorage中
instance.setToken = (token) => {
  instance.defaults.headers['X-Token'] = token
  window.localStorage.setItem('token', token)
}

function refreshToken () {
    // instance是當前request.js中已建立的axios例項
    return instance.post('/refreshtoken').then(res => res.data)
}

// 建立一個axios例項
const instance = axios.create({
  baseURL: '/api',
  timeout: 300000,
  headers: {
    'Content-Type': 'application/json',
    'X-Token': getLocalToken() // headers塞token
  }
})

// 是否正在重新整理的標記
let isRefreshing = false
// 重試佇列,每一項將是一個待執行的函式形式
let requests = []

instance.interceptors.response.use(response => {
  const { code } = response.data
  if (code === 1234) {
    const config = response.config
    if (!isRefreshing) {
      isRefreshing = true
      return refreshToken().then(res => {
        const { token } = res.data
        instance.setToken(token)
        config.headers['X-Token'] = token
        config.baseURL = ''
        // 已經重新整理了token,將所有佇列中的請求進行重試
        requests.forEach(cb => cb(token))
        requests = []
        return instance(config)
      }).catch(res => {
        console.error('refreshtoken error =>', res)
        window.location.href = '/'
      }).finally(() => {
        isRefreshing = false
      })
    } else {
      // 正在重新整理token,將返回一個未執行resolve的promise
      return new Promise((resolve) => {
        // 將resolve放進佇列,用一個函式形式來儲存,等token重新整理後直接執行
        requests.push((token) => {
          config.baseURL = ''
          config.headers['X-Token'] = token
          resolve(instance(config))
        })
      })
    }
  }
  return response
}, error => {
  return Promise.reject(error)
})

export default instance
複製程式碼

希望對大家有幫助。感謝看到最後,感謝點贊^_^。

後續更新

針對方法一的實現,請大家閱讀: axios如何利用promise無痛重新整理token(二)

相關文章