上周排查一个生产环境的内存泄漏问题时,我盯着Chrome DevTools里那条持续增长的蓝色曲线,后背发凉——一个本该被卸载的组件,其状态竟然一直挂在内存里。这让我意识到:React的状态管理,远不止"把数据存进useState"这么简单。
一、幽灵组件:谁偷走了我的内存?
场景是这样的:一个后台管理系统,用户频繁切换Tab页(每个Tab对应一个动态加载的组件)。上线第三天,客户反馈系统越来越卡,最终崩溃。性能分析显示,切换Tab时,旧组件的内存未被释放。
你可能会想:"我用React.memo了,也用useEffect清理了定时器,还能漏哪儿?" 但问题出在闭包上。来看这段典型的问题代码:
function UserList() { const [users, setUsers] = useState([]); useEffect(() => { const fetchData = async () => { const data = await fetch('/api/users'); setUsers(await data.json()); }; fetchData(); // 添加一个事件监听(问题所在!) window.addEventListener('resize', () => { console.log('Current users:', users); // 这里捕获了users的引用 }); return () => { window.removeEventListener('resize', () => {}); // 这里根本移除不掉! }; }, []); // 空依赖数组 }- 根因:事件监听回调中引用了
users状态,形成闭包。由于依赖数组为空,初始回调始终持有第一次渲染时的users引用。即使组件卸载,由于事件监听未被正确移除(回调函数是新创建的),整个组件作用域(包括users)被保留在内存中。
二、从原理看闭包陷阱
React的函数组件在执行时,每次渲染都会创建新的函数作用域。当你在useEffect中引用外部变量时,它实际捕获的是
更隐蔽的是,
即使你返回了清理函数,如果移除的监听函数和添加的不是同一个引用,清理也会失效。这就是为什么上面代码中的removeEventListener像个摆设。三、正确解法:函数引用与依赖数组
解决这个问题的关键是:
useCallback或组件外部修正后的代码:
function UserList() { const [users, setUsers] = useState([]); // 用useCallback保持引用稳定 const handleResize = useCallback(() => { console.log('Current users:', users); }, [users]); // 显式声明依赖 useEffect(() => { const fetchData = async () => { const data = await fetch('/api/users'); setUsers(await data.json()); }; fetchData(); window.addEventListener('resize', handleResize); return () => { window.removeEventListener('resize', handleResize); }; }, [handleResize]); // 依赖handleResize }实测对比:在频繁切换组件的场景下,原代码10分钟后内存占用达到1.2GB,修正后稳定在200MB左右。
四、避坑清单:状态管理的隐藏成本
useEffect、useCallback、事件监听中引用的状态,必须检查是否会导致闭包保留旧引用eslint-plugin-react-hooks不是万能的,复杂的表达式可能导致依赖项遗漏memo)五、什么时候该用状态管理库?
经过这次教训,我总结了一个简单的判断规则:
- 如果组件间需要共享
react-query或SWR- 如果共享的是
- 其他情况优先用
useState+ 组件组合,
最后留个思考题:你们项目中那些"看似无害"的useEffect空依赖数组,真的都安全吗?欢迎分享你的踩坑经历。