什么是竞态问题
竞态问题(竞态冒险race hazard)又叫竞态条件、竞争条件(race condition)。它旨在描述一个系统或者进程的输出依赖于不受控制的事件出现顺序或者出现时机。此词源自于两个信号试着彼此竞争,来影响谁先输出。
对于前端来说最常见的竞态问题一般发生在异步请求时,举个例子来说:
现在有一个列表支持了分页,且现在总共有多页数据。当我们在进行切换页码的时候请求列表数据的时间不一定(有时快有时慢)。现在我们进行如下操作:当位于第一页时点击第二页,不等数据请求完毕立即点击第三页。此时如果请求第三页的接口响应快于第二页。则会出现页面显示第三页数据,待第二页请求响应后页面又渲染了第二页的数据。如此第二页和第三页的请求就产生了竞态问题。
在上面的例子中我们就可以知道出现竞态的原因其实就是异步操作结束时机紊乱导致的,由此我们可以联想到的解决办法有:
- 发出新请求时,取消上次请求
- 请求响应时忽略旧的请求,只关注于最近的请求
取消请求
XMLHttpRequest
XMLHttpRequest(XHR)是一个内置的API函数集,通过HTTP在浏览器和web服务器之间收发XML或其他数据。在请求被发出后,可以通过abort()方法立即中止请求。
const xhr=new XMLHttpRequest();
xhr.open('GET','https://......');// 创建请求
xhr.send();// 发送请求
xhr.abort();// 取消请求
fetch
fetch作为AJAX的替代品,也可以发出类似XMLHttpRequest的网络请求。区别在于fetch返回了promise,要中止fetch发出的请求,需要使用AbortController类。
const controller = new AbortController();
const signal = controller.signal;
fetch('/xxx', {
signal,
}).then(function(response) {
//...
});
controller.abort(); // 取消请求
axios
axios本质上是对XMLHttpRequest的封装后,基于promise的实现版本。因此axios请求也可以被取消。
从v0.22.0开始,axios像fetch一样支持通过AbortController类实现取消请求。
const controller = new AbortController();
axios.get('/xxx', {
signal: controller.signal
}).then(function(response) {
//...
});
controller.abort() // 取消请求
忽略请求
指令式Promise
我们知道promise的resolve/reject只能在promise内部调用,当我们发出请求后无法从外部调用resolve/reject中断promise,但是此类中断promise的场景又很常见,所以很多插件通过指令式promise实现了promise的cancel能力。
以awesome-imperative-promise为例,我们可以看到cancel()方法中把resolve/reject置为null,这样promise就永远不会变更为fulfilled/rejected状态,永远不会调用resolve/reject。
使用方法
import { createImperativePromise } from 'awesome-imperative-promise';
const { resolve, reject, cancel } = createImperativePromise(promise);
resolve("some value");// 自定义resolve
reject(new Error());// 自定义 reject
cancel();// 取消promise
通过观察代码逻辑我们可以知道cancel并没有执行请求上的取消,只是让promise的状态不再变更,回调也不会执行。所以这种方式其实是忽略了接口响应。
封装指令式Promise
在上面我们通过指令式promise实现了promise对请求响应的忽略。那么我们是否可以对其进行二次封装,实现自动忽略过期请求的高阶函数呢,答案是肯定的。我们以awesome-only-resolves-last-promise的实现进行分析:
export function onlyResolvesLast<T extends (...args: any[]) => any>(
asyncFunction: T,
): T {
let cancelPrevious: CancelCallback | null = null;
const wrappedFunction = (...args: ArgumentsType<T>) => {
cancelPrevious && cancelPrevious();
const initialPromise = asyncFunction(...args);
const { promise, cancel } = createImperativePromise(initialPromise);
cancelPrevious = cancel;
return promise;
};
return wrappedFunction as any; // TODO fix TS
}
我们在使用的时候方法如下:
const fn = (duration) =>
new Promise(reslove => {
setTimeout(reslove, duration);
});
const wrappedFn = onlyResolvesLast(fn);
wrappedFn(500).then(() => console.log(1));
wrappedFn(1000).then(() => console.log(2));
wrappedFn(100).then(() => console.log(3));
// 输出结果:3
使用唯一id标识
除了指令式Promise,我们还可以通过给请求加标识,通过标识来忽略上次请求。
这种方式的主要思路就是通过记录请求的标识,在请求回调中判断当前请求标识是不是最新的,如果不是就忽略此次回调。
根据思路构思的大致逻辑:
// 唯一标识
let requestId=0;
const queryAPI=()=>{
// 记录当前请求的id,并生成新的id
const id=++requestId;
// 发送请求
fetch.then(res=>{
// 判断是否是最新请求
if(id===requestId){
// 回调逻辑
}
}).catch(()=>{
// 判断是否是最新请求
if(id===requestId){
// 回调逻辑
}
})
}
通过对逻辑的补充和封装,可以得到类似上面onlyResolvesLast的方法
function onlyResolvesLast(fn) {
// 利用闭包保存最新的请求 id
let id = 0;
const wrappedFn = (...args) => {
// 发起请求前,生成新的 id 并保存
const fetchId = id + 1;
id = fetchId;
// 执行请求
const result = fn.apply(this, args);
return new Promise((resolve, reject) => {
// result 可能不是 promise,需要包装成 promise
Promise.resolve(result).then((value) => {
// 只处理最新一次请求
if (fetchId === id) {
resolve(value);
}
}, (error) => {
// 只处理最新一次请求
if (fetchId === id) {
reject(error);
}
});
})
};
return wrappedFn;
}
const fn = (duration) =>
new Promise(reslove => {
setTimeout(reslove, duration);
});
const wrappedFn = onlyResolvesLast(fn);
wrappedFn(500).then(() => console.log(1));
wrappedFn(1000).then(() => console.log(2));
wrappedFn(100).then(() => console.log(3));
// 输出 3
两种途径的总结
取消请求
使用取消请求的方式,可以实际上的中止请求,一定程度上可以减轻服务端的压力,但是取消请求依赖底层API,如果已经对请求做了封装,使用取消请求可能存在一定困难。
忽略请求
忽略请求把目光放在了请求回调上,不考虑请求的中止也就不依赖底层API,在使用上更简单,也更容易做封装,使得忽略请求的方式更加通用。