异步请求竞态问题出现的场景
参考资料
场景
考虑一个搜索框,在输入信息时,自动请求接口获取展示列表。当输入a时,关于a的请求发出,紧接着输入b,关于ab的请求发出,客户端无法预知a的请求和ab的请求到底哪个更快返回响应,或者说,客户端无法保证这两个请求的响应是按请求发出的顺序响应的,因此在展示响应列表时,有可能ab的响应先到达,a的响应后到达,界面上就会出现输入的是ab,展示的却是a的响应这样的bug。这就是异步请求的竞态问题。
不仅仅是搜索框自动展示结果的场景,tab页面切换,分页切换,在异步操作快速且网络环境不平稳时,都会出现竞态问题。理论上讲,任何异步操作都要考虑竞态问题,只是竞态问题在异步操作不频繁且网络稳定的情况下,基本上不会出现。所以,考虑竞态问题的场景,通常只限于异步操作可能会比较频繁的情况下(网络条件一般都比较平稳,波动性小)。
说明
异步请求(操作)竞态问题:异步请求(操作)的完成时间不可预知,因此一系列顺序的异步操作,完成时间非原有顺序,出现竞态现象,导致完成后的回调函数执行顺序不可预测。而应用程序最忌讳的就是不可预测性,容易出现bug。
解决方案
对于复杂的一系列异步操作,最好的解决方案就是使用RxJS库。RxJS库基于观察者模式(Observable: 被观察的对象和observer: 观察者)、异步流和函数式编程思想,将一系列异步操作进行管道式管理,通过RxJS提供的100多个操作符对一系列异步操作进行转换,保证异步操作的竞态现象处于可预测的范围内。
Observable对象
可以理解为一个类似于Promise的模型,内部包含一系列值,在时间轴上,以事件的方式不断通知观察者,并传入值。
Observer对象
订阅了Observable的对象,可以被Observable通知并获取值
Subscription
Observable对象只要在调用subscribe后,才开始不断触发事件。未调用订阅函数之前,Observable是不会触发事件,通知Observer的。
调用subscribe后,返回一个Subscription对象,可以用Subscription对象来取消订阅。
其实这些模式,跟react的清除副作用,vue3的取消watchEffect都是类似的方式。
Subject
一个代理对象,通常用于将Observable的事件进行广播,通知所有订阅的Observer。因为通常情况下,一个Observer订阅一个Observable实例,没有办法多个Observer订阅同一个Observable实例。通过Subject代理,可以做到广播行为。
RxJS举例
以上述的搜索框场景为例,我们只需要最后发出的请求对应的响应,其他之前的请求都不需要,也就是说,对一系列异步请求,只要最新的异步请求即可。
下面用RxJS的在线ide来举例
// from将promise转为Observable, last是筛选获取最新的操作符
import { from, last } from 'rxjs';
// 模拟网络请求
const pGenerator = (v) => {
return new Promise((resolve) => {
setTimeout(resolve, Math.random() * 5000, v);
});
};
const solve = (p) => {
p.then((res) => {
console.log('p then', res);
});
console.log('solve', p);
};
// 一系列异步请求, 可以调整元素顺序来尝试查看结果是否是预期的
const promises = [pGenerator('66'),pGenerator('11'), pGenerator('22')];
// 打印solve <Promise>
// p then 22
from(promises).pipe(last()).subscribe(solve);
可取消的 promise
原生 promise 并不支持 cancel,但 cancel 对于异步操作来说又是个很常见的需求。所以社区很多仓库都自己实现了 promise 的 cancel 能力。
我们以awesome-imperative-promise 为例,来看看 cancel 的实现,它的 cancel 实现基于指令式 promise, 源码一共只有 40 行。
什么是指令式 promise?
我们普遍使用的 promise,它的 resolve/reject 只能在 new Promise 内部调用,而指令式 promise 支持在 promise 外部手动调用 resolve/reject 等指令。
通过它的用法能更好地理解何为指令式 promise:
import { createImperativePromise } from 'awesome-imperative-promise';
const { resolve, reject, cancel } = createImperativePromise(promise);
resolve("some value");
// or
reject(new Error());
// or
cancel();
内部的 cancel 方法其实就是将 resolve,reject 设为 null,让 promise 永远不会 resolve/reject。
一直没有 resolve 也没有 reject 的 Promise 会造成内存泄露吗?
有兴趣的同学可以了解下这篇知乎讨论,回答众说纷纭。
我个人认为,如果没有保留对 promise 的引用,就不会造成内存泄露。
回到 promise cancel,可以看到,虽然 API 命名为 cancel,但实际上没有任何 cancel 的动作,promise 的状态还是会正常流转,只是回调不再执行,被“忽略”了,所以看起来像被 cancel 了。
因此解决竞态问题的方法,除了「取消请求」,还可以「忽略请求」。
当请求响应时,只要判断返回的数据是否需要,如果不是则忽略即可。
忽略过期请求
我们又有哪些方式来忽略过期的请求呢?
封装指令式 promise
利用指令式 promise,我们可以手动调用 cancel API 来忽略上次请求。
但是如果每次都需要手动调用,会导致项目中相同的模板代码过多,偶尔也可能忘记 cancel。
我们可以基于指令式 promise 封装一个自动忽略过期请求的高阶函数 onlyResolvesLast。
在每次发送新请求前,cancel 掉上一次的请求,忽略它的回调。
function onlyResolvesLast(fn) {
// 保存上一个请求的 cancel 方法
let cancelPrevious = null;
const wrappedFn = (...args) => {
// 当前请求执行前,先 cancel 上一个请求
cancelPrevious && cancelPrevious();
// 执行当前请求
const result = fn.apply(this, args);
// 创建指令式的 promise,暴露 cancel 方法并保存
const { promise, cancel } = createImperativePromise(result);
cancelPrevious = cancel;
return promise;
};
return wrappedFn;
}
以上就是 github.com/slorber/awe… 的实现。
只需要将 onlyResolvesLast 包装一下请求方法,就能实现自动忽略,减少很多模板代码。
const fn = (duration) =>
new Promise(r => {
setTimeout(r, duration);
});
const wrappedFn = onlyResolvesLast(fn);
wrappedFn(500).then(() => console.log(1));
wrappedFn(1000).then(() => console.log(2));
wrappedFn(100).then(() => console.log(3));
// 输出 3
1万+

被折叠的 条评论
为什么被折叠?



