35

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

 4 years ago
source link: https://segmentfault.com/a/1190000020986592
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

JviEVnR.jpg!web

前言

前段时间写了篇文章 《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方便调试。

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


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK