在react中实现一个可以进行异步函数重试的状态机hook

最近需要实现一个状态机,一开始没当回事,后来写着写着,发现其中的状态管理和变化需要被仔细设计才能保证没有问题,今天就简单记录其中一版。

状态机转换图

注意这个图中有一个细节,举个例子来说,continueRun在success指向running的箭头上,这说明continueRun可以将状态从success变为running,同时在fail到running上没有continueRun,说明,continueRun并不能把状态从fail变为running

状态机源码

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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import { useEffect, useMemo } from "react";

type AsyncFunc<T, U> = (...args: T[]) => Promise<U>;

type FunctionStatus = 'init' | 'running' | 'stoped' | 'success' | 'fail';

type FunctionStatusChangeCallback<R> = (status: FunctionStatus, result?: R) => void;

interface RetryableFunctionParams<R> {
count?: number;
timeout?: number;
retryDuration?: number;
errorConsumeCount?: boolean;
callback?: FunctionStatusChangeCallback<R>
}

class Retryable<T, U> {
private fn: AsyncFunc<T, U>;
private maxRetries: number;
private retries: number;
private errorConsumeCount: boolean;
private startTime: number;
private timeout: number;
private args: T[];
private retryDuration: number;
private planToStop: boolean;
private resolve: ((value: U | PromiseLike<U>) => void) | null;
private reject: ((reason?: any) => void) | null;
private status: FunctionStatus;
private callback?: FunctionStatusChangeCallback<U>;

constructor(fn: AsyncFunc<T, U>, params: RetryableFunctionParams<U>) {
this.fn = fn;
this.callback = params.callback;
this.maxRetries = params.count ?? 3;
this.retries = params.count ?? 3;
this.retryDuration = params.retryDuration ?? 500;
this.errorConsumeCount = params.errorConsumeCount;
this.startTime = new Date().getTime();
this.timeout = params.timeout ?? 1000;
this.args = [];
this.planToStop = false;
this.resolve = null;
this.reject = null;
this.changeStatus('init');
}

getStatus = () => {
return this.status;
};

stop = (): void => {
this.planToStop = true;
if (this.status !== 'running') {
this.changeStatus('stoped');
}
};

continueRun = (resetTimeout?: boolean): boolean => {
if (resetTimeout) {
this.startTime = new Date().getTime();
}
if (this.canRetry() && this.status === 'success') {
setTimeout(() => {
this.execute();
}, this.retryDuration);
return true;
}
return false;
};

restart = (...args: T[]): Promise<U> => {
if (this.planToStop) return;
this.retries = this.maxRetries;
this.startTime = new Date().getTime();
this.args = args;
return new Promise<U>((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
this.execute();
});
};

resetFunc = (fn: AsyncFunc<T, U>): void => {
this.fn = fn;
};

private canRetry() {
return !this.planToStop && this.retries > 0 && (new Date().getTime() - this.startTime < this.timeout);
}

private changeStatus: FunctionStatusChangeCallback<U> = (status, result) => {
this.status = status;
this.callback?.(status, result);
};

private execute = async (): Promise<void> => {
try {
if (this.planToStop) {
this.changeStatus('stoped');
return;
}
this.changeStatus('running');
this.retries--;
const result = await this.fn(...this.args);
this.resolve && this.resolve(result);
if (this.planToStop) {
this.changeStatus('stoped', result);
return;
}
this.changeStatus('success', result);
} catch (error) {
if (!this.errorConsumeCount) {
this.retries++;
}
if (this.planToStop) {
this.changeStatus('stoped');
return;
}
if (this.canRetry()) {
setTimeout(() => {
this.execute();
}, this.retryDuration);
} else {
this.reject && this.reject(error);
this.changeStatus('fail');
}
}
};
}

export function useRetryableAsyncFunc<T, U>(fn: AsyncFunc<T, U>, params: RetryableFunctionParams<U>) {
const retryAble = useMemo(() => {
return new Retryable(fn, params);
}, []);

useEffect(() => {
retryAble.resetFunc(fn);
}, [fn]);

return retryAble;
}