axios如何利用promise无痛刷新token
转载自:
需求
最近遇到个需求:前端登录后,后端返回token
和token有效时间
,当token过期时要求用旧token去获取新的token,前端需要做到无痛刷新token
,即请求刷新token时要做到用户无感知。
需求解析
当用户发起一个请求时,判断token是否已过期,若已过期则先调refreshToken
接口,拿到新的token后再继续执行之前的请求。
这个问题的难点在于:当同时发起多个请求,而刷新token的接口还没返回,此时其他请求该如何处理?接下来会循序渐进地分享一下整个过程。
实现思路
由于后端返回了token的有效时间,可以有两种方法:
方法一:
在请求发起前拦截每个请求,判断token的有效时间是否已经过期,若已过期,则将请求挂起,先刷新token后再继续请求。
方法二:
不在请求前拦截,而是拦截返回后的数据。先发起请求,接口返回过期后,先刷新token,再进行一次重试。
对比
方法一
- 优点: 在请求前拦截,能节省请求,省流量。
- 缺点: 需要后端额外提供一个token过期时间的字段;使用了本地时间判断,若本地时间被篡改,特别是本地时间比服务器时间慢时,拦截会失败。
PS:token有效时间建议是时间段,类似缓存的MaxAge,而不要是绝对时间。当服务器和本地时间不一致时,绝对时间会有问题。
- 方法二
- 优点:不需额外的token过期字段,不需判断时间。
- 缺点: 会消耗多一次请求,耗流量。
综上,方法一和二优缺点是互补的,方法一有校验失败的风险(本地时间被篡改时,当然一般没有用户闲的蛋疼去改本地时间的啦),方法二更简单粗暴,等知道服务器已经过期了再重试一次,只是会耗多一个请求。
前提条件
前端登录后,后端返回token
和token有效时间段tokenExprieIn
,当token过期时间到了,前端需要主动用旧token去获取一个新的token,做到用户无感知地去刷新token。
PS:
tokenExprieIn
是一个单位为秒的时间段,不建议使用绝对时间,绝对时间可能会由于本地和服务器时区不一样导致出现问题。
方法一实现
基本骨架
在请求前进行拦截,我们主要会使用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)
})
这里有两个需要注意的地方:
- 之前说到登录或刷新token的接口返回的是一个单位为秒的时间段
tokenExpireIn
,而我们存到localStorage
中的是已经是一个基于当前时间
和有效时间段
算出的最终时间tokenExpireTime
,是一个绝对时间,比如当前时间是12点,有效时间是3600秒(1个小时),则存到localStorage
的过期时间是13点的时间戳,这样可以少存一个当前时间的字段到localStorage
中,使用时只需要判断该绝对时间即可。 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中
}
// 是否正在刷新的标记
let isRefreshing = false
// 重试队列,每一项将是一个待执行的函数形式
let requests = []
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来实现,方法一是请求前拦截,所以会使用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