Appearance
@sud-web/http
基于 axios 的后台请求封装,提供 CSRF Token 注入、响应头 Token 缓存、默认业务响应处理、401 refresh 重试和请求/响应 hook。
安装
bash
pnpm add @sud-web/http axiosaxios 是 peer dependency,需要由业务项目安装。
导出
ts
import { createAxios, RefreshManager } from '@sud-web/http'| API | 说明 |
|---|---|
createAxios | 创建带拦截器的 axios 实例 |
RefreshManager | 401 刷新与并发请求队列管理 |
实际使用
@sud-web/http 不直接在业务里裸用,而是分三层落地,下面三个文件就是一个真实项目里的完整链路:
- request 层
utils/service.ts:用createAxios装配实例(CSRF、refresh、错误提示),对外只导出request。 - api 层
api/table/index.ts:把每个接口写成带类型的函数,统一调用request<T>,页面不关心 url / method。 - 使用场景
views/UserList.vue:页面只 import api 函数,配合PcTable的request-api或手动调用。
ts
import type { AxiosRequestConfig } from 'axios'
import { createAxios, RefreshManager } from '@sud-web/http'
import { get } from 'lodash-es'
import { refreshTokenApi } from '@/api/login'
import router from '@/router'
import { redirectToLogin } from '@/utils'
// 401 刷新:refreshTokenApi 自己要带 enableRefresh:false,避免刷新接口又触发刷新
const refreshManager = new RefreshManager(refreshTokenApi, {
onRefreshFail: () => redirectToLogin()
})
function createService() {
return createAxios({
refreshManager,
axiosConfig: {
timeout: 30000,
baseURL: import.meta.env.VITE_BASE_API
},
hooks: {
// 接管成功响应:自定义 code 分支(提供后跳过默认 code===0 判断)
onResponse(response) {
const code = get(response, 'data.code')
if (code === 0) return Promise.resolve(response.data)
if (code === 2000) router.replace({ name: 'Dashboard' })
else ElMessage.error(response.data?.msg || '接口错误')
return Promise.reject(response.data)
},
// 接管 HTTP 错误(非业务 code)
onResponseError(error) {
const status = get(error, 'response.status')
const map: Record<number, string> = {
400: '请求错误', 403: '拒绝访问', 404: '请求地址出错',
500: '服务器内部错误', 502: '网关错误', 504: '网关超时'
}
ElMessage.error(map[status] || '网络异常')
return Promise.reject(error)
}
}
})
}
/** 网络请求实例 */
export const service = createService()
/** 业务统一用这个方法发请求 */
export function request<T>(config: AxiosRequestConfig): Promise<T> {
return service(config)
}ts
import type * as Table from './types/table'
import { request } from '@/utils/service'
/** 增 */
export function createTableDataApi(data: Table.ICreateTableRequestData) {
return request({ url: 'table', method: 'post', data })
}
/** 删 */
export function deleteTableDataApi(id: string) {
return request({ url: `table/${id}`, method: 'delete' })
}
/** 改 */
export function updateTableDataApi(data: Table.IUpdateTableRequestData) {
return request({ url: 'table', method: 'put', data })
}
/** 查(列表) */
export function getTableDataApi(params: Table.IGetTableRequestData) {
return request<Table.GetTableResponseData>({
url: 'table',
method: 'get',
params
})
}ts
export interface ICreateTableRequestData {
username: string
password: string
}
export interface IUpdateTableRequestData {
id: string
username: string
password?: string
}
export interface IGetTableRequestData {
/** 当前页码 */
currentPage: number
/** 每页条数 */
size: number
/** 查询参数:用户名 */
username?: string
}
export interface IGetTableData {
id: string
username: string
phone: string
status: boolean
createTime: string
}
// IApiResponseData 是项目全局声明的统一响应包裹:{ code, data, msg }
export type GetTableResponseData = IApiResponseData<{
list: IGetTableData[]
total: number
}>vue
<script setup lang="ts">
import { ref } from 'vue'
import {
getTableDataApi,
deleteTableDataApi
} from '@/api/table'
import type { IGetTableData } from '@/api/table/types/table'
const list = ref<IGetTableData[]>([])
const total = ref(0)
// 列表查询:api 层已带好类型,页面只关心入参 / 出参
async function load() {
const res = await getTableDataApi({ currentPage: 1, size: 10 })
list.value = res.data.list
total.value = res.data.total
}
// 删除:错误提示已在 service.ts 的 hooks 里统一处理,这里只管成功逻辑
async function remove(id: string) {
await deleteTableDataApi(id)
ElMessage.success('删除成功')
load()
}
load()
</script>配合
PcTable时更简单:直接把 api 函数传给request-api,分页/loading 由组件接管,页面连load()都不用写。vue<PcTable :request-api="getTableDataApi" :colum-list="columList" />
哪些是包能力,哪些是项目特有
上面的 service.ts 里,并非每一行都是 @sud-web/http 必需的。区分如下,换项目时包能力照搬,项目特有按自己的方案改写:
| 代码 | 归属 | 说明 |
|---|---|---|
createAxios({ ... }) / request 封装 | 包能力 | 固定写法,任何项目一致 |
RefreshManager + onRefreshFail | 包能力 | 401 刷新重试,固定写法 |
onResponse 里 code===0 约定、错误 reject | 包能力(约定) | 后端响应体 { code, data, msg } 的统一处理 |
onResponse 里 code===2000 跳 Dashboard 等业务分支 | 项目特有 | 业务约定的状态码,与包无关 |
CSRF / Token 的请求头注入与存取,包已内置默认方案,例子里不需要写任何代码(详见下方「关于 onCsrfToken」)。
关于 onCsrfToken
CSRF Token 的存取,包本身已有默认方案,不写 onCsrfToken 就会自动生效:
- 请求时:自动把
localStorage里的x-xsrf-token注入请求头X-XSRF-TOKEN; - 响应时:自动把响应头里的
x-xsrf-token写回localStorage。
所以多数项目什么都不用做,Token 头就会自动带上。只有当项目的 Token 方案和默认不一样(比如改用 cookie 存、用自定义头名)时,才需要自己接管:
ts
hooks: {
// 请求时:把自定义来源的 Token 注入到自定义请求头
onRequest(config) {
config.headers[CacheKey.TOKEN] = getToken()
config.headers[CacheKey.X_TOKEN] = getCsrfToken()
return config
},
// 响应时:把响应头里的 Token 回写到自定义存储(提供后默认 localStorage 存取整段跳过)
onCsrfToken(headers) {
headers[CacheKey.TOKEN] && setToken(headers[CacheKey.TOKEN])
headers[CacheKey.X_TOKEN] && setCsrfToken(headers[CacheKey.X_TOKEN])
}
}注意:一旦提供 onCsrfToken,默认的请求头注入也会一起被跳过,所以 onRequest + onCsrfToken 要么都不写(用默认 localStorage 方案),要么成对出现(用自定义方案),不要只写一个。
createAxios
createAxios(options) 返回一个配置好拦截器的 axios 实例(即上面的 service)。
参数
ts
interface CreateAxiosOptions {
hooks?: AxiosHooks
refreshManager?: RefreshHandler
axiosConfig?: Record<string, any>
}| 参数 | 说明 |
|---|---|
axiosConfig | 透传给 axios.create 的原生配置(baseURL / timeout 等) |
refreshManager | RefreshManager 实例;配置后 401 走刷新重试 |
hooks | 请求/响应各阶段的钩子,见下 |
Hooks
ts
interface AxiosHooks {
showErrorMessage?: (msg: string) => void
onCsrfToken?: (headers: Record<string, any>, response: AxiosResponse) => void
onRequest?: (
config: InternalAxiosRequestConfig
) => InternalAxiosRequestConfig | Promise<InternalAxiosRequestConfig>
onResponse?: (response: AxiosResponse) => any | Promise<any>
onResponseError?: (error: AxiosError) => any
}| Hook | 触发时机 | 默认行为关系 |
|---|---|---|
showErrorMessage | 默认响应处理发现 data.code !== 0 时 | 只负责提示,随后仍会 reject response.data |
onCsrfToken | 成功响应和错误响应都有 response 时 | 提供后不走默认 CSRF 存取逻辑 |
onRequest | 请求发出前 | 返回值会浅合并到原 config |
onResponse | 响应成功后 | 提供后跳过默认 code 判断 |
onResponseError | 非 2xx 错误且未进入 refresh 流程时 | 返回值会作为错误拦截器结果 |
默认行为
不传 hooks 中对应钩子时,走以下内置逻辑。上面的 service.ts 因为接管了 onCsrfToken / onResponse,实际跳过了默认 CSRF 存取与默认 code 判断。
请求拦截
默认从 localStorage 读取 x-xsrf-token,并写入请求头 X-XSRF-TOKEN。
ts
config.headers['X-XSRF-TOKEN'] = localStorage.getItem('x-xsrf-token')如果配置了 hooks.onCsrfToken,请求阶段不会自动注入 X-XSRF-TOKEN。
成功响应
响应头中存在 x-xsrf-token 时,默认写入 localStorage。
默认响应体格式约定为:
ts
type ApiResponse<T> = {
code: number
data?: T
msg?: string
}处理规则:
| 条件 | 返回 |
|---|---|
data.code === 0 | resolve response.data |
data.code !== 0 | 调用 showErrorMessage(data.msg) 后 reject response.data |
配置了 onResponse | 直接返回 onResponse(response) 的结果 |
错误响应
401 且配置了 refreshManager 时,会优先走 refresh 逻辑。其他错误会交给 onResponseError;未配置时 reject 原错误。
禁用单个接口的自动 refresh(刷新接口自身必须禁用,否则会死循环):
ts
export function refreshTokenApi() {
return request({
url: '/auth/token/refresh',
method: 'post',
config: {
enableRefresh: false
}
})
}注意:当前实现读取的是 config.config.enableRefresh,不是 config.options.enableRefresh。
RefreshManager
封装 401 刷新与并发请求排队:刷新进行中时,其余 401 请求挂起入队,刷新成功后统一重试。
ts
const refreshManager = new RefreshManager(refreshTokenApi, {
onRefreshFail: () => redirectToLogin()
})
const service = createAxios({ refreshManager })构造参数
ts
new RefreshManager(
refreshFn: () => Promise<any>,
options?: {
onRefreshFail?: () => void
}
)| 参数 | 说明 |
|---|---|
refreshFn | 刷新 Token 的请求函数,内部需带 config.enableRefresh = false |
onRefreshFail | 刷新失败回调,通常跳登录页 |
行为
| 场景 | 行为 |
|---|---|
| 首个 401 | 标记当前请求 _retry = true,调用 refreshFn |
| refresh 期间再次 401 | 请求加入队列,不重复调用 refreshFn |
| refresh 成功 | 重试原请求,并依次重试队列中的请求 |
已经 _retry 的请求再次进入 | 调用 onRefreshFail 并 reject refresh retry failed |
| refresh 失败 | 调用 onRefreshFail 并 reject refresh 错误 |
当前边界
以下行为保持当前实现,不在文档中承诺自动兜底:
| 边界 | 当前表现 |
|---|---|
请求 config 没有 headers | 默认 CSRF 注入会依赖 headers 已存在 |
axios 网络错误没有 response | 错误拦截器会读取 response.headers,业务侧建议在 axios 层保证错误格式或后续修复 |
| refresh 失败时已有排队请求 | 当前只 reject 触发 refresh 的请求 |
测试
bash
cd packages/http
pnpm test