@sop-cli/request
v0.0.5
Published
```bash pnpm add @sop-cli/request ```
Readme
Quick Start 快速开始
pnpm add @sop-cli/request架构与配置合并
设计原则
| 原则 | 说明 |
|------|------|
| 多实例隔离 | 每个 createHttpClient() 独立 Axios、拦截器、缓存、租户、运行时 |
| 无全局污染 | 无进程级 RequestGlobals;配置均在 ClientContext 实例内 |
| 配置分层 | 默认 < 实例 < 单次请求,见下表 |
| 无 UI 耦合 | message / loading / 401 均由业务注入回调 |
配置合并策略
DEFAULT_APP_META + DEFAULT_AXIOS_CONFIG ← 库级默认(config/config.ts)
↓ merge
HttpClientOptions(createHttpClient) ← 实例级:baseURL、headers、runtime、tenantId
↓ merge(拦截器 + invoke)
RequestConfig(单次 get/post/...) ← 请求级:showLoading、retryCount、cache 等- Axios 配置(
baseURL、timeout、headers、withCredentials、responseType):实例创建时mergeAxiosConfig深度合并 headers - AppRequestMeta(
retryCount、showError、cache等):mergeRequestConfig(instanceMeta, requestConfig),请求级覆盖实例级 - 运行时(
getToken、messageHandler、on401):仅存于ClientContext,实例configureRuntime()配置 - 租户:
tenantId/getTenantId→ 请求头X-Tenant-Id(可配置)
默认 Axios 配置
| 项 | 默认值 |
|----|--------|
| responseType | json |
| withCredentials | true |
| timeout | 30s(normal 分级) |
| Content-Type | application/json |
导出常量:DEFAULT_APP_META、DEFAULT_AXIOS_CONFIG、DEFAULT_CONFIG(合集)
5 分钟上手
0. 配置默认 request(main.tsx 最先执行)
request 为懒加载实例,首次使用前可透传全部 Axios / 实例参数:
import { configureDefaultHttpClient, request } from '@sop-cli/request';
configureDefaultHttpClient({
baseURL: import.meta.env.VITE_API_BASE_URL,
timeout: 30_000,
withCredentials: true,
responseType: 'json',
headers: { 'X-App-Id': 'my-app' },
// 实例级 AppRequestMeta
showLoading: true,
retryCount: 2,
// 实例级 runtime / 租户
runtime: { getToken: () => localStorage.getItem('token') },
tenantId: 'tenant-001',
});也可在首次调用 getDefaultHttpClient(options) 时传入(二者合并,仅初始化一次)。
1. 实例配置(推荐在 main.tsx)
所有配置均通过 HttpClient 实例方法 完成。使用库预设的 request 即可:
import { request } from '@sop-cli/request';
import { message } from 'antd';
import * as Sentry from '@sentry/react';
// Loading
request.setLoadingManager({
start: () => message.loading('加载中...', 0),
done: () => message.destroy(),
});
// 错误上报
request.setErrorReport({
report: (error, config) => {
console.error('请求错误:', { error, config });
Sentry.captureException(error, {
extra: { requestConfig: config },
});
},
});2. 运行时与错误处理(推荐在 main.tsx)
import { request } from '@sop-cli/request';
import { APP_NAME, TOKEN } from '@/constant';
import { removeToken } from '@/utils/auth';
request.configureRuntime({
getToken: () => localStorage.getItem(TOKEN),
// Cookie 认证(withCredentials: true)时配置 CSRF
getCsrfToken: () => {
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/);
return match ? decodeURIComponent(match[1]) : null;
},
csrfHeaderKey: 'X-XSRF-TOKEN', // Spring 默认;Django 常用 X-CSRFToken
messageHandler: (type, content, ctx) => {
// ctx.responseData 含后端完整响应体,如 { code, success, message }
console[type === 'error' ? 'error' : 'log'](content, ctx.responseData);
},
on401: () => {
removeToken();
window.location.assign(`/${APP_NAME}/login`);
},
});
request.setupErrorHandlers();3. 发起请求
import { request } from '@sop-cli/request';
interface User {
id: number;
name: string;
email: string;
}
// GET 请求
const user = await request.get<User>('/api/users/1');
// POST 请求
const newUser = await request.post<User>('/api/users', {
name: '张三',
email: '[email protected]',
});
// PUT 请求
const updatedUser = await request.put<User>('/api/users/1', {
name: '李四',
});
// DELETE 请求
await request.del('/api/users/1');4. 错误处理
import {
isBusinessError,
isNetworkError,
isCancelError,
} from '@sop-cli/request';
try {
const data = await request.get('/api/data');
} catch (error) {
if (isCancelError(error)) {
console.log('请求被取消');
return;
}
if (isBusinessError(error)) {
console.log('业务错误:', error.code, error.message);
// 处理特定业务错误
if (error.code === 'USER_NOT_FOUND') {
// 跳转到登录
}
return;
}
if (isNetworkError(error)) {
if (error.networkType === 'timeout') {
console.log('请求超时,请重试');
} else {
console.log('网络连接失败,请检查网络');
}
return;
}
console.log('未知错误:', error);
}5. 高级配置
// 开启全局 Loading(默认关闭)
await request.get('/api/data', undefined, {
showLoading: true,
});
// 关闭错误提示
await request.get('/api/data', undefined, {
showError: false,
});
// 跳过 Token(用于登录等公开接口,默认 skipToken: false)
await request.post('/api/login', credentials, {
skipToken: true,
});
// 禁用重复请求取消
await request.post('/api/submit', data, {
autoCancel: false,
});
// 自定义重试次数
await request.get('/api/unstable', undefined, {
retryCount: 3,
});
// 跳过全局错误处理
try {
await request.get('/api/data', undefined, {
skipErrorHandler: true,
});
} catch (error) {
// 自行处理
}6. 文件上传
import { request, type UploadProgressCallback } from '@sop-cli/request';
// 6.1 简单上传(FormData)
const formData = new FormData();
formData.append('file', file);
formData.append('name', filename);
const result = await request.upload('/api/upload', formData);
// 6.2 上传进度回调
const onProgress: UploadProgressCallback = (progressEvent) => {
if (progressEvent.total) {
const percent = Math.round((progressEvent.loaded * 100) / progressEvent.total);
console.log(`上传进度: ${percent}%`);
}
};
await request.upload('/api/upload', formData, {
onUploadProgress: onProgress,
});
// 6.3 自定义上传超时(默认 10 分钟)
await request.upload('/api/upload', formData, {
timeout: 20 * 60 * 1000, // 20 分钟
});7. 多实例隔离
每个 createHttpClient() 创建完全独立的实例:Axios、缓存、取消队列、运行时配置(token / messageHandler / on401)、Loading、错误上报、租户上下文 互不干扰。适合多域名、多账号、多租户并行场景。
库导出预设默认实例 request;业务层也可自行维护单例:
import { createHttpClient, type HttpClient } from '@sop-cli/request';
let appClient: HttpClient | undefined;
/** 业务侧自管单例 */
export function getAppClient() {
if (!appClient) {
appClient = createHttpClient({
baseURL: import.meta.env.VITE_API_BASE_URL,
runtime: {
getToken: () => localStorage.getItem('token'),
messageHandler: (type, msg) => console[type === 'error' ? 'error' : 'log'](msg),
on401: () => { /* 业务自行处理 */ },
},
});
appClient.setupErrorHandlers();
}
return appClient;
}多域名 + 多账号完全隔离(互不影响 token、Loading、错误处理):
import { createHttpClient } from '@sop-cli/request';
const accountA = createHttpClient({
baseURL: 'https://api-a.example.com',
runtime: {
getToken: () => getTokenFor('account-a'),
messageHandler: toastA.show,
on401: () => logout('account-a'),
},
});
accountA.setLoadingManager(loadingA);
accountA.setErrorReport({ report: sentryA.captureException });
const accountB = createHttpClient({
baseURL: 'https://api-b.example.com',
runtime: {
getToken: () => getTokenFor('account-b'),
resolveBaseURL: (cfg) => `https://api-b.example.com/${cfg.version ?? 'v1'}`,
},
});
await accountA.get('/users');
await accountB.get('/users');多实例场景使用
createHttpClient(),每个实例独立调用configureRuntime()、setLoadingManager()等方法。
8. 多租户 tenantId 注入
创建实例时可传入 tenantId,请求拦截器会自动在请求头携带租户标识(默认头名 X-Tenant-Id)。
import { createHttpClient } from '@sop-cli/request';
const client = createHttpClient({
baseURL: '/api',
tenantId: 'tenant-001',
runtime: {
getToken: () => localStorage.getItem('token'),
},
});
// 自动携带: X-Tenant-Id: tenant-001
await client.get('/users');动态切换租户(用户切换组织/租户后):
// 方式 1:直接设置
client.setTenantId('tenant-002');
// 方式 2:动态解析(优先级高于 tenantId)
client.setTenantOptions({
getTenantId: () => store.getState().currentTenantId,
});
// 方式 3:自定义请求头名称
client.setTenantOptions({
tenantHeaderKey: 'X-Org-Id',
tenantId: 'org-123',
});单次请求跳过租户头(公开接口等):
await client.get('/public/config', undefined, { skipTenant: true });多租户 + 多实例(每个租户独立 client,完全隔离):
import { createHttpClient, type HttpClient } from '@sop-cli/request';
const tenantClients = new Map<string, HttpClient>();
function getTenantClient(tenantId: string) {
if (!tenantClients.has(tenantId)) {
const client = createHttpClient({
baseURL: import.meta.env.VITE_API_BASE_URL,
tenantId,
runtime: {
getToken: () => getTokenForTenant(tenantId),
on401: () => switchTenant(null),
},
});
client.setupErrorHandlers();
tenantClients.set(tenantId, client);
}
return tenantClients.get(tenantId)!;
}
await getTenantClient('tenant-a').get('/orders');
await getTenantClient('tenant-b').get('/orders');9. 业务模块 API 组织
// src/domains/user/api.ts
import { request } from '@sop-cli/request';
const USER_BASE = '/api/users';
export const userApi = {
getList: (params?: { page?: number; size?: number }) =>
request.get(`${USER_BASE}`, params),
getDetail: (id: number) =>
request.get(`${USER_BASE}/${id}`),
create: (data: CreateUserParams) =>
request.post(USER_BASE, data),
update: (id: number, data: UpdateUserParams) =>
request.put(`${USER_BASE}/${id}`, data),
delete: (id: number) =>
request.del(`${USER_BASE}/${id}`),
};
// 使用
import { userApi } from '@/domains/user/api';
const users = await userApi.getList({ page: 1, size: 10 });
const user = await userApi.getDetail(1);10. React Query 集成
import { useQuery, useMutation } from '@tanstack/react-query';
import { userApi } from '@/domains/user/api';
function UserList() {
const { data, isLoading, error } = useQuery({
queryKey: ['users'],
queryFn: () => userApi.getList(),
});
return <div>{/* 渲染用户列表 */}</div>;
}
function CreateUserForm() {
const mutation = useMutation({
mutationFn: userApi.create,
onSuccess: () => {
// 刷新列表
},
});
return <form onSubmit={(e) => mutation.mutate(formData)}>{/* 表单内容 */}</form>;
}11. 取消请求
场景:组件卸载时
import { useEffect, useState } from 'react';
import { request } from '@sop-cli/request';
const CancelRequestExample = () => {
const [data, setData] = useState(null);
useEffect(() => {
// 创建临时 AbortController
const controller = new AbortController();
const fetchData = async () => {
try {
const res = await request.get('/long-task', undefined, {
signal: controller.signal,
});
setData(res);
} catch (err) {
if (err.name === 'CanceledError') {
console.log('请求已取消:', err);
}
}
};
fetchData();
// 组件卸载时取消请求
return () => {
controller.abort();
};
}, []);
return <div>{data ? JSON.stringify(data) : '加载中...'}</div>;
};后端 API 格式
后端返回数据格式(推荐规范):
export interface ApiResponse<T = unknown> {
code?: number | string;
success: boolean; // if request is success
data: T; // response data
status: number | string;
name?: string;
message?: string;
error?: string;
traceId?: string; // unique request ID
host?: string; // Convenient for backend Troubleshooting: host of current access server
}如果格式不一致,可以:
- 通过
client.addResponseInterceptor()添加自定义响应拦截器进行适配 - 单个请求配置
skipErrorHandler: true跳过默认错误处理 - 实例级
client.setupErrorHandlers()覆盖错误码处理
API 参考
实例创建
| 方法 | 说明 |
|------|------|
| createHttpClient(options?) | 创建独立 HTTP 客户端(推荐多实例场景) |
| request | 库预设默认实例(多数业务可直接使用) |
| getDefaultHttpClient(config?) | 获取预设默认实例(懒初始化) |
HttpClient 实例方法
| 方法 | 说明 |
|------|------|
| configureRuntime(config) | 配置 token、CSRF、messageHandler、on401、resolveBaseURL |
| setTenantId(id) | 动态设置租户 ID |
| setTenantOptions(options) | 配置 tenantId / tenantHeaderKey / getTenantId |
| resolveTenantId() | 获取当前生效的租户 ID |
| setLoadingManager(manager) | 实例级 Loading |
| setErrorReport(reporter) | 实例级错误上报 |
| setupErrorHandlers(options?) | 注册 401 等错误码处理 |
| addRequestInterceptor(...) | 添加自定义请求拦截器 |
| addResponseInterceptor(...) | 添加自定义响应拦截器 |
| cancelByGroup(group) | 按 group 批量取消 |
| destroy() | 销毁实例,释放拦截器与 pending 请求 |
HTTP 方法
| 方法 | 说明 |
|------|------|
| request.get<T>(url, params?, config?) | GET 请求 |
| request.post<T>(url, data?, config?) | POST 请求 |
| request.put<T>(url, data?, config?) | PUT 请求 |
| request.patch<T>(url, data?, config?) | PATCH 请求 |
| request.del<T>(url, params?, config?) | DELETE 请求 |
| request.upload<T>(url, formData, config?) | 文件上传 |
| request.request<T>(config) | 通用请求 |
HttpClientOptions(createHttpClient 专用)
| 选项 | 类型 | 说明 |
|------|------|------|
| runtime | RequestRuntimeOptions | 实例级运行时(token、messageHandler、on401、resolveBaseURL) |
| runtime.getCsrfToken | () => string \| null | POST/PUT/PATCH/DELETE 自动注入 CSRF 头 |
| runtime.csrfHeaderKey | string | CSRF 请求头名,默认 X-CSRF-Token |
| tenantId | string \| null | 租户 ID,自动注入请求头 |
| tenantHeaderKey | string | 租户请求头名,默认 X-Tenant-Id |
| getTenantId | () => string \| null | 动态解析租户 ID(优先于 tenantId) |
配置选项 (RequestConfig)
| 选项 | 类型 | 默认值 | 说明 |
|------|------|--------|------|
| showLoading | boolean | false | 是否显示全局 Loading |
| showError | boolean | true | 是否显示错误提示 |
| autoCancel | boolean | true | 是否自动取消重复请求 |
| skipToken | boolean | false | 是否跳过 Token 注入 |
| skipCsrf | boolean | false | 是否跳过 CSRF Token 注入 |
| skipTenant | boolean | false | 是否跳过租户请求头注入 |
| retryCount | number | 1 | 重试次数 |
| skipErrorHandler | boolean | false | 是否跳过全局错误处理 |
| timeout | number | 30000 | 请求超时(ms);也可用 timeoutTier |
| timeoutTier | 'fast' \| 'normal' \| 'slow' \| 'upload' | 'normal' | 超时分级 |
| unwrapData | boolean | false | 是否只返回 data 字段 |
| cache | boolean | object | — | GET 响应缓存 |
错误类型
| 类型 | 守卫函数 | 说明 |
|------|----------|------|
| ApiError | isApiError() | HTTP 状态码错误 |
| BusinessError | isBusinessError() | 业务逻辑错误 |
| NetworkError | isNetworkError() | 网络错误 |
| CancelError | isCancelError() | 请求取消错误 |
企业级能力清单
- 多实例 + 多租户(
tenantId/X-Tenant-Id) - Token / CSRF 自动注入(可
skipToken/skipCsrf) - 请求取消 + 防重复(AbortController)
- 失败重试(指数退避,5xx/408/网络错误)
- GET 缓存 / 防抖 / 节流
- 错误分类:
ApiError/BusinessError/NetworkError/CancelError - 泛型 REST 方法 +
ErrorMessageContext(含response.data) - 自定义拦截器(内置拦截器不可移除)
