Skip to content

@sud-web/http

基于 axios 的后台请求封装,提供 CSRF Token 注入、响应头 Token 缓存、默认业务响应处理、401 refresh 重试和请求/响应 hook。

安装

bash
pnpm add @sud-web/http axios

axios 是 peer dependency,需要由业务项目安装。

导出

ts
import { createAxios, RefreshManager } from '@sud-web/http'
API说明
createAxios创建带拦截器的 axios 实例
RefreshManager401 刷新与并发请求队列管理

实际使用

@sud-web/http 不直接在业务里裸用,而是分三层落地,下面三个文件就是一个真实项目里的完整链路:

  1. request 层 utils/service.ts:用 createAxios 装配实例(CSRF、refresh、错误提示),对外只导出 request
  2. api 层 api/table/index.ts:把每个接口写成带类型的函数,统一调用 request<T>,页面不关心 url / method。
  3. 使用场景 views/UserList.vue:页面只 import api 函数,配合 PcTablerequest-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 刷新重试,固定写法
onResponsecode===0 约定、错误 reject包能力(约定)后端响应体 { code, data, msg } 的统一处理
onResponsecode===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 等)
refreshManagerRefreshManager 实例;配置后 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 === 0resolve 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