如上图所示,点击不同的 Tab
页签,Table
中展示不同的数据,这是一个很常见的页面交互逻辑。
设想一下,当前的处理逻辑是:有三个 Tab
页签和一个 Table
,每点击 Tab
后,都会发起一个请求,然后请求成功后,会对 Table
重新赋值。也即,不同的 Tab
切换,都会对这个唯一的 Table
进行重新赋值。
仔细想一下,这会有问题吗?
有的,这里隐藏着一个 “竞态” 问题。当点击 Tab
页签的操作过于频繁时,能保证当前 Table
里展示的数据是最后一次点击的 Tab
对应的数据吗?
一顿操作之后,Table
的数据究竟来自哪个 Tab
页签,估计没一个人能说的清楚。如何解决这个问题呢?先来分析一下问题出现的原因。
- 首先,因为发起请求是异步,不能保证请求一定是按照触发的顺序依次返回对应的结果,从而导致了数据的不确定性;
- 其次,每次发起新请求后,之前的历史请求都已经 “过期” 了,不应该再用历史请求获取到的数据去更新表格,这是历史请求所带来的 “副作用”。
解决问题的关键就是如何消除这种 “副作用”:怎么让历史请求不再更新 Table
中的数据 。
规避这一问题还有一个更简单的方法:拆分数据。就是定义 3 个变量分别用来记录不同页签返回的数据,根据当前点击的
Tab
展示对应的数据。而在实际开发中,是采用拆分数据,还是共用同一份数据,需要依据具体场景具体分析。在这里,采用共用同一份数据的方式,作为解决竞态问题的前提,其他暂不考虑。
按照这个解决思路,每当有新请求发起时,就给历史请求打上 “过期” 的标记,等当前请求成功后判断是否过期,只有未过期的请求才去更新 Table
。那么如何将一个历史请求标记为 “过期” 呢?不妨借鉴一下 Vue 中 onCleanup
的原理来实现。
Vue3 中 watch
方法的回调函数 WatchCallback
,相较于以往的 newValue
和 oldValue
,新增了一个参数 onCleanup
,用以清理过期的回调函数,👉 源码地址。每次 WatchCallback
执行时,都会先执行 onCleanup
里的回调函数。用法如下:
js
watch(id, async (newVal, oldVal, onCleanup) => {
// 用一个标识记录当前回调是否过期
let expired = false
// cleanup 执行时,将标识置为 true
onCleanup(() => {
expired = true
})
const data = await fetch(`/xxx/${id}`)
// 判断当前回调是否过期,未过期才会打印 data
if (!expired) {
console.log(data)
}
})
上述代码是如何运作的:
watch
内部执行WatchCallback
时会先判断是否有cleanup
,若有则执行,随后再执行WatchCallback
内部的逻辑。首次执行WatchCallback
时,cleanup
为空;- 每次
WatchCallback
执行时,都会在内部定义一个expired
变量,同时向onCleanup
注册一个回调cleanup
,用以将expired
赋值为false
; - 当本轮
WatchCallback
还未执行完毕,又触发新一轮的WatchCallback
时,此时,cleanup
有值,执行cleanup
后,会将上一轮WatchCallback
中的expired
置为false
; - 当上一轮的
WatchCallback
中的fetch
执行完后,此时expired
已被置为false
,因此不会打印data
; - 而当最新的
WatchCallback
中的fetch
执行完后,此时expired
还是true
,因此会打印data
。
按照这个思路,先来实现一个监听普通回调函数的版本:
js
/**
* 监听回调函数是否过期
* @param {(args: Array<any>, onCleanup: () => void) => any} callback
* @return {(...args: Array<any>) => ReturnType<callback>}
*/
function watchExpiredCallback(callback) {
let cleanup
const onCleanup = fn => {
cleanup = fn
}
return function (...args) {
cleanup?.()
// 原函数的入参,通过 args 透传进去
// 新增入参 onCleanup
return callback.apply(this, [args, onCleanup])
}
}
用法如下:
js
// 原本的异步函数
const request = id => {
return new Promise(resolve => {
setTimeout(() => {
resolve(id)
}, 1000)
})
}
// 可监听是否过期的异步函数
const newRequest = watchExpiredCallback(async function (args, onCleanup) {
let expired = false
onCleanup(() => {
expired = true
})
const id = args[0]
const data = await request(id)
if (!expired) {
return data
} else {
return Promise.reject("request expired")
}
})
newRequest(1).then(res => {
console.log(res)
})
newRequest(2).then(res => {
console.log(res)
})
newRequest(3).then(res => {
console.log(res)
})
执行结果如下:
连续调用了三次 newRequest
,只有最后一次打印了结果,前两次都当作 “过期” 处理。竞态问题就这样轻松解决啦~
但是呢,现在这还不够好,前两次请求已经过期了,可还是发起请求并获取了响应结果,有点浪费资源,应该把过期的请求都取消掉才合理。而取消请求可以借助原生 JS 的 AbortController 类实现,而且 axios 自 v0.22.0
后也支持这一特性,很是方便。
再把 request
和 newRequest
改造一下:
js
const request = id => {
const controller = new AbortController()
const response = fetch("/", {
signal: controller.signal
})
const cancel = () => controller.abort(`fetch id(${id}) is canceled.`)
return { response, cancel }
}
const newRequest = watchExpiredCallback(function (args, onCleanup) {
const id = args[0]
const { response, cancel } = request(id)
onCleanup(cancel)
return response
})
newRequest(1).then(res => {
console.log("from 1", res)
})
newRequest(2).then(res => {
console.log("from 2", res)
})
newRequest(3).then(res => {
console.log("from 3", res)
})
改造后的 newRequest
代码逻辑更简洁了,执行结果如下:
可以看到前两次请求已经被取消啦,完美!
2024/08/22 更新:实际开发中发现参数使用起来很奇怪,改成下面这种参数结构更加方便一点。
js
function watchExpiredCallback(callback) {
let cleanup
const onCleanup = fn => {
cleanup = fn
}
return function (...args) {
cleanup?.()
- return callback.apply(this, [args, onCleanup])
+ return callback.apply(this, [onCleanup, ...args])
}
}
用起来就是下面这样:
js
- const newRequest = watchExpiredCallback(function (args, onCleanup) {
- const id = args[0]
+ const newRequest = watchExpiredCallback(function (onCleanup, id) {
const { response, cancel } = request(id)
onCleanup(cancel)
return response
})