@sgysldz/http-request
v1.0.0
Published
企业级 axios 封装:拦截器、错误处理、取消、重试、Token 管理
Readme
http-request
基于 axios 的传输层封装(企业常见 HTTP 能力的基础设施),在不大改 axios 使用方式的前提下,补齐:
| 能力 | 摘要 |
| -------------- | --------------------------------------------------------------------------------------------- |
| Trace Id | 对每个请求自动补齐 trace 请求头(可按请求关闭或覆盖) |
| 取消与竞态 | 通过 cancelKey 让「同语义」请求只保留最新一次,旧请求自动 abort |
| 在途去重 | 对幂等请求(默认 GET / HEAD / OPTIONS)合并尚未结束的相同请求,共享同一个 Promise |
| 重试 | 可配置次数、是否重试、退避;默认偏保守,避免 POST 等误重试 |
| 离线短路 | 在支持 navigator 的环境里,离线时尽早失败,避免无意义等待 |
| 错误归一 | 将 axios / 取消 / 未知抛错统一为 HttpError,便于类型守卫与上报 |
本包刻意不做:鉴权、Authorization / Token 刷新、业务 { code, data } 协议解包、根据 HTTP 401 跳转登录、全局 Toast/MessageBox。这些应在业务项目里通过 axios 拦截器或再包一层 API 模块实现,以便与你们的账号体系、路由、UI 框架一致。
目录
- 设计边界与分层建议
- 安装与引用
- 创建客户端
createHttpClient - 请求级扩展字段
RequestExtraConfig - 拦截器与内部流程
- 取消:
cancelKey与cancel/cancelAll - 去重:
dedupe与buildDedupeKey - 重试:
retry、默认策略与退避公式 - Trace Id
- 错误模型
HttpError与归一化规则 - 调试
debug onTransportError与全局提示- 与业务拦截器组合
- 构建与发布产物
- 类型与导出索引
- 常见问题(FAQ)
设计边界与分层建议
推荐的分层是:
- 本包:网络传输语义(取消、去重、重试、trace、错误形状)。
- 业务 axios 拦截器:请求侧注入 Token / 租户头;响应侧解析后端业务信封(若有)、统一错误或刷新 token。
- API 函数层:按接口路径组织
getUser(id)等,对外返回业务 DTO。
这样 http-request 可以稳定复用在多个项目,而业务规则只写一次在你自己的封装里。
安装与引用
Monorepo 内其他包在 package.json 中声明依赖(示例):"http-request": "workspace:*",然后:
import { createHttpClient, HttpError, isHttpError, IDEMPOTENT_METHODS } from "http-request";对外发布时,本包 不会把 axios打进 bundle(tsdown 中将 axios 标为 external),宿主项目仍需安装 axios,版本建议与该 monorepo 的 pnpm catalog 保持一致。
源码开发时,package.json 的 "main" / "exports" 可能指向 ./src/index.ts,由宿主工具链解析;发布后 publishConfig 指向 dist/**。
创建客户端 createHttpClient
import { createHttpClient } from "http-request";
const http = createHttpClient({
baseURL: "/api",
timeout: 15_000,
headers: { "X-Client": "web" },
retry: 2,
retryDelay: 300,
dedupe: true,
traceIdHeader: "X-Trace-Id",
onTransportError: (err) => console.error("[transport]", err.type, err.message),
debug: false,
});
type User = { id: string; name: string };
const list = await http.get<User[]>("/users");返回值类型为 HttpClient:在 raw 未为 true 时,get/post 等方法返回 Promise<T>(即 response.data),与 axios 常见用法一致;需要 headers / status 时使用 { raw: true }。
实例上额外挂载:
http.cancel(key: string):按 key 中止当前未完成请求;http.cancelAll():取消池内全部请求并清空去重池(见下文)。
工厂选项 CreateHttpClientOptions(默认值)
| 选项 | 类型 | 默认 | 说明 |
| ------------------ | ------------------------ | ------------------------------ | ------------------------------------------------------------------- |
| baseURL | string | 无 | 传给 axios.create。 |
| timeout | number | 15000 | 毫秒;超时归一为 HttpError,type: "timeout"。 |
| headers | Record<string, string> | 无 | 实例级默认头;单次请求仍可覆盖。 |
| retry | number | 0 | 实例默认不重试;可被单次请求的 config.retry 覆盖。 |
| retryDelay | number | 300 | 重试基础间隔(毫秒),见 退避公式。 |
| retryCondition | RetryCondition | 源码内默认值 | (error, nextAttemptNumber) => boolean。 |
| retryBackoff | RetryBackoff | 指数退避 | 单次等待上限内部硬编码为 10000 ms。 |
| dedupe | boolean | true | false 时关闭整实例的在途 GET 去重包装。 |
| traceIdHeader | string | "X-Trace-Id" | 设为 "" 可关闭自动 trace。 |
| genTraceId | () => string | randomUUID 去横线 / 32位 hex | 自定义 trace 生成。 |
| onTransportError | (HttpError) => void | 无 | 见 专节。 |
| debug | boolean | false | 打印请求/响应行级日志,不含 body。 |
请求级扩展字段 RequestExtraConfig
这些字段挂在标准 axios config 上;axios 本身忽略它们,仅本库拦截器读取。
| 字段 | 说明 |
| ---------------- | --------------------------------------------------------------------------------------------- |
| cancelKey | 字符串。同 key 的新请求会 abort 尚未完成的旧请求;结束或失败(非 cancel)时从取消池释放。 |
| dedupe | false 时本条请求不参与在途去重(仅当工厂未关闭去重时有效)。 |
| retry | 覆盖工厂默认重试次数。 |
| retryDelay | 覆盖工厂默认基础延迟。 |
| retryCondition | 覆盖工厂默认判别函数。 |
| retryBackoff | 覆盖工厂默认退避。 |
| raw | true 时返回完整 AxiosResponse;否则返回 response.data。 |
| silent | true 时失败仍 reject,但不会触发 onTransportError。 |
| meta | 任意 JSON 友好的对象信息;暴露在 HttpError.toJSON() 中便于上报附加字段。 |
示例
await http.get("/search", {
params: { q: "vue" },
cancelKey: "search-list",
meta: { scene: "list", tab: "all" },
});
const res = await http.get("/health", { raw: true });
console.log(res.status, res.headers);
http.cancel("search-list");
http.cancelAll();拦截器与内部流程
用一张简化的因果关系说明「一次调用」经过哪些步骤(不涉及你在业务里额外注册的拦截器先后顺序):
- 可选:GET 去重包装
若工厂开启 dedupe,且本条满足 去重条件,则
instance.request会先查表里是否已有未结束的同 key Promise;若有,直接返回该 Promise(不再走入下一步的新请求)。 - 请求拦截器(本库)
- 离线:若在浏览器环境里
navigator.onLine === false,直接抛出HttpError(offline),不发起 HTTP。 - Trace:若配置了
traceIdHeader且请求头尚无该键,则写入新 id。 - cancelKey:若存在
cancelKey且config.signal未预设,则通过CancelManager绑定AbortController。若你自带signal,本库不会覆盖,方便与外层AbortController组合。
- 离线:若在浏览器环境里
- axios 发起真实网络请求。
- 响应拦截器(本库)
- 成功:释放
cancelKey、按raw决定返回AxiosResponse或data。 - 失败:将错误归一为
HttpError;若非「取消」则释放cancelKey;再根据重试策略 延迟后 再次调用instance.request(replayConfig),或reject。
- 成功:释放
重试克隆配置时会 去掉上一轮 signal,避免沿用已失效的 Abort 状态。
取消:cancelKey 与 cancel / cancelAll
CancelManager 行为(实例内部使用)
acquire(key):若该 key 上已有控制器,会先对旧控制器abort(),再为本次请求放入新AbortController。release(key, controller):请求完结时移除;若池中当前 controller 不是传入的这个(例如期间又来了一次acquire),则不误删,避免释放错对象。cancel(key):对当前 key abort 并从池移除。
http.cancel(key)、http.cancelAll()
http.cancel(key):等价于对内部CancelManager做一次cancel(key)。http.cancelAll():对所有追踪中的请求abort,并dedupePool.clear(),避免去重表中残留已不再需要的 Promise。
典型用法
- 列表检索:筛选条件变化时对同一
cancelKey(如"user-list")发新请求,旧请求自动作废。 - 路由离开 / 注销登录:调用
cancelAll()一次清场。
去重:dedupe 与 buildDedupeKey
何时会去重(全部满足)
- 工厂
dedupe !== false; - 本条
dedupe !== false; - HTTP 方法属于
IDEMPOTENT_METHODS:get、head、options; - 走包装后的
request入口。
POST / PUT / DELETE 等不会去重,避免合并「本应两次提交」的请求。
buildDedupeKey 的规则
源码形式(概念上):
METHOD url|stableStringify(params)|stableStringify(data)
METHOD会转成大写,url为该项上的config.url。stableStringify:对「普通对象」的键排序后再JSON.stringify,使属性顺序不同的对象仍得到相同 key;无法序列化时退化为String(value),undefined视为空字符串段。
生命周期
- 仅合并 in-flight:第一次请求未完成前,后来的同 key 调用共享同一
Promise。 - 任一 settle 后:该 key 从池移除;下一次相同参数会发起新一轮 HTTP。
- 与取消:全局
cancelAll会清空去重池,避免长时间持有引用。
导出 buildDedupeKey、DedupePool 可供你在不写 createHttpClient 的场景下自建合并逻辑。
重试:retry、默认策略与退避公式
参与计算的变量
maxRetry:config.retry ?? 工厂.retry(工厂默认常为0)。- 内部
state.attempt:从 0 开始累加;每进入一次「准备重试」前会+1。 retryCondition(err, attemptNumber)的第二个参数:实现里传入的是state.attempt + 1,即含义为「这一轮是不是第几次重试尝试(从 1 起计数)」。
仅当 state.attempt < maxRetry 且 retryCondition 返回 true 时才会 sleep 后 instance.request(replayConfig)。
默认 retryCondition 语义(简述)
源码逻辑可概括为:
| 分支 | 结果 |
| --------------------------------------------- | ------------------------------------------------------ |
| HttpError.type === "cancel" | 不重试 |
| HTTP 方法不是 IDEMPOTENT_METHODS 之一 | 不重试(故默认 POST 等不会因网络失败自动重试) |
| timeout 或错误路径体现的 offline | 重试 |
| network 且无 response?.status | 重试(视为未拿到 HTTP 语义的差错) |
| network 且 response.status 在 500–599 | 重试 |
| 其他(含 4xx) | 不重试 |
你可以为某次调用单独传入 retryCondition,在确认接口幂等的前提下让 POST 也能重试。
退避公式(默认实现)
对第 attempt 次重试前等待:
[ \text{delay} = \min\bigl(\text{baseDelay} \times 2^{(\text{attempt} - 1)},\ 10000\bigr) ]
其中 baseDelay = config.retryDelay ?? 工厂.retryDelay(默认 300 ms)。attempt 这里与内部 state.attempt 一致(第一次重试前 state.attempt 已加为 1,因此第一次等待通常是 baseDelay * 2^0)。
单次等待上限:10000 ms(代码常量 DEFAULT_RETRY_MAX_DELAY)。
Trace Id
- 默认响应头名为
X-Trace-Id。 - 若调用方已在 headers 设置同名键,库不会覆盖(便于接入已有网关链路)。
traceIdHeader设为""时整块逻辑跳过(不自动生成、不写头)。- 默认生成:
globalThis.crypto.randomUUID()去掉横线;不可用时降级为随机 32 位十六进制字符串。
错误模型 HttpError 与归一化规则
类型一览 HttpErrorType
| type | 典型含义 |
| --------- | ----------------------------------------------------------------------------------------------- |
| network | 多数 AxiosError 归为这一类;附带 response、code(多为 HTTP status)。不代表一定断网。 |
| timeout | axios 判定超时(源码以 ECONNABORTED 等为依据)。 |
| offline | 请求拦截器抛出,或 axios 分支里检测到 navigator.onLine === false。 |
| cancel | axios.isCancel 或 Abort 链路。 |
| unknown | 非 AxiosError、非已有的 HttpError 的普通 Error;或其它值被转成字符串 |
说明
4xx在默认策略下通常为network+code,是否与 401 搭配登录跳转由业务自行实现,本库只提供常见 status 的中文message模板(便于直接展示或使用toJSON上报)。401/403的文案仅是默认提示字符串,不包含「自动跳转」语义。
默认状态文案表(节选,完整见源码 STATUS_HINT):400 / 401 / 403 / 404 / 408 / 429 / 500 / 502 / 503 / 504 等。
HttpError 上有用的字段
type、message、name(固定为HttpError)code:常为 HTTP statusconfig:最近一次相关请求配置(若可得)response:axios response(若有)cause:在非旧环境可能没有;支持时指向原始AxiosError等
toJSON()
返回普通对象:name、type、code、message、url、method、status、meta(来自 config.meta)等字段,便于 JSON.stringify 发往日志/SDK。
类型守卫
import { isHttpError, isCanceledError, isNetworkError, isTimeoutError } from "http-request";
try {
await http.get("/x");
} catch (e) {
if (isCanceledError(e)) return;
if (isTimeoutError(e)) {
// 超时弱网提示等
}
if (isNetworkError(e) && e.code === 401) {
// 业务:跳转登录、清 token —— 与本库无关,自行实现
}
if (isHttpError(e)) {
console.log(e.toJSON());
}
}调试 debug
debug: true 时向控制台打印(不含 body):
- 请求发出:
→ METHOD url - 成功响应:
← status METHOD url
请勿在生产环境长期开启(避免噪音与潜在的路径信息泄露)。
onTransportError 与全局提示
若在工厂传入 onTransportError:
- 在
HttpError已确定、reject之前,若非cancel且请求未设silent: true,会调用onTransportError(err)。 - 常用于「全局 toast:网络开小差了」等车;不负责路由跳转等业务副作用。
若在页面内自行 try/catch 并希望不要触发全局 toast,使用该请求的 silent: true。
与业务拦截器组合
常见做法
const http = createHttpClient(...)后立即http.interceptors.request.use,注入Authorization、租户、语言等。- 全局
onTransportError弹出非侵入式提示;关键页面用silent+ 本地catch覆盖体验。
关于执行顺序
axios 对响应阶段的拦截器采用「后注册先执行」等链式规则;你在 createHttpClient 之后追加的拦截器与库内置拦截器的先后,会决定你拿到的是原始 AxiosError 还是已归一的 HttpError。若你需要完全掌控,请阅读当前使用的 axios 官方文档中 Interceptor 章节,并通过实验确认错误对象形态。稳妥做法:页面与模块级逻辑以 catch + isHttpError 为主,减少对响应拦截器的依赖。
构建与发布产物
pnpm --filter http-request buildtsdown产出dist:ESM、CJS 与d.ts(见tsdown.config.ts)。axios:不打进 bundle,deps.neverBundle包含axios。platform:配置为browser(若以 Node 直接跑,请注意navigator等与运行环境是否兼容)。
类型与导出索引
值 / 类
| 导出 | 作用 |
| --------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| createHttpClient | 工厂函数,返回带 cancel/cancelAll 的 HttpClient |
| CancelManager | 可按 key acquire/release/cancel 的取消管理器 |
| DedupePool | 按 key 缓存在途 Promise,结束后清理 |
| buildDedupeKey | 由 method/url/params/data 生成稳定 key |
| HttpError | 归一错误类 |
| IDEMPOTENT_METHODS | 默认幂等方法名列表(常量) |
| isHttpError / isCanceledError / isNetworkError / isTimeoutError | 类型守卫 |
常用类型(export type)
CreateHttpClientOptions、HttpClient、HttpInternalRequestConfig、HttpRequestConfig、RequestExtraConfig、RetryBackoff、RetryCondition、TraceIdGenerator、HttpErrorOptions、HttpErrorType 等——详情以 src/types.ts 为准。
常见问题(FAQ)
为什么在请求拦截器里就抛 offline,而不是发到服务器再报错?
尽早失败可缩短加载态与无用等待;是否与「假在线」环境下的策略冲突,可由业务评估(例如再配合你们自己的连通性探测)。
GET 去重会和 cancelKey 冲突吗?
二者解决不同问题:去重合并「完全相同」且在途的请求;cancelKey 取消「同 key」上的上一次未完成请求。建议对不同语义分支使用不同 cancelKey,避免本应分开的去重被撞在一起。
为什么默认不给 POST 重试?
重复提交可能产生重复订单、重复扣款等;默认只在幂等方法上自动重试。若业务确认安全,再开放 retry + 自定义 retryCondition。
Node 环境没有 navigator 会怎样?
离线检测分支可能不触发;若在 Node 报错,可自行评估 polyfill / 不传 navigator 相关逻辑。平台仍以浏览器为主(见 tsdown platform)。
为何有时 cancel 的 HttpError 没带 config?
归一路径里对 axios.isCancel 的实现只保证 HttpError(..., message),可按需在业务侧用 cause(若运行时支持)追查。
