
开发一个文档编辑器,产品要求每 30 秒自动保存一次草稿,以防用户意外关闭页面丢失内容。我们很容易写出如下代码:
jsx
function DocumentEditor() {
const [content, setContent] = useState('');
useEffect(() => {
const timer = setInterval(() => {
saveDraft(content); // content 始终是 ''
}, 30000);
return () => clearInterval(timer);
}, []);
return <textarea value={content} onChange={e => setContent(e.target.value)} />;
}
测试时会发现一个诡异的现象:在编辑器中输入大段大段文字,但每次自动保存到后端的,都是空白字符串。明明 content 在 state 里已经更新了,为什么 saveDraft(content) 拿到的永远是初始值?
排查半天,最后发现罪魁祸首只有一个------闭包。
原来,useEffect 的依赖数组为空 [],意味着副作用只在组件挂载时执行一次。那个定时器回调,在创建时就把当时的 content(空字符串)捕获进了闭包。之后无论 state 怎么更新,定时器里的 content 始终是那个"过期"的值。这就是 React 函数组件中最经典的过期闭包陷阱。
知道了原因,修复思路就很清晰:我们需要让定时器始终能访问到最新的 content,但又不想频繁重建定时器(把 content 加入依赖会导致定时器不断重置)。这里可以借助 useRef 来"穿透"闭包:
jsx
function DocumentEditor() {
const [content, setContent] = useState('');
const contentRef = useRef(content);
contentRef.current = content; // 渲染期间同步最新值
useEffect(() => {
const timer = setInterval(() => {
saveDraft(contentRef.current); // 始终拿到最新 content
}, 30000);
return () => clearInterval(timer);
}, []);
return <textarea value={content} onChange={e => setContent(e.target.value)} />;
}
ref 本身是一个稳定的引用,定时器只创建一次;但通过 contentRef.current 这个"盒子",我们总能拿到最新渲染的值,完美绕开了过期闭包。
这个"自动保存空内容"的问题,其实是 React 函数组件中过期闭包的缩影。类似地,你可能还会遇到:倒计时从 300 秒跳到 299 就卡住不动、表单提交永远发送初始数据、切换账号后消息轮询还带着旧用户 ID...... 这些问题看似五花八门,根源却完全一致。下面,我们就来逐一击破这些场景,帮你彻底搞懂闭包陷阱。
更多场景:逐一击破过期闭包
场景 2:倒计时卡在 299 ------ 定时器中的过期 state
需求:支付页面倒计时,每秒减 1,到 0 自动超时。
jsx
function PaymentTimer({ initialSeconds }) {
const [timeLeft, setTimeLeft] = useState(initialSeconds); // 300 秒
useEffect(() => {
const timer = setInterval(() => {
if (timeLeft <= 1) {
clearInterval(timer);
handleTimeout();
} else {
setTimeLeft(timeLeft - 1); // 永远从 300 减 1
}
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖,只挂载一次
return <div>剩余支付时间:{timeLeft} 秒</div>;
}
现象:页面从 300 变 299,然后卡住不动。定时器明明在跑,倒计时却停了。
原因 :useEffect 依赖数组为 [],定时器只在组件挂载时创建一次。闭包里的 timeLeft 永远是初始值 300。每次执行 setTimeLeft(timeLeft - 1) 都是在做 300 - 1 = 299,所以永远卡在 299。
修复:使用函数式 setState。
jsx
useEffect(() => {
const timer = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) {
clearInterval(timer);
handleTimeout();
return 0;
}
return prev - 1; // 基于最新值减 1
});
}, 1000);
return () => clearInterval(timer);
}, []);
setTimeLeft(prev => ...) 让 React 把最新的 state 作为 prev 传入,不依赖闭包里过期的 timeLeft。这是此类问题最简洁的解法。
场景 3:表单提交永远发送初始数据 ------ useCallback 依赖缺失
需求:表单输入后,点击提交按钮发送数据。
jsx
function Parent() {
const [formData, setFormData] = useState({ name: '' });
// 依赖数组为空,闭包里的 formData 始终是初始值
const handleSubmit = useCallback(() => {
submitForm(formData); // 永远提交空对象
}, []);
return <Child onSubmit={handleSubmit} />;
}
现象 :用户填写表单后提交,后端收到的总是 { name: '' }。
原因 :useCallback 的依赖数组为 [],回调中的 formData 被捕获为初始值,之后永远不会更新。
修复方案一:正确填写依赖
jsx
const handleSubmit = useCallback(() => {
submitForm(formData);
}, [formData]); // formData 变化时,重新创建函数
修复方案二:用 ref 保持引用稳定
如果子组件用 React.memo 优化,希望 handleSubmit 引用不变,可以这样:
jsx
const formDataRef = useRef(formData);
formDataRef.current = formData;
const handleSubmit = useCallback(() => {
submitForm(formDataRef.current);
}, []); // 引用不变,但始终拿到最新数据
两种方案各有适用场景:重建函数简单直接,使用 ref 则能保持函数引用稳定,避免子组件无谓渲染。
场景 4:切换账号后消息数不更新 ------ 轮询中的过期 props
需求 :导航栏消息气泡,每 3 秒轮询未读消息数,需携带当前登录的 userId。支持多账号切换。
jsx
function MessageBadge({ userId }) {
const [unreadCount, setUnreadCount] = useState(0);
useEffect(() => {
const fetchUnread = () => {
api.getUnreadCount({ userId }); // userId 永远是初始值
};
fetchUnread();
const timer = setInterval(fetchUnread, 3000);
return () => clearInterval(timer);
}, []); // 空依赖,只在挂载时启动
return <span>{unreadCount}</span>;
}
现象 :初始化时正常,切换账号后,请求仍然携带旧的 userId,导致新的未读消息永远无法显示。
原因 :useEffect 依赖为空,定时器和回调闭包里的 userId 被固化为挂载时的值。
修复:使用 ref 穿透闭包。
jsx
function MessageBadge({ userId }) {
const [unreadCount, setUnreadCount] = useState(0);
const userIdRef = useRef(userId);
userIdRef.current = userId;
useEffect(() => {
const fetchUnread = () => {
api.getUnreadCount({ userId: userIdRef.current })
.then(res => setUnreadCount(res.count));
};
fetchUnread();
const timer = setInterval(fetchUnread, 3000);
return () => clearInterval(timer);
}, []);
// 切换账号时清空旧数据
useEffect(() => {
setUnreadCount(0);
}, [userId]);
return <span>{unreadCount}</span>;
}
ref 保证了定时器内的 userId 始终为最新;额外的 useEffect 负责在切换账号时清空旧消息数,提升用户体验。
附:词法作用域、执行上下文与闭包
如果你对前面的案例已经感同身受,可以再看看底层的概念,我们一起来回顾下核心知识。
1. 词法作用域 ------ 变量在哪里能被访问?
JavaScript 采用词法作用域,函数的作用域在定义时就确定了,而不是调用时。
javascript
const name = '全局';
function outer() {
const name = '外层';
function inner() {
console.log(name); // '外层'
}
inner();
}
outer();
inner 定义在 outer 内部,所以变量查找会沿着 inner → outer → 全局 的作用域链,取到 '外层'。
2. 执行上下文 ------ 代码运行时发生了什么?
每调用一个函数,都会创建一个执行上下文,压入调用栈。每个上下文包含:
- 变量对象(Variable Object):存放变量、函数声明、参数。
- 作用域链(Scope Chain):当前变量对象 + 所有外层上下文的变量对象。
- this 指向。
函数执行完毕后,上下文弹出,局部变量一般会被回收------除非被闭包保留。
3. 闭包的定义
闭包 = 函数 + 该函数能访问的外层作用域变量。
当一个函数"记住"了自己定义时的环境,即使它在别处被调用,也能访问那个环境里的变量,这就是闭包。
javascript
function createCounter() {
let count = 0;
return function increment() {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
createCounter 执行完后,count 本应被销毁,但 increment 闭包保持着对它的引用,所以它一直存活。
4. 闭包与内存管理
闭包会让外部变量不被回收,合理使用是"记忆",不合理则可能变成"泄漏"。在 React 中,组件卸载时 useEffect 的清理函数会切断闭包引用,帮助回收内存。
两个原则,避开React开发中 90% 的闭包坑
- 需要"看到最新值,但不想重建函数/定时器" → 用
useRef保持最新值。 - 新状态依赖旧状态,且更新在闭包中进行 → 使用函数式
setState(prev => ...)。
每次写 useEffect、useCallback 时,多问自己一句:这个闭包会活多久?它捕获的值将来会不会变?养成这个习惯,React 的闭包陷阱就不再可怕。