Skip to content

D12 · 闭包陷阱与解决方案 ​

对应主课: L37 Composition API 设计哲学 适用版本: Vue 3.5、React 19 最后核对: 2026-09-23


1. 闭包捕获的是绑定 ​

JavaScript 闭包保留对词法环境中变量绑定的访问,并不统一“复制创建时的值”。是否读到旧数据,取决于回调读的是哪个绑定,以及那个绑定之后有没有改变。MDN 闭包

javascript
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 100)
}
// 3、3、3:三个回调共享同一个函数/全局作用域的 i,执行时循环已结束

for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 100)
}
// 0、1、2:let 为每次迭代建立相应绑定

第一种错误恰恰是回调读到了后来变成 3 的同一个变量,不是保存了旧值。“陈旧闭包”通常指程序需要当前数据,却保留并读取了之前那次执行中的绑定。


2. Vue 回调为什么能读到当前 ref 值 ​

组件 setup 通常在每个实例初始化时运行一次。回调若持续持有同一个 ref,在执行时访问 .value,就会读取这个容器当时的值;ref 的访问器还负责响应式依赖收集与通知,普通对象读取本身并不提供这些能力。

typescript
import { ref } from 'vue'

const count = ref(0)
const initialCount = count.value
const readCurrent = () => count.value
const readSnapshot = () => initialCount

count.value = 3
console.log(readCurrent())  // 3
console.log(readSnapshot()) // 0

这不能保证回调永远正确:它可能持有已经退出页面的旧实例,也可能收到过期请求的结果。Vue 回调中提前保存原始值、对象旧引用或旧参数,同样要判断这些快照是否符合需求。


3. React 的 render 快照 ​

React 每次调用函数组件都会产生该次 render 的 props、state 与局部绑定。保留某次 render 创建的回调,就会保留对那一轮快照的访问。下例故意展示缺失依赖的问题,不作为项目实现:

jsx
import { useEffect, useState } from 'react'

export default function StaleCounter() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const id = setInterval(() => console.log(count), 1000)
    return () => clearInterval(id)
  }, []) // 故意漏 count;exhaustive-deps 应报告,记录的一直是初始值
  return <button onClick={() => setCount(c => c + 1)}>{count}</button>
}

若需求是记录当前 count,把 count 加入依赖,旧 interval 清理后会用新快照重建;若要保持同一个 interval,可用 D09 的 ref 方案,在 Effect 中同步最新提交值。不要为了躲避依赖检查,在 render 中随意写 ref.current。useRef 使用约束

另一个常见需求是“每秒把当前计数加一”。这时可以把更新函数交给 React:

jsx
// ReactTickCounter.jsx
import { useEffect, useState } from 'react'

export default function ReactTickCounter() {
  const [count, setCount] = useState(0)
  useEffect(() => {
    const id = setInterval(() => setCount(current => current + 1), 1000)
    return () => clearInterval(id)
  }, [])
  return <p>计数:{count}</p>
}

updater 收到待更新的状态,所以不需要捕获 count;它只解决“根据前值计算下一个状态”,不会让同一回调中的其他旧 props、state 自动变新。updater 要保持纯函数;开发 StrictMode 的额外调用用于检查纯度。状态更新队列

事件中延迟使用点击时的 count,未必是 bug。如果用户要提交“点击时选择的商品”,保留那次快照正是预期;若产品要求显示执行时的最新数量,才需要改为持续读取当前来源。State as a Snapshot


4. Vue 中仍需注意的场景 ​

4.1 解构原始值与保留旧对象 ​

typescript
import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0, profile: { name: 'A' } })
const { count: snapshot, profile: oldProfile } = state
const { count: liveCount } = toRefs(state)

state.count = 2
state.profile = { name: 'B' }
console.log(snapshot)        // 0,普通数字快照
console.log(liveCount.value) // 2,连接 state.count
console.log(oldProfile.name) // A,仍是旧对象的响应式代理

不是所有解构都变成非响应式数据:对象属性可能仍是代理,但它不会自动跟随父属性替换。普通 ref / reactive 解构的连接边界详见 D05。Vue 3.5 中,同一 <script setup> 内直接解构 defineProps 还有编译器转换,不能把普通 reactive 对象的解构结论原样套过去。

4.2 watch 的来源 ​

typescript
import { nextTick, reactive, watch } from 'vue'
const state = reactive({ count: 0 })

// watch(state.count, ...) 传的是数字,不是合法的响应式来源;TS 也会报错
const stop = watch(() => state.count, value => console.log(value))
state.count++
await nextTick() // 本段在 ESM 模块中运行,等待输出 1
stop()          // 脱离组件时,由调用方结束 watcher

getter 让 watcher 在收集依赖时读取属性。它解决观察来源的问题,不会自动让异步请求避免竞态,见 D13。

4.3 第三方回调只认识自己的协议 ​

库长期保存一个回调,并不意味着回调内的 ref 读取会过期;关键仍是读取哪个绑定。也不能任意把 ref 传给只接收数字的库。下面是参数形状示意,应按目标库真实 API 接线:

typescript
// 若 API 要求 () => number,就传 getter:() => count.value
// 若 API 只接收 number,就传 count.value,并在变化时调用其 update 方法
// 注册监听后,保存其 unsubscribe,并在卸载时调用

5. 选择当前值还是快照 ​

需求选择
执行时读取当前 Vue 状态回调内访问同一个 ref.value / 响应式属性
保留点击时的选择在发起操作时保存普通值或必要的数据副本
React 根据前值更新状态纯函数 updater
React 副作用随参数改变声明完整依赖,让旧副作用清理后重建
React 长期回调读取最新提交值适合时用 ref,由 Effect 同步;参照 D09
生命周期结束清理定时器、监听器,取消或忽略在途工作

computed 是派生的响应式容器,仍需在使用时读取 .value;提前把 .value 取出来不会留下自动更新的快照。

6. 动手实验:绑定、快照与 ref ​

在 L31 建立的 labs/mini-reactivity 中保存 closure-demo.ts,运行 npx tsx closure-demo.ts。使用真实 Vue ref;同步执行足以观察闭包语义,不需要定时器制造等待。

typescript
// closure-demo.ts
import assert from 'node:assert/strict'
import { ref, type Ref } from 'vue'

let plainCount = 0
const readBinding = () => plainCount
const box = ref(0)
const readBox = () => box.value

plainCount = 42
box.value = 42
assert.equal(readBinding(), 42)
assert.equal(readBox(), 42)

// 每次调用建立独立的参数绑定,类似两次 render 各自拥有局部变量
function createSnapshotReader(value: number) {
  return () => value
}
const oldReader = createSnapshotReader(plainCount)
plainCount = 100
const newReader = createSnapshotReader(plainCount)
assert.equal(oldReader(), 42)
assert.equal(newReader(), 100)

function createBoxReader(value: Ref<number>) {
  return () => value.value
}
const liveReader = createBoxReader(box)
box.value = 100
assert.equal(liveReader(), 100)

// 读出的原始值仍会成为快照
const copied = box.value
const copiedReader = () => copied
box.value = 200
assert.equal(copiedReader(), 100)
assert.equal(liveReader(), 200)

console.log('同一普通变量绑定:', readBinding()) // 100
console.log('不同调用的参数绑定:', oldReader(), newReader()) // 42、100
console.log('同一 ref 与提前复制的值:', liveReader(), copiedReader()) // 200、100

这个函数实验用于说明绑定,不能替代 React 的 render、commit 和更新队列模型。Ref 也没有让旧回调转而绑定到一个新建的组件实例。


7. 真实 Debug 场景 ​

场景:任务数量变了,标题还停在初始值 ​

提前生成 const text = `任务数量: ${count.value}` 会得到一次性的字符串。下面用完整组件在副作用执行时读取 count;DOM 访问放在 mounted 中,卸载时恢复之前标题。商城已有其他标题管理器时,应交由同一个管理器协调,避免多个组件竞争。

vue
<!-- TaskTitleDemo.vue -->
<script setup lang="ts">
import { onMounted, onUnmounted, ref, watchEffect } from 'vue'

const count = ref(0)
let previousTitle: string | undefined
onMounted(() => {
  previousTitle = document.title
  watchEffect(() => {
    document.title = `任务数量: ${count.value}`
  })
})
onUnmounted(() => {
  if (previousTitle !== undefined) document.title = previousTitle
})
</script>

<template><button @click="count++">增加任务:{{ count }}</button></template>

此处 watcher 在 mounted 钩子中同步创建,归当前组件作用域,卸载时会停止。若迁到异步回调中创建,应检查作用域归属并自行保存停止函数。

读取路径 ​

8. 排查顺序 ​

先确认业务要的是“发起操作时的值”还是“回调执行时的值”,再找到回调保存的绑定。最后检查来源变化和组件退出后的清理。Vue 与 React 都需要这三步,不能用“闭包永远安全”代替它们。