背景:
在项目开发过程中,我们经常会遇到重复请求的场景,如果不对重复的请求进行处理,则可能会导致系统出现各种问题。
例如假设用户在系统内的列表搜索条件连续查询两次,第一次查询条件较复杂,请求的返回结果比第二次慢,就会导致查询出来是第一次的结果,这是不符合预期的,便产生了竞态问题。
其实竞态不止发生在请求间,凡是异步操作同一数据的(包括 DOM 元素),都有可能出现竞态问题。而网络请求的频繁程度、响应时长的不确定性,使其成为最常见的竞态场景。
解决这个问题的核心思路是如何忽略前面几次无效请求,只处理最后一次请求的结果。\
方案一:增加请求ID
在每次请求时,都生成一个随机的字符串数据带入请求中去,后端在返回数据时,再将该requestID返回;本地也有一个全局变量,记录最新的requestID。
在数据返回后,拿数据中的requestID与本地最新的requestID做比对,如果相等,则展示数据,不相等,则舍掉数据。
const reqId = useRef(+new Date());
const run = (async (params) => {
reqId.current = +new Date();
const data = await Axios.get("/getSomething", { params: { ...params, reqId: reqId.current } });
if (reqId.current.toString() === data.data.reqId) {
setData(data.data);
}
});
优点:逻辑较简单
缺点:需要依赖后端的配合,最好完全由前端处理。
方案二:利用useEffect特性
useRequest
ahooks的useRequest在取消请求中这样描述“竞态取消,当上一次请求还没返回时,又发起了下一次请求,则会取消上一次请求”,说明其默认处理了竞态问题。\
自行实现
先来看一下react hooks的执行流程:
注:Cleanup 会在下面两种情况下被执行:
- 组件销毁的时候
- 下一次将要调用此 effect 时
\
可以发现,在每次Update阶段,都会先执行一次cleanUp Effects,然后再执行Effects函数。借助此效果,我们可以定义一个Ref外部变量,每次Effect clean阶段改变该值。在effect阶段,将该值赋给局部变量。在请求结束之后,判断该次请求是否被取消。
useEffect(() => {
let didCancel = false;
httpGet(time).then((res) => {
if (!didCancel) {
setData(res);
}
});
return () => {
didCancel = true;
};
}, [time]);
要理解上面的代码,需要知道httpGet 和 cleanup 方法形成了两个闭包,这两个闭包使用了同一个 didCancel 变量。每次请求产生的目标方法都有自己的 didCancel ,而每一个 didCancel 又只会影响本目标方法的执行。当后一次请求触发时会调用前一次请求方法的cleanup,将其didCancel置为true,当前一次请求返回时,他的结果就不会被处理。
优点:不依赖后端实现
缺点:依据useEffect的实现方案,没法做到全局统一处理
提取为单独hooks
// 使用函数返回值的方式保证原来的数据
export const useCurrentEffect = (effect, deps) => {
useEffect(() => {
let isCurrent = true;
// 如果返回了清理函数,则放在cleanup阶段执行
const cleanup = effect(() => isCurrent);
return () => {
isCurrent = false;
cleanup && cleanup();
};
}, deps);
};
使用:
useCurrentEffect(
(getCurrent) => {
if(!time) return;
const run = async () => {
const data = await Axios.get("/getSth", { params: { time } });
if (getCurrent()) {
setData(data);
}
};
run();
},
[time]
);
方案三:取消上一次请求
还有一种实现思路,我们可以在下一次请求的时候,直接将上一次未结束的网络请求取消掉。取消网络请求,对应的api是
XMLHttpRequest.abort()
let xhr = new XMLHttpRequest();
xhr.open("GET", "https://developer.mozilla.org/", true);
xhr.send();
setTimeout(() => xhr.abort(), 300);
Axios的 CancelToken
返回实例用法
const CancelToken = axios.CancelToken;
const source = CancelToken.source();
Axios.post('/user', { params }, {
cancelToken: source.token
})
source.cancel();
构造函数用法
const CancelToken = axios.CancelToken;
let cancel;
axios.get('/user/12345', {
cancelToken: new CancelToken(function executor(c) {
cancel = c;
})
});
cancel(); // 取消请求
Axios的cancelToken的实现方式是利用了一个cancel token的Promise,如果存在该Promise,并且该promise resolve了,则执行xhr.abort()方法来取消掉请求。
单应用场景实现
const cancelSource = useRef(null);
const run = async (time) => {
if (cancelSource.current) {
// 如果之前存在请求,则取消掉
cancelSource.current.cancel();
cancelSource.current = null;
}
const CancelToken = Axios.CancelToken;
cancelSource.current = CancelToken.source();
const data = await Axios.get("/user", {
params,
cancelToken: cancelSource.current.token,
});
// 清空cancelSource值
cancelSource.current = null;
setData(data);
}
\
全局统一处理实现
\
如何判断重复请求
当请求方式、请求 URL 地址都一样时,我们就认为请求是一样的。因此在每次发起请求时,根据当前请求的请求方式、请求 URL 地址生成一个唯一的 key,同时为每个请求创建一个专属的 CancelToken,然后把 key 和 cancel 函数以键值对的形式保存到 Map 对象中,使用 Map 的好处是可以快速的判断是否有重复的请求:
注:如果请求参数也要相同才认为两个请求一样,在key中加入参数即可
// 生成唯一key
function generateReqKey(config) {
const { method, url} = config;
return [method, url].join("&");
}
// 把当前请求信息添加到pendingRequest对象中
const pendingRequest = new Map();
function addPendingRequest(config) {
const requestKey = generateReqKey(config);
config.cancelToken = config.cancelToken || new axios.CancelToken((cancel) => {
if (!pendingRequest.has(requestKey)) {
pendingRequest.set(requestKey, cancel);
}
});
}
当出现重复请求的时候,就可以使用 cancel 函数来取消前面已经发出的请求,在取消请求之后,我们还需要把取消的请求从 pendingRequest 中移除。
// 从pendingRequest对象中移除请求
function removePendingRequest(config) {
const requestKey = generateReqKey(config);
if (pendingRequest.has(requestKey)) {
const cancelToken = pendingRequest.get(requestKey);
cancelToken(requestKey);
pendingRequest.delete(requestKey);
}
}
全局拦截器设置
请求拦截器
axios.interceptors.request.use(
function (config) {
removePendingRequest(config); // 检查是否存在重复请求,若存在则取消已发的请求
addPendingRequest(config); // 把当前请求信息添加到pendingRequest对象中
return config;
},
(error) => {
return Promise.reject(error);
}
);
响应拦截器
axios.interceptors.response.use(
(response) => {
removePendingRequest(response.config); // 从pendingRequest对象中移除请求
return response;
},
(error) => {
removePendingRequest(error.config || {}); // 从pendingRequest对象中移除请求
if (axios.isCancel(error)) {
console.log("已取消的重复请求:" + error.message);
} else {
// 添加异常处理
}
return Promise.reject(error);
}
);
\
优点:纯JS解决方案,并且可以在拦截器中做统一处理。
缺点:有些场景下不需要竞态处理