症状
经典复现只要五行:UI 上 count 显示 1、2、3……而 interval 的日志永远打印 0。机制是:每次渲染都创建捕获该渲染状态的新闭包,但空依赖数组让 Effect 只在首次渲染运行一次,interval 里执行的始终是第一渲染的那份闭包。控制台里闭包值是旧的而组件渲染新值,对照即确诊;为了“避免重复运行”而故意省略依赖、异步操作在创建闭包的渲染之后很久才 resolve,都是同一机制的不同入口。
诊断思路
先在闭包内外各打一行日志对比新旧;再把依赖数组当合同读——空数组挂着读取变化状态的闭包,就是“故意冻结”模式;最后用定时器测试复现:点击递增后等一秒,断言回调打印的是当前值。exhaustive-deps lint 被全局关闭的代码库要重点排查,那等于把每个未来 Effect 都变成潜在过期闭包。
根因
React 每次渲染创建捕获当次状态的新闭包。不重跑的 Effect 持续使用创建那次渲染的闭包,读到的值冻结在那一刻。省略依赖阻止了本可刷新捕获的重跑。
修复与验证
按需三选:只需要最新值时用函数式更新 setCount(c => c + 1),根本不捕获 count;Effect 应该跟随值时把依赖写全,随变化重跑换新闭包;长命订阅不该重订阅时用 ref 存最新值(每次渲染同步 latest.current,订阅回调读 latest.current),订阅一次、读取永远新鲜。保持 exhaustive-deps 开启——它正是这份合同的编译器侧守卫。验证时定时器/订阅回调跨更新读到当前值,测试推进状态后断言回调输出匹配。
代码示例
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
console.log('count is', count); // always 0
}, 1000);
return () => clearInterval(id);
}, []); // empty deps: the closure is from render #1 forever
setInterval(() => setCount((c) => c + 1), 1000); // no capture of count at all
useEffect(() => {
const id = setInterval(() => api.report(count), 5000);
return () => clearInterval(id);
}, [count]); // re-runs per change, always fresh closure
const latest = useRef(count);
useEffect(() => { latest.current = count; }); // every render
useEffect(() => {
const sub = ws.on('tick', () => handle(latest.current));
return () => ws.off('tick', sub);
}, []); // subscribes once, reads current via ref