- 修复 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>
268 行
8.6 KiB
TypeScript
268 行
8.6 KiB
TypeScript
/**
|
||
* 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,
|
||
}
|
||
}
|