在使用 useFetch 之前, axios 是一个被广泛使用 Promise 请求库。
然而,axios 的缺点在于,它需要我们自行管理 loading 和 error 状态以及处理请求结果,这样的处理方式较为繁琐。
相比之下,useFetch 内置 loading 和 error 的处理,并且请求的返回结果也是响应式的,更符合现代 vue的理念。
本文,我们将使用 vueuse 的 createFetch、useFetch封装一个基础的请求方法,并且实现双 Token 无感刷新的功能。
首先 安装 @vueuse/core :
npm i @vueuse/core
基础请求库的封装
在与后端进行http交互时,一般会在请求头带上token 。因此,我们使用createFetch创建一个实例,统一处理 token 以及返回结果
import { getToken } from './utils'
import { createFetch } from '@vueuse/core'
// utils.ts
// export const getToken = () => localStorage.getItem('token')
const baseUrl = 'http://example.com'
export const _useFetch = createFetch({
baseUrl,
options: {
async beforeFetch({ options, cancel }) {
const token = getToken()
// 添加 Authorization
if (token) {
options.headers = {
...options.headers,
Authorization: `Bearer ${token}`,
}
} else {
cancel()
}
return { options }
},
async afterFetch(params) {
if (
params.response.headers
.get('Content-Type')
?.includes('application/json')
) {
params.data = await params.response.json()
// 对请求返回结果 code 进行处理
// Response { code: number, data: unknown, message: string }
if (params.data.code !== 0) {
throw new Error(params.data.message)
}
}
return params
}
}
})
至此,一个简单的 useFetch 请求方法已经封装完成。 接下来,我们使用 mock 的方式进行测试。
由于
useFetch是对原生fetch的一层封装,因此我们需要mock底层的fetch
安装 fetch-mock:
npm i fetch-mock
对请求 /user 进行 mock
// user.mock.ts
import fetchMock from 'fetch-mock'
fetchMock.get(`http://example.com/user`, () => {
return {
code: 0,
data: 'user',
message: '请求成功',
}
}, { overwriteRoutes: true })
接下来编写单元测试
这里使用
vitest进行单元测试,具体安装这里不多赘述
import { describe, test, expect, vi } from 'vitest'
import { watch } from 'vue'
import './user.mock' // 导入上述的 mock 请求
import { _useFetch } from './request'
const { token } = vi.hoisted(() => ({
token: { value: 'token' }
}))
vi.mock('./utils', () => {
return {
getToken: vi.fn(() => token.value),
}
})
describe('_useFetch should work', () => {
/**
* @vitest-environment jsdom
*/
test('should return user data with successful response', async () => {
return new Promise<void>(resolve => {
const { isFinished, data: res } = _useFetch('/user')
watch(isFinished, () => {
expect(res.value.code).toBe(0)
expect(res.value.data).toBe('user')
expect(res.value.message).toBe('请求成功')
resolve()
})
})
})
/**
* @vitest-environment jsdom
*/
test('should return null when token is not provided', () => {
token.value = null
return new Promise<void>(resolve => {
const { isFinished, data: res } = _useFetch('/user')
watch(isFinished, () => {
expect(res.value).toBe(null)
resolve()
// reset token value
token.value = 'token'
})
})
})
})
运行 npx vitest 进行结果测试:
至此,一个简单的请求方法也就封装完成了。
双 Token 无感刷新
目前,使用双 Token 实现无感刷新是一种广泛采用的解决方案。接下来,我们使用 useFetch 实现。
首先,是对 afterFetch 进行改进,上述我们只判断了 code 为 success(0) 的情况,现在我们需要添加一个对 token expired 的判断情况, 并且调用 refreshToken 的 Api
+ import { refreshApi } from './refresh'
// ...
async afterFetch(params) {
if (
params.response.headers
.get('Content-Type')
?.includes('application/json')
) {
params.data = await params.response.json()
// 对请求返回结果 code 进行处理
// Response { code: number, data: unknown, message: string }
if (params.data.code !== 0) {
+ // token expaired
+ if (params.data.code === 401 && localStorage.getItem('refreshToken')) {
+ // 需要阻塞这个请求结果,后续获取到新的 Token 时,还需重新调用接口获取结果
+ try {
+ const data = await refreshApi()
+ const { token, refreshToken } = data
+ localStorage.setItem('token', token)
+ localStorage.setItem('refreshToken', refreshToken)
+ } catch (error) {
+ localStorage.removeItem('token')
+ localStorage.removeItem('refreshToken')
+ throw new Error(`[refresh token]: ${error?.toString()} ${params.data.message}`)
+ }
+ } else {
throw new Error(params.data.message)
+ }
}
}
return params
}
refresh Api
接下来就是对 refreshApi 的实现,根据上述的需求,我们需要将 refreshApi 封装一个返回Promise 的方法。
这里我们还是使用
useFetch实现。
import { useFetch } from "@vueuse/core"
import { effectScope, watch } from "vue"
export const refreshApi = () => {
const scope = effectScope()
const ret = new Promise((resolve, reject) => {
scope.run(() => {
const { isFinished, data: response } = useFetch('http://example.com/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer ' + (localStorage.getItem('token') || ''),
},
}, {
afterFetch: async (params) => {
if (
params.response.headers
.get('Content-Type')
?.includes('application/json')
) {
try {
params.data = await params.response.json()
if (params.data.code !== 0) {
throw new Error(params.data.message)
}
} catch (error) {
// error
}
}
return params
},
})
watch(isFinished, () => {
if (response.value.code === 0) {
resolve(response.value.data)
} else {
reject(response.value.message)
}
})
})
})
ret.finally(() => {
scope.stop()
}).catch((error) => {
console.error(error)
})
return ret
}
现在,我们实现了 token 过期时自动请求refreshApi 获取新的token。
重新发起请求
由于在 afterFetch 我们拿不到header 以及请求的 url ,因此我们需要对 _useFetch 在封装一层。
注意: 上述实现的是
_useFetch,为的就是现在还需封装一层。
import { ref, effectScope } from 'vue'
// ...
export function useRequest<T>(...rest: Parameters<typeof _useFetch>) {
const {
onFetchResponse,
isFetching: _isFetching,
isFinished: _isFinished,
onFetchError,
data,
execute,
...otherParams
} = _useFetch<T>(...rest)
const isFetching = ref(true)
const isFinished = ref(false)
const scope = effectScope()
scope.run(() => {
watchEffect(() => {
if (_isFetching.value !== isFetching.value) {
isFetching.value = _isFetching.value
}
if (_isFinished.value !== isFinished.value) {
isFinished.value = _isFinished.value
}
})
})
const stop = () => {
scope.stop()
isFetching.value = false
isFinished.value = true
}
onFetchResponse(async (ctx) => {
try {
if (ctx.headers.get('Content-Type') === 'application/json') {
const response = data.value as { code: number } | null
if (response?.code === 401 && localStorage.getItem('token')) {
// 重新执行请求
await execute()
}
}
} finally {
stop()
}
})
onFetchError(() => {
stop()
})
return {
onFetchResponse,
onFetchError,
isFetching,
isFinished,
data,
...otherParams,
}
}
接下来编写单元测试:
由于篇幅原因,这里只简述了一小段的有用的单测(:
import { describe, vi, test, expect } from 'vitest';
import fetchMock from 'fetch-mock'
import { useRequest } from './request';
import { watch } from 'vue';
const { refreshData, count } = vi.hoisted(() => {
return {
count: { value: 0 },
refreshData: { value: null as any },
getData: () => count.value++
}
})
fetchMock.get(`http://example.com/user1`, () => {
count.value++
if (count.value % 2 === 0) {
return {
code: 0,
data: {
name: 'John',
age: 30,
},
message: '请求成功'
}
}
return {
code: 401,
data: null,
message: '请求失败'
}
}, { overwriteRoutes: true })
fetchMock.get(`http://example.com/user2`, () => {
return {
code: 401,
data: null,
message: '请求失败 refresh token 过期'
}
}, { overwriteRoutes: true })
fetchMock.post('http://example.com/refresh', () => {
return refreshData.value
})
class MockError extends Error {
constructor(message: string) {
super(message);
this.name = 'MockError';
}
}
const localStorageMock = {
store: {},
setItem(key, value) {
this.store[key] = value;
},
getItem(key) {
return this.store[key] || null;
},
removeItem(key) {
delete this.store[key];
},
clear() {
this.store = {};
}
};
const consoleErrorMock = vi.fn();
vi.stubGlobal('console', { ...console, error: consoleErrorMock });
vi.stubGlobal('localStorage', localStorageMock);
vi.stubGlobal('Error', MockError);
describe('refresh module', () => {
/**
* @vitest-environment jsdom
*/
test('refresh is success', () => {
refreshData.value = {
code: 0,
data: {
token: 'new-token',
refreshToken: 'new-refresh-token'
},
message: '请求成功'
}
localStorageMock.setItem('token', 'mockToken')
localStorageMock.setItem('refreshToken', 'refreshToken')
return new Promise<void>(resolve => {
const { data: response, isFinished } = useRequest('http://example.com/user1', { method: 'get' })
watch(isFinished, () => {
expect(response.value.data).toEqual({ name: 'John', age: 30, })
refreshData.value = null
localStorageMock.removeItem('token')
localStorageMock.removeItem('refreshToken')
resolve()
})
})
})
/**
* @vitest-environment jsdom
*/
test('refreshToken is not exist', () => {
localStorageMock.setItem('token', 'mockToken')
return new Promise<void>(resolve => {
const { data: response, isFinished, error } = useRequest('http://example.com/user1', { method: 'get' })
watch(isFinished, () => {
expect(response.value).toEqual(null)
expect(error.value).toEqual('请求失败')
localStorageMock.removeItem('token')
resolve()
})
})
})
/**
* @vitest-environment jsdom
*/
test('refresh token is expired', () => {
refreshData.value = {
code: 401,
data: null,
message: 'token 已过期'
}
localStorageMock.setItem('token', 'mockToken')
localStorageMock.setItem('refreshToken', 'refreshToken')
return new Promise<void>(resolve => {
const { data: response, isFinished,error } = useRequest('http://example.com/user2', { method: 'get' })
watch(isFinished, () => {
expect(response.value).toEqual(null)
expect(error.value).toEqual('[refresh token]: token 已过期 请求失败 refresh token 过期')
expect(consoleErrorMock).toBeCalledWith('token 已过期')
refreshData.value = null
localStorageMock.removeItem('token')
localStorageMock.removeItem('refreshToken')
resolve()
})
})
})
})
运行结果如下:
至此,我们实现了一个简易版本的请求框架,并且能够实现双 Token 刷新功能。