原创:仅30行代码,利用Generator优雅地解决Vue,React中的请求竞态问题(part 1)

前端的常见的竞态问题有哪些

在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中。可以解决较为复杂的请求并发问题。但也有缺点:

  1. 这2个框架都需要一些上手时间。如果本身项目比较简单。只有少数的常见需要控制竞态的请求。那么引入这2个框架,也会导致项目的复杂度增加。构建成本也会增加一些。项目中的其他同学也需要学习一下框架使用。如果是在已有的旧项目中改,那么改动成本也会比较大。
  2. redux-saga并不适合在vue中使用。
  3. 并且不太好用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()来打断请求,节约用户的流量带宽。

第二阶段是第一阶段的升级版,我们在下一篇文章中继续讲解。

相关推荐
ct978几秒前
组件间的通信
前端·javascript·vue.js
左手吻左脸。32 分钟前
Vue 全栈面试题大全(2026 最新版最详细)
前端·javascript·vue.js
Aphasia31133 分钟前
手写KeepAlive组件
前端·react.js·面试
两个西柚呀38 分钟前
js中的同步和异步,三种处理异步任务的方式
前端·javascript
pe7er1 小时前
软件设计不要“既要又要”
前端·后端·架构
kyriewen1 小时前
从Webpack到Vite:我们迁移了一个10万行代码的项目,总结了这7个坑
前端·webpack·vite
IT_陈寒1 小时前
Java Stream并行流的坑:我花了3小时才找到的线程安全问题
前端·人工智能·后端
小新1101 小时前
最简单但完整的 Vue 响应式示例(一个简单的计数器按钮)
前端·javascript·vue.js
鹿青3 小时前
给设计稿做体检:我搓了个 Skill,专治 Figma 转代码出垃圾
前端·claude·视觉设计
陈_杨3 小时前
鸿蒙APP开发:足球战术App怎么做拖拽交互?球员拖动与路线绘制
前端