说说 Hooks 的闭包陷阱?如何解决?
一、是什么
闭包陷阱(Stale Closure)是 React Hooks 中最常见的 bug 之一。它指的是在 useEffect、useCallback、事件处理器等闭包中,捕获了某次渲染时的 state/props 旧值,而非最新值,导致行为不符合预期。
要理解闭包陷阱,首先要理解 React 函数组件的运行模型:每次渲染都是一次函数调用,每次调用都有自己的 props、state 和闭包环境。
function Counter() {
const [count, setCount] = useState(0);
// 这个函数"捕获"了当前渲染的 count 值
const handleClick = () => {
setTimeout(() => {
alert(`你点击时 count 是 ${count}`);
}, 3000);
};
return (
<div>
<p>{count}</p>
<button onClick={() => setCount(count + 1)}>+1</button>
<button onClick={handleClick}>显示 count</button>
</div>
);
}
如果你快速点击 +1 三次,然后点"显示 count",3 秒后弹窗显示的是点击时的值(比如 3),而不是弹窗出现时的最新值。这是闭包的正常行为,但在某些场景下会成为陷阱。
二、常见的闭包陷阱场景
场景一:useEffect 中的定时器
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
// 闭包捕获了 count = 0,永远打印 0
console.log('当前 count:', count);
setCount(count + 1); // 永远是 0 + 1 = 1
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖 → effect 只在挂载时执行一次
return <div>{count}</div>;
}
这里 count 永远停留在初始值 0。因为 useEffect 的回调函数在挂载时创建,闭包捕获了当时的 count = 0,之后的 setInterval 回调一直引用这个旧值。
场景二:useCallback 中的过时状态
function MessageSender() {
const [message, setMessage] = useState('');
const [messages, setMessages] = useState([]);
const sendMessage = useCallback(() => {
// messages 被闭包捕获,始终是空数组
setMessages([...messages, message]);
setMessage('');
}, []); // 缺少依赖
return (
<div>
<input value={message} onChange={e => setMessage(e.target.value)} />
<MemoizedButton onClick={sendMessage} />
<ul>{messages.map((m, i) => <li key={i}>{m}</li>)}</ul>
</div>
);
}
因为 sendMessage 的依赖数组为空,它始终引用挂载时的 messages(空数组),每次发送都会覆盖之前的消息。
场景三:事件处理器中的异步操作
function ProfileEditor() {
const [name, setName] = useState('Alice');
const handleSave = async () => {
await saveToServer(name);
// 如果在 await 期间用户修改了 name
// 这里的 name 是点击保存时的值,不是最新值
alert(`已保存:${name}`);
};
return (
<div>
<input value={name} onChange={e => setName(e.target.value)} />
<button onClick={handleSave}>保存</button>
</div>
);
}
三、解决方案
方案一:正确填写依赖数组
最直接的方案,让 effect 在依赖变化时重新创建闭包:
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
console.log('当前 count:', count);
setCount(count + 1);
}, 1000);
return () => clearInterval(timer);
}, [count]); // 加上 count 依赖
return <div>{count}</div>;
}
但这带来新问题:每次 count 变化都要销毁旧定时器、创建新定时器,不够高效。
方案二:函数式更新(推荐)
当新 state 只依赖旧 state 时,使用 setter 的函数形式:
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setInterval(() => {
setCount(prev => prev + 1); // 不依赖外部的 count 变量
}, 1000);
return () => clearInterval(timer);
}, []); // 空依赖没问题了
return <div>{count}</div>;
}
函数式更新的回调接收的 prev 参数始终是最新的 state 值,因此不受闭包影响。但如果需要读取最新 state(而非更新),这个方案就不够了。
方案三:useRef 保存最新值
useRef 创建的对象在组件的整个生命周期内保持同一个引用,.current 可以随时被更新和读取:
function Timer() {
const [count, setCount] = useState(0);
const countRef = useRef(count);
// 每次渲染时同步最新值到 ref
useEffect(() => {
countRef.current = count;
});
useEffect(() => {
const timer = setInterval(() => {
// 始终读取最新值
console.log('当前 count:', countRef.current);
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
封装成通用 Hook:
function useLatest(value) {
const ref = useRef(value);
ref.current = value;
return ref;
}
function Timer() {
const [count, setCount] = useState(0);
const latestCount = useLatest(count);
useEffect(() => {
const timer = setInterval(() => {
console.log('当前 count:', latestCount.current);
setCount(prev => prev + 1);
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{count}</div>;
}
方案四:useReducer 替代 useState
当状态更新逻辑复杂时,dispatch 是稳定引用,不存在闭包陷阱:
function reducer(state, action) {
switch (action.type) {
case 'tick':
return { ...state, count: state.count + 1 };
case 'setStep':
return { ...state, step: action.step };
default:
return state;
}
}
function Timer() {
const [state, dispatch] = useReducer(reducer, { count: 0, step: 1 });
useEffect(() => {
const timer = setInterval(() => {
dispatch({ type: 'tick' }); // dispatch 引用稳定
}, 1000);
return () => clearInterval(timer);
}, []);
return <div>{state.count}</div>;
}
方案五:useEffectEvent(实验性 API)
React 团队提出的 useEffectEvent(目前在实验阶段)专门解决"effect 中需要读取最新值但不想把它加入依赖"的场景:
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// onConnected 总是读取最新的 message,但不作为 effect 的依赖
const onConnected = useEffectEvent(() => {
showNotification(`已连接!当前消息:${message}`);
});
useEffect(() => {
const conn = createConnection(roomId);
conn.on('connected', onConnected);
conn.connect();
return () => conn.disconnect();
}, [roomId]); // 不需要把 message 加到依赖中
}
useEffectEvent 创建的函数始终能读取最新的 props/state,但 React 将其视为"effect 的一部分"而非响应式依赖,从而解决了"需要最新值"和"不想触发重新执行"之间的矛盾。
四、心智模型
理解闭包陷阱的关键是建立正确的心智模型:
渲染 #1: count=0 → 创建闭包 A → 闭包 A 中 count 永远是 0
渲染 #2: count=1 → 创建闭包 B → 闭包 B 中 count 永远是 1
渲染 #3: count=2 → 创建闭包 C → 闭包 C 中 count 永远是 2
每次渲染就像拍了一张快照。闭包捕获的是快照中的值,而不是一个"实时更新"的变量。这和 class 组件的 this.state 行为相反——this.state 始终指向最新值,因为 this 是可变的。
对比 class 组件
class Timer extends React.Component {
state = { count: 0 };
componentDidMount() {
this.timer = setInterval(() => {
// this.state.count 始终是最新值
this.setState({ count: this.state.count + 1 });
}, 1000);
}
componentWillUnmount() {
clearInterval(this.timer);
}
render() {
return <div>{this.state.count}</div>;
}
}
class 组件不会有闭包陷阱,因为 this.state 是可变引用。而函数组件的每次 state 值都是不可变的快照。
五、实践建议
- 开启
eslint-plugin-react-hooks:exhaustive-deps 规则能自动检测遗漏的依赖
- 优先使用函数式更新:
setState(prev => prev + 1) 比 setState(count + 1) 更安全
- 需要读取最新值时用 useRef:配合
useLatest 自定义 Hook 简化使用
- 复杂状态逻辑用 useReducer:dispatch 天然不受闭包影响
- 不要为了避免闭包而关闭 ESLint 规则:
// eslint-disable-next-line 往往会埋下更深的 bug
六、总结
闭包陷阱的本质是 JavaScript 闭包机制和 React 函数式渲染模型的交汇产物。每次渲染都会创建新的闭包环境,如果一个长期存在的回调(定时器、事件监听、异步操作)引用了某次渲染的变量,就会"定格"在那个时刻。
理解了"每次渲染都是一张快照"这个心智模型,闭包陷阱就不再神秘。选择正确的解决方案取决于场景:能用函数式更新就用函数式更新,需要读取最新值就用 useRef,复杂逻辑就用 useReducer。