前端的常见的竞态问题有哪些
在Vue,React中,我们经常会遇到这样的问题:在一个页面中,快速的执行同一个操作,比如翻页,搜索框,会触发多次请求操作,由于网络问题,或者数据查询的快慢不同。可能导致后发送的请求,先返回。先发送的后返回,导致渲染出了前面的数据。这种就是前端常见的竞态问题。 但是我们只需要最后一次请求的结果,前面的请求结果都可以忽略。
以前的解决方案
之前常见的解决方案有下面的几种:
1. 通过变量标记,在给data赋值前判断是否打断操作
javascript
watchEffect((onCleanup) => {
let isCancel = false
fetchSomething().then(res => {
if (isCancel) return
renderData.value = res
})
onCleanup(() => {
// 下一次执行watch的时候,执行清理函数
isCancel = true
})
})
2. 利用 about()
, 在清理函数中打断请求
这种方案更好一些,能够在直接打断请求,节省用户的带宽和流量,我们以fetch api举例
javascript
watchEffect((onCleanup) => {
const ac = new AbortController();
const res = fetch('url', {
signal: ac.signal
}).then(res => {
renderData.value = res
})
onCleanup(() => {
// 下一次执行watch的时候,执行清理函数
ac.abort()
})
})
3. 使用 RxJS
详情参考: juejin.cn/post/720329...
js
const target = useRef(null); // 指向搜索按钮
useEffect(() => {
if (target) {
const subscription = fromEvent(target.current, 'click') // 可观察的点击事件流
.pipe(
debounceTime(300), // 对事件流防抖
switchMap(cur => // 将事件流转换成请求流,当有新的请求开始产生数据,停止观察老的请求
from( // promise --> Observable
postData(
'url',
{
name: keyword,
},
),
),
),
map(cur => cur?.data?.name),
tap(result => { // 处理副作用
setData(result);
}),
)
.subscribe(); // 触发
return () => subscription.unsubscribe();
}
}, [target, keyword]);
4. react中还可以使用 redux-saga
利用 redux-saga 的 takeLatest,cancel 等方法,可以很方便的解决竞态问题。 详情见官网: redux-saga.js.org/
js
import { take, put, call, fork, cancel, cancelled, delay } from 'redux-saga/effects'
import { someApi, actions } from 'somewhere'
function* bgSync() {
try {
while (true) {
yield put(actions.requestStart())
const result = yield call(someApi)
yield put(actions.requestSuccess(result))
yield delay(5000)
}
} finally {
if (yield cancelled())
yield put(actions.requestFailure('Sync cancelled!'))
}
}
function* main() {
while ( yield take(START_BACKGROUND_SYNC) ) {
// 启动后台任务
const bgSyncTask = yield fork(bgSync)
// 等待用户的停止操作
yield take(STOP_BACKGROUND_SYNC)
// 用户点击了停止,取消后台任务
// 这会导致被 fork 的 bgSync 任务跳进它的 finally 区块
yield cancel(bgSyncTask)
}
}
上述方案的一些不足
方案1,2中。实现起来比较简单。但是需要我们自己来声明变量来控制,没有很好的逻辑內聚。假如我们的watch函数是需要串行2个接口请求,如果想实现精确控制,那么我们不得不给每个接口都声明一个变量来控制。这样就会导致代码的可读性变差。
方案3,4中。可以解决较为复杂的请求并发问题。但也有缺点:
- 这2个框架都需要一些上手时间。如果本身项目比较简单。只有少数的常见需要控制竞态的请求。那么引入这2个框架,也会导致项目的复杂度增加。构建成本也会增加一些。项目中的其他同学也需要学习一下框架使用。如果是在已有的旧项目中改,那么改动成本也会比较大。
- redux-saga并不适合在vue中使用。
- 并且不太好用
about()
来打断请求,节约用户的流量带宽。
利用Generator迭代器,实现一个更优雅的解决方案
首先我们先看一下Generator的基本用法
js
// 声明一个迭代器函数
function* helloWorldGenerator(args1) {
const res1 = yield args1;
console.log(res1)
return 'ending';
}
// 执行迭代器函数,这会返回一个迭代器
const hw = helloWorldGenerator('hello');
// 执行next()函数, 这会让迭代器函数内部的代码开始执行,直到遇到yield关键字
// yield 这种关键字比较特殊,需要把这行代码分割成2部分,
// yield 左边的代码,我们先称为左值
// yield 右边的代码,我们先称为右值。
// 当执行next函数,就会把当前 右值的执行结果,返回放在 value 中返回。也就是 args1 的值 'hello'
const { value } = hw.next() // { value: 'hello', done: false }
// 如果我们不再执行next, 程序就会卡住。不会给res1 赋值。
// 如果我们继续执行next, 并加上参数。那么就执行 yield 左值的赋值语句, 赋值的内容就是 next 的参数。 而不是 yield 右值的内容。
hw.next(value + ' next')
// console.log('hello next')
// { value: 'ending', done: true }
本文讲介绍一下利用generator解决此类问题。不需要引入额外的框架。也不需要外部的变量来控制。整体相对于以前用 async/await 方式来写的代码差别很小。
首先我们模拟一个稍微复杂的场景 用户点击按钮后修改一个变量。然后根据这个变量去串行请求2个接口。每个接口返回后都会立刻渲染到页面上。
不做处理的代码
首先如何我们不做竞态处理,主要代码如下:
javascript
const currentIndex = ref();
const firstData = ref();
const secondData = ref();
const queryList = [
{
time: 1,
data: 1,
},
{
time: 2,
data: 2,
},
];
// 监听变量的变化,发送请求
watch(
() => currentIndex.value,
(val, oldVal, onCleanup) => {
fetchAndSetData(val)
}
);
// 发送2次请求,并分别设置
async function fetchAndSetData(val) {
const args = queryList[val]
beforeData.value = await fetchDelayTime({ data: args.data + 'b', time: args.time / 2 })
resData.value = await fetchDelayTime(args);
return res
}
// 封装模拟请求的接口。 data会在请求的返回数据中原样返回。 time是请求的延迟时间。
async function fetchDelayTime({ data, time }) {
let res = await fetch(`/api/delayTime?data=${data}&time=${time * 1000}`, {
cache: "no-store",
})
res = await res.json()
return res.data
}
template 如下:
html
<template>
<div>currentIndex: {{ currentIndex }}</div>
<button
class="btn"
v-for="(item, index) in queryList"
:key="index"
@click="() => {currentIndex = index}">
time: {{ item.time }} data: {{ item.data }}
</button>
<div>firstData: {{ firstData }}</div>
<div>secondData: {{ secondData }}</div>
</template>
第一阶段用 generator 简单改造
这里我们主要分2个阶段。第一阶段实现方案1类似的逻辑。第二阶段实现方案2类似的逻辑。
第一阶段,先完成一个简单的版本。我们的目标是完成像方案1类似的判断逻辑,也就是在获取到数据后,进行一次判断。如果取消了,那么就不进行后续的赋值等操作。 我们知道generator的主要作用就是可以在函数执行的过程中,暂停函数的执行,然后在外部通过next()方法来控制函数的执行。我们可以利用这个特性来实现我们的目标。
1.这里先修改fetchAndSetData
函数修改为如下形式:
js
function * fetchAndSetData(val) {
const args = queryList[val]
beforeData.value = yield fetchDelayTime({ data: args.data + 'b', time: args.time / 2 })
resData.value = yield fetchDelayTime(args);
return res
}
await
确实很好用,但是它是自动的等待后面的Promise执行完,然后程序会自动执行后续的赋值操作。外部无法做出任何干预,除非遇到报错停止函数执行。 我们使用yield来替换原来的await。 这样我们就可以手动操作,在获取到数据后,进行一次判断。如果取消了,那么就不进行后续的赋值等操作。
2.接下来修改watch的回调函数如下:
js
// 监听变量的变化,发送请求
watch(
() => currentIndex.value,
async (val, oldVal, onCleanup) => {
// 生成迭代器
const fetchIterator = fetchAndSetData(val)
// 是否取消后续操作
let isCancel = false
// 上次 yield 右值的结果,需要在下次 next 的时候传入
let lastNext
// 清理函数
onCleanup(() => {
isCancel = true
})
// 循环调用迭代器, 并判断是否需要取消
while(true) {
if (isCancel) {
// 取消后打断迭代器的后续操作,这里用了return(),用throw()也可以打断。
// 不过后面我们需要统一在控制器内部处理 "取消错误",让业务函数处理其他常规的错误如 网络 500, 语法错误
// 而且如果逻辑内部如果单独catch这个异步函数,这样 throw() 是没法打断函数后面的所有操作的!
fetchIterator.return('cancel')
}
const { value, done } = fetchIterator.next(lastNext)
// 如果yield右值是 Promise,那么这里等待Promise执行完,然后将结果传入下次next
// 这里把 await 等待Promise, 写在了控制 generator 的程序中,而不是直接写 generator 中。 后续将解释原因。
try{
if (value instanceof Promise) {
// 这里在外面等待Promise执行完,但有可能返回的是 reject 如网络 500 错误, 此时应该把错误交给业务迭代器内部,在业务代码中统一 catch 处理。
lastNext = await value
} else {
lastNext = value
}
} catch (err) {
fetchIterator.throw(err)
}
if (done) break
}
}
);
这样我们的主要功能就完成了。可以看到,在多个串行请求下。我们依然能够正常的打断上次回调函数的运行。
3.优化封装一下
当然我们这里还是有很多变量在watch中写的。为了后续其他的地方方便使用,进行一下封装。
js
watch(
() => currentIndex.value,
vueWatchCallbackWarp(fetchAndSetData)
)
// 封装一个函数, 用来控制generator的执行, 其他地方也会用的这个函数
// fetchGenerator 是一个generator函数, ...args 是 generator 函数的参数,也是watch的回调函数的参数
// 我们在 generatorCtrl 这个函数中执行 fetchGenerator
async function generatorCtrl(fetchGenerator, ...args) {
const fetchIterator = fetchGenerator(...args)
let isCancel = false
let lastNext
function cancel() {
isCancel = true
}
// 执行 fetchGenerator 内部函数代码
async function doFetch() {
while(true) {
if (isCancel) {
fetchIterator.return('cancel')
}
const { value, done } = fetchIterator.next(lastNext)
try{
if (value instanceof Promise) {
lastNext = await value
} else {
lastNext = value
}
} catch (err) {
if (err.name === 'AbortError') {
fetchIterator.return('cancel')
} else {
fetchIterator.throw(err)
}
}
if (done) break
}
return lastNext
}
return {
doFetch,
cancel
}
}
// watch函数,执行回调和清理操作的代码。 我们也进行一次封装
function vueWatchCallbackWarp(fetchGenerator) {
return (val, oldVal, onCleanup) => {
const { cancel, doFetch } = generatorCtrl(fetchGenerator, val)
onCleanup(cancel)
doFetch()
}
}
4.针对对点击按钮后的直接请求,也需要取消上次的请求
当然我们代码中不光用watch的情况。对于点击一个按钮后,直接发起的请求。我们也需要在连续请求的时候,处理这种竞态问题. 这样的话,我们需要保存一下cancel()
函数。在用户点击按钮的时候,执行上一次函数的 cancel()
操作。这样就可以打断上次的请求了。 增加代码如下。
js
const clickFetchHandle = takeLatestWarp(fetchAndSetData)
// 封装高阶函数, 保存每次的取消函数
function takeLatestWarp (fetchGenerator) {
// 保存取消函数
let lastCancel
return async function (...args) {
// 如果有取消函数,先取消打断
if (lastCancel) {
lastCancel()
}
// 执行 generator 函数,并赋值新的取消函数
const { cancel, doFetch } = generatorCtrl(fetchGenerator, ...args)
lastCancel = cancel
doFetch()
}
}
html
<!-- 修改button点击事件 -->
<button
...
@click="clickFetchHandle(index)"
>
...
</button>
后续在其他地方的代码,只需要调用takeLatestWarp
函数包装一下, 就可以实现每次点击按钮后, 打断上次的请求了。
第一阶段总结
这样,我们仅用了30多行代码,我们就完成了对竞态问题的处理。 而在我们实际的项目中,改动点仅仅是在原来的业务代码中,把 await
改成 yield
就搞定了。相比其他旧的方案修改量非常小。 并且我们写代码的思路,写法也是和原来的一样的。十分方便。不需要像Rxjs一样,学习一套新的api,用Rxjs的思路来写代码。 此时我们可以发现, Generator 其实是非常强大的! 我们常用的 Async/await 并不是 Generator 的加强版,而是阉割版!
如果不追求极致的话,上面的代码就完全可以了。主要是真的原来的代码来说,改动量很少。
下一章我们来看一下,如何做到更极致一点,也就是利用about()
来打断请求,节约用户的流量带宽。
第二阶段是第一阶段的升级版,我们在下一篇文章中继续讲解。