React 16.8 发布 Hooks 大概一年了,用得越顺手越容易踩到一个坑:stale closure(过期闭包)。写了一个看起来正确的代码,但跑起来状态不对,打断点发现读到的是旧值。这个问题本质上不是 React 的 bug,而是 JavaScript 闭包的工作机制。
先复现问题
写一个每秒打印 count 的定时器:
1 | function Counter() { |
点几次按钮,按钮上的数字在增加,但控制台一直打印 当前 count:0。
为什么会这样
关键在于 useEffect 的回调是在第一次渲染时执行的,那个时候 count 是 0。setInterval 的回调函数通过闭包捕获了那个时刻的 count——值是 0,并且这个 count 变量永远不会更新,因为它是第一次渲染时那个函数作用域里的值。
之后每次 setCount 触发重渲染,React 会调用 Counter 函数,创建新的 count 变量(值是最新的),但旧的 setInterval 回调里捕获的仍然是旧的 count。
可以把每次渲染想象成一个快照:
1 | 第1次渲染:count_1 = 0, effect 里的 count 引用的是 count_1 |
解法一:在依赖数组里加上 count
1 | useEffect(() => { |
每次 count 变化,effect 重新执行,新的 setInterval 闭包捕获了最新的 count。
缺点:count 每变一次,定时器就销毁重建一次,不够优雅。
解法二:useRef 存可变值
1 | function Counter() { |
useRef 返回的对象在整个组件生命周期里是同一个引用,.current 是可变的。闭包里读 countRef.current,每次读的时候才取值,所以永远是最新的。
这个模式还可以封装成自定义 hook:
1 | function useLatest(value) { |
解法三:函数式更新(针对 setState 的特殊场景)
如果在定时器里只是要累加,不需要”读当前 count”,用 setState 的函数式更新就够了:
1 | useEffect(() => { |
setCount(prev => prev + 1) 里的 prev 是 React 保证的最新 state,和闭包无关。
useCallback 的同类问题
这个坑在 useCallback 里很常见:
1 | // ❌ 每次 userId 变化,回调不会更新(因为 useCallback 缓存了旧版本) |
ESLint 插件救场
手动维护 useEffect / useCallback 的依赖数组很容易漏,推荐装 eslint-plugin-react-hooks:
1 | npm install --save-dev eslint-plugin-react-hooks |
1 | // .eslintrc |
这个 lint 规则能静态分析 effect 里用到了哪些外部变量,自动检测缺失的依赖,能拦住大部分 stale closure 问题。
根本原因
React Hooks 的心智模型需要转变:每次渲染都是独立的,有自己的 props、state、事件处理函数和 effect。闭包里的值是”渲染时的快照”,不是”永远实时的当前值”。
接受了这个模型,stale closure 就不是坑了,而是符合预期的行为。需要打破这个规则的地方,用 ref 显式保存可变引用。
评论加载中…