第二阶段: 利用 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阶段的代码就行了呢?
其实这里还有一些必须额外要注意的点:
- 如果需要用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上增加这个方法。
- 不要使用
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
}
- 在控制器中我们执行
return()
,throw()
不一定让业务函数完全停止
如果业务函数中有 try...catch
语句,那么 throw()
只会打断 try
语句中的代码,而不会打断 catch
和 try...catch
语句外的代码。
如果有try...finally
语句,那么 return()
会打断 try
语句中的代码,而 finally
语句中的代码会继续执行下去。
-
嵌套Generator需要用
yield*
来处理
不要用fo...of
在内部擅自处理 generator ,这和yield*
不等价!! 如果你是故意的,当我没说。
我们只要向外委托当前的 generator 即可。不然无法做到正常的打断。
yield*
右值需是 generator , 此 generator 执行到最后return
出来的值会自动赋给yield*
左值。这里是不能用next()
控制的。
next()
只能控制给当前yield
关键字的左值赋值,无论是直接的 generator, 还是委托的 generator 都一样。 -
虽然我们上面部分包装函数会把
doFetch()
的异步结果返回。但是这个值并不一定是你想要的!分为以下几种情况:
a. 业务代码没有打断,return 正好就是你想要的结果。
b. 业务代码cancel打断了,但是没有 finally 语句,那么 return 的值是 ‘cancel’, 也就是我们在控制器中 return 的值。
c. 业务代码cancel打断了,但是有 finally 语句,那么 return 的值是 finally 语句中的值。
d. 如果报错了,那么 return 的值是 catch 语句中的值。
所以,最好是不要用doFetch()
的返回值,或者你有办法判断一下。
typescript
- 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
}
这里可以看一下上面类似的用例代码。
https://github.com/maotong06/take-latest-generator-co
我将部分函数进行了整理,也上传到了npm上。可以直接安装使用。
npm i take-latest-generator-co