238 行
7.8 KiB
TypeScript
238 行
7.8 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
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ─── 内部 ValidationError(对齐 @szyx-mobile/use-axios 的 ValidationError)────
|
|||
|
|
|
|||
|
|
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'
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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 ? params : (overrideConfig?.params ?? config?.params),
|
|||
|
|
data: isGet ? (overrideConfig?.data ?? config?.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,
|
|||
|
|
}
|
|||
|
|
}
|