为什么我把项目的 Axios 换成了 ky:模块化 Hook 实践

用 ky v2 替代 Axios 的完整实践。零依赖、原生 fetch、可组合的 Hook 系统——重新思考 HTTP 客户端在项目中的角色

#ky#Axios#HTTP#React#TypeScript#前端架构

在一个 React + Remotion 视频编辑器项目中,我们用 ky v2 替代了传统的 Axios 方案。零依赖、原生 fetch、可组合的 Hook 系统——这次迁移不只是换个库,而是重新思考 HTTP 客户端在项目中的角色。

起点

以下是迁移后的核心代码示例:

TypeScript
// 代码案例: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)里,它开始显得笨重。

维度Axiosky v2
依赖内置 polyfill,~15KB gzipped0 运行时依赖,~4KB gzipped
基础自定义 XHR/fetch 封装原生 fetch 的轻量包装
拦截器单链式 interceptors.request/response,全局注册数组式 Hook,按实例隔离
TypeScript需要维护类型声明原生 TS,类型与实现同步
实例继承axios.create() + 拦截器传递ky.extend() 天然继承 hooks
模块化拦截器集中在一个函数里每个 Hook 是独立函数

关键差异在 Hook 系统

Axios 的拦截器是一个单链:

TypeScript
// 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 是数组,每个函数独立:

TypeScript
// ky 方式:每个 Hook 单一职责
afterResponse: [errorToastHook, authHook, loggerHook]

当你想加一个新逻辑时,区别就出来了:Axios 需要修改已有的拦截器函数;ky 只需要写一个新函数并推入数组。

ky 的 Hook 执行模型

ky v2 的 AfterResponseHook 签名接收一个 state 对象:

TypeScript
type AfterResponseHook = (state: AfterResponseState) =>
  | Response
  | void
  | Promise<Response | void>

interface AfterResponseState {
  request: KyRequest
  options: NormalizedOptions
  response: KyResponse    // clone 后的
  retryCount: number
}

执行规则:

  1. 顺序串行:按数组索引依次执行,前一个的返回值(如果是 Response)会替换 response 传给下一个
  2. 异步支持:hook 可以是 async 函数,ky 会 await 每个结果再执行下一个
  3. Response 是一次性的:hook 收到的是 clone,但如果你消费了 body(如 .json()),后续 hook 和调用者就看不到了。需要读取时再 clone
  4. ky.retry() 短路:返回 ky.retry() 会跳过剩余 hook 直接重试
  5. 抛出的错误是致命的:不会触发重试,直接传播给调用者

项目中的实践

认证 Hook

TypeScript
// 代码案例: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

TypeScript
// 代码案例: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 参数会自动去重,同一个请求的多次失败只弹一次。

执行顺序

TypeScript
afterResponse: [errorToastHook, authHook]

通用逻辑(通知、日志)在前,业务逻辑(认证)在后。理由:

  • 如果 authHook 将来需要刷新 token 并返回新的 Response(通过 ky.retry()),errorToastHook 已经在第一轮执行过了,不会被重复触发
  • 认证处理可能需要替换 response,放在后面可以确保通知层已经记录了原始状态

可扩展性

新增一个日志 Hook 只需要三个步骤:

TypeScript
// 代码案例:新增日志 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() 天然支持:

TypeScript
// 代码案例: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-redirectsform-data 这些在浏览器环境根本不需要的东西
  • Tree-shaking 友好:不用的部分直接被摇掉
  • fetch 原生:行为与浏览器 fetch 一致,没有封装层带来的意外行为差异
  • 无缝回退:如果哪天想去掉 ky,你的 Request/Response 代码不需要改——ky 本身就是它们的薄包装

对比 Axios 的依赖图(包含 follow-redirectsform-dataproxy-from-env 等),ky 在浏览器端的包袱几乎为零。

代价

ky 不是银弹。有些场景需要权衡:

  • 服务端需要 polyfill:Node.js 18+ 有原生 fetch,但更低版本需要 undicinode-fetch
  • 没有请求拦截器:ky 有 beforeRequest hook,但它是基于实例的。如果你需要全局的请求签名注入,需要自己设计
  • 生态小:没有 axios-mock-adapter 这种成熟的测试工具。Mock 测试需要自己用 beforeRequest 返回 Response 来模拟
  • 拦截器 vs Hook 心智模型不同:团队从 Axios 迁移时需要适应——Hook 是数组不是链,每个函数独立执行,不能像 Axios 那样通过 next(error) 传递错误

文件结构

Plain Text
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/Response API 的技术栈
  • 代价是生态较小,需要自建 Mock 方案和请求拦截模式

最后更新于: 2026-04-15