theme: qklhk-chocolate
关于 Race Condition
关于 Race Condition,维基上有具体介绍(英文版的更详细):
举个例子,大概就是两个线程去修改全局资源,理想的情况:
但在缺少同步锁的情况下,实际的情况可能是这样:
如何去解决:
大意是大部分语言都提供了资源锁/同步锁
这种东西,根据不同的语言选择不同的方法去处理这个问题
在前端的表现形式
javascript
是单线程的,理应不会出现上面的情况。但是在异步渲染的时候,还是会出现渲染的时序问题,表现形式,大概是,一个详情组件,watch/useEffect
传进来的id
,然后根据id
向后端发送请求,然后异步的渲染。
因为是异步的,你没法保证先发出的请求就一定是最先返回的,就会出现了页面展示的id
和详情对不上的情况:
运用同步锁的概念,可以定义一个blocked
的变量,在请求时,阻止后续请求的发送:
看似解决了渲染的时序问题,但仔细观察会发现,这样处理会导致新的问题产生:
- 整体的渲染周期变长了很多
- 前端是重交互和UI的,这种“锁”也会导致用户的操作被阻塞,对用户的使用影响也是不好的
- 场景太过单一,打个比方,如果处理的是输入框智能提示的时序问题,你不可能在前一个请求未返回前阻止用户继续输入
请求1有话说
现实中的案例
登陆/切换账户场景
- 用户未登陆的状况下,点击登陆,在登陆成功前点了取消,请求是异步的,取消是同步的,会导致,即使用户点击取消,用户仍然登陆成功了。
- 用户切换账号,场景类似,就是切换成功前取消切换,但是切换成功后的操作还是会执行的,如果用户感知不到账户切换,会出现比较大的 bug。
切换 tab/搜索
点击什么字母就会返回什么字母,这块对接口做了处理,早先的请求响应更慢,当你连续点击a
-> ab
-> abc
,会出现:先展示abc
->ab
->a
,搜索的场景同理
结果:
原因
产生这种时序问题的原因很多,简单概括包含以下几点:
- 当前所处的网络环境差,不稳定,没法保证请求返回的稳定性
- 后端的处理逻辑不同。打个比方,不同的两个接口都能触发组件的更新,但后端对这两个接口的处理策略不同,或者这两个接口访问的数据量不同,就会导致请求的处理周期不同,也就没法保证时序
- 此时的用户是个倒霉蛋,第1个请求就是比第2个请求返回慢
如何去解决
测试案例
一个简单的Vue
组件,根据输入的内容展示不同的结果,这块的接口做了处理,先发送的请求依然是响应最慢,会出现搜索和结果不匹配的情况
方案1:从最底层出发,“取消”请求
目前的请求方式大概两种:XMLHttpRequest
+ Fetch
,目前主流的方案还是XMLHttpRequest
,Fetch
因为兼容性的问题使用的还是不多,基于XMLHttpRequest
,用的最多的大概是axios,这种一般都会把取消请求的方法封装好了
我们还是以Fetch
为例子。Fetch
还是比较尴尬,本身就有兼容性的问题,对于请求控制的 AbortController 的兼容性相比更差,这块先不考虑这些。关于AbortController
MDN上有详细说明:
按照官方的例子这样处理就好了:
async handleSearch() {
try {
this.isCanceled = false;
if (this.controller) {
this.controller.abort();
this.isCanceled = true;
}
this.controller = new AbortController();
const { result } = await fetch(
`http://localhost:3000/list?search=${this.text}`,
{
signal: this.controller.signal,
}
).then((response) => response.json());
this.result = result;
console.log("result", result);
} catch (err) {
console.log("err", err);
// this.controller.signal.aborted
if (this.isCanceled) {
console.log("aborted");
} else {
this.$message("请求出错了");
}
}
}
⚠️需要注意的点:
- 取消的请求会走到
catch
,会和一些异常场景耦合,所以需要单独处理 - 这块每次都去生成新的实例,我没有找到相对应的
reset
方法 error
拿不到取消请求的信息,controller.signal.aborted
能够判断请求是否aborted
,但因为每次生成新实例的原因,只能用变量去控制
不会吧不会吧,难道真有人会去取消请求的?
- 百度,只会保留最新的请求,前面的请求都会被取消:
- 谷歌,谷歌会保留最大4个的并行请求,然后取消前面的所有请求:
奇怪的是,都没有做防抖处理
取消 Promise
取消Promise
,其实就是让Promise
提前resolved
或者rejected
。关于取消的具体姿势,可以看下how-to-cancel-your-promise
就是下面几点:
- Pure Promises
- Switch to generators
- Note on async/await
简单写法:
const request = (...arg) => {
let cancel;
const promise = new Promise((resolve, reject) => {
cancel = () => reject("aborted");
fetch(...arg).then(resolve, reject);
});
return [promise, cancel];
};
// ...
async handleSearch() {
try {
if (this.cancel) {
this.cancel();
}
const [promise, cancel] = request(
`http://localhost:3000/list?search=${this.text}`
);
this.cancel = cancel;
const result = (await promise.then((response) => response.json()))
.result;
this.result = result;
console.log("result", result);
} catch (err) {
if (err === "aborted") {
console.log(err);
} else {
this.$message("请求出错了");
}
}
}
匹配请求
只有当前处理的是请求匹配时才处理,否则不管,这里分为两种情况:
有唯一
key
区分的,例如商品详情:// 存在 id async handleSearch() { try { const detail = await fetch(`xx/${this.id}`); if (detail.id === this.id) { this.detail = detail; } } catch (err) { this.$message("请求出错了"); } }
不存在唯一
key
,记录最后Promise
引用,再匹配async handleSearch() { try { const curPromise = fetch(`xx/${this.id}`); this.promiseRef = curPromise; const detail = await curPromise; if (this.promiseRef === curPromise) { this.detail = detail; } } catch (err) { this.$message("请求出错了"); } }
我用过的库
redux-saga
redux-saga,我以前使用React
的时候喜欢用,是Redux
的一个中间件,主要就是处理副作用的,即请求。感觉这个库实现了个小型的IO系统,这块内容感兴趣的同学自行了解,我只说下解决方法,redux-saga
提供了TakeLatest
的辅助辅助函数去处理这种问题:
function* loadStarwarsHeroSaga() {
yield* takeLatest(
'LOAD_STARWARS_HERO',
function* loadStarwarsHero({ payload }) {
try {
const hero = yield call(fetchStarwarsHero, [
payload.id,
]);
yield put({
type: 'LOAD_STARWARS_HERO_SUCCESS',
hero,
});
} catch (err) {
yield put({
type: 'LOAD_STARWARS_HERO_FAILURE',
err,
});
}
},
);
}
rx-js
rx-js是一个响应式的库,官方说了,算是异步的lodash
。把所有的数据封装成流的形式进行处理。用到的操作方法主要就是SwitchMap
:
import { Subject, merge, of } from "rxjs";
import { ajax } from "rxjs/ajax";
import { switchMap, catchError, tap } from "rxjs/operators";
export default {
name: "HelloWorld",
data() {
return {
text: "",
result: "holder",
};
},
mounted() {
this.subject = new Subject();
this.subject
.pipe(
tap(() => {
console.log("text:", this.text);
}),
switchMap((str) =>
ajax(`http://localhost:3000/list?search=${this.text}`)
),
catchError((err, caught$) => {
return merge(of({ err }), caught$);
})
)
.subscribe((response) => {
if (response.err) {
this.$message("请求失败");
} else {
const result = response.response.result;
console.log("result:", result);
this.result = result;
}
});
},
beforeDestroy() {
this.subject.unsubscribe();
},
methods: {
handleSearch() {
this.subject.next();
},
},
};
因为把数据当作流去处理,避免了时序的问题:
结束语
我整理的大概这么多,解决方式不止这些,还有像GraphQL
等,了解的不多,就没写了。“竞态”问题出现在一些简单应用中的概率相对小很多,但在一些复杂应用中就会比较容易出现,自从我从B端项目切换到活动页以后,就再也没有碰到这种问题了(活动页赛高),只是我朋友碰到了这个问题,所以就简单整理了下,大概这么多,谢谢阅读。