跳到主要内容

HTTP 与 API 客户端

HttpClient

基于 Axios 构建的底层 HTTP 客户端,负责处理:

  • 自动注入访问令牌(Authorization: Bearer …
  • 401 响应时协调式的令牌刷新(一次共享刷新,等待方可感知中止)
  • 响应信封上的业务错误码检测
  • 带鉴权的文件获取与浏览器下载
  • 路径参数替换

获取实例

HttpClient 不能直接构造——createHttpClient 是一个内部工厂函数,并未从包根导出(包根仅将 HttpClient 作为类型导出)。应用代码应将 HttpClientOptions 配置为 ApiClientOptions.http,并传递给 createApiClient(见下文);随后可通过 HTTP_CLIENT symbol 获取配置好的实例。

const httpOptions = {
baseUrl: "/api",
timeout: 30_000,
okCode: 0,
tokenExpiredCode: 1002,
getAuthTokens: () => tokenStore.getTokens(),
setAuthTokens: tokens => tokenStore.setTokens(tokens),
refreshToken: async tokens => {
const response = await fetch("/api/auth/refresh", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(tokens)
});
return response.json();
},
onUnauthenticated: () => router.navigate({ to: "/login" }),
showErrorMessage: message => notification.error(message)
};

HttpClientOptions

选项类型默认值说明
baseUrlstring—(必填)API 的基础 URL;请求仍可使用绝对 URL,此时会绕过它
timeoutnumber30000请求超时时间(毫秒)
getAuthTokens() => Awaitable<Readonly<AuthTokens> | undefined>获取当前令牌;每次带鉴权的请求发出前都会等待其完成
setAuthTokens(tokens: Readonly<AuthTokens>) => Awaitable<void>持久化刷新后的令牌(对象在传入前会被冻结)
refreshToken(tokens: Readonly<AuthTokens>) => Awaitable<Readonly<AuthTokens>>刷新回调;接收当前令牌,必须返回新令牌,失败时应 reject
okCodeMaybeArray<number>0响应信封上可接受的业务成功码
tokenExpiredCodeMaybeArray<number>[]401 响应中触发静默刷新流程的信封错误码
onUnauthenticated() => Awaitable<void>鉴权最终失败时调用(401 且无可刷新的错误码,或刷新失败)
onAccessDenied() => Awaitable<void>403 响应后调用
showInfoMessage(message: string) => void信息提示处理函数(缺省回退到 console.info
showWarningMessage(message: string) => void警告提示处理函数(缺省回退到 console.warn);也用于业务码失败和 400/403 响应
showErrorMessage(message: string) => void错误提示处理函数(缺省回退到 console.error

AuthTokens

interface AuthTokens {
accessToken: string;
refreshToken?: string;
}
字段类型说明
accessTokenstringAuthorization: Bearer … 形式注入的访问令牌
refreshTokenstring | undefined刷新令牌。自 v2.10.0 起为可选(破坏性变更):当后端签发有状态的不透明令牌会话时不存在该字段——会话保存在服务端并采用滑动过期,没有刷新往返,因此整个刷新流程(refreshToken 回调、tokenExpiredCode)根本无需配置。只有 JWT 模式的后端才会返回它。

请求默认值

底层 Axios 实例创建时带有以下配置:

  • 请求头 Content-Type: application/json
  • 查询参数用 qs 序列化(arrayFormat: "repeat"skipNulls: true
  • responseType: "json",UTF-8 响应编码
  • 仅 2xx 状态视为成功(validateStatus
  • withCredentials: false

请求生命周期

每个请求都会按以下顺序经过客户端的拦截器:

  1. 刷新闸门——如果一次令牌刷新正在进行,且该请求没有跳过鉴权,则请求会等待刷新完成(契约见下文)。若刷新失败,请求在触达网络之前即被拒绝。
  2. 令牌注入——等待 getAuthTokens 完成;当返回值包含 accessToken 时,设置 Authorization: Bearer … 请求头。跳过鉴权的请求会绕过这两步,且标记请求头在发送前会被剥除。
  3. 路径参数替换——参见路径参数
  4. 响应信封校验——对常规(非文件)请求,响应体必须是有效的 ApiResult 信封(code 为数字、message 为字符串、data 存在),否则抛出 TypeError("Invalid API response envelope")。通过 requestFile / download 发起的文件请求以原始模式处理,跳过信封校验。
  5. 业务码检查——如果 result.codeokCode 不匹配,则通过 showWarningMessage 提示消息,并抛出携带 codemessagedataBusinessError

HTTP 错误状态的处理方式如下:

状态行为
400通过 showWarningMessage 以信封消息发出警告提示
401令牌刷新流程(见下文);当静默刷新 + 重试成功时,重试后的响应会返回给原始调用方,而不是过期的 401
403通过 showWarningMessage 发出警告提示,然后等待 onAccessDenied 完成
其他通过 showErrorMessage 以信封消息发出错误提示

错误响应中读取信封时,客户端还接受以字符串、BlobArrayBuffer 或类型化数组形式承载的 JSON(文件请求失败时会出现这种情况),但拒绝解析大于 1 MiB 的响应体。被取消的请求(CanceledError)会原样重新抛出。

令牌刷新协调

当 401 响应的信封错误码与 tokenExpiredCode 匹配时,客户端会发布一次共享的刷新操作getAuthTokens → refreshToken → setAuthTokens,新令牌在持久化前会被冻结)。契约(按实际实现):

  • 刷新期间发起的新并发请求,会通过请求作用域、可感知中止的等待器等待刷新完成,然后携带更新后的令牌继续。取消一个等待中的请求(通过其 signal)只会取消该请求——绝不会取消全局刷新。
  • 刷新成功后,触发刷新的那个 401 请求会携带更新后的令牌重试,其调用方收到的是重试后的响应。
  • 若刷新失败,onUnauthenticated 只会被调用一次——即使触发方自身在此期间已被取消——并且所有等待中的请求都会被拒绝。
  • 刷新已在进行中时收到的 401,意味着刷新请求本身以 401 失败;它会立即抛出以避免死锁。
  • 错误码与 tokenExpiredCode 不匹配(或没有信封)的 401 会完全跳过刷新,直接调用 onUnauthenticated
  • 刷新要求 getAuthTokens / refreshToken / setAuthTokens 三者齐备;缺少任何一个时,刷新立即以失败结束。

ensureTokenRefreshed

public async ensureTokenRefreshed(triggerCallback = true): Promise<boolean>

主动执行(或加入)共享的令牌刷新。适用于自行管理请求的外部代码——例如基于 fetch 的 SSE 客户端将其用作 onTokenExpired 处理函数。成功时返回 trueensureTokenRefreshed(false) 会在失败时抑制 onUnauthenticated 回调,但仅当本次调用拥有该刷新周期时生效(不会覆盖进行中的刷新周期已选定的失败策略)。

HTTP 方法

所有请求方法都接受 RequestOptionssignalheaders)以及下方所示的各方法专属字段,并返回解析后的 ApiResult<R> 信封。

// GET — get<R = unknown, P = unknown>(url, options?: RequestOptions & { params?: P })
const result = await http.get<UserInfo>("/user/info", { params: { id: 1 } });

// POST — post<R = unknown, D = unknown, P = unknown>(url, options?: RequestOptions & { data?: D; params?: P })
const result = await http.post<CreateResult>("/user/create", { data: payload });

// PUT — put<R = unknown, D = unknown, P = unknown>(url, options?: RequestOptions & { data?: D; params?: P })
const result = await http.put<void>("/user/update", { data: payload });

// DELETE — delete<R = unknown, P = unknown>(url, options?: RequestOptions & { params?: P })
const result = await http.delete<void>("/user/delete", { params: { id: 1 } });

// Upload — upload<R = unknown, P = unknown>(url, options?: RequestOptions & {
// params?: P; data: FormData; onProgress?: (event: ProgressEvent) => void
// })
const result = await http.upload<UploadResult>("/file/upload", {
data: formData,
onProgress: event => console.log(event.loaded / event.total)
});

upload 发送 multipart/form-data(Axios postForm),并通过 Axios 上传进度事件报告进度。若需面向框架 sys/storage 后端的分片、可断点续传上传,请改用 Uploader

requestFile

public async requestFile<D = unknown, P = unknown>(
url: string,
options?: RequestOptions & {
method?: "get" | "post";
data?: D;
params?: P;
onProgress?: (progress: ProgressEvent) => void;
}
): Promise<HttpFileResponse>

Blob 形式获取文件,并具备客户端完整的请求语义——Bearer 注入、401 刷新、路径参数、中止信号。这是带鉴权文件预览(例如文件预览宿主获取 priv/ 对象)的构建基石。

选项类型默认值说明
method"get" | "post""get"HTTP 方法
dataD请求体(仅 post
paramsP查询 / 路径参数
onProgress(progress: ProgressEvent) => void下载进度回调
signalGenericAbortSignal中止信号(来自 RequestOptions
headersRawAxiosRequestHeaders额外请求头(来自 RequestOptions

行为说明:

  • 请求以原始响应模式运行,responseType: "arraybuffer";响应体会被规范化为带有响应 Content-Type 标记的 Blob(浏览器的 Blob 响应体直接透传;Node 的 ArrayBuffer / 类型化数组响应体会被安全复制)。
  • 一个实际携带后端 JSON 业务信封(≤ 1 MiB)的 2xx 响应,会以 BusinessError 的形式抛出,而不是作为文件内容返回。
  • filenameContent-Disposition 响应头解析——RFC 5987 的 filename* 参数(UTF-8)优先,其次回退到普通的 filename 参数——服务端两者都未发送时为 undefined
const { blob, filename } = await http.requestFile("/file/preview", {
params: { id: 1 },
signal: controller.signal
});

download

public async download<D = unknown, P = unknown>(
url: string,
options?: RequestOptions & {
method?: "get" | "post";
data?: D;
params?: P;
onProgress?: (progress: ProgressEvent) => void;
filename?: string | ((filename: string) => string);
}
): Promise<void>

通过 requestFile 获取文件(继承其全部选项与语义),并借助临时对象 URL 与合成的锚点点击触发浏览器下载;对象 URL 事后会被回收。

选项类型默认值说明
filenamestring | ((filename: string) => string)服务端提供的文件名覆盖保存的文件名。回调形式接收服务端提供的文件名(服务端未发送时为 "download"),返回实际使用的名称。
await http.download("/file/export", {
params: { id: 1 },
filename: name => `backup-${name}`
});

路径参数

URL 中的 :paramName 片段会在请求发送前由 params 替换——/users/:id 配合 { id: 123 } 会变成 /users/123。确切的替换规则:

  • 参数仅在路径片段起始处(紧跟 / 之后)才算数。
  • 参数名必须以字母或下划线开头,后续可为单词字符(正则:/(?<=\/):(?<key>[A-Z_]\w*)/gi)。
  • 因此,绝对 URL 中的端口(https://host:9000/api)和片段内部的冒号(/time/12:30永远不会被当作参数。
  • 被引用的参数在 params 中缺失、或其值为空值(nullish)时,会输出一条控制台警告,并以字面量字符串 unknown 替换。
  • 匹配到的值用 String(value) 字符串化;已使用的 key 不会从 params 中移除(它们仍会被序列化进查询字符串)。

跳过鉴权

对于不应携带 Authorization 请求头的请求(登录、公开端点):

import { skipAuthenticationHeader, skipAuthenticationValue } from "@vef-framework-react/core";

await http.post("/auth/login", {
data: credentials,
headers: {
[skipAuthenticationHeader]: skipAuthenticationValue
}
});

skipAuthenticationHeader"X-Skip-Authentication"skipAuthenticationValue"1"。跳过鉴权的请求也不会等待进行中的令牌刷新,且标记请求头会在请求离开客户端之前被剥除。

ApiResult<T>

所有常规 HTTP 方法都返回 ApiResult<T>

interface ApiResult<T = unknown> {
readonly code: number;
readonly message: string;
readonly data: T;
}

HttpFileResponse

requestFile 返回:

字段类型说明
blobBlob文件内容
filenamestring | undefinedContent-Disposition 解析出的文件名;服务端未发送时缺省

RequestOptions

每个请求方法都接受的通用选项:

字段类型说明
signalGenericAbortSignal请求的中止信号(也会中止对共享令牌刷新的等待)
headersRawAxiosRequestHeaders额外请求头

ProgressEvent

type ProgressEvent = AxiosProgressEvent——传给 onProgress 回调的 Axios 进度事件(loadedtotalprogress 等)。

isBusinessError

当响应信封携带非成功业务码时抛出 BusinessError。它在 Error 基础上扩展了:

字段类型说明
name"BusinessError"错误名称
codenumber(只读)信封中的业务错误码
messagestring信封消息
dataunknown(只读)信封中原始的 data
import { isBusinessError } from "@vef-framework-react/core";

try {
await http.post("/user/create", { data: payload });
} catch (error) {
if (isBusinessError(error)) {
// error.code, error.message, error.data are available
}
}

因此错误分为两族:BusinessError(API 返回了非成功业务码)与网络/Axios 错误(4xx/5xx 状态、超时、取消)。


ApiClient

ApiClientHttpClientQueryClient 组合为单一对象。在大多数项目中,它通过 starter.createApiClient() 创建一次,并在整个应用中共享。

createApiClient(core)

core 层级的工厂函数。在应用代码中,优先使用 starter.createApiClient(),它在此基础上增加了令牌存储、消息反馈和未鉴权处理。

import { createApiClient } from "@vef-framework-react/core";

const apiClient = createApiClient({
http: {
baseUrl: "/api",
okCode: 0
},
query: {
staleTime: 5_000,
gcTime: 300_000
}
});

ApiClientOptions

选项类型默认值说明
httpHttpClientOptions—(必填)传递给 HttpClient 的选项
queryQueryClientOptions传递给 QueryClient 的选项(参见查询与变更

访问底层客户端

被包装的两个客户端通过两个导出的 symbol 暴露:

import { HTTP_CLIENT, QUERY_CLIENT } from "@vef-framework-react/core";

const http = apiClient[HTTP_CLIENT]; // Readonly<HttpClient>
const query = apiClient[QUERY_CLIENT]; // QueryClient

例如,UploaderuseUpload 正是通过这种方式获取 HttpClient 的。

createQueryFn

创建一个带有自动 abort signal 注入的类型化查询函数。

export const findUserPage = apiClient.createQueryFn(
"find_user_page",
http => async (params, pageParam, meta) => {
const result = await http.post("/user/page", { data: params });
return result.data;
}
);
createQueryFn<TResult = unknown, TParams = never, TPageParam = never>(
key: string,
factory: (http: Readonly<HttpClient>) => (
queryParams: TParams,
pageParam: TPageParam,
meta?: QueryMeta
) => Awaitable<TResult>
): QueryFunction<TResult, TParams, TPageParam>

请求生命周期隔离(自 v2.11.0 起的破坏性变更): factory每次查询执行时运行一次,每个处理函数收到的是一个以本次调用为作用域的 HttpClient 代理,其请求方法(getpostputdeleteuploaddownloadrequestFile)自动携带本次调用的 AbortSignal。当某个请求还传入了自己的 signal 时,两个信号会被合并——中止任意一个都会取消该请求。由于 factory 每次执行都会重新运行,它必须无副作用、不得在多次执行之间保留状态、也不能执行一次性的初始化;跨请求状态与副作用应放在 factory 之外。

返回的函数带有 .key 属性,可用于 queryKey 数组:

useQuery({
queryKey: [findUserPage.key, searchParams],
queryFn: findUserPage
});

createMutationFn

创建一个类型化的变更函数。与 createQueryFn 不同,factory 在创建时仅调用一次(变更不会收到框架注入的信号),因此同一个处理函数会在多次变更执行之间复用。

createMutationFn<TResult = unknown, TParams = never>(
key: string,
factory: (http: Readonly<HttpClient>) => (params: TParams) => Awaitable<TResult>
): MutationFunction<TResult, TParams>
export const createUser = apiClient.createMutationFn(
"create_user",
http => params => http.post("/user/create", { data: params })
);

fetchQueryprefetchQuery

用于在 React 组件之外进行命令式数据获取。两者都委托给被包装的 QueryClient,并接受 TanStack 的 FetchQueryOptions(除去由框架控制的 queryHash / queryKeyHashFn):

const userInfo = await apiClient.fetchQuery({
queryKey: [getUserInfo.key, { id: 1 }],
queryFn: getUserInfo
});

await apiClient.prefetchQuery({
queryKey: [getUserInfo.key, { id: 1 }],
queryFn: getUserInfo
});

fetchQuery 以数据 resolve(出错时 reject);prefetchQueryvoid resolve 且从不抛出。

executeMutation

用于在 React 组件之外进行命令式变更(登录流程、事件处理器)。它在共享的 mutation 缓存上以 mutationKey: [mutationFn.key] 构建变更,因此生命周期回调(onMutateonSuccess 等)、mutation meta 以及 useHasMutating 的匹配仍然生效:

await apiClient.executeMutation({
mutationFn: login,
params: { username, password }
});

params 字段的类型随变更函数而定:TParams 必填时它必填,可选时它可选,变更不接受参数时则禁止传入。

createApiRequest

为框架的 RPC 风格端点构建 ApiRequest 信封。三参数形式将 version 默认为 "v1";四参数形式则固定显式版本:

import { createApiRequest } from "@vef-framework-react/core";

// Default version ("v1")
createApiRequest("sys/storage", "abort_upload", { claimId });

// Explicit version
createApiRequest("sys/storage", "init_upload", "v2", { filename, size });
function createApiRequest<P extends object, M extends object>(
resource: string,
action: string,
params?: P,
meta?: M
): ApiRequest<P, M>;
function createApiRequest<P extends object, M extends object>(
resource: string,
action: string,
version: string,
params?: P,
meta?: M
): ApiRequest<P, M>;

ApiRequest

框架所有 RPC 调用(例如 sys/storage 上传协议)共享的线路级信封:

字段类型说明
resourcestring动作所针对的资源(例如 "sys/storage"
actionstring要在该资源上调用的动作(例如 "init_upload"
versionstring动作的兼容版本;createApiRequest 默认为 "v1"
paramsP | undefined每次调用的参数;结构由目标动作定义
metaM | undefined每次调用的元数据(例如分页);结构由目标动作定义

QueryKey<TParams>

框架中通用的类型化查询键格式:

type QueryKey<TParams = never> = readonly [Key, ...If<IsNever<TParams>, [], [TParams]>];

MutationFunction<TData, TParams>

在 TanStack 的变更函数基础上扩展了 .key 属性,用于 useHasMutating 中的匹配。

QueryFunction<TData, TParams, TPageParam>

在 TanStack 的查询函数基础上扩展了 .key 属性,用于 useHasFetching 中的匹配。