JavaScript网络请求(一):处理race condition竞态问题

竞争危害(race hazard)又名竞态条件、竞争条件(race condition),它旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机

在前端中的表现常见于异步操作的结果返回的时间顺序和预期不同,比如常见的网络请求结果的顺序。

首先看一个异步请求返回结果顺序不同,导致数据不符合预期的场景。



先请求的数据后返回,导致覆盖掉了最新的请求

以上功能的完整实现代码如下,我们接下来需要一点点的对其进行改动。

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
const UserSearch: React.FC = () => {
const [data, setData] = useState<any>({});
const [loading, setLoading] = useState<boolean>(false);

const run = useCallback(async (name: string) => {
setLoading(true);
const data = await Axios.get("/thirdparty/users", { params: { name } });
setData(data.data);
setLoading(false);
}, []);

const onSearch = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value) {
run(e.target.value);
}
},
[run]
);

return (
<div className='inner-card'>
<Card>
<Input.Search onChange={onSearch} placeholder='请输入用户名进行搜索' />
</Card>
<Card>
<List
loading={loading}
itemLayout='horizontal'
dataSource={data?.items}
renderItem={(item: any) => (
<List.Item>
<List.Item.Meta
avatar={<Avatar src={item.avatar_url} />}
title={<a href={item.html_url}>{item.login}</a>}
description={item.node_id}
/>
</List.Item>
)}
/>
</Card>
</div>
);
};

使用debounce

最常见的缓解该状况的方案便是使用debounce,在用户输入完成之后,再发起请求。

1
2
3
4
5
6
7
8
9
const run = useCallback(
debounce(async (name: string) => {
setLoading(true);
const data = await Axios.get("/thirdparty/users", { params: { name } });
setData(data.data);
setLoading(false);
}, 500),
[]
);
使用debounce后,在全部输入结束后再发起请求

这样,就可以极大的缓解了请求返回顺序不同导致的错误问题。

但是这样已经改变了需求的内容,原本期望的是实时更新,现在变成了等输入完成之后再更新,如果用户输入的内容比较长,界面就会一直空白,因此还需要考虑其他的解决方案。

根据请求ID,对所需的数据做取舍

我们在每次请求的时候,都生成一个随机的字符串数据(自增ID,时间戳、nanoID)带入请求中去,后端在返回数据的同时,再原封不动的将该requestID返回;本地也有一个全局变量,记录最新的requestID。

在数据返回之后,我们拿数据中的requestID与本地的最新的requestID做比对,如果相等,则展示数据,不相等,则舍掉数据。

1
2
3
4
5
6
7
8
9
10
11
12
// 只记录最新的请求ID
const reqId = useRef<number>(+new Date());

const run = useCallback(async (name: string) => {
reqId.current = +new Date();
setLoading(true);
const data = await Axios.get("/thirdparty/users", { params: { name, reqId: reqId.current } });
if (reqId.current.toString() === data.data.reqId) {
setData(data.data);
}
setLoading(false);
}, []);

后端返回的数据格式:

1
2
3
4
5
6
{
"reqId": "1604456870887",
"total_count": 228912,
"incomplete_results": false,
"items": []
}

这样,根据请求ID的比对,我们就可以将旧的请求舍弃掉。

即使先请求的数据后返回,也会被舍弃

这种方案的弊端太明显,需要依赖后端的配合,因此尝试寻找完全由前端处理的方案。

使用useEffect

在使用ahooks的useRequest的时候,官方的文档中提到了这样一个API介绍

默认情况下,新请求会覆盖旧请求。如果设置了 fetchKey,则可以实现多个请求并行,fetches 存储了多个请求的状态。外层的状态为最新触发的 fetches 数据。

那么意味着useRequest是完全可以处理竞态问题的。我们用useRequest来尝试一下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const { run, data, loading } = useRequest(
(name: string) => {
const param = new URLSearchParams({
name,
});
return {
url: "/thirdparty/users?" + param,
method: "get",
};
},
{
manual: true,
initialData: { items: [] },
}
);

const onSearch = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.value) {
run(e.target.value);
}
},
[run]
);

这个时候再次进行请求之后发现,完全符合我们的预期。

那么我们如果也期望自己去实现该效果的话,则可以借助React的useEffect来实现。

首先我们看一下React Hooks的执行流程。

react hooks flow

可以发现,在每次Update阶段,都会先执行一次cleanUp Effects,然后再执行Effects函数。借助此效果,我们可以定义一个Ref外部变量,每次Effect clean阶段自增该值。在effect阶段,将该值赋给局部变量。在请求结束之后,比对局部变量与自增的Ref值是否相等,然后即可对数据做取舍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
const [name, setName] = useState<string>("");
const counter = useRef<number>(0);

useEffect(() => {
if (name) {
const reqId = counter.current;
const run = async () => {
setLoading(true);
const data = await Axios.get("/thirdparty/users", { params: { name } });
if (reqId === counter.current) {
setData(data.data);
}
setLoading(false);
};
run();
}
return () => {
counter.current++;
};
}, [name]);
使用useEffect来处理返回数据

同时,Dan Abramov 在A Complete Guide to useEffect 也提到了该问题的解决方案,基于此,我们可以取消掉Ref这个外部变量来实现。这个可以实现的原因和该情况类似。

因为useEffect的cleanup函数不是和effects一一对应执行的,并不是Mount阶段执行的Effect,一定在UnMount阶段执行对应的cleanup。而是在下一次effect执行之前,执行上一个effect的cleanup函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
useEffect(() => {
let didCancel = false;
if (name) {
const run = async () => {
setLoading(true);
const data = await Axios.get("/thirdparty/users", { params: { name } });
if (!didCancel) {
setData(data.data);
}
setLoading(false);
};
run();
}
return () => {
// 用于下一次effect clean的时候更新该值
didCancel = true;
};
}, [name]);

提取为单独的Hooks

可以看到,使用react useEffect是可以满足我们的需求的。但是,针对每一个请求都要写一次这样的代码,会做很多重复性的操作,因此我们可以将该功能提取为一个单独的hooks使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { useEffect } from "react";

// 使用函数返回值的方式保证原来的数据
type ICurrentCallback = () => boolean;
type IEffectCallback = (getCurrent: ICurrentCallback) => void | (() => void | undefined);

export const useCurrentEffect = (
effect: IEffectCallback,
deps: React.DependencyList | undefined
) => {
useEffect(() => {
let isCurrent = true;

// 如果返回了清理函数,则放在cleanup阶段执行
const cleanup = effect(() => isCurrent);

return () => {
isCurrent = false;
cleanup && cleanup();
};
}, deps);
};

使用时,直接获取最新的current值即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
useCurrentEffect(
(getCurrent) => {
if (name) {
const run = async () => {
setLoading(true);
const data = await Axios.get("/thirdparty/users", { params: { name } });
if (getCurrent()) {
setData(data.data);
}
setLoading(false);
};
run();
}
},
[name]
);

abort上一次网络请求

还有一种实现思路,我们可以在下一次请求的时候,直接将上一次未结束的网络请求取消掉。取消网络请求,针对不同的协议对应的api是

  • XMLHttpRequest.abort()
  • fetch 的 AbortController

Axios中的具体实现,对应的为 CancelToken。使用CancelToken的方式官方介绍地址 https://github.com/axios/axios#cancellation ,被取消的请求会执行Promise的reject,错误内容为cancel的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const cancelSource = useRef<CancelTokenSource | null>(null);

const run = useCallback(async (name: string) => {
if (cancelSource.current) {
// 如果之前存在请求,则取消掉
cancelSource.current.cancel();
cancelSource.current = null;
}
const CancelToken = Axios.CancelToken;
cancelSource.current = CancelToken.source();

setLoading(true);
const data = await Axios.get("/thirdparty/users", {
params: { name },
cancelToken: cancelSource.current.token,
});
// 清空cancelSource值
cancelSource.current = null;
setData(data.data);
setLoading(false);
}, []);

canceled

看效果这样也满足了我们的需求,这样先请求的数据,如果还没有完成,就不会再回来了,也就不会出现覆盖的情况了。

使用cancel处理请求

Axios的cancelToken的实现方式是利用了一个cancel token的Promise,如果存在该Promise,并且该promise resolve了,则执行xhr.abort()方法来取消掉请求。

具体代码地址: https://github.com/axios/axios/blob/16aa2ce7fa42e7c46407b78966b7521d8e588a72/lib/adapters/http.js#L268

rxjs的SwitchMap

A reactive programming library for JavaScript

使用rxjs处理异步操作,非常的轻松,因此处理该问题时,在不依赖react hooks直接使用rxjs的switchMap 操作符即可完成。

switchMap特点是在每次发出时,会取消前一个内部 observable 的订阅,然后订阅一个新的 observable。这个思想和react的useEffect的clearup类似,因此也是一样的实现思路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { map, switchMap } from "rxjs/operators";
import { fromEvent, from } from "rxjs";

// 创建一个基于keyup事件的Observer
let userTypesInSearchBox = fromEvent(document.getElementById("#search-box"), "keyup").pipe(
map((event) => event.currentTarget.value),
switchMap((name) => {
return from(Axios.get("/thirdparty/users", { params: { name } }));
}),
map((res) => res.data)
);

userTypesInSearchBox.subscribe((data) => {
if (data) {
render(data);
}
});

function render(data) {
// 渲染数据
}

参考

Avoiding Race Conditions when Fetching Data with React Hooks

Avoiding useEffect race conditions with a custom hook

A Complete Guide to useEffect

Axios - GitHub

Axios取消请求CancelToken

How to use throttle or debounce with React Hook?