美文网首页前端实战笔录Vue让前端飞
axios如何利用promise无痛刷新token(二)

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

作者: fengxianqi | 来源:发表于2019-11-13 11:10 被阅读0次
    16e6265fc4958f20.jpg

    前言

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

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

    相关文章

      网友评论

        本文标题:axios如何利用promise无痛刷新token(二)

        本文链接:https://www.haomeiwen.com/subject/zkrhictx.html