前端竞态问题处理
厚积薄发。
前言
竞态问题通常在多进程或多线程编程中被提及,前端工程师可能很少讨论它,但在日常工作中你可能早就遇到过与竞态问题相似的场景:
let finalData;
watch(obj, async () => {
// 发送网络请求
const res = await fetch('/path');
// 将请求结果赋值给data
finalData = res;
})
最近在《Vue.js设计与实现》(霍春阳著)一书中,发现在实现watch监听时,涉及到了竞态问题,以及如何处理过期副作用函数。
在前端日常开发工作中,请求也会遇到竞态问题,但是得益于网络顺利和用户操作按部就班,没有出现过差错,前端人对于这个问题的处理也就自动忽略了。
竞态问题
竞态问题,又叫竞态条件(race condition),它旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机。
简单来说,竞态问题出现的原因是无法保证异步操作的完成会按照他们开始时同样的顺序。举个🌰:
- 有一个分页列表,快速地切换第二页,第三页;
- 先后请求 data2 与 data3,分页器显示当前在第三页,并且进入 loading;
- 但由于网络的不确定性,先发出的请求不一定先响应,所以有可能 data3 比 data2 先返回;
- 在 data2 最终返回后,分页器指示当前在第三页,但展示的是第二页的数据。
这就是竞态条件,在前端开发中,常见于搜索,分页,选项卡等切换的场景。
那么如何解决竞态问题呢?
取消请求
针对以上场景,很容易想到:当发出新的请求时,取消掉上次请求即可。
-
对于
XMLHttpRequest
可以直接调用abort
方法终止请求。const xhr = new XMLHttpRequest(); xhr.open('GET', '/api/path'); xhr.send(); // 取消请求 xhr.abort();
-
对于
fetch
可以使用AbortController
这个控制器对象。const controller = new AbortController(); const signal = controller.signal; const url = "video.mp4"; const downloadBtn = document.querySelector(".download"); const abortBtn = document.querySelector(".abort"); downloadBtn.addEventListener("click", fetchVideo); abortBtn.addEventListener("click", () => { controller.abort(); console.log("Download aborted"); }); function fetchVideo() { fetch(url, { signal }) .then((response) => { console.log("Download complete", response); }) .catch((err) => { console.error(`Download error: ${err.message}`); }); }
你可以查看它的在线演示。
-
对于
axios
基于XMLHttpRequest
,在v0.22.0版本已经支持AbortController
来取消请求了。至于以前的cancelToken
方法已经不推荐了,存在是为了兼容旧版本。用法和 fetch 一样,调用 abort 来取消请求。
在
React
工程中,我们可以使用useEffect
的清理函数来方便地使用取消请求来避免竞态问题。useEffect(async () => { const controller = new AbortController(); const signal = controller.signal; const data = await axios.get(`/path/${id}`, { signal }); setData(data); return () => { controller.abort(); } },[id])
忽略过期请求
除了取消请求外,还可以忽略过期请求。
-
当发起新请求时,将之前的请求标记为过期请求,将来响应到来时就可以忽略掉。
还是使用
useEffect
在 id 更新时,触发清理函数,将flag
设置为 true,等待上一次请求响应回来时,flag
已经赋值了 true,手动判断将响应结果忽略掉。useEffect(async () => { const flag = false; const data = await axios.get(`/path/${id}`); if(!flag){ setData(data); } return () => { flag = true; } },[id])
-
设置唯一标志位,响应到来后判断当前是否是最新请求,如果不是最新请求则忽略响应。
在React项目中,我们可以很容易想到
useRef
来保存唯一标志位。const count = useRef(0); useEffect(async () => { const curCount = count.current; const data = await axios.get(`/path/${id}`); if(count.current === curCount){ setData(data); } return () => { count.current++; } },[id])
原生JS的竞态问题处理
上面都是依赖 ReactuseEffect
提供的清除副作用实现的忽略过期请求,那如果没有使用框架该如何实现忽略过期请求呢。这也是基于我前言中提到的watch
过期函数的实现得来的。
首先,实现一个高阶函数,包裹我们的请求函数,并传递一个过期参数,提供给开发自主选择是否处理过期请求。
function withRaceFn(fn) {
let cleanup;
function onInvalidate(fn) {
cleanup = fn;
}
return function resolveRace(...args) {
if (cleanup) {
// 下次请求时成功调用,并将第一次请求中的标识更新
cleanup();
}
// 选择引用类型,作为参数传递
// 如果采用基础类型作为参数传递,是对值的复制,无法获取到更新后的值
let isExpires = {
value: false
};
onInvalidate(() => {
isExpires.value = true;
})
return fn.call(this, isExpires, ...args);
}
}
然后,使用withRaceFn
来处理包裹实际的请求函数(异步函数)。
let finalData;
const request = async (isExpires, ...args) => {
// 模拟请求,2s后返回数据
const res = await new Promise((resolve) => {
setTimeout(resolve, 2000, args)
})
// 开发可选择是否处理过期请求
if (!isExpires.value) {
finalData = res;
console.log('res:', res);
}
}
const resolveRace = withRaceFn(request);
resolveRace(1)
resolveRace(2)
resolveRace(3)
console.log('end');
从控制台可以看出,成功忽略了过期请求,只打印了最新的结果。
我们首先定义了cleanup
变量,这个变量用来存储用户通过onInvalidate
函数注册的过期回调。可以看到 onInvalidate
函数的实现非常简单,只是把过期回调赋值给了 cleanup
变量。这里的关键点在 resolveRace
函数内,每次执行回调函数 resolveRace
之前,先检查是否存在过期回调,如果存在,则执行过期回调函数cleanup
。最后我们把是否过期参数对象作为resolveRace
函数的第一个参数传递,以便用户使用。
处理loading
上面的例子还有一个容易忽略的地方,那就是loading。
前端发起请求时,一般会设置loading,在请求结束时取消loading。
let loading = false;
let finalData;
// 发起请求
new Promise(resolve => {
// 开启loading
loading = true;
setTimeout(resolve, 2000)
}).then(res => {
finalData = res;
}).finally(() => {
// 取消loading
loading = false;
})
假设每个请求需要3s返回结果。
请求1在1s时发起,请求2在2s时发起,在请求2发起时,请求1还未返回,则将请求1设为过期请求,并在then
内忽略结果,如果忘记了finally
内忽略过期loading,在3s时请求1结束时就取消了loading,请求2结果在4s时才返回,导致提前取消了loading,中间有1s的无loading效果,空白页面。请求1为过期请求,也应该忽略过期的loading。
let finalData, loading = false;
const request = async (isExpires, ...args) => {
try {
loading = true;
// 模拟请求,2s后返回数据
const res = await new Promise((resolve) => {
setTimeout(resolve, 2000, args)
})
// 开发可选择是否处理过期请求
if (!isExpires.value) {
finalData = res;
}
} finally {
// 过期的loading处理
if (!isExpires.value) {
loading = false;
}
}
}
最后
该方案已发npm
,赶紧下载race-resolver
到你本地体验一下吧。
👇👇
npm i race-resolver -S
转载自:https://juejin.cn/post/7208479235131703351