问题一:如何拦截用户的请求,并判断是否需要刷新 token
问题二:如何实现用户同一时间里多次请求,但只刷新一次 token
问题
项目中,在处理用户请求时,如果用户的 token 已过期,会触发刷新 token 的请求,请求有两种结果:
- code 1200,自动刷新成功
- code 1201,自动刷新失败,需要退出登录
一般情况下,自动刷新的时候用户是无感知的,也就是说,假设本次操作刚好触发 token 已过期,那么,刷新 token 成功之后,此时应该是继续响应之前的操作,不应该阻塞用户的本次操作。
这是问题一,建立在只有一次请求基础上,那我们只需要等待刷新 token 成功后的新 token,再次请求之前的操作即可。
那如果不止一次请求,如何在刷新 token 之后,继续响应这不止一次的请求呢?如果按照单次请求的逻辑,那每次请求都会重新刷新 token,在同一时间里会刷新 token 多次,很容易触发 1201,导致用户被迫下线的风险。
综上所述:
- 问题一:如何拦截用户的请求,并判断是否需要刷新 token
- 问题二:如何实现用户同一时间里多次请求,但只刷新一次 token
使用 umi-request 拦截器
首先,在项目中使用了umi-request,所以拦截器就是按照 umi-request 来建立的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106
| import { extend } from 'umi-request'
const codeMessage = { 200: '服务器成功返回请求的数据。', 201: '新建或修改数据成功。', 202: '一个请求已经进入后台排队(异步任务)。', 204: '删除数据成功。', 400: '发出的请求有错误,服务器没有进行新建或修改数据的操作。', 401: '用户没有权限(令牌、用户名、密码错误)。', 403: '用户得到授权,但是访问是被禁止的。', 404: '发出的请求针对的是不存在的记录,服务器没有进行操作。', 406: '请求的格式不可得。', 410: '请求的资源被永久删除,且不会再得到的。', 422: '当创建一个对象时,发生一个验证错误。', 500: '服务器发生错误,请检查服务器。', 502: '网关错误。', 503: '服务不可用,服务器暂时过载或维护。', 504: '网关超时。' }
const errorHandler = error => { const { response, data } = error if (response && response.status) { const errorText = codeMessage[response.status] || response.statusText const { status, url } = response console.error('Response Error', { message: `请求错误 ${status}: ${url}`, description: errorText }) } if (data && data.code && data.message) { console.error('Response Data', { code: data.code, message: data.message }) } return response }
const resquestHandler = (url, options) => { const { Authorization } = options.headers const { access_token } = store.getState().user.token
let headers = access_token && { Authorization: `Bearer ${access_token}` } if (Authorization) { headers = { Authorization } }
return { url, options: { ...options, headers: { ...options.headers, ...headers }, interceptors: true } } }
const responseHandler = async (response, options) => { const res = await response.clone().json() if (res && res.NOT_LOGIN) { location.href = '/' } return response }
const request = extend({ prefix: '/v1/api', timeout: 5000, headers: { appId: '80001001', 'Cache-Control': 'no-cache', Pragma: 'no-cache' }, errorHandler })
request.interceptors.request.use(resquestHandler)
request.interceptors.response.use(responseHandler)
export { request }
|
拦截用户请求,判断是否需要刷新 token
responseHandler
针对这个问题,我们可以依据上述完成的拦截器,只需要在 responseHandler 里做判断即可。如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| const responseHandler = async (response, options) => { const res = await response.clone().json() if (res.code === 1200) { const { refresh_token } = store.getState().user.token const res = await accountLoginService.get_new_token(refresh_token) store.dispatch(onLogon(res)) return request(response.url, { ...options, prefix: '', params: {}, headers: { Authorization: 'Bearer ' + res.access_token } }) } else if (res.code === 1201) { store.dispatch(onLogout()) } return response }
|
模拟实际效果
我们用一下代码来模拟下请求的实际效果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| const request = () => console.log('重新请求当前接口') const refreshToken = refresh_token => { console.log('refreshToken 接口请求') return { access_token: '111', refresh_token: '333' } }
const responseHandler = async (res = {}) => { console.log(1) if (res.code === 1200) { console.log(2) const refresh_token = '222' const res = await new Promise((resolve, reject) => { setTimeout(() => { resolve(refreshToken(refresh_token)) }, 100) }) console.log(3) return request(res) } console.log('4走response') return true }
responseHandler({ code: 1200 })
|
直接在浏览器输出:
1 2 3 4 5
| 1 2 refreshToken 接口请求 3 重新请求当前接口
|
那如果多次请求呢?比如请求两次:
1 2 3 4 5 6 7 8 9 10 11
| 1 2 1 2 refreshToken 接口请求 3 重新请求当前接口
refreshToken 接口请求 3 重新请求当前接口
|
结果显示会依次请求两次,很符合我们的逻辑,所以,接下来我们就是想办法解决多次请求的问题。
同时多个请求导致多次刷新 token
思路
首先,同时间触发刷新 token,我们只请求第一个去拿最新 token,那后面其他的请求怎么办?
其次,我们可以将其他请求存到一个队列中,等待使用最新 token,然后依次执行队列中的每一项,但如何让其处于等待状态呢?
所以,解决等待的问题,需要借助 Promise 的特性了。
这里有最详细的图文并茂的讲解:前端请求 token 过期时,刷新 token 的处理
Promise 特性
一个 Promise 必然处于以下几种状态之一:
- 待定(pending): 初始状态,既没有被兑现,也没有被拒绝。
- 已兑现(fulfilled): 意味着操作成功完成。
- 已拒绝(rejected): 意味着操作失败。
当异步代码执行成功时,会调用 resolve(), 失败时就会调用 reject()
1 2 3 4 5 6
| const res = await new Promise((resolve, reject) => { setTimeout(() => { resolve(refreshToken(refresh_token)) }, 100) })
|
当同时有多个请求时,我们将请求存进队列中,同时返回一个未 resolve 的 Promise,让这个 Promise 一直处于 Pending 状态,当刷新请求的接口返回来后,我们再调用 resolve,依次执行队列里的每一项。
模拟实际情况
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65
| let isRefreshing = false const refresh_token = '222' const subscribers = [] const addSubscriber = listener => subscribers.push(listener)
const refreshToken = refresh_token => { console.log('refreshToken 请求') return new Promise((resolve, reject) => { setTimeout(() => { resolve({ access_token: '111', refresh_token: '333' }) }, 100) }) }
const refreshTokenRequest = async refresh_token => { const res = await refreshToken(refresh_token) notifySubscriber(res.access_token) isRefreshing = false }
const request = (target = 1) => new Promise((resolve, reject) => { setTimeout(() => { resolve(console.log('重新请求当前接口' + target)) }, 100) })
const notifySubscriber = (newToken = '') => { console.log('执行被缓存等待的接口事件') subscribers.forEach(callback => callback(newToken)) subscribers.length = 0 }
const responseHandler = async (res = {}, target) => { console.log(1) if (res.code === 1200) { console.log(2) if (!isRefreshing) { isRefreshing = true refreshTokenRequest(refresh_token) } console.log(3) return new Promise((resolve, reject) => { console.log('new Promise') addSubscriber(() => resolve(request(target))) }) } console.log('4走response') return true }
responseHandler({ code: 1200 }, 1) responseHandler({ code: 1200 }, 2) responseHandler({ code: 1200 }, 3)
|
执行结果如下,非常符合我们的预期,不仅只刷新一次 token,而且会继续执行被拦截的多次请求。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| 1 2 refreshToken 请求 3 new Promise 1 2 3 new Promise 1 2 3 new Promise 执行被缓存等待的接口事件 重新请求当前接口1 重新请求当前接口2 重新请求当前接口3
|
完整代码
模拟已经实现了,接下来就是在拦截器里继续完善相关逻辑:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59
| let isRefreshing = false const subscribers = []
const addSubscriber = listener => subscribers.push(listener)
const notifySubscriber = (newToken = '') => { subscribers.forEach(callback => callback(newToken)) subscribers.length = 0 }
const refreshTokenRequst = async () => { const { refresh_token } = window.localStorage.getItem('token') try { const res = await refreshToken(refresh_token) notifySubscriber(res.access_token) } catch (e) { console.error('请求刷新 token 失败') } isRefreshing = false }
function checkStatus(response, options) { const { url } = response if (!isRefreshing) { isRefreshing = true refreshTokenRequst() }
return new Promise(resolve => { addSubscriber(newToken => { const newOptions = { ...options, prefix: '', params: {}, headers: { Authorization: 'Bearer ' + newToken } } resolve(request(url, newOptions)) }) }) }
const responseHandler = async (response, options) => { const res = await response.clone().json()
if (res.code === 1200) { return checkStatus(response, options) } else if (res.code === 1201) { return onLogout() }
return response }
|
其他思路
在请求发起前拦截每个请求,判断 token 的有效时间是否已经过期,若已过期,则将请求挂起,先刷新 token 后再继续请求。
- 优点:在请求前拦截,能节省请求,省流量。
- 缺点:需要后端额外提供一个 token 过期时间的字段 refreshTime;使用了本地时间判断,若本地时间被篡改,特别是本地时间比服务器时间慢时,拦截会失败。
详细内容如下文章:
参考资料