axios如何利用promise无痛刷新token(二)

前言

前段时间写了篇文章《axios如何利用promise无痛刷新token》,陆陆续续收到一些反馈。发现很多同窗会想要从在请求前拦截的思路入手,甚至收到了几个邮件来询问博主遇到的问题,因此索性再写一篇文章来讲说另外一个思路的实现和注意的地方。过程会稍微啰嗦,不想看实现过程的同窗能够直接拉到最后面看最终代码。前端

PS:在本文就略过一些前提条件了,请新同窗阅读本文前先看一下前一篇文章《axios如何利用promise无痛刷新token》ios

前提条件

前端登陆后,后端返回token和token有效时间段tokenExprieIn,当token过时时间到了,前端须要主动用旧token去获取一个新的token,作到用户无感知地去刷新token。json

PS: tokenExprieIn是一个单位为秒的时间段,不建议使用绝对时间,绝对时间可能会因为本地和服务器时区不同致使出现问题。axios

实现思路

方法一

在请求发起前拦截每一个请求,判断token的有效时间是否已通过期,若已过时,则将请求挂起,先刷新token后再继续请求。后端

方法二

不在请求前拦截,而是拦截返回后的数据。先发起请求,接口返回过时后,先刷新token,再进行一次重试。api

前文已经实现了方法二,本文会从头实现一下方法一数组

实现

基本骨架

在请求前进行拦截,咱们主要会使用axios.interceptors.request.use()这个方法。照例先封装个request.js的基本骨架:promise

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

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方便调试。

感谢看到最后,感谢点赞^_^。

相关文章
相关标签/搜索