仅30行代码,利用Generator优雅地解决Vue,React中的请求竞态问题 (第一部分)

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

在Vue,React中,我们经常会遇到这样的问题:在一个页面中,快速的执行同一个操作,比如翻页,搜索框,会触发多次请求操作,由于网络问题,或者数据查询的快慢不同。可能导致后发送的请求,先返回。先发送的后返回,导致渲染出了前面的数据。这种就是前端常见的竞态问题。
但是我们只需要最后一次请求的结果,前面的请求结果都可以忽略。

以前的解决方案

之前常见的解决方案有下面的几种:

1. 通过变量标记,在给data赋值前判断是否打断操作

watchEffect((onCleanup) => {
  let isCancel = false

  fetchSomething().then(res => {
    if (isCancel) return
    renderData.value = res
  })

  onCleanup(() => {
    // 下一次执行watch的时候,执行清理函数
    isCancel = true
  })
})

2. 利用 about(), 在清理函数中打断请求

这种方案更好一些,能够在直接打断请求,节省用户的带宽和流量,我们以fetch api举例

watchEffect((onCleanup) => {
  const ac = new AbortController();

  const res = fetch('url', {
    signal: ac.signal
  }).then(res => {
    renderData.value = res
  })

  onCleanup(() => {
    // 下一次执行watch的时候,执行清理函数
    ac.abort()
  })
})

3. 使用 RxJS

详情参考: https://juejin.cn/post/7203294201313034301

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 等方法,可以很方便的解决竞态问题。
详情见官网: https://redux-saga.js.org/

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的基本用法

// 声明一个迭代器函数
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个接口。每个接口返回后都会立刻渲染到页面上。

不做处理的代码

首先如何我们不做竞态处理,主要代码如下:



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 如下:

<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函数修改为如下形式:
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的回调函数如下:
// 监听变量的变化,发送请求
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中写的。为了后续其他的地方方便使用,进行一下封装。


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() 操作。这样就可以打断上次的请求了。
增加代码如下。

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()
  }
}
<!-- 修改button点击事件 -->
<button
  ...
  @click="clickFetchHandle(index)"
  >
  ...
</button>

后续在其他地方的代码,只需要调用takeLatestWarp函数包装一下, 就可以实现每次点击按钮后, 打断上次的请求了。

第一阶段总结

这样,我们仅用了30多行代码,我们就完成了对竞态问题的处理。
而在我们实际的项目中,改动点仅仅是在原来的业务代码中,把 await 改成 yield 就搞定了。相比其他旧的方案修改量非常小。
并且我们写代码的思路,写法也是和原来的一样的。十分方便。不需要像Rxjs一样,学习一套新的api,用Rxjs的思路来写代码。
此时我们可以发现, Generator 其实是非常强大的! 我们常用的 Async/await 并不是 Generator 的加强版,而是阉割版!

如果不追求极致的话,上面的代码就完全可以了。主要是真的原来的代码来说,改动量很少。

下一章我们来看一下,如何做到更极致一点,也就是利用about()来打断请求,节约用户的流量带宽。

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

  • 21
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值