intro.png

如上图所示,点击不同的 Tab 页签,Table 中展示不同的数据,这是一个很常见的页面交互逻辑。

设想一下,当前的处理逻辑是:有三个 Tab 页签和一个 Table,每点击 Tab 后,都会发起一个请求,然后请求成功后,会对 Table 重新赋值。也即,不同的 Tab 切换,都会对这个唯一的 Table 进行重新赋值。

update.png

仔细想一下,这会有问题吗?

有的,这里隐藏着一个 “竞态” 问题。当点击 Tab 页签的操作过于频繁时,能保证当前 Table 里展示的数据是最后一次点击的 Tab 对应的数据吗?

update-unclear.png

一顿操作之后,Table 的数据究竟来自哪个 Tab 页签,估计没一个人能说的清楚。如何解决这个问题呢?先来分析一下问题出现的原因。

  • 首先,因为发起请求是异步,不能保证请求一定是按照触发的顺序依次返回对应的结果,从而导致了数据的不确定性;
  • 其次,每次发起新请求后,之前的历史请求都已经 “过期” 了,不应该再用历史请求获取到的数据去更新表格,这是历史请求所带来的 “副作用”。

解决问题的关键就是如何消除这种 “副作用”:怎么让历史请求不再更新 Table 中的数据

规避这一问题还有一个更简单的方法:拆分数据。就是定义 3 个变量分别用来记录不同页签返回的数据,根据当前点击的 Tab 展示对应的数据。而在实际开发中,是采用拆分数据,还是共用同一份数据,需要依据具体场景具体分析。在这里,采用共用同一份数据的方式,作为解决竞态问题的前提,其他暂不考虑。

按照这个解决思路,每当有新请求发起时,就给历史请求打上 “过期” 的标记,等当前请求成功后判断是否过期,只有未过期的请求才去更新 Table。那么如何将一个历史请求标记为 “过期” 呢?不妨借鉴一下 VueonCleanup 的原理来实现。

Vue3watch 方法的回调函数 WatchCallback,相较于以往的 newValueoldValue,新增了一个参数 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)
})

执行结果如下:

code-v1

连续调用了三次 newRequest,只有最后一次打印了结果,前两次都当作 “过期” 处理。竞态问题就这样轻松解决啦~

但是呢,现在这还不够好,前两次请求已经过期了,可还是发起请求并获取了响应结果,有点浪费资源,应该把过期的请求都取消掉才合理。而取消请求可以借助原生 JSAbortController 类实现,而且 axiosv0.22.0 后也支持这一特性,很是方便。

再把 requestnewRequest 改造一下:

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 代码逻辑更简洁了,执行结果如下:

code-v2

可以看到前两次请求已经被取消啦,完美!

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
  })