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

发布于:2024-04-25 ⋅ 阅读:(10) ⋅ 点赞:(0)

第二阶段: 利用 about()来打断请求

1. 封装fetch函数, 支持打断请求

首先我们需要封装一下fetch函数,让它支持about()方法。

// 重新封装一下 fetch 函数, 将 about() 停止请求封装到一起。
function fetchWithCancel(input, init = {}) {
  const ac = new AbortController();
  const { signal } = ac;
  const fetch2 = fetch(input, {
    signal,
    ...init,
  })
  // fetch2是一个Promise实例,也是一个对象,我们可以给它添加一个cancel方法。
  // 这里给他添加对象,而不是单独的返回 cancel 函数,目的也是为了能其他用fetch请求的地方。 能做到写代码的时候,使用fetch和以前一样。不用关心是不是需要取消请求。
  // 缺点是我们污染了这个对象的属性,考虑到一般不会有人操作 Promise 上的属性,所以这里利大于弊。让我们后续更方便。
  fetch2.cancel = () => {
    ac.abort()
  }
  return fetch2
}

2. 修改接口函数,让控制器能拿到携带cancel方法的Promise实例

同样需要修改一下接口函数,使用新的fetch函数。 并且我们不再使用 await , 而是用 yield。

// 为了能够打断请求,必须要在外部的控制器中,拿到执行中的Promise实例,和对应cancel方法。所以这里不能用 await,而是用 yield。
// 这样 yield 右值就是一个带有cancel方法的 Promise 实例,我们可以在外部的控制器拿到这个实例,然后执行 cancel 方法。
function * fetchDelayTime({ data, time }) {
  let res = yield fetchWithCancel(`/api/delayTime?data=${data}&time=${time * 1000}`, {
    cache: "no-store",
  })
  res = yield res.json()
  res = res.data
  return res
}

3. 修改业务逻辑函数 fetchAndSetData, 委托内部的Generator给外部的控制器

yield 右值是 generator函数 的地方,改成 yield*, 这样可以始终把所有的generator的执行权,交给我们最外面的控制器。

yield* 表达式作用: 用于委托给另一个generator 或可迭代对象。

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
}

4. 修改控制器 generatorCtrl, 能够在适当的时候执行Promise的cancel函数

能够在适当的时候执行Promise的cancel函数

async function generatorCtrl(fetchGenerator, ...args) {
  const fetchIterator = fetchGenerator(...args)
  let isCancel = false
  // 保存当前的Promise下的取消函数
  let cancelFun
  let lastNext
  function cancel() {
    isCancel = true
    // 执行当前的Promise下的取消函数
    cancelFun?.()
  }
  async function doFetch() {
    while(true) {
      if (isCancel) {
        fetchIterator.return('cancel')
      }
      const { value, done } = fetchIterator.next(lastNext)
      try {
        if (value instanceof Promise) {
          if (typeof value.cancel === 'function') {
            // 保存当前的Promise下的取消函数
            cancelFun = value.cancel
          }
          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
  }
}

到这里基本上就完成了。当快速点击的时候。在控制台,我们可以看到,会打断前面的请求。

另外,如果想处理更复杂一点的异步操作,我们还需要在处理一下 Promise.all 等静态函数。

5. 针对Promise并发操作,需要处理Promise上的静态函数

处理Promise上的静态函数。比如 Promise.all ,需要在 Promise.all 返回的 Promise 实例上也有 cancel 方法,能够取消数组中的所有 Promise 实例。

['all', 'allSettled', 'any', 'race'].forEach(i => {
  if (Promise[i]) {
    Promise[i] = function (...args) {
      const resPromise = Promise[i](...args)
      resPromise.cancel = (reason) => {
        args.forEach(i => i.cancel?.(reason))
      }
      return resPromise
    }
  }
})

这样我们在使用 Promise.all 的时候,也能够打断所有的 Promise 实例了。

6. 针对使用axios的时候,对axios进行处理。

在项目中我们常用的是axios较多,这里看一下如何修改axios。 因为axios内部 axios.get, axios.post 等方法都是调用的 axios.request 所以我们这里只修改 axios.request 就行了。 当然也可以用其他的方式封装。

import originAxios from 'axios'

const Axios = originAxios.Axios

const oldRequest = Axios.prototype.request
// 重写 request。返回的 Promise 实例上增加 cancel 方法。和上面的fetch一样,方便我们后续的打断操作
Axios.prototype.request = function (config) {
  const ac = new AbortController()
  const promise = oldRequest.call(this, {
    signal: ac.signal,
    ...config
  })
  promise.cancel = () => {
    ac.abort()
  }
  return promise
}
const axios = originAxios.create()

// 接口函数
function * fetchDelayTime({ data, time }) {
  let res = yield axiosGetWithCancel(`/api/delayTime?data=${data}&time=${time * 1000}`)
  res = res.data.data
  return res
}

function axiosGetWithCancel(input, init = {}) {
  return axios.get(input)
}

第二阶段总结

这样我们第二阶段的改造就完成了。使用方法和第一阶段一样地。我们并没有修改针对 点击事件 和 watch回调 2个封装的高阶函数 可以发现,主要流程和上一个阶段没有很多差别,关键用了一个取巧的方法,给请求的Promise实例上增加了一个cancel方法,能进行 about() 打断。 这样的话我们在其他地方,还是像以前一样用 async/await 写代码也是不影响的。只是增加很少的内存开销,考虑到页面的请求并发也不会很多,而且请求完成后会自动清理,所以这里的内存开销可以忽略不计。

注意事项:

前面我们已经完成了对竞态问题的处理。2阶段看起来很好,使用方法和1阶段一样,那是不是直接都直接改成2阶段的代码就行了呢?

其实这里还有一些必须额外要注意的点:

  1. 如果需要用about的形式打断请求,那么含有 cancel() 方法的那个 Promise 实例必须直接的放在 yield 右值的位置。后边不能再有 then 处理。比如下面的代码就是不行的。
function * fetchDelayTime({ data, time }) {
  // 这里用 含有 cancel 方法的 fetch函数距离, fetchWithCancel 返回的promise实例上是有 cancel 方法的
  let res = yield fetchWithCancel(`/api/delayTime?data=${data}&time=${time * 1000}`, {
    cache: "no-store",
  })
  // 这里执行 .then, 按照以前的逻辑是没问题,但是会返回新的Promise实例, 
  // 导致此 yield 的右值不再是含有 cancel 方法的那个 Promise 实例,而是新的 Promise 实例。
  // 这就导致了我们的控制器中拿到的是错误的Promise实例,没有cancel方法。 不能停止请求。还是按照 方案1 的方式打断的!!
  .then(res => res.json())
  res = res.data
  return res
}

同理,在对axios 添加 cancel方法 封装的时候,我们也是一样,为了保证axios普通的使用方法兼容,yield一般不会直接写在最接近axios请求函数的地方,以免影响其他地方的常规使用。这样就需要在保证在业务代码中写的yield的右值,是含有cancel方法的Promise实例。 如果你封装的axios会处理Promise的then,那么你需要在封装的时候,保证then后返回的Promise实例,也是含有cancel方法的Promise实例。 如果必须加then,需要手动将含有cancel方法的Promise实例,然后在 then 返回的Promise上增加这个方法。

  1. 不要使用 AsyncGeneratorFunction 也就是不要用下面的语法:
async function * fetchDelayTime({ data, time }) {
  let res = yield fetchWithCancel(`/api/delayTime?data=${data}&time=${time * 1000}`, {
    cache: "no-store",
  })
  // 这里用了 await, 理论上是没问题的。如果我们手动操作这个迭代器,我们能正常的操作。
  // 但是我们在嵌套 Generator的时候,会使用 yield* 来处理。 yield* 没法处理 AsyncGeneratorFunction。可能后续es标准会支持。
  // 所以建议统一都换成 yield。
  res = await res.json()
  res = res.data
  return res
}
  1. 在控制器中我们执行return(),throw()不一定让业务函数完全停止

如果业务函数中有 try...catch 语句,那么 throw() 只会打断 try 语句中的代码,而不会打断 catchtry...catch 语句外的代码。 如果有try...finally 语句,那么 return() 会打断 try 语句中的代码,而 finally 语句中的代码会继续执行下去。

  1. 嵌套Generator需要用 yield* 来处理 不要用 fo...of 在内部擅自处理 generator ,这和yield*不等价!! 如果你是故意的,当我没说。 我们只要向外委托当前的 generator 即可。不然无法做到正常的打断。 yield* 右值需是 generator , 此 generator 执行到最后 return 出来的值会自动赋给yield*左值。这里是不能用 next() 控制的。 next() 只能控制给当前 yield 关键字的左值赋值,无论是直接的 generator, 还是委托的 generator 都一样。

  2. 虽然我们上面部分包装函数会把 doFetch() 的异步结果返回。但是这个值并不一定是你想要的!分为以下几种情况: a. 业务代码没有打断,return 正好就是你想要的结果。 b. 业务代码cancel打断了,但是没有 finally 语句,那么 return 的值是 'cancel', 也就是我们在控制器中 return 的值。 c. 业务代码cancel打断了,但是有 finally 语句,那么 return 的值是 finally 语句中的值。 d. 如果报错了,那么 return 的值是 catch 语句中的值。 所以,最好是不要用 doFetch() 的返回值,或者你有办法判断一下。

typescript

  1. Typescript 使用 原来业务函数中返回的是 Promise,现在返回的是 Generator,所以需要修改一下类型。
// 原来
function fetchSomething(): Promise<TReturn>
// 现在
function fetchSomething(): Generator<any, TReturn, any>

在react 中的使用

如果您以前业务代码主要在redux,useReducer, 等类似在渲染函数外面的代码中,那么可以像上面一样,用闭包处理一下就行了。这里不再详述。 如果主要的代码是在hooks中,因为react渲染会重新声明函数,就不能直接用上面的方式了。可以封装自定义hook来处理。

1. 自定义 useEffect ,监听 state变化的时候

export function useEffectTakeLast(fetchGenerator, dep) {
  useEffect(() => {
    const { cancel, doFetch } = generatorCtrl(fetchGenerator, val)
    doFetch()
    // 在state改变后执行清理函数, 这会让上一次请求打断
    return cancel
  }, dep)
}

2. 自定义 useCallback, 用于点击事件


import {useRef, useCallback} from 'react'

export default function useCallBackTakeLast(fetchGenerator, dep) {
  // 缓存取消函数,下一次执行时,先取消一下
  const lastCancel = useRef()
  return useCallback(function (...args) {
    // 如果有取消函数,先取消打断
    if (lastCancel.current) {
      lastCancel.current()
    }
    // 执行 generator 函数,并赋值新的取消函数
    const { cancel, doFetch } = generatorCtrl(fetchGenerator, ...args)
    lastCancel.current = cancel
    doFetch()
  }, dep)
}

其他

上面对fetch增加了cancel方法。对于其他异步操作我们也可以这样 如:定时器

function delayTime(ms) {
  const ac = new AbortController()
  const { signal } = ac
  let timer
  const promise = new Promise((resolve, reject) => {
    timer = setTimeout(resolve, ms)
    signal.addEventListener('abort', () => {
      clearTimeout(timer)
      reject(new DOMException(signal?.reason || 'user aborted', 'AbortError'))
    })
  })
  promise.cancel = (reason) => {
    ac.abort()
    promise.__ABORT_REASON__ = reason
  }
  return promise
}

这里可以看一下上面类似的用例代码。

我将部分函数进行了整理,也上传到了npm上。可以直接安装使用。

npm i take-latest-generator-co


网站公告

今日签到

点亮在社区的每一天
去签到