背景 上一篇文章已经简单介绍使用了Hooks
, 而Hooks
带来的好处就是可以封装常用逻辑, 在前端中最常见的就是request
请求了, 那么看看如何封装异步请求Hooks
比较合适
开发 useAsync
首先, 需要构建类似useState
方法的Hooks
, 只是传入值应该是一个Promise
(如果非Promise
, 直接当做已经完成的值就行), 返回的state
也有三个属性: data/loading/error
, 就叫它useAsync
吧
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 export type UseAsyncArgs<T> = T | (() => Promise <T>);export type UseAsyncState<T> = { loading : boolean ; error: Error ; data: T };export type UseAsyncPromise<T> = T | Promise <T>;export type UseAsyncReturn<T> = [UseAsyncState<T>, Dispatch<SetStateAction<UseAsyncPromise<T>>>];const useAsync = <T>(_promise: UseAsyncArgs<T>): UseAsyncReturn<T> => { const [promise, setPromise] = useState<UseAsyncPromise<T>>(_promise); const [jobState, setJobState] = useState<UseAsyncState<T>>({ loading : true , error : null , data : null , }); return [jobState, setPromise]; };
然后是我们需要在此Hooks
里得到promise
的执行情况, 并根据执行结果setState
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 const useAsync = <T>(_promise: UseAsyncArgs<T>): UseAsyncReturn<T> => { const [promise, setPromise] = useState<UseAsyncPromise<T>>(_promise); const [jobState, setJobState] = useState<UseAsyncState<T>>({ loading : true , error : null , data : null , }); const doJob = async () => { const _jobState = { ...jobState }; try { if (promise instanceof Promise || _.isFunction(_.get(promise, 'then' ))) { _jobState.loading = true ; _jobState.error = null ; _jobState.data = null ; setJobState({ ..._jobState }); const result = await promise; _jobState.data = result; } else { _jobState.loading = false ; _jobState.error = null ; _jobState.data = promise; } } catch (error) { console .log('useAsync error' , error); _jobState.error = error; } finally { _jobState.loading = false ; setJobState({ ..._jobState }); } }; useEffect(() => { doJob(); }, [promise]); return [jobState, setPromise]; };
初步看起来这样没问题了, 但是试用后会发现, 如果第一次setPromise
的返回结果比第二次慢, 那么实际上生效的data
会是返回比较慢的值, 但它应该是要被覆盖的值才对. 所以需要加上promise
是否最新的判断
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 const useAsync = <T>(_promise: UseAsyncArgs<T>): UseAsyncReturn<T> => { const [promise, setPromise] = useState<UseAsyncPromise<T>>(_promise); let isPromise = false ; if (promise instanceof Promise || _.isFunction(_.get(promise, 'then' ))) { isPromise = true ; } const [jobState, setJobState] = useState<UseAsyncState<T>>({ loading : isPromise ? true : false , error : null , data : isPromise ? null : (promise as T), }); const currentJobRef = useRef<Promise <T>>(null ); const doJob = async () => { const _jobState = { ...jobState }; try { currentJobRef.current = promise as Promise <T>; if (promise instanceof Promise || _.isFunction(_.get(promise, 'then' ))) { _jobState.loading = true ; _jobState.error = null ; _jobState.data = null ; setJobState({ ..._jobState }); const result = await promise; if (currentJobRef.current === promise) { _jobState.data = result; } } else { _jobState.loading = false ; _jobState.error = null ; _jobState.data = promise; } } catch (error) { console .log('useAsync error' , error); if (currentJobRef.current === promise) { _jobState.error = error; } } finally { if (currentJobRef.current && currentJobRef.current === promise) { _jobState.loading = false ; setJobState({ ..._jobState }); } } }; if (isPromise && currentJobRef.current !== promise) { doJob(); } useEffect(() => { return () => { currentJobRef.current = null ; }; }, []); return [jobState, setPromise]; };
useFetch
然后再实现useFetch
, 实现接口的自动请求
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 const isAxiosResponse = <T>(result: unknown): result is AxiosResponse<T> => (result as AxiosResponse)?.request instanceof XMLHttpRequest;export type UseFetchReturn<R, T> = [UseAsyncState<R>, (promise: UseAsyncPromise<T> ) => UseAsyncPromise<T>];const defaultGetData = <T, R>(result: T) => { if (isAxiosResponse<R>(result)) { return result.data; } return result as unknown as R; }; const useFetch = <T, R = T extends AxiosResponse ? T['data'] : T>( promise: UseAsyncArgs<T> = null, getData: (result: T) => R = defaultGetData, ): UseFetchReturn<R, T> => { const [asyncState, setPromise] = useAsync<T>(promise); const login = useLogin(); if (asyncState.error instanceof UTILS.FetchError.AuthError) { login(); } const resolveAsyncState: { loading: boolean; error: Error; data: R } = { ...asyncState, data: useMemo<R>( () => (_.isArray(asyncState.data) ? (asyncState.data.map(getData) as any) : getData(asyncState.data)), [asyncState.data, getData], ), }; return [resolveAsyncState, setPromise]; };
完善流程 以上两个Hooks
完成后, 已经实现了请求的完整流程控制, 使用起来也比较简单
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 const [pageData, setPagePromise] = useFetch(null );const clickRefreshData = useCallback( async (id: string ) => { setPagePromise( UTILS.post<{ data : string ; }>(api.webApi.api(), { id }), ); }, [setPagePromise], ); useEffect(() => { }, [pageData]);
但是这样使用起来实际上是会打断流程的, 甚至比以前的回调地狱还难用, 因为闭包之间是没有互相包含的, 如果有个异步串行请求, 那么实现起来会非常痛苦
普通异步请求流程 flowchart LR
subgraph A[闭包空间Event]
direction LR
E1[事件触发] --> F1[执行异步Job]
F1 -. 完成 .-> F2[异步回调]
F2 --> F3[处理结果]
end
Hooks
请求流程flowchart LR
subgraph AA[闭包空间Event]
direction LR
EE1[事件触发]-->FF1[执行异步Job]
end
subgraph BB[闭包空间 useEffect]
direction LR
FF1 -. 完成 .-> FF2[异步回调]
FF2 --> FF3[处理结果]
end
那么有办法解决这个问题吗? 如果能将setPagePromise
这个方法也能直接添加异步返回, 就能直接变成普通异步请求的流程. 对于无所不能的js
来说, 好像也是可以实现的
useFetchWithErrorHandler
按上面所描述的, 我们只需要在useFetch
的时候, 将setPromise
的返回值修改为与原始promise
返回值同步的操作就行, 这里把项目中常用的error-modal
弹窗也直接加入进来了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 const getErrorMessage = (error: Error ) => React.createElement(React.Fragment, null , React.createElement('div' , null , _.get(error, 'text' )), React.createElement('div' , null , error.message)); export type UseFetchWithErrorHandlerReturn<T> = [ UseAsyncState<T>, <TT, RR = TT extends AxiosResponse ? TT['data'] : TT>( promise?: Promise<TT> | Promise<any>, _showError?: boolean, _throwError?: boolean, ) => Promise<RR>, ]; const useFetchWithErrorHandler = <T>(promise: UseAsyncArgs<T> = null, showError: boolean = true): UseFetchWithErrorHandlerReturn<T> => { const [resolveAsyncState, setPromise] = useFetch<T | any>(promise); const setPromiseWithErrorHandler = useCallback( <TT, RR = TT extends AxiosResponse ? TT['data'] : TT>( _promise: Promise<TT> | Promise<any> = null, _showError: boolean = showError, _throwError: boolean = false, ): Promise<RR> => { return new Promise<RR>((resolve, reject) => { const isPromise = _promise instanceof Promise || _.isFunction(_.get(_promise, 'then')); if (isPromise) { setPromise( (_promise as Promise<RR>).then( (data: any) => { resolve(defaultGetData(data)); return data; }, (error: Error) => { if (_showError) { Modal.error({ centered: true, title: error.name, content: getErrorMessage(error), }); } if (_throwError) { reject(error); } return Promise.reject(error); }, ), ); } else { resolve(setPromise(_promise)); } }); }, [setPromise, showError], ); return [resolveAsyncState, setPromiseWithErrorHandler]; };
使用方法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 const [pageData, setPagePromise] = useFetchWithErrorHandler(null );const clickRefreshData = useCallback( async (id: string ) => { try { const result = await setPagePromise( UTILS.post<{ data : string ; }>(api.webApi.api(), { id }), ); } catch (e) { } }, [setPagePromise], );
全局进度条 既然实现了全局统一的useFetch
以及error-msg
, 那么顺便把请求进度条也一起加上
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 type ProgressConfig = { start : number; progress: number; intervalId?: number; }; type RequestConfig = FetchRequestConfig & { requestId : string; }; const LoadingProgress: React.FC = () => { const loadingRef = useRef<{ [key: string]: ProgressConfig; }>({}); const [progressState, setProgressState] = useState({ status : 'none' , progress : 0 , }); const calcProgressState = () => { let currentProgress = 0 ; _.map(loadingRef.current, ({ progress } ) => { currentProgress += progress; }); const totalProgress = _.size(loadingRef.current) * 100 ; if (currentProgress === totalProgress) { _.map(_.keys(loadingRef.current), (key ) => Reflect .deleteProperty(loadingRef.current, key)); setTimeout ( () => setProgressState({ status : 'none' , progress : 0 , }), 300 , ); } setProgressState({ status : currentProgress === totalProgress ? 'done' : 'progress' , progress : (currentProgress / totalProgress) * 100 , }); }; useEffect(() => { const reqOff = addFetchEventListener('request' , (config: RequestConfig ) => { if (config._hasProgress) { config.requestId = _.uniqueId(`axios_request_` ); const progressConfig: ProgressConfig = { start : new Date ().getTime(), progress : 20 , }; loadingRef.current[config.requestId] = progressConfig; calcProgressState(); progressConfig.intervalId = window .setInterval(() => { const spendTime = new Date ().getTime() - progressConfig.start; progressConfig.progress = 20 + (spendTime / (spendTime + 500 )) * 0.7 * 100 ; calcProgressState(); }, 100 ); } return config; }); const resOff = addFetchEventListener( 'response' , (response: AxiosResponse ) => { const { config } = response; if ((config as RequestConfig)._hasProgress) { const progressConfig = loadingRef.current[(config as RequestConfig).requestId]; progressConfig.progress = 100 ; clearInterval (progressConfig.intervalId); calcProgressState(); } return response; }, (error: AxiosError ) => { const { config } = error; if ((config as RequestConfig)._hasProgress) { const progressConfig = loadingRef.current[(config as RequestConfig).requestId]; progressConfig.progress = 100 ; clearInterval (progressConfig.intervalId); calcProgressState(); } return Promise .reject(error); }, ); return () => { reqOff(); resOff(); }; }, []); return ( <div className ={classNames( 'loading-progress-wrapper ', { hidden: progressState.status === 'none' })}> <Progress className ='loading-progress' type ='line' showInfo ={false} status ={progressState.status === 'progress' && progressState.progress > 80 ? 'active' : 'normal'} percent={progressState.progress} strokeColor={{ '0%': '#108ee9', '100%': '#87d068', }} strokeWidth={5} /> <style jsx global > {` .loading-progress-wrapper { z-index : 2000 ; height : 3px ; width : 100% ; position : absolute; .loading-progress { display : block; line-height : 0px ; height : 3px ; } } `} </style > </div > ); }; export default LoadingProgress;