从一段看似正常的代码,到深入理解 React Hooks 的闭包陷阱
前言
之前面试,面试官递过来一段代码:"看看这段代码有啥问题?"
我扫了一眼------标准的 React 组件,用了 useState
和 useEffect
,设置了个定时器每秒打印计数。代码看起来挺规范的,没有明显的语法错误。点击按钮,UI 上的数字也正常更新:1、2、3...
但打开 Console 一看,我愣住了:
makefile
Count: 0
Count: 0
Count: 0
Count: 0
...
UI 明明在变,为什么打印的永远是 0?
带着这个困惑,我回来后花了个晚上把 React Hooks 的闭包机制翻了个底朝天。没想到这个看起来简单的 bug,背后藏着的是 JavaScript 闭包和 React 渲染机制的深层交互。
先抛几个问题,看看你能答对几个:
- 为什么 UI 正常更新,但 console 输出错误?
- 闭包是怎么"困住"旧值的?
useEffect
的空依赖数组[]
有什么影响?- 这个 bug 有几种修复方法?哪种最优?
这篇文章会详细讲解:
- Bug 的完整复现和分析
- JavaScript 闭包机制
- useEffect 的依赖机制
- 5 种解决方案的完整对比
- 如何避免类似问题
目录
Bug 演示
完整代码
javascript
import { useEffect, useState } from "react";
export function App() {
const [count, setCount] = useState(0);
function handleLog() {
console.log("Count:", count);
}
useEffect(() => {
const id = setInterval(handleLog, 1000);
return () => clearInterval(id);
}, []);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
运行效果
UI 显示:
csharp
Count: 3 ← 点击了3次,显示正常
[Increment 按钮]
Console 输出:
makefile
Count: 0 ← 一直是0!
Count: 0
Count: 0
Count: 0
...
问题分析:陈旧闭包
什么是陈旧闭包(Stale Closure)?
这个 bug 的根源是 陈旧闭包(Stale Closure) ------函数"记住"了它创建时的环境,但这个环境里的值已经过时了。
为什么会出现?
执行流程详解
-
初始渲染(count = 0)
- 创建
handleLog
函数,捕获count = 0
useEffect
执行,设置setInterval(handleLog, 1000)
- 注意:
useEffect
的依赖是[]
,所以只执行一次
- 创建
-
用户点击按钮
setCount(1)
→ 触发重新渲染- 创建新的
handleLog
函数,捕获新的count = 1
- 但是!
useEffect
不会再执行(依赖是[]
) setInterval
调用的还是第一次渲染时的旧handleLog
-
结果
- UI 显示的是最新的
count
(React 状态正常更新) setInterval
调用的handleLog
里的count
永远是0
(闭包捕获的旧值)
- UI 显示的是最新的
原理深挖:闭包如何困住旧值
闭包基础
先用个简单例子理解闭包:
scss
function createCounter() {
let count = 0; // 被"捕获"的变量
return function() {
console.log(count); // 能访问外层的 count
};
}
const counter1 = createCounter();
const counter2 = createCounter();
counter1(); // 输出: 0
counter2(); // 输出: 0
// 即使外层函数执行完了,内层函数还能访问 count
闭包说穿了就是:函数能"记住"它创建时的环境。
React 中的闭包陷阱
scss
// 第一次渲染(count = 0)
function App() {
const count = 0; // ← 这个值
function handleLog() {
console.log(count); // ← 被这个函数捕获
}
useEffect(() => {
setInterval(handleLog, 1000); // ← interval 记住了这个 handleLog
}, []); // ← 空数组,只执行一次
// ...
}
// 第二次渲染(count = 1)
function App() {
const count = 1; // ← 新的值
function handleLog() {
console.log(count); // ← 新的函数,捕获新值
}
// useEffect 不执行(依赖是空数组)
// interval 还在调用第一次渲染时的旧 handleLog
// ...
}
关键点:
- 每次渲染都会创建新的
count
变量和新的handleLog
函数 - 但
useEffect
只在首次渲染时执行(依赖是[]
) setInterval
调用的是第一次渲染时的handleLog
- 那个
handleLog
里捕获的count
永远是0
解决方案对比
下面介绍 5 种修复方法,每种都有适用场景。
方案对比表
方案 |
---|
1. 添加 count 依赖 |
2. 使用 useRef |
3. 函数式更新 |
4. useLatest 自定义 Hook |
5. useEffectEvent (React 18+) |
方案 1:添加 count 依赖
思路 :让 useEffect
在 count
变化时重新执行。
scss
useEffect(() => {
const id = setInterval(handleLog, 1000);
return () => clearInterval(id);
}, [count]); // ← 添加 count 依赖
✅ 优点:
- 简单直接,一行改动
❌ 缺点:
-
性能差 :每次
count
变化都会:- 清除旧的 interval
- 创建新的 interval
-
对于快速更新的状态,会频繁重建 interval
方案 2:使用 useRef ⭐⭐⭐⭐
思路 :用 useRef
保存最新值,interval 读取 ref。
javascript
import { useEffect, useState, useRef } from "react";
export function App() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
// 同步 count 到 ref
useEffect(() => {
countRef.current = count;
}, [count]);
function handleLog() {
console.log("Count:", countRef.current); // ← 读取 ref
}
useEffect(() => {
const id = setInterval(handleLog, 1000);
return () => clearInterval(id);
}, []); // ← 空数组,只设置一次
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
为什么有效?
ref.current
是可变的,修改它不会触发重新渲染- 每次
count
更新时,同步到countRef.current
handleLog
读取countRef.current
,总是最新值
✅ 优点:
- 性能好(interval 只创建一次)
- 总是读取最新值
❌ 缺点:
- 需要额外的
useEffect
同步值 - 代码稍显冗余
方案 3:函数式更新
思路 :利用 setState
的函数式更新,不依赖闭包捕获的值。
javascript
import { useEffect, useState } from "react";
export function App() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount((prevCount) => {
console.log("Count:", prevCount); // ← 读取最新值
return prevCount; // 不修改,只打印
});
}, 1000);
return () => clearInterval(id);
}, []);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
✅ 优点:
- 简单,不需要 ref
- 不需要依赖数组
❌ 缺点:
- 只适合简单场景:如果需要访问多个状态,代码会很丑陋
- 滥用
setState
作为"读取"手段,语义不清晰
方案 4:useLatest 自定义 Hook ⭐⭐⭐⭐⭐
思路:封装方案 2 的 ref 逻辑,提高复用性。
javascript
import { useEffect, useState, useRef } from "react";
// 自定义 Hook:保存最新值
function useLatest(value) {
const ref = useRef(value);
useEffect(() => {
ref.current = value;
}, [value]);
return ref;
}
export function App() {
const [count, setCount] = useState(0);
const countRef = useLatest(count); // ← 封装成 Hook
function handleLog() {
console.log("Count:", countRef.current);
}
useEffect(() => {
const id = setInterval(handleLog, 1000);
return () => clearInterval(id);
}, []);
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
✅ 优点:
- 复用性好,可在多个地方使用
- 语义清晰:
useLatest
明确表示"总是最新值" - 性能好
❌ 缺点:
- 需要额外维护自定义 Hook
方案 5:useEffectEvent (React 18+) ⭐⭐⭐⭐
思路:使用 React 官方的实验性 API。
javascript
import { useEffect, useState, experimental_useEffectEvent as useEffectEvent } from "react";
export function App() {
const [count, setCount] = useState(0);
// useEffectEvent:创建一个"总是最新"的事件处理函数
const handleLog = useEffectEvent(() => {
console.log("Count:", count);
});
useEffect(() => {
const id = setInterval(handleLog, 1000);
return () => clearInterval(id);
}, []); // ← 不需要添加 handleLog 依赖
return (
<div>
<p>Count: {count}</p>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
✅ 优点:
- 官方解决方案,专为此设计
- 语义清晰
- 不需要手动管理 ref
❌ 缺点:
- 实验性 API(React 18 中可用,但可能变动)
- 需要 React 18+
类似陷阱举例
闭包陷阱不仅出现在 useEffect
中,下面是几个常见场景:
1. 事件监听器
javascript
function App() {
const [count, setCount] = useState(0);
useEffect(() => {
function handleClick() {
console.log("Count:", count); // ← 闭包捕获旧值
}
document.addEventListener("click", handleClick);
return () => {
document.removeEventListener("click", handleClick);
};
}, []); // ← 空数组,只执行一次
return <button onClick={() => setCount((c) => c + 1)}>Increment</button>;
}
修复 :添加 count
依赖,或使用 useRef
。
2. 异步回调
javascript
function App() {
const [count, setCount] = useState(0);
function handleAsync() {
setTimeout(() => {
console.log("Count:", count); // ← 3秒后打印,可能已经变了
}, 3000);
}
return (
<div>
<button onClick={handleAsync}>异步打印</button>
<button onClick={() => setCount((c) => c + 1)}>Increment</button>
</div>
);
}
问题:
- 点击"异步打印"时
count = 0
- 3秒内点击 Increment 多次,
count = 5
- 3秒后 setTimeout 执行,打印
Count: 0
(闭包捕获的旧值)
修复 :使用 useRef
或 useLatest
。
3. 防抖/节流函数
scss
function App() {
const [searchTerm, setSearchTerm] = useState("");
const handleSearch = useMemo(
() =>
debounce(() => {
console.log("搜索:", searchTerm); // ← 闭包捕获旧值
}, 500),
[] // ← 空数组,只创建一次
);
return <input onChange={(e) => setSearchTerm(e.target.value)} />;
}
修复 :添加 searchTerm
依赖,或使用 useLatest
。
避坑指南
1. 开启 ESLint 规则
json
// .eslintrc.json
{
"rules": {
"react-hooks/exhaustive-deps": "warn"
}
}
这个规则会检查:
useEffect
、useCallback
、useMemo
的依赖数组- 如果函数内使用了外部变量,但没有加入依赖,会报警告
2. 检查清单
遇到闭包相关的 bug 时,问自己这几个问题:
- 函数内是否使用了组件的 props 或 state?
useEffect
的依赖数组是否完整?- 是否有定时器、事件监听器、异步回调?
- 是否需要总是读取最新值?
3. 快速识别方法
看到这些代码模式,立即警惕:
scss
useEffect(() => {
// 使用了 state/props,但依赖是空数组
console.log(someState);
}, []); // ← 🚨 危险!
useEffect(() => {
setInterval(() => {
// 使用了 state/props
console.log(someState);
}, 1000);
}, []); // ← 🚨 危险!
useEffect(() => {
document.addEventListener("click", () => {
// 使用了 state/props
console.log(someState);
});
}, []); // ← 🚨 危险!
4. 最佳实践
推荐顺序(从简单到复杂):
- 首选:使用 ESLint,添加完整依赖
- 性能要求高 :使用
useLatest
自定义 Hook - React 18+ :使用
useEffectEvent
(实验性) - 简单场景:使用函数式更新
总结
这个看起来简单的 bug,背后是 JavaScript 闭包和 React 渲染机制的交互:
核心原理:
- 每次渲染都会创建新的函数和变量
- 闭包会"记住"函数创建时的环境
useEffect
的依赖数组决定何时重新执行- 空依赖数组
[]
导致 effect 只执行一次,捕获的是初始值
陈旧闭包的特征:
- UI 正常,但异步操作(定时器、事件监听、回调)读取到旧值
useEffect
依赖不完整- 函数内使用了外部变量,但没有加入依赖
推荐解决方案:
- 通用场景 :使用
useLatest
自定义 Hook(复用性好) - 简单场景 :使用
useRef
(手动同步值) - React 18+ :使用
useEffectEvent
(官方方案,实验性)
面试启示: 这类题考察的是:
- 对 JavaScript 闭包的理解
- 对 React Hooks 机制的掌握
- 解决实际问题的能力(多种方案对比)
下次写 useEffect
时,多问一句:这个函数用到的变量,是不是总是最新的? 养成这个习惯,就能避开大部分闭包陷阱。
相关资源
- React 官方文档 - useEffect - 官方文档
- A Complete Guide to useEffect - Dan Abramov 的经典长文
- JavaScript 闭包 - MDN - 闭包基础
- useEffectEvent RFC - React 官方提案