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

fengxianqi發表於2019-11-13

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

前言

前段時間寫了篇文章《axios如何利用promise無痛重新整理token》,陸陸續續收到一些反饋。發現不少同學會想要從在請求前攔截的思路入手,甚至收到了幾個郵件來詢問博主遇到的問題,所以索性再寫一篇文章來說說另一個思路的實現和注意的地方。過程會稍微囉嗦,不想看實現過程的同學可以直接拉到最後面看最終程式碼。

PS:在本文就略過一些前提條件了,請新同學閱讀本文前先看一下前一篇文章《axios如何利用promise無痛重新整理token》

前提條件

前端登入後,後端返回token和token有效時間段tokenExprieIn,當token過期時間到了,前端需要主動用舊token去獲取一個新的token,做到使用者無感知地去重新整理token。

PS: tokenExprieIn是一個單位為秒的時間段,不建議使用絕對時間,絕對時間可能會由於本地和伺服器時區不一樣導致出現問題。

實現思路

方法一

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

方法二

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

前文已經實現了方法二,本文會從頭實現一下方法一

實現

基本骨架

在請求前進行攔截,我們主要會使用axios.interceptors.request.use()這個方法。照例先封裝個request.js的基本骨架:

import axios from 'axios'

// 從localStorage中獲取token,token存的是object資訊,有tokenExpireTime和token兩個欄位
function getToken () {
  let tokenObj = {}
  try {
    tokenObj = storage.get('token')
    tokenObj = tokenObj ? JSON.parse(tokenObj) : {}
  } catch {
    console.error('get token from localStorage error')
  }
  return tokenObj
}

// 給例項新增一個setToken方法,用於登入後方便將最新token動態新增到header,同時將token儲存在localStorage中
instance.setToken = (obj) => {
  instance.defaults.headers['X-Token'] = obj.token
  window.localStorage.setItem('token', JSON.stringify(obj)) // 注意這裡需要變成字串後才能放到localStorage中
}

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

// 請求發起前攔截
instance.interceptors.request.use((config) => {
  const tokenObj = getToken()
  // 為每個請求新增token請求頭
  config.headers['X-Token'] = tokenObj.token
  
  // **接下來主要攔截的實現就在這裡**
  
  return config
}, (error) => {
  // Do something with request error
  return Promise.reject(error)
})

// 請求返回後攔截
instance.interceptors.response.use(response => {
  const { code } = response.data
  if (code === 1234) {
    // token過期了,直接跳轉到登入頁 
    window.location.href = '/'
  }
  return response
}, error => {
  console.log('catch', error)
  return Promise.reject(error)
})

export default instance
複製程式碼

與前文略微不同的是,由於方法二不需要用到過期時間,所以前文localStorage中只存了token一個字串,而方法一這裡需要用到過期時間了,所以得存多一個資料,因此localStorage中存的是Object型別的資料,從localStorage中取值出來需要JSON.parse一下,為了防止發生錯誤所以儘量使用try...catch

axios.interceptors.request.use()實現

首先不需要想得太複雜,先不考慮多個請求同時進來的情況,我們從最常見的場景入手:從localStorage拿到上一次儲存的過期時間,判斷是否已經到了過期時間,是就立即重新整理token然後再發起請求。

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

instance.interceptors.request.use((config) => {
  const tokenObj = getToken()
  // 為每個請求新增token請求頭
  config.headers['X-Token'] = tokenObj.token
  if (tokenObj.token && tokenObj.tokenExpireTime) {
      const now = Date.now()
      if (now >= tokenObj.tokenExpireTime) {
          // 當前時間大於過期時間,說明已經過期了,返回一個Promise,執行refreshToken後再return當前的config
          return refreshToken().then(res => {
            const { token, tokenExprieIn } = res.data
            const tokenExpireTime = now + tokenExprieIn * 1000
            instance.setToken({ token, tokenExpireTime }) // 存token到localStorage
            console.log('重新整理成功, return config即是恢復當前請求')
            config.headers['X-Token'] = token // 將最新的token放到請求頭
            return config
          }).catch(res => {
            console.error('refresh token error: ', res)
          })
      }
  }
  return config
}, (error) => {
  // Do something with request error
  return Promise.reject(error)
})
複製程式碼

這裡有兩個需要注意的地方:

  1. 之前說到登入或重新整理token的介面返回的是一個單位為秒的時間段tokenExpireIn,而我們存到localStorage中的是已經是一個基於當前時間有效時間段算出的最終時間tokenExpireTime,是一個絕對時間,比如當前時間是12點,有效時間是3600秒(1個小時),則存到localStorage的過期時間是13點的時間戳,這樣可以少存一個當前時間的欄位到localStorage中,使用時只需要判斷該絕對時間即可。
  2. instance.interceptors.request.use中返回一個Promise,就可以使得該請求是先執行refreshToken後再return config的,才能保證先重新整理token後再真正發起請求。

其實博主直接執行上面程式碼後發現了一個嚴重錯誤,進入了一個死迴圈。這是因為博主沒有注意到一個問題:axios.interceptors.request.use()會攔截所有使用該例項發起的請求,即執行refreshToken()時又一次進入了axios.interceptors.request.use(),導致一直在return refreshToken()

因此需要將重新整理token和登入這兩種情況排除出去,登入和重新整理token都不需要判斷是否過期的攔截,我們可以通過config.url來判斷是哪個介面:

instance.interceptors.request.use((config) => {
  const tokenObj = getToken()
  // 為每個請求新增token請求頭
  config.headers['X-Token'] = tokenObj.token
  // 登入介面和重新整理token介面繞過
  if (config.url.indexOf('/refreshToken') >= 0 || config.url.indexOf('/login') >= 0) {
    return config
  }
  if (tokenObj.token && tokenObj.tokenExpireTime) {
      const now = Date.now()
      if (now >= tokenObj.tokenExpireTime) {
          // 當前時間大於過期時間,說明已經過期了,返回一個Promise,執行refreshToken後再return當前的config
          return refreshToken().then(res => {
            const { token, tokenExprieIn } = res.data
            const tokenExpireTime = now + tokenExprieIn * 1000
            instance.setToken({ token, tokenExpireTime }) // 存token到localStorage
            console.log('重新整理成功, return config即是恢復當前請求')
            config.headers['X-Token'] = token // 將最新的token放到請求頭
            return config
          }).catch(res => {
            console.error('refresh token error: ', res)
          })
      }
  }
  return config
}, (error) => {
  // Do something with request error
  return Promise.reject(error)
})
複製程式碼

問題和優化

接下來就是要考慮複雜一點的問題了

防止多次重新整理token

當幾乎同時進來兩個請求,為了避免多次執行refreshToken,需要引入一個isRefreshing的進行標記:

let isRefreshing = false
instance.interceptors.request.use((config) => {
  const tokenObj = getToken()
  // 為每個請求新增token請求頭
  config.headers['X-Token'] = tokenObj.token
  // 登入介面和重新整理token介面繞過
  if (config.url.indexOf('/refreshToken') >= 0 || config.url.indexOf('/login') >= 0) {
    return config
  }
  if (tokenObj.token && tokenObj.tokenExpireTime) {
      const now = Date.now()
      if (now >= tokenObj.tokenExpireTime) {
          if (!isRefreshing) {
            isRefreshing = true
            return refreshToken().then(res => {
              const { token, tokenExprieIn } = res.data
              const tokenExpireTime = now + tokenExprieIn * 1000
              instance.setToken({ token, tokenExpireTime }) // 存token到localStorage
              isRefreshing = false //重新整理成功,恢復標誌位
              config.headers['X-Token'] = token // 將最新的token放到請求頭
              return config
            }).catch(res => {
              console.error('refresh token error: ', res)
            })  
          }
      }
  }
  return config
}, (error) => {
  // Do something with request error
  return Promise.reject(error)
})
複製程式碼

多個請求時存到佇列中等重新整理token後再發起

我們已經知道了當前已經過期或者正在重新整理token,此時再有請求發起,就應該讓後面的這些請求等一等,等到refreshToken結束後再真正發起,所以需要用到一個Promise來讓它一直等。而後面的所有請求,我們將它們存放到一個requests的佇列中,等重新整理token後再依次resolve

instance.interceptors.request.use((config) => {
  const tokenObj = getToken()
  // 新增請求頭
  config.headers['X-Token'] = tokenObj.token
  // 登入介面和重新整理token介面繞過
  if (config.url.indexOf('/refreshToken') >= 0 || config.url.indexOf('/login') >= 0) {
    return config
  }
  if (tokenObj.token && tokenObj.tokenExpireTime) {
    const now = Date.now()
    if (now >= tokenObj.tokenExpireTime) {
      // 立即重新整理token
      if (!isRefreshing) {
        console.log('重新整理token ing')
        isRefreshing = true
        refreshToken().then(res => {
          const { token, tokenExprieIn } = res.data
          const tokenExpireTime = now + tokenExprieIn * 1000
          instance.setToken({ token, tokenExpireTime })
          isRefreshing = false
          return token
        }).then((token) => {
          console.log('重新整理token成功,執行佇列')
          requests.forEach(cb => cb(token))
          // 執行完成後,清空佇列
          requests = []
        }).catch(res => {
          console.error('refresh token error: ', res)
        })
      }
      const retryOriginalRequest = new Promise((resolve) => {
        requests.push((token) => {
          // 因為config中的token是舊的,所以重新整理token後要將新token傳進來
          config.headers['X-Token'] = token
          resolve(config)
        })
      })
      return retryOriginalRequest
    }
  }
  return config
}, (error) => {
  // Do something with request error
  return Promise.reject(error)
})
複製程式碼

這裡做了一點改動,注意到refreshToken()這一句前面去掉了return,而是改為了在後面return retryOriginalRequest,即當發現有請求是過期的就存進requests陣列,等refreshToken結束後再執行requests佇列,這是為了不影響原來的請求執行次序。 我們假設同時有請求1請求2請求3依次同時進來,我們希望是請求1發現過期,refreshToken後再依次執行請求1請求2請求3。 按之前return refreshToken()的寫法,會大概寫成這樣


  if (tokenObj.token && tokenObj.tokenExpireTime) {
    const now = Date.now()
    if (now >= tokenObj.tokenExpireTime) {
      // 立即重新整理token
      if (!isRefreshing) {
        console.log('重新整理token ing')
        isRefreshing = true
        return refreshToken().then(res => {
          const { token, tokenExprieIn } = res.data
          const tokenExpireTime = now + tokenExprieIn * 1000
          instance.setToken({ token, tokenExpireTime })
          isRefreshing = false
          config.headers['X-Token'] = token
          return config // 請求1
        }).catch(res => {
          console.error('refresh token error: ', res)
        }).finally(() => {
          console.log('執行佇列')
          requests.forEach(cb => cb(token))
          // 執行完成後,清空佇列
          requests = []
        })
      } else {
        // 只有請求2和請求3能進入佇列
        const retryOriginalRequest = new Promise((resolve) => {
          requests.push((token) => {
            config.headers['X-Token'] = token
            resolve(config)
          })
        })
        return retryOriginalRequest
      }
    }
  }
  return config
複製程式碼

佇列裡面只有請求2請求3,程式碼看起來應該是return了請求1後,再在finally執行佇列的,但實際的執行順序會變成請求2請求3請求1,即請求1變成了最後一個執行的,會改變執行順序。

所以博主換了個思路,無論是哪個請求進入了過期流程,我們都將請求放到佇列中,都return一個未resolve的Promise,等重新整理token結束後再一一清算,這樣就可以保證請求1請求2請求3這樣按原來順序執行了。

這裡多說一句,可能很多剛接觸前端的同學無法理解requests.forEach(cb => cb(token))是如何執行的。

// 我們先看一下,定義fn1
function fn1 () {
    console.log('執行fn1')
}

// 執行fn1,只需後面加個括號
fn1()

// 迴歸到我們request陣列中,每一項其實存的就是一個類似fn1的一個函式
const fn2 = (token) => {
    config.headers['X-Token'] = token
    resolve(config)
}

// 我們要執行fn2,也只需在後面加個括號就可以了
fn2()

// 由於requests是一個陣列,所以我們想遍歷執行裡面的所有的項,所以用上了forEach
requests.forEach(fn => {
  // 執行fn
  fn()
})
複製程式碼

最後完整程式碼

import axios from 'axios'

// 從localStorage中獲取token,token存的是object資訊,有tokenExpireTime和token兩個欄位
function getToken () {
  let tokenObj = {}
  try {
    tokenObj = storage.get('token')
    tokenObj = tokenObj ? JSON.parse(tokenObj) : {}
  } catch {
    console.error('get token from localStorage error')
  }
  return tokenObj
}

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

// 給例項新增一個setToken方法,用於登入後方便將最新token動態新增到header,同時將token儲存在localStorage中
instance.setToken = (obj) => {
  instance.defaults.headers['X-Token'] = obj.token
  window.localStorage.setItem('token', JSON.stringify(obj)) // 注意這裡需要變成字串後才能放到localStorage中
}

instance.interceptors.request.use((config) => {
  const tokenObj = getToken()
  // 新增請求頭
  config.headers['X-Token'] = tokenObj.token
  // 登入介面和重新整理token介面繞過
  if (config.url.indexOf('/rereshToken') >= 0 || config.url.indexOf('/login') >= 0) {
    return config
  }
  if (tokenObj.token && tokenObj.tokenExpireTime) {
    const now = Date.now()
    if (now >= tokenObj.tokenExpireTime) {
      // 立即重新整理token
      if (!isRefreshing) {
        console.log('重新整理token ing')
        isRefreshing = true
        refreshToken().then(res => {
          const { token, tokenExprieIn } = res.data
          const tokenExpireTime = now + tokenExprieIn * 1000
          instance.setToken({ token, tokenExpireTime })
          isRefreshing = false
          return token
        }).then((token) => {
          console.log('重新整理token成功,執行佇列')
          requests.forEach(cb => cb(token))
          // 執行完成後,清空佇列
          requests = []
        }).catch(res => {
          console.error('refresh token error: ', res)
        })
      }
      const retryOriginalRequest = new Promise((resolve) => {
        requests.push((token) => {
          // 因為config中的token是舊的,所以重新整理token後要將新token傳進來
          config.headers['X-Token'] = token
          resolve(config)
        })
      })
      return retryOriginalRequest
    }
  }
  return config
}, (error) => {
  // Do something with request error
  return Promise.reject(error)
})

// 請求返回後攔截
instance.interceptors.response.use(response => {
  const { code } = response.data
  if (code === 1234) {
    // token過期了,直接跳轉到登入頁 
    window.location.href = '/'
  }
  return response
}, error => {
  console.log('catch', error)
  return Promise.reject(error)
})

export default instance
複製程式碼

建議一步步除錯的同學,可以先去掉window.location.href = '/'這個跳轉,保留log方便除錯。

感謝看到最後,感謝點贊^_^。

相關文章