人人都会AI编程

30.3 闭包导致的状态不同步问题

更新时间:2026-07-11

在 Vue 开发中有一个非常经典且隐蔽的陷阱:你在事件处理函数、定时器或异步回调中读取了一个响应式变量,但拿到的是“那一刻”的旧值,而不是当前最新值。这背后的元凶通常是 JavaScript 闭包

问题是怎么发生的

看一段我们再熟悉不过的代码:

<script setup>
import { ref, onMounted } from 'vue'

const count = ref(0)

function startTimer() {
  setInterval(() => {
    console.log(count.value)   // 你觉得会打印什么?
  }, 1000)
}

onMounted(() => {
  startTimer()
})
</script>

<template>
  <button @click="count++">count is {{ count }}</button>
</template>

你不断点击按钮,count 一直在涨,但控制台里每隔一秒打印的却始终是 0

为什么?

setInterval 的回调函数被创建时,它捕获了当前作用域下的 count 变量——而 count 是一个 ref 对象,count.value 在那一刻还是 0。这个回调被推入任务队列后,每隔一秒都会执行一次同一个闭包,它引用的始终是创建时的那一层作用域,自然不会读取到最新的 count.value

在 Vue 语境下常见的表现

这种“闭包陈旧值”问题在以下场景中尤为高发:

  1. 定时器/延时回调

就像上面的例子,setIntervalsetTimeout 内的函数如果不借助 Vue 的响应式依赖追踪机制,就会拿到创建闭包时的快照。

  1. 事件监听回调

如果你手动用 addEventListener 绑定的事件处理函数中直接使用了 ref.value,同样会遇到相似问题:函数是在挂载时创建的,此后每次触发事件拿到的都是初始值。

   onMounted(() => {
     window.addEventListener('resize', () => {
       console.log(count.value)  // 初始值,永不更新
     })
   })
   
  1. 异步请求的回调
   function fetchData() {
     api.getData().then(res => {
       // 如果 count 在请求期间被修改了,这里拿到的是请求前的旧值吗?
       console.log(count.value)
     })
   }
   

这里虽然 .then 回调中的 count 是同一个 ref 对象,但因为回调本身并不再重新建立依赖,它读取的确实是“此刻”的 count.value,而不是请求时的克隆值。但问题在于:如果开发者错误地认为它会“自动同步”并基于旧值做计算,就可能产生逻辑偏差。更典型的是,你在一个较长的异步链里把 count.value 赋值给了某个普通变量,后续操作全基于那个普通变量,这样就彻底脱离了响应式。

解决办法

解决思路很简单:要么让闭包“别闭得太死”,要么让 Vue 的响应式系统替你追踪

方法一:使用 watchwatchEffect 代替手动定时器逻辑
如果你只是想监听一个值的变化并做出响应,Vue 的侦听器天生就不会有闭包陈旧问题:

const count = ref(0)

watch(count, (newVal) => {
  console.log(newVal)   // 每次变化都会触发,拿到最新值
})

方法二:将需要在回调中使用的值通过函数参数传入,或用计算属性保持响应性
如果必须使用 setInterval,可以让回调每次执行时都从最新的 count 中读取(直接读 count.value 本身就是响应式的,但关键在于闭包是否捕获了 ref 本身)。你可能会问:前面不是说 setInterval 的回调拿到的是旧值吗?那是因为我们把 count.value 的值在闭包建立时就“固化”了。但如果我们不在闭包里捕获值的副本,而是每次通过响应式引用去读,count 本身是一个对象,闭包里永远拿着这个对象的引用,所以 count.value 依然能读到最新值——前提是闭包没有被重新创建,并且你确实在回调里用 count.value 而不是用预先存下的中间变量

前面的例子之所以不行,是因为 ref 在模板之外 .value 确实是响应的,闭包持有的也是 count 这个 ref 对象,按理说每次打印的 count.value 应该是递增的。但在 Vue 3 的实际场景中,如果代码没有任何地方触发组件的重新渲染或依赖收集,setInterval 的回调仍然可以读到最新值——这取决于代码结构。实际上,前面的 setInterval 示例有时确实能打印最新值,只要回调没有被优化。但常见的问题是:当你把 count.value 赋值给一个普通变量并在定时器里使用那个变量时,例如:

const count = ref(0)
setInterval(() => {
  const currentCount = count.value  // 这个没问题
  console.log(currentCount)
}, 1000)

以上写法实际上是可以拿到最新值的,因为每次执行回调时都重新访问了 count.value。那么问题多出现在哪里?出现在把 ref 解构或取了个“别名”导致的响应性丢失,或者在回调外部定义变量捕获那一刻的值

const count = ref(0)
const fixedCount = count.value   // 取出初始值 0
setInterval(() => {
  console.log(fixedCount)   // 永远是 0
}, 1000)

或者在使用 reactive 对象时对属性进行了解构:

const state = reactive({ count: 0 })
const { count } = state    // 解构后 count 是普通 number,丢失响应性
setInterval(() => {
  console.log(count)       // 永远是 0
}, 1000)

所以真正的闭包旧值问题,大多源于无意中把响应式数据“拍平”成了普通值

方法三:使用 reftoRef 保持响应链接
如果必须解构或传递属性,使用 toRef 来创建一个保持连接的引用:

const state = reactive({ count: 0 })
const countRef = toRef(state, 'count')
setInterval(() => {
  console.log(countRef.value)   // 每次都读到最新值
}, 1000)

方法四:使用 watchEffect 自动清理和重建副作用
如果需要根据某个状态来创建/销毁定时器,watchEffect 会在依赖变化时重新执行:

const id = ref(null)

watchEffect((onCleanup) => {
  const timer = setInterval(() => {
    if (id.value) {
      console.log('polling...')
    }
  }, 2000)
  
  onCleanup(() => clearInterval(timer))
})

方法五:在事件监听里用 computed 或直接读 ref
绑定事件时,如果把响应式值的读取放在回调内部而不是外部变量,就不会有陈旧值问题。但需要注意的是,如果在回调内访问 ref.value,这次访问将是响应式的——不过对普通的 addEventListener 绑定,Vue 并不会自动让回调重新执行;所以如果回调里需要根据最新值做判断,直接用 ref.value 是没问题的,因为它总是指向最新值。

总结一下
闭包导致状态不同步,十有八九是因为你把一个响应式数据的当前值“拷贝”到了一个普通变量里,然后闭包里一直用那个普通变量。解决方法就是始终保持对响应式数据本身的引用(不管是 refreactive 还是 computed),在需要读取值的地方始终通过 .value 或对象属性去访问,这样每次拿到的都是最新的。同时,善用 watchwatchEffectcomputed 来替代手动闭包 + 定时器的逻辑,既避免这类 bug,也让代码的响应式语义更清晰。