Vue 响应数据遇上计时器

301 阅读3分钟

阅前提示:下面要说的情况我个人觉得是很少能遇得上的,但当我遇上的时候,确实让我有种是不是发现了vue出现bug的错觉。当我思考很久后,才明白这只是一种用法不太正确的问题。所以我决定记录一下,从vue响应式的实现和微任务简单的说明一下

以下所有代码都是基于vue3环境运行的

直接先上代码

如下面代码所示,在页面中我需要个展示个 username 的值,而 username 的获取,需要在一个计时器结束之后通过 count 的值来得到,当 count 的值发生变化时,我希望原有的计时器被清除后开始新的计时器。关键在于 onCancel 函数的回调中是否会在 count 的值发生改变后执行清除计时器的操作。可以先在这里思考一下答案和原因

<template>
  <div>{{ username }}</div>
</template>

<script setup>
  import { ref, watchEffect } from 'vue'
  const count = ref(0)
  const username = ref('loading')
  function inc () {
    count.value++
  }

  watchEffect(function (onCancel) {
    let timer = setTimeout(() => {
      username.value = `${count.value} - zhuang`
    }, 500)
    onCancel(() => {
      console.log(`clear timer-new value:${count.value}`) // 关键在于这里是否会在count的值发生改变后执行这里面的清除函数
      clearTimeout(timer)
    })
  })

  inc()
</script>

答案是:不会。onCancel 函数传入的回调没有和我设想一样在 count 改变之后执行,而实际渲染的时候,count 的值也确实是已经发生了改变。难道 vue 的响应系统有漏洞?

其实,要想解决这个问题很简单,只需要稍微的修改一下代码

<template>
  <div>{{ username }}</div>
</template>

<script setup>
  import { ref, watchEffect } from 'vue'
  const count = ref(0)
  const username = ref('loading')
  function inc () {
    count.value++
  }

  watchEffect(function (onCancel) {
    console.log(count.value) // 在这里读取 count 的值
    let timer = setTimeout(() => {
      username.value = `${count.value} - zhuang`
    }, 500)
    onCancel(() => {
      console.log(`clear timer-new value:${count.value}`) // 关键在于这里是否会在count的值发生改变后执行这里面的清除函数
      clearTimeout(timer)
    })
  })

  inc()
</script>

就如上面代码所示,只需要在 watchEffect 副作用函数中读取一下 count 的值,onCancel 传入的回调就按设想一样去执行。这其中的原理其实也很简单。

首先要先了解一下,vue 中的数据响应更新,是一个怎样的过程。简单来说,vue 的响应式系统是对源数据进行代理返回一个代理对象。这个代理对象会收集到所有依赖于该数据的订阅,在数据发生更新时,代理通知所有订阅更新。而代理对象收集订阅,是通过第一次获取依赖数据来完成的。

回到上面的代码,在 watchEffect 的副作用函数中,一开始代码第一次获取代理对象的数据是在 setTimeout 的回调函数中,这个回调函数不是立刻执行的,而是在微任务队列中等待主任务队列的所有任务执行结束后才会被推入栈中执行。因此,在回调函数执行时,vue 的收集订阅过程已经结束了,因此回调函数的订阅并不在代理对象的订阅集中。而当我们在 watchEffect 副作用函数中执行获取代理对象的数据是,watchEffect 的订阅被正确收集到。因此当响应数据发生改变时,会通知该该订阅去更新,清除先前的计时器并重新执行副作用函数。

到此,问题就已经很明朗。关键在于收集订阅的时机被错开导致更新不能按我们所设想的正确的被执行,这其中涉及到 vue 的响应原理和微任务。但是在日常的开发中,我们几乎不会遇到会需要用到上述代码的场景(只是预估)。而看到这里如果完全明白了其中的缘由的话,对于原来的需求实现其实就可以改成下面的代码

<template>
  <div>{{ username }}</div>
</template>

<script setup>
  import { ref, watchEffect } from 'vue'
  const count = ref(0)
  const username = ref('loading')
  function inc () {
    count.value++
  }

  watchEffect(function (onCancel) {
    const value = count.value
    let timer = setTimeout(() => {
      username.value = `${value} - zhuang`
    }, 500)
    onCancel(() => {
      console.log(`clear timer-new value:${count.value}`) // 关键在于这里是否会在count的值发生改变后执行这里面的清除函数
      clearTimeout(timer)
    })
  })

  inc()
</script>