react 项目更加倾向于使用原生的fetch请求方式,而 ky
正是底层使用fetch
的api做请求。github星数是8.2K,源码地址:https://github.com/sindresorhus/ky ,
ky简介
ky
是一个基于 fetchAPI
封装的请求库,兼容浏览器和node,类似于axios
,但是axios
的实现是在浏览器端是基于XMLHttpRequests
而在node
端则是基于原生的http
模块.
安装与快速上⼿
这么装:
npm i ky # 或 pnpm add ky / bun add ky
这么用:
import ky from 'ky';
// GET 并直接解析 JSON
const list = await ky.get('/api/todos').json<Todo[]>();
// POST JSON,自动加 Content-Type 与 Accept
await ky.post('/api/todos', {json: {title: 'Write doc'}});
// 40 ms 超时+3 次指数退避重试
await ky.get('/slow', {timeout: 40_000, retry: 3});
核心 API ⼀览
ky(input, options?)
: 基础函数,等同于 ky.get() 默认 GETky.get/post/put/patch/delete/head()
: 常⽤ HTTP 动词静态⽅法ky.extend(defaultOptions)
: 返回新的 Ky 实例,⽤于共享 headers、hooks 等ky.create(defaultOptions)
: 功能同extend
,但更贴近 SDK 场景,常与多个后端域名并存.json() / .text() / .arrayBuffer() / .blob()
: 给Response
动态挂载的 链式解析器,失败时抛HTTPError
Options
:json
、form
、searchParams
、headers
、timeout
、retry
、hooks
、onDownloadProgress
解析器 必须在 链式 调⽤:
ky.get(...).json()
;否则你将得到原始Response
对象。
Hook 体系(Ky 的灵魂)
beforeRequest
:触发时机-请求发送前 , 场景:统一加签名 / Token / 追踪 IDafterResponse
:触发时机-响应收到后 , 场景:401 刷新 Token 并重放、全局业务错误包装beforeRetry
:触发时机-即将重试前 , 场景:记录日志、动态调整间隔beforeError
:触发时机-抛出异常前 , 场景:统一错误格式化、打点
刷新 Token 模板:
const api = ky.create({
hooks: {
afterResponse: [
async (req, opts, res) => {
if (res.status === 401) {
const token = await ky.post('/refresh').text();
// 重放原请求
return ky(req, {...opts, headers: {...opts.headers, Authorization: `Bearer ${token}`}});
}
return res;
}
]
}
});
使用场景:
源码
Ky 代码总量约 1.4 k 行 TypeScript,阅读⻔槛低。
source/
├─ index.ts // 默认导出函数,包装 createKy()
├─ core/
│ ├─ Ky.ts // Ky 实例类,负责请求构造 & Hook 管理
│ ├─ normalize.ts// 规范化 options / URL 处理
│ └─ utils.ts // 深度合并、退避算法、type guards
└─ errors.ts // HTTPError、TimeoutError 等定义
调用流程:
index.ts 暴露 ky 函数,内部 return new Ky(input, options).request().
Ky.request()
- 组装
Request
-> 触发beforeRequest
fetch
真正发起请求- 正常返回或触发内部重试
- 将
Response
克隆后串行执⾏afterResponse
- 对
response.ok
做短路:不 OK 抛HTTPError
- ⽤
augmentResponse
给Response
动态挂载.json()
等解析器
- 错误路径:
beforeError
-> 抛出(可能已被包装)
举个栗子
src/api.ts
import ky from 'ky';
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const globalAny: any = globalThis;
const api = ky.create({
prefixUrl: 'http://localhost:8989',
timeout: 10_000,
hooks: {
beforeRequest: [
request => {
if (globalAny.token) {
request.headers.set('Authorization', `Bearer ${globalAny.token}`);
}
}
],
afterResponse: [
async (request, options, response) => {
if (response.status === 401 && !request.headers.get('x-auto-retry')) {
console.log('Token expired, refreshing...');
const newToken: string = await ky.post('http://localhost:4000/refresh').text();
globalAny.token = newToken;
return ky(request, {
...options,
headers: {
...Object.fromEntries(request.headers),
Authorization: `Bearer ${newToken}`,
'x-auto-retry': 'true'
}
});
}
return response;
}
]
}
});
export default api;
src/client.ts
import api from './api.js';
(async () => {
try {
console.log('--- GET /posts');
const posts = await api.get('posts').json<any[]>();
console.table(posts);
console.log('--- POST /posts');
const newPost = await api
.post('posts', { json: { title: 'Using Ky demo', body: 'Everything is typed!' } })
.json();
console.log('Created:', newPost);
console.log('--- GET /protected (should trigger 401 -> refresh -> retry)');
const secret = await api.get('protected').json();
console.log(secret);
console.log('Demo finished');
} catch (err: any) {
if (err.response) {
console.error('HTTP error', err.response.status);
} else {
console.error(err);
}
}
})();
服务端代码如下:
import express from 'express';
import cors from 'cors';
const app = express();
const PORT = process.env.PORT || 8989;
app.use(cors());
app.use(express.json());
let POSTS = [{ id: 1, title: 'Hello Ky', body: 'First post' }];
let TOKEN = 'initial-token';
app.get('/posts', (_req, res) => {
res.json(POSTS);
});
app.post('/posts', (req, res) => {
const post = { id: Date.now(), ...req.body };
POSTS.push(post);
res.status(201).json(post);
});
app.get('/protected', (req, res) => {
const auth = req.headers['authorization'];
if (auth === `Bearer ${TOKEN}`) {
res.json({ secret: 'You have accessed a protected resource!' });
} else {
res.status(401).json({ message: 'Token expired or missing' });
}
});
app.post('/refresh', (_req, res) => {
TOKEN = 'token-' + Date.now();
console.log('Issued new token:', TOKEN);
res.send(TOKEN);
});
app.listen(PORT, () => {
console.log(`Mock API running at http://localhost:${PORT}`);
});