为什么我把项目的 Axios 换成了 ky:模块化 Hook 实践
用 ky v2 替代 Axios 的完整实践。零依赖、原生 fetch、可组合的 Hook 系统——重新思考 HTTP 客户端在项目中的角色
在一个 React + Remotion 视频编辑器项目中,我们用 ky v2 替代了传统的 Axios 方案。零依赖、原生 fetch、可组合的 Hook 系统——这次迁移不只是换个库,而是重新思考 HTTP 客户端在项目中的角色。
起点
以下是迁移后的核心代码示例:
// 代码案例:src/services/http.ts
import ky from "ky"
import { API_PREFIX } from "@/constants/api"
import { authHook, errorToastHook } from "./http-hooks"
export const api = ky.create({
timeout: 15000,
hooks: {
afterResponse: [errorToastHook, authHook],
},
})
// 扩展一个带 API 前缀的实例
export const appApi = api.extend({ prefix: API_PREFIX })12 行。没有拦截器工厂,没有 instance 包装类,没有 try/catch 全局处理。两个独立的 Hook 文件各自负责一件事:
errorToastHook—— 对 >= 400 的响应显示 toast 通知authHook—— 处理 401 未授权,必要时跳转登录页
Axios vs ky:选型对比
Axios 用了十年,生态成熟。但在我们的技术栈(React 19 + Vite 7 + TypeScript 6)里,它开始显得笨重。
| 维度 | Axios | ky v2 |
|---|---|---|
| 依赖 | 内置 polyfill,~15KB gzipped | 0 运行时依赖,~4KB gzipped |
| 基础 | 自定义 XHR/fetch 封装 | 原生 fetch 的轻量包装 |
| 拦截器 | 单链式 interceptors.request/response,全局注册 | 数组式 Hook,按实例隔离 |
| TypeScript | 需要维护类型声明 | 原生 TS,类型与实现同步 |
| 实例继承 | axios.create() + 拦截器传递 | ky.extend() 天然继承 hooks |
| 模块化 | 拦截器集中在一个函数里 | 每个 Hook 是独立函数 |
关键差异在 Hook 系统。
Axios 的拦截器是一个单链:
// Axios 方式:所有逻辑堆在一个函数里
axios.interceptors.response.use(
(response) => response,
(error) => {
// 401 处理
if (error.response?.status === 401) { ... }
// toast 通知
if (error.response?.status >= 400) { ... }
// 日志
logger.error(...)
// 全部塞在这里
return Promise.reject(error)
}
)ky 的 afterResponse 是数组,每个函数独立:
// ky 方式:每个 Hook 单一职责
afterResponse: [errorToastHook, authHook, loggerHook]当你想加一个新逻辑时,区别就出来了:Axios 需要修改已有的拦截器函数;ky 只需要写一个新函数并推入数组。
ky 的 Hook 执行模型
ky v2 的 AfterResponseHook 签名接收一个 state 对象:
type AfterResponseHook = (state: AfterResponseState) =>
| Response
| void
| Promise<Response | void>
interface AfterResponseState {
request: KyRequest
options: NormalizedOptions
response: KyResponse // clone 后的
retryCount: number
}执行规则:
- 顺序串行:按数组索引依次执行,前一个的返回值(如果是 Response)会替换 response 传给下一个
- 异步支持:hook 可以是 async 函数,ky 会 await 每个结果再执行下一个
- Response 是一次性的:hook 收到的是 clone,但如果你消费了 body(如
.json()),后续 hook 和调用者就看不到了。需要读取时再 clone ky.retry()短路:返回ky.retry()会跳过剩余 hook 直接重试- 抛出的错误是致命的:不会触发重试,直接传播给调用者
项目中的实践
认证 Hook
// 代码案例:src/services/http-hooks/auth-hook.ts
import { navigateToLogin } from "@/utils/auth" // 替换为你的登录跳转逻辑
import type { AfterResponseHook } from "ky"
export const authHook: AfterResponseHook = async ({ request, response }) => {
if (response.status === 401) {
const skipRedirect = request.headers.get("X-Silent-Auth") === "true"
if (!skipRedirect) {
navigateToLogin()
}
}
}这里有一个细节:X-Silent-Auth 请求头。某些后台轮询请求(如检查任务状态)不需要因为 token 过期就跳转登录页——用户可能根本没感知到那次请求。通过请求头标记,让调用方自己决定是否要静默处理。
错误通知 Hook
// 代码案例:src/services/http-hooks/error-toast-hook.ts
import { toast } from "sonner" // 可替换为你的通知库
import type { AfterResponseHook } from "ky"
export const errorToastHook: AfterResponseHook = ({ request, response }) => {
if (response.status >= 400) {
const pathname = new URL(request.url).pathname
const toastId = `${request.method}:${pathname}`
toast.error("请求失败", {
id: toastId,
description: `HTTP ${response.status}`,
})
}
}用 method:path 作为 toast id,防止重复请求产生多个相同的错误提示。Sonner 的 id 参数会自动去重,同一个请求的多次失败只弹一次。
执行顺序
afterResponse: [errorToastHook, authHook]通用逻辑(通知、日志)在前,业务逻辑(认证)在后。理由:
- 如果
authHook将来需要刷新 token 并返回新的 Response(通过ky.retry()),errorToastHook已经在第一轮执行过了,不会被重复触发 - 认证处理可能需要替换 response,放在后面可以确保通知层已经记录了原始状态
可扩展性
新增一个日志 Hook 只需要三个步骤:
// 代码案例:新增日志 Hook
// 1. 写文件:src/services/http-hooks/logger-hook.ts
export const loggerHook: AfterResponseHook = ({ request, response }) => {
console.log(`${response.status} ${request.method} ${request.url}`)
}
// 2. 导出到 index.ts
export { loggerHook } from "./logger-hook"
// 3. 加入数组
afterResponse: [loggerHook, errorToastHook, authHook]不需要碰已有的文件。每个 Hook 独立测试,独立维护。
如果你需要为某个 API 实例创建不同的 hook 组合,ky.extend() 天然支持:
// 代码案例:ky.extend() 实例组合
// 带日志的调试实例
export const debugApi = api.extend({
hooks: {
afterResponse: [loggerHook],
},
})
// 不带错误通知的静默实例
export const silentApi = api.extend({
hooks: {
afterResponse: undefined, // 清除继承的 hooks
},
})零依赖的优势
ky 没有运行时依赖。它的 package.json 里只有 devDependencies。
这意味着:
- 没有 polyfill 包袱:不引入
follow-redirects、form-data这些在浏览器环境根本不需要的东西 - Tree-shaking 友好:不用的部分直接被摇掉
- fetch 原生:行为与浏览器
fetch一致,没有封装层带来的意外行为差异 - 无缝回退:如果哪天想去掉 ky,你的
Request/Response代码不需要改——ky 本身就是它们的薄包装
对比 Axios 的依赖图(包含 follow-redirects、form-data、proxy-from-env 等),ky 在浏览器端的包袱几乎为零。
代价
ky 不是银弹。有些场景需要权衡:
- 服务端需要 polyfill:Node.js 18+ 有原生 fetch,但更低版本需要
undici或node-fetch - 没有请求拦截器:ky 有
beforeRequesthook,但它是基于实例的。如果你需要全局的请求签名注入,需要自己设计 - 生态小:没有
axios-mock-adapter这种成熟的测试工具。Mock 测试需要自己用beforeRequest返回 Response 来模拟 - 拦截器 vs Hook 心智模型不同:团队从 Axios 迁移时需要适应——Hook 是数组不是链,每个函数独立执行,不能像 Axios 那样通过
next(error)传递错误
文件结构
src/services/
├── http.ts # 主入口:创建 api 实例,组合 hooks
├── http-hooks/
│ ├── index.ts # 聚合导出
│ ├── auth-hook.ts # 401 认证处理
│ └── error-toast-hook.ts # >= 400 错误 toast 通知每个文件不超过 20 行。新增 Hook 不需要修改已有代码。
总结
- ky v2 是
fetch的轻量包装,0 运行时依赖,~4KB gzipped - Hook 系统是数组而非单链,每个 Hook 独立、可测试、可复用
ky.extend()天然支持实例继承和 hook 组合- 适合现代浏览器项目,尤其是已经用
fetch/Request/ResponseAPI 的技术栈 - 代价是生态较小,需要自建 Mock 方案和请求拦截模式