XuqmGroup-RNSDK/packages/common/src/api/useRequest.ts
XuqmGroup 1143224194 fix(common): 对齐 @szyx-mobile useRequest 行为并修复 POST body 丢失
- 修复 useRequest POST/PUT/UPLOAD/POSTFORM 请求忽略 fetchAsync data 的问题
- RequestOptions 增加 tag/log 字段,与旧项目兼容
- 导出 ValidationError,供宿主二次封装使用 instanceof 判断
- usePageApi 支持 list 与 records 两种分页字段
- 升级 devDependencies 版本(axios 1.18.0、zod 3.25.76 等)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-20 18:26:45 +08:00

268 行
8.6 KiB
TypeScript

此文件含有模棱两可的 Unicode 字符

此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。

/**
* useRequest — 替换 @szyx-mobile/use-request。
* 接口与原版完全兼容,底层使用 axios.create() + AbortController。
*/
import { useCallback, useEffect, useMemo, useReducer, useRef } from 'react'
import axios, {
type AxiosRequestConfig,
type AxiosResponse,
isAxiosError,
isCancel,
} from 'axios'
import { z } from 'zod'
import { RequestError } from './errors'
import { _notifyApiError } from './globalErrorHandler'
export interface RequestOptions {
/** true = 不自动触发请求(需手动调用 fetch/fetchAsync。*/
manual?: boolean
/** 延迟多少毫秒后才将 loading 置为 true默认 0。*/
loadingDelay?: number
/** 请求标签,用于日志或错误追踪。*/
tag?: string
/** true = 在响应拦截器中打印完整响应。*/
log?: boolean
}
// ─── 内部 ValidationError对齐 @szyx-mobile/use-axios 的 ValidationError────
export class ValidationError<T = unknown, D = unknown> extends z.ZodError {
constructor(
issues: z.ZodIssue[],
public readonly response: AxiosResponse<T, D>,
) {
super(issues)
this.name = 'ValidationError'
}
}
// ─── Reducer ──────────────────────────────────────────────────────────────────
interface State<R> {
response: R | undefined
error: RequestError | undefined
loading: boolean
}
type Action<R> =
| { type: 'start' }
| { type: 'resolve'; response: R }
| { type: 'reject'; error: RequestError }
function reducer<R>(state: State<R>, action: Action<R>): State<R> {
switch (action.type) {
case 'start':
return { ...state, loading: true }
case 'resolve':
return { response: action.response, error: undefined, loading: false }
case 'reject':
return { ...state, error: action.error, loading: false }
}
}
const initialState = { response: undefined, error: undefined, loading: false }
// ─── useRequest ───────────────────────────────────────────────────────────────
export function useRequest<
S extends z.ZodTypeAny,
T extends z.infer<S> = z.infer<S>,
D = unknown,
R = AxiosResponse<T, D>,
>(
url: string,
method: 'GET' | 'POSTJSON' | 'POSTFORM' | 'PUT' | 'UPLOAD',
params?: D,
validationSchema?: S,
options?: RequestOptions,
config?: AxiosRequestConfig<D>,
): {
response: R | undefined
error: RequestError | undefined
loading: boolean
fetch: (config?: AxiosRequestConfig<D>) => void
fetchAsync: (config?: AxiosRequestConfig<D>) => Promise<R>
cancel: () => void
requestInterceptors: ReturnType<typeof axios.create>['interceptors']['request']
responseInterceptors: ReturnType<typeof axios.create>['interceptors']['response']
} {
const [state, dispatch] = useReducer(reducer<R>, initialState as State<R>)
// axios 实例用 useRef 持有,避免每次 render 重建(丢失拦截器)
// eslint-disable-next-line react-hooks/exhaustive-deps
const instance = useMemo(() => axios.create(), [])
const abortControllerRef = useRef<AbortController | undefined>(undefined)
const validationSchemaRef = useRef(validationSchema)
const optionsRef = useRef(options)
// 保持 ref 同步(避免闭包陈旧引用)
useEffect(() => { validationSchemaRef.current = validationSchema })
useEffect(() => { optionsRef.current = options })
// Zod 校验响应拦截器(对齐 useValidatedAxios 行为)
useEffect(() => {
const id = instance.interceptors.response.use(
(r: AxiosResponse) => {
const schema = validationSchemaRef.current
if (schema) {
const result = schema.safeParse(r.data)
if (!result.success) {
return Promise.reject(new ValidationError(result.error.issues, r))
}
r.data = result.data
}
return r
},
)
return () => { instance.interceptors.response.eject(id) }
}, [instance])
const cancel = useCallback(() => {
abortControllerRef.current?.abort()
}, [])
// axios 方法映射
function resolveAxiosMethod(m: typeof method): string {
switch (m) {
case 'GET': return 'get'
case 'PUT': return 'put'
default: return 'post'
}
}
function mergeRequestData(base: unknown, override: unknown): unknown {
if (override === undefined || override === null) {
return base
}
if (base === undefined || base === null) {
return override
}
if (
typeof base === 'object' &&
typeof override === 'object' &&
!(override instanceof FormData) &&
!(override instanceof ArrayBuffer) &&
!Array.isArray(override)
) {
return {...base as Record<string, unknown>, ...override as Record<string, unknown>}
}
return override
}
const fetchAsync = useCallback(
async (overrideConfig?: AxiosRequestConfig<D>): Promise<R> => {
const loadingDelay = optionsRef.current?.loadingDelay ?? 0
const timer = setTimeout(() => { dispatch({ type: 'start' }) }, loadingDelay)
cancel()
abortControllerRef.current = new AbortController()
// Content-Type 按 method 设置
const methodHeaders: Record<string, string> = {}
if (method === 'POSTFORM') {
methodHeaders['Content-Type'] = 'application/x-www-form-urlencoded'
} else if (method === 'UPLOAD') {
methodHeaders['Content-Type'] = 'multipart/form-data'
}
const axiosMethod = resolveAxiosMethod(method)
const isGet = method === 'GET'
const mergedConfig: AxiosRequestConfig<D> = {
timeout: 30_000,
...config,
...overrideConfig,
url,
method: axiosMethod,
params: isGet
? (overrideConfig?.params ?? config?.params ?? params)
: (overrideConfig?.params ?? config?.params),
data: isGet
? (overrideConfig?.data ?? config?.data)
: mergeRequestData(config?.data, overrideConfig?.data ?? params),
headers: {
...methodHeaders,
...(config?.headers as Record<string, string> | undefined),
...(overrideConfig?.headers as Record<string, string> | undefined),
},
signal: abortControllerRef.current.signal,
}
try {
const r = await instance.request<T, AxiosResponse<T, D>, D>(mergedConfig)
clearTimeout(timer)
dispatch({ type: 'resolve', response: r as unknown as R })
return r as unknown as R
} catch (e: unknown) {
clearTimeout(timer)
let wrapped: RequestError
if (isCancel(e)) {
wrapped = new RequestError('网络请求已取消', 'Cancel', e)
} else if (e instanceof ValidationError) {
if (e.issues.length === 0) {
wrapped = new RequestError('数据格式校验错误', 'ValidationError', e)
} else {
const first = e.issues[0]
if (first.path.length > 0 && first.path[0] === 'status') {
const resp = e.response as AxiosResponse<{ message?: string }> | undefined
wrapped = new RequestError(
resp?.data?.message ?? '接口返回失败',
'ValidationError',
e,
)
} else {
wrapped = new RequestError(first.message, 'ValidationError', e)
}
}
} else if (isAxiosError(e)) {
wrapped = new RequestError('网络请求失败', 'AxiosError', e)
} else {
wrapped = new RequestError('网络请求失败', 'OtherError', e)
}
_notifyApiError(wrapped)
dispatch({ type: 'reject', error: wrapped })
return Promise.reject(wrapped)
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[cancel, config, instance, method, params, url],
)
const fetch = useCallback(
(overrideConfig?: AxiosRequestConfig<D>) => {
fetchAsync(overrideConfig).catch(() => { /* error already in state */ })
},
[fetchAsync],
)
// 自动触发manual !== true 时挂载自动发请求)
useEffect(() => {
if (!optionsRef.current?.manual) {
fetch()
}
// 仅在 mount 时触发一次
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// unmount 时取消请求
useEffect(() => { return () => { cancel() } }, [cancel])
return {
response: state.response,
error: state.error,
loading: state.loading,
fetch,
fetchAsync,
cancel,
requestInterceptors: instance.interceptors.request,
responseInterceptors: instance.interceptors.response,
}
}