React Async Hooks

背景

上一篇文章已经简单介绍使用了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(() => {
// handler data
}, [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 }),
);
// handler result
} catch (e) {
// handler error
}
},
[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;
// 2s 完成 80%
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;
作者

Mosby

发布于

2019-02-24

许可协议

评论