总览
- react 开发中遇到的闭包问题
- 闭包及闭包陷阱产生的原因
- 解决方案
问题场景
在 React Hooks 中,你可能会遇到这种情况:
const [userInput, setUserInput] = useState<string>('');
const createSSE = (text: string) => {
const eventSource = new EventSource(url);
eventSource.onmessage = (event) => {
console.log(userInput); // ← 为什么总是 ''?
}
}
const onSend = (text: string) => {
setUserInput(text); // ← 明明设置了这个值
createSSE(text);
}
现象:在 onmessage 回调中,userInput 永远是初始值 '',拿不到最新值。
原因:闭包陷阱(Stale Closure)
Stale Closure(过期闭包):一个闭包捕获了过时的变量值,当外部变量已更新时,闭包内仍使用旧值。
这是 React 社区对这类问题的正式术语,也称为 "闭包陷阱"或"陈旧闭包问题"。
什么是闭包?
闭包 = 函数 + 函数创建时的词法环境
function outer() {
const value = 'initial';
return function inner() {
console.log(value); // inner "记住"了创建时的 value
};
}
const fn = outer();
fn(); // 输出 'initial'(value 是 outer 内部变量,闭包保持对其引用)
OK 这里需要注意,是创建时的词法环境。需要与 函数调用执行栈中的上下文 区分开。
分析一个例子
const [count, setCount] = useState(0);
// 组件第一次渲染:count = 0
useEffect(() => {
const timer = setInterval(() => {
console.log(count); // ← 永远是 0
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖数组,只执行一次
// 后来调用 setCount(5)
// 但定时器回调里还是用旧值 0
注意:
- 空依赖数组是核心原因 !!
- 闭包只是问题产生的一部分,关键在于 count 变化并没有触发 Effect 更新
- 由于 依赖数组为 [], 因此 React 认为这个 Effect 不需要重新执行
- 这个才是关键 !!!
时间线分析
时刻 1:组件首次渲染
├── userInput = '' (初始值)
├── createSSE 函数被定义
└── onmessage 回调被创建,捕获了 userInput = ''
时刻 2:用户点击发送
├── onSend('hello') 被调用
├── setUserInput('hello') 被调用
└── state 被标记为需要更新
时刻 3:组件重新渲染
├── userInput = 'hello' (新值)
└── 但之前的 onmessage 回调还在用旧值 ''
时刻 4:SSE 返回消息
├── onmessage 回调执行
└── 用的还是创建时的 '' 值
关键:回调函数记住的是它被创建时的值,不会自动更新。
为什么会这样?
这就涉及到 闭包特性 和 react 的渲染机制了
JavaScript 的闭包特性
// 闭包捕获的是创建时的词法环境
function createClosure() {
const value = 'A';
return function inner() {
console.log(value); // inner 捕获了创建时的 value
};
}
const fn1 = createClosure();
fn1(); // 输出 'A'
// 即使后来创建了新的闭包
const fn2 = createClosure();
fn2(); // 还是输出 'A',因为每次 createClosure 都创建独立的作用域
React 的渲染机制
关键概念:每次渲染都有自己的 props 和 state
✅ 正常工作的情况
function Component() {
const [state, setState] = useState(0);
// 每次渲染都是新的函数作用域
const handleClick = () => {
console.log(state); // 这里的 state 是渲染时的值
};
return <button onClick={handleClick}>Click</button>;
}
// 第一次渲染:state = 0,创建 handleClick 捕获了 0
// 点击按钮 → 打印 0
// setState(5) → 触发重新渲染
// 第二次渲染:state = 5,创建新的 handleClick 捕获了 5
// 点击按钮 → 打印 5 ✅
为什么能正常工作?
因为 handleClick 是在组件体内定义的,每次渲染都会重新创建,所以它总是捕获最新的 state。
❌ 闭包陷阱的情况
const [userInput, setUserInput] = useState<string>('');
const createSSE = (input: string) => {
// 这里需要填写后端SSE 服务的 api 地址
const eventSource = new EventSource(`http://...`);
eventSource.onmessage = (event) => {
console.log(userInput); // 永远是初始值,闭包陷阱!
const data = JSON.parse(event.data);
// 处理消息...
};
return eventSource;
};
const onSend = (text: string) => {
setUserInput(text); // 更新 state
createSSE(text); // 但 onmessage 回调捕获的是创建时的旧值
};
为什么是闭包陷阱?
createSSE函数本身在组件体内,每次渲染会重新创建- 但
onmessage回调是注册到外部系统(EventSource) - EventSource 不会随组件重新渲染而更新回调
- 回调中使用的
userInput永远是第一次渲染时的值
定时器中的场景同理
解决方案
方案一:参数传递(推荐)
// 把需要用的值作为参数传进去
const createSSE = (query: string) => {
eventSource.onmessage = (event) => {
console.log(query); // 用参数
};
};
createSSE(text);
特点:
- ✅ 简单直接
- ✅ 不需要额外 hook
- ✅ 性能好
- ⚠️ 需要改函数签名
方案二:useRef
const valueRef = useRef(initialValue);
// 每次 render 更新 ref
valueRef.current = value;
// 异步回调中使用
eventSource.onmessage = () => {
console.log(valueRef.current); // 总是最新的
};
特点:
- ✅ 回调不需要改
- ✅ ref 值立即更新
- ⚠️ 需要额外变量
- ⚠️ 不会触发重新渲染
方案三:函数式更新
// 只适用于 setState
setCount(prev => prev + 1); // prev 总是最新的
特点:
- ✅ 简洁
- ✅ 不需要依赖
- ⚠️ 只能用于 setState
- ⚠️ 复杂逻辑不好处理
方案四:useCallback
const handleClick = useCallback(() => {
console.log(value); // 依赖 value,会更新
}, [value]);
特点:
- ✅ 自动追踪依赖
- ✅ 可以做性能优化
- ⚠️ 有性能开销
- ⚠️ 需要正确设置依赖
说明
- 几种解决方案的核心:拿到最新的值
- 无论是 直接传参 使用useRef 还是 useCallback 都是为了让 onmessage 拿到更新后的值。也就是所谓的打破闭包陷阱的限制。
- useCallback 实际上是通过 函数重新创建的方式来解决这个问题,并不适用于 SSE 场景。可能会导致 SSE 连接异常。这里只是做为演示需要 ,请谨慎使用!!!
方案对比
| 方案 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 参数传递 | 函数调用 | 简单直接 | 需要改函数签名 |
| useRef | 异步回调 | 总是最新的值 | 需要额外变量 |
| 函数式更新 | setState | 不需要依赖 | 只能用于 setState |
| useCallback | 复杂依赖 | 自动追踪变化 | 有性能开销 |
这里补充一下:
为什么 useRef 能绕过闭包?
根据 React 官方文档,Refs 提供了一种方式,访问 DOM 节点或在 render 中创建的 React 元素。
关键特性:
useRef返回一个可变的.current属性- Ref 的变化不会触发重新渲染
- Ref 对象在组件的整个生命周期内保持稳定(同一个引用)
const valueRef = useRef(initialValue);
// 每次渲染都同步更新 ref.current
valueRef.current = value;
useEffect(() => {
const handler = () => {
// 闭包捕获的是 valueRef 这个对象的引用
// 而对象内部的内容可以变化 --- 这里是关键!!!!
console.log(valueRef.current);
};
}, []); // 空依赖数组可行,因为 valueRef 引用永不改变
工作原理:闭包捕获的是 valueRef 对象的引用,而 .current 是该对象的一个可变属性。即使闭包是在第一次渲染时创建的,它仍然可以访问最新的 .current 值。
总结
闭包 = 函数 + 词法环境(对变量的引用)
React 中的闭包陷阱 = 回调使用的是创建时那次渲染的 state 值
state 更新后 = 新的渲染 = 新的作用域 = 新的函数
但旧的回调函数仍然引用着旧的 state 值
记住这个
| 场景 | 解决方案 |
|---|---|
| 函数调用 | 用参数传递 |
| 异步回调 | 用 useRef |
| setState | 用函数式更新 setCount(prev => prev + 1) |
| 事件监听 | 用 useRef 或 useCallback |
| 定时器 | 用 useRef 或函数式更新 |
一句话
想用最新值?用参数或 useRef 或者函数式更新 ~