竟态问题的解决方案(vue3中onInvalidate的实现思路)

590 阅读3分钟

问题描述

比如现在有一个需求是,页面上有一个数据列表,每当点击列表中的一项时,就会发起一个请求 A 来更新一个数据 res,再点击第二次,发起一个新的请求 B。如果请求 A 由于某些原因,延迟比较大,而 B 很快就返回来了,这就会导致 A 会在 B 之后完成并更新数据,此时 res 中的数据就是上一次点击的结果而并非正确的数据。这就是竟态问题了。

vue3给出的解决方法

首先来看 vue3 中是怎么解决这个问题的:

watch(
  () => obj.type,
  (newVal, oldVal, onInvalidate) => {
    let isExpired = false // 是否过期 
    onInvalidate(() => { isExpired = true }) 
    
    // 这里 res 拿到的就是 simulationRequest 中生成的 random 随机数 
    const res = await simulationRequest()  // 模拟一个网络请求,下面的例子中会有
    
    // 如果 isExpired 过期了就不再更新数据了 
    if (!isExpired) { 
      // data 总能保证是拿到的是最近的操作获得的数据,而不是最久延迟获得的数据 
      data = res
      console.log(data); 
    }
  }
)

watch 的第二个参数是一个回调函数 callback,而这个 callback 的第三个参数就是一个钩子函数 onInvalidate,这个钩子函数的作用就是,在下一次更新数据 obj.type 之前执行 onInvalidate 中的参数方法。因此如果出现文章开头问题描述中的问题,那么后一次的请求 B 执行之前就会调用这个钩子函数,使得 A 请求过期,这样就能保证拿到的结果是正确的。

原生js中如何实现

那么如果在 js 中应该怎么解决这个问题呢,实际上上面的思路已经有了,就是通过一个变量来控制本次请求的结果是否过期来实现的,问题就是原生 js 中没有 onInvalidate 这个钩子函数。

因此我们的思路应该是,onInvalidate 是如何实现的。在 vue3 中,watch 是通过 effect 以及 scheduler 调度函数来实现的,不过这里我们不考虑如何实现一个 watch,只考虑 onInvalidate 的实现,用它来解决我们的竟态问题。

思路重点如下:

  • onInvalidate 是用来更新过期函数(让上一次请求的结果过期)的
  • 过期函数一定要在数据更新之前执行
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Document</title>
</head>

<body>
  <button id="btn">模拟发送网络请求</button>
  <script>
    let data = null // 存储最终需要的数据

    let clearup;  // 保存过期函数

    /**
     * 钩子函数,用于更新过期函数
     */
    function onInvalidate(fn) {
      clearup = fn
    }

    // 添加点击
    const btn = document.getElementById('btn')
    btn.onclick = () => {
      // 如果存在过期函数,则执行一次过期函数,这样就能让上一次异步数据过期
      if (clearup) {
        clearup()
      }
      // 过期函数一定要在下一次操作开始之前执行
      handleClickLiItem(onInvalidate)
    }

    /**
     * 点击事件的回调
     * @onInvalidate: function  是一个钩子函数,用于更新过期函数,使得每次执行点击事件都能使上一次的数据过期
     */
    async function handleClickLiItem(onInvalidate) {
      let isExpired = false // 是否过期
      onInvalidate(() => {
        isExpired = true
      })

      // 这里 res 拿到的就是 simulationRequest 中生成的 random 随机数
      const res = await simulationRequest()

      // 如果 isExpired 过期了就不再更新数据了
      if (!isExpired) {
        // data 总能保证是拿到的是最近的操作获得的数据,而不是最久延迟获得的数据
        data = res
        console.log(data);
      }
    }

    /**
     * 模拟延迟场景
     */
    function simulationRequest() {
      let random = Math.floor(Math.random() * 10) + 1
      let result;
      console.log('本次延迟' + random + '秒');

      let p = new Promise((resolve) => {
        setTimeout(() => {
          result = random
          resolve(result)
        }, random * 1000)
      })

      return p
    }
  </script>
</body>

</html>

在上面的例子中:

  • 过期函数对应的就是 clearup
  • 数据更新则对应的是 handleClickLiItem 这个方法

可以明显的看到,过期函数是在‘数据更新’发生之前执行的。

当第一次点击按钮的时候,会先判断是否有国企函数,因为第一次进来的时候还并没有给 cleanup 赋值,自然就不会有也不会执行过期函数,然后执行了按钮的点击事件对应的回调方法 handleClickLiItem,这个方法会携带一个钩子函数 onInvalidate,在调用 onInvalidate 的时候就会更新过期函数 clearup,这个过期函数的作用就是将请求 A 的过期状态变量 isExpired 设置为过期状态 true ,之后会发送请求 A,这里我们假设 A 延迟了 10 秒。之后,在这 10 秒结束之前,我们再次点击按钮,此时因为 cleanup 已经有对应的内容了(设置 A 为过期状态),因此就会执行这个过期回调,使得 A 过期。然后再执行按钮的点击事件对应的回调方法,发送请求 B,这样就能保证即使 BA 之前返回来也没有关系。