React组件卸载不清理副作用?内存泄漏白屏的终极排查指南
发布时间:2026/9/8 3:46:21 作者:尧图编辑部 阅读量:1,286

先说个真实经历。前阵子我维护的一个看板项目准备发版测试反馈说切换功能模块时偶发白屏我打开任务管理器一看内存曲线像心电图一样一路往上飙切到第三个页面的时候标签页直接卡死。当时第一反应是数据量太大后来排查了一圈发现根子不在数据渲染而在组件卸载时压根没清副作用——定时器还在跑、事件监听还挂着、异步请求的回包还在往已经卸载的组件里塞数据。这个坑React 开发者十个里面至少踩过五个。今天就把这个事彻底聊透从“为什么卸载要清副作用”到 7 个致命场景再到代码级急救方案和内存排查路径一次说清楚。这篇文章适合谁前端刚入门、写过几个月 React 但被白屏或内存问题折磨过的同学以及带团队做代码 review 时想建立一份规范化 check list 的 senior 开发。不夸张地说只要你的项目里有useEffect、setInterval、addEventListener、fetch、WebSocket这五样东西里的任意一样这篇文章就值得你花十分钟读完。1. 先搞清楚一件事你对 React 卸载做了什么1.1 那不是 bug是副作用在组件死后继续跑很多同学把“卸载时清副作用”理解成 React 的附加要求觉得清不清都行。这个认知是错的。React 的职责是管理 UI 树它负责在组件卸载时销毁 DOM、切断 Fiber 树上的引用但它没有义务替你关掉定时器、解绑事件、关闭连接、取消请求。这些游离在 React 之外的东西是你这个开发者自己引入到程序里的资源。你不关它们就永远活着。打个比方组件卸载相当于房间退租。React 帮你把家具搬走了、把电断了但你在房间里牵出去的一根网线、一个外接摄像头、一个一直在广播的喇叭如果不拔掉隔壁住户照样能被你网络的信号干扰到。白屏和内存飙升就是信号干扰到极限后的表现。React 函数式组件里的“副作用”通常分两类一类是渲染副作用比如改 DOM、写 localStorage另一类是资源副作用比如开定时器、绑事件、发请求、建连接。第二类资源型副作用是全场重点因为它们大概率不会随组件卸载自动释放。1.2 useEffect 的清理函数到底在清什么useEffect回调里你可以选择返回一个函数这个函数就是清理函数。它在三个时机被调用依赖项变化、effect 即将重新执行前——先清理上一次的副作用组件卸载时——清理本次副作用StrictMode 开发环境下组件模拟卸载后——也会触发一次清理。所以清理函数的核心价值就四个字对称关闭。你在 effect 里申请了什么就在返回函数里释放什么。有一说一能对称关闭的 effect 占到了质量问题的九成。useEffect(() { const timer setInterval(() { setCount(c c 1); }, 1000); return () { clearInterval(timer); }; }, []);这段代码是最基础的模板但实际项目里很少有人只用一个定时器、只绑一个事件。一旦依赖数组里有变量、处理函数是匿名函数、请求带参数局面就变得复杂了复杂之后就容易漏。后面第 3 章我会专门讲怎么封装成不容易漏的写法。1.3 StrictMode 为什么老在开发环境搞事情React 18 开始StrictMode在开发环境下会故意让每个 effect 执行两次挂载 → 卸载 → 再挂载。这个机制的目的就是帮你暴露“清理函数缺失”的问题。很多人第一次看到console.log打了两次以为代码写错了实际上是官方故意为之。如果你在 effect 里开了一个定时器但没写清理函数StrictMode 下你会看到两个定时器同时在跑页面数据疯狂跳动——这就是给你一个早期预警。注意如果你在开发环境没开StrictMode这类问题会完全隐藏直到线上用户切了几十个页面后内存爆掉。所以我对项目的建议是别关 StrictMode也别嫌它烦它帮你在发布前挡掉大量泄漏隐患。2. 七大致命场景每一个都在生产环境挂过下面这 7 个场景我全部在生产代码里实际遇到过每个场景我都按“事故现场 — 问题分析 — 修复方案”的顺序来拆。代码都是简化版但问题逻辑和真实线上完全一致。2.1 计时器数组越堆越多页面越来越卡事故现场一个数据大屏页面进入后每 2 秒轮询一次后端接口。用户切到别的菜单再切回来发现接口请求频率翻倍多切几次后页面开始掉帧。function Dashboard() { const [data, setData] useState([]); useEffect(() { const timer setInterval(() { fetchData().then(res setData(res.data)); }, 2000); // 这里没有返回清理函数 }, []); return Chart data{data} /; }问题分析组件卸载后setInterval没有clearInterval定时器继续每 2 秒发一次请求setData更新一个脱离 DOM 树的组件状态。你以为页面关了但函数闭包还活着React 还在默默跑更新队列。来回切换 5 次就有 5 个定时器在工作内存自然只涨不降。修复方案useEffect(() { const timer setInterval(() { fetchData().then(res setData(res.data)); }, 2000); return () clearInterval(timer); }, []);这里有个附加经验如果轮询接口的快慢会影响下一次调度的起始时间建议用setTimeout递归代替setInterval这样能避免请求堆积。清理方式同理。2.2 事件监听同一处理函数被注册 N 次事故现场某个列表页监听了scroll事件做无限加载。用户滚动正常但切换到详情页再返回后滚动 一下 页面就发两三个请求而且越切越卡。useEffect(() { window.addEventListener(scroll, () { if (nearBottom()) { loadMore(); } }); // 没解绑 }, []);问题分析addEventListener在同一事件上重复添加监听器时即便传入不同的函数引用浏览器也会持续累积监听数量。组件每次挂载都往window上多挂一个监听器卸载时一个都不回收。伴随loadMore的闭包引用旧组件的整个作用域链都被 long 引用挂着内存泄漏就是从这里开始的。修复方案useEffect(() { const handleScroll () { if (nearBottom()) { loadMore(); } }; window.addEventListener(scroll, handleScroll); return () { window.removeEventListener(scroll, handleScroll); }; }, []);这里必须强调removeEventListener传入的函数必须和addEventListener传入的是同一个引用。如果直接用匿名函数removeEventListener是找不到目标的。实战小技巧如果事件处理函数依赖组件的 props 或 state用useCallback把它包起来然后在 effect 的依赖数组里加上它。这样函数引用稳定effect 不会反复重启事件也能正常解绑。const handleScroll useCallback(() { if (nearBottom()) { loadMore(); } }, [loadMore]); useEffect(() { window.addEventListener(scroll, handleScroll); return () window.removeEventListener(scroll, handleScroll); }, [handleScroll]);2.3 异步请求请求回包覆盖已经卸载的页面事故现场列表页带搜索过滤用户输入关键词后立刻切到详情页。结果详情页偶发白屏且越操作越频繁。useEffect(() { let url /api/list?kw${keyword}; fetch(url) .then(res res.json()) .then(data { setList(data.list); // 组件已卸载时执行 }); }, [keyword]);问题分析请求是异步的。用户切走页面组件卸载但 fetch 的回包回来后代码依然执行了setList。在 React 17 及以前这个操作会在控制台报警告Cant perform a React state update on an unmounted componentReact 18 之后官方干脆把这个警告删了改成静默忽略。但静默忽略不代表不消耗性能——回调函数、闭包里引用的组件状态、DOM 引用依然被 Promise 链持有直到请求完成才释放。搜索场景下请求频繁返回顺序还可能错乱旧请求的返回覆盖新请求的结果白屏就是这么来的。修复方案useEffect(() { let ignore false; fetch(/api/list?kw${keyword}) .then(res res.json()) .then(data { if (!ignore) { setList(data.list); } }) .catch(err { if (!ignore) { console.error(err); } }); return () { ignore true; }; }, [keyword]);用一个ignore标志位卸载后回包直接丢弃。这个模式是官方文档推荐的标准写法简单可靠。2.4 WebSocket 与订阅连接堆积服务端跟着遭殃事故现场一个协作编辑工具打开文档建立 WebSocket 连接。用户同时开多个标签页操作不同文档结果服务端并发连接数爆了所有文档开始卡顿。useEffect(() { const ws new WebSocket(wss://api.example.com/ws); ws.onmessage e { setMessages(JSON.parse(e.data)); }; // 没有关闭 ws }, []);问题分析组件卸载后WebSocket 连接仍然保持消息回调继续触发setMessages。连接不关闭服务端资源一直被占用前端也会持续收到推送消息触发布 Render。类似的还有EventSource、Web Worker、BroadcastChannel、Notification权限回调等。修复方案useEffect(() { const ws new WebSocket(wss://api.example.com/ws); ws.onmessage e { setMessages(JSON.parse(e.data)); }; return () { ws.onmessage null; ws.close(); }; }, []);注意ws.close()之前最好把onmessage置空否则在关闭过程中可能还有消息回调穿插进来。另外如果 WebSocket 回调里引用了大量数据置空 handler 还能帮助浏览器提前回收这部分闭包内存。2.5 各种 Observer监听器不销毁白屏迟早的事事故现场一个富文本编辑器页面用ResizeObserver监听工具栏尺寸变化连续开关编辑器模块多次后页面不白屏但编辑区域完全不响应了。useEffect(() { const observer new ResizeObserver(entries { // 更新工具栏样式 entries.forEach(() updateStyle()); }); observer.observe(toolbarRef.current); // 没有 disconnect }, []);问题分析ResizeObserver、IntersectionObserver、MutationObserver这三位都是重量级监听器。它们内部会持有被观察元素和回调的强引用。组件卸载后 observer 没disconnect被观察的 DOM 即便已经被 React 移除只要 observer 还引用着它浏览器就无法真正回收这块内存。反复挂载卸载观察器越来越多每次 DOM 布局变化都会触发一堆僵尸回调最终导致页面失去响应。修复方案useEffect(() { const observer new ResizeObserver(entries { entries.forEach(() updateStyle()); }); observer.observe(toolbarRef.current); return () { observer.disconnect(); }; }, []);三种 Observer 都提供了disconnect()方法调用后一次性解除所有观察目标。如果只是部分目标需要停止观察用unobserve(target)更精细。2.6 媒体播放与硬件资源音频流不释放内存只涨不降事故现场在线会议应用用户退出会议室后麦克风指示灯仍亮着且其他应用无法使用麦克风浏览器内存持续上升。useEffect(() { let stream null; navigator.mediaDevices .getUserMedia({ audio: true }) .then(s { stream s; audioRef.current.srcObject s; }); // 没有停止轨道 }, []);问题分析getUserMedia打开的麦克风/摄像头在调用track.stop()之前硬件资源一直被网页占着。组件卸载后浏览器标签页还在系统层面不会自动释放设备权限。移动端尤其明显——摄像头指示灯常亮、系统相机打不开这些都是硬件资源泄漏的直观表现。修复方案useEffect(() { let stream null; navigator.mediaDevices .getUserMedia({ audio: true }) .then(s { stream s; if (audioRef.current) { audioRef.current.srcObject s; } }) .catch(err { console.error(麦克风获取失败, err); }); return () { if (stream) { stream.getTracks().forEach(track track.stop()); } if (audioRef.current) { audioRef.current.srcObject null; } }; }, []);这里要注意stream.getTracks()返回的是所有轨道包括音频轨和视频轨全部stop()准没错。视频播放器的video标签卸载时建议把srcObject置空否则浏览器会继续持有视频帧缓冲。2.7 第三方库实例你以为销毁了其实它还在内存里事故现场项目管理看板用hello-pangea/dnd做拖拽页面切走再切回就报 “Element is not attached to the DOM”。useEffect(() { const echartsInstance echarts.init(chartRef.current); echartsInstance.setOption(option); // 没调用 dispose() }, []);问题分析很多第三方库比如 ECharts、地图实例、富文本编辑器、拖拽库都会在实例内部创建自己的事件代理、ResizeObserver、Canvas 缓存。它们不知道 React 什么时候卸载组件必须由开发者手动调用销毁方法。不销毁就切换路由实例依然存在DOM 元素虽然没了但它们绑定的内部事件引用还在。修复方案useEffect(() { const echartsInstance echarts.init(chartRef.current); echartsInstance.setOption(option); const handleResize () echartsInstance.resize(); window.addEventListener(resize, handleResize); return () { window.removeEventListener(resize, handleResize); echartsInstance.dispose(); }; }, [option]);通用经验是阅读第三方库文档找 “destroy” “dispose” “unmount” “cleanup” 这类关键字。ECharts 用dispose()hello-pangea/dnd在拖拽结束时自动清理内部事件但对外绑定的 HTML5 拖拽事件需要你自己解除富文本编辑器的editor.destroy()要确保在 DOM 移除前调用。3. 代码级急救方案一次讲清这些清理套路3.1 useEffect 清理函数的三种标准写法第一种最基础的资源对称关闭。适用定时器、事件监听、ObserveruseEffect(() { const resource createResource(); return () destroyResource(resource); }, []);第二种依赖更新时先清旧再建新。比如搜索框要根据 keyword 重新请求但用户快速输入时旧请求要能被取消useEffect(() { const controller new AbortController(); fetch(/api/search?q${keyword}, { signal: controller.signal, }) .then(res res.json()) .then(data setResult(data)) .catch(err { if (err.name ! AbortError) { console.error(err); } }); return () controller.abort(); }, [keyword]);第三种针对外部订阅。比如状态管理库、事件总线、通知中心useEffect(() { const subscription eventBus.subscribe(message, handler); return () subscription.unsubscribe(); }, []);这三种路由覆盖了绝大多数场景重点在于识别资源的类型一次性资源、持续型资源、订阅型资源。识别对了清理方案自然就写对了。3.2 自定义 Hook把清理逻辑封装成肌肉记忆每次手写ignore标志太容易忘不如封装成自定义 Hook。这是我在团队里强制执行的规范之一function useAsyncData(asyncFn, deps) { const [state, setState] useState({ data: null, loading: true, error: null, }); useEffect(() { let cancelled false; setState({ data: null, loading: true, error: null }); asyncFn() .then(data { if (!cancelled) { setState({ data, loading: false, error: null }); } }) .catch(error { if (!cancelled) { setState({ data: null, loading: false, error }); } }); return () { cancelled true; }; }, deps); return state; } // 使用 const { data, loading, error } useAsyncData( () fetchList({ projectId }), [projectId] );组件卸载时cancelled自动变true回调一律被忽略。团队成员只要统一用这个 Hookclass 级别的高频泄漏入口就被堵了一半。注意cancelled方案拦得住状态更新拦不住底层请求本身。如果请求体量很大建议在 Hook 内部同时支持AbortController让发出的请求真正中断而不是等它白白跑完。3.3 用 AbortController 消灭请求竞态请求竞态是另一个隐蔽问题快速切换筛选条件时第一个请求比第二个请求晚返回旧数据覆盖新数据。useEffect(() { const controller new AbortController(); const url /api/list?filter${filter}; fetch(url, { signal: controller.signal }) .then(res res.json()) .then(data setList(data)) .catch(err { if (err.name AbortError) return; setError(err.message); }); return () controller.abort(); }, [filter]);每次依赖变化先abort()上一次请求再从源头阻断竞态。不管是旧请求覆盖新数据还是卸载后回包更新组件状态都一并解决。AbortController是 Web 标准 API兼容性已经非常好现代浏览器和 React Native 的 fetch 都支持。旧代码里如果还依赖 axios可以用axios.CancelToken或者直接在拦截器里配置 signal效果一样。3.4 把防抖、节流这类副作用收进可清理容器大量内存泄漏的帮凶其实是防抖、节流这类“临时任务”。比如搜索框每输入一个字符去请求后端按 500ms 防抖但组件卸载时防抖定时器还在排队。useEffect(() { const timer setTimeout(() { fetchSearchResults(keyword).then(setResult); }, 500); return () clearTimeout(timer); }, [keyword]);这个例子和定时器、请求合流在一起是最容易被忽略的区域。只要你用了debounce、throttle、setTimeout做延迟操作必须让清理函数能取消相应的调度。如果用了 lodash 的debounce注意debouncedFn.cancel()和flush()的区别cancel()取消未执行的调用flush()立即执行待调用的函数。组件卸载时调用cancel()即可。const debouncedSearch useMemo( () debounce(fetchSearchResults, 500), [] ); useEffect(() { return () { debouncedSearch.cancel(); }; }, [debouncedSearch]);3.5 在编译期拦截ESLint react-hooks 规则做守门员运行时的经验总结再多都不如编译期拦截来得稳。React 官方提供了eslint-plugin-react-hooks其中的exhaustive-deps规则能在你写代码时就提醒漏掉的清理时机。npm install eslint-plugin-react-hooks --save-dev// .eslintrc { plugins: [react-hooks], rules: { react-hooks/rules-of-hooks: error, react-hooks/exhaustive-deps: warn } }rules-of-hooks保证 Hook 只能写在组件顶层exhaustive-deps会让你把 effect 依赖的每个变量都列进依赖数组。依赖数组越完整effect 的重新执行时机越可预测清理函数被正确调用的概率越高。我把exhaustive-deps从warn升成了error来强制执行。一开始团队有人抱怨太严格但坚持两周后白屏问题的工单数量肉眼可见地下降了。4. 内存泄漏和白屏怎么定位、怎么复盘4.1 前端内存泄漏的系统化排查路径如果你已经遇到了内存飙升下面这套路径是亲测有效的。第一步打开 Chrome DevTools → Performance → 勾选 Memory → 点击 Record。在页面上重复操作 10 次“挂载 → 卸载”的完整流程比如反复切换 Tab、打开关闭弹窗。操作完停止录制看内存曲线。正常情况内存先涨后落整体呈锯齿状但底部基线大致水平。 泄漏情况内存曲线每次操作后都往上抬一截回不到原来的水平整体呈现阶梯式上升。第二步切到 Memory → 录制一次 heap snapshot再重复操作再录制第二次。用 Snapshot 3 对比 Snapshot 2选择 “Objects allocated between Snapshot 2 and Snapshot 3”重点看两类对象Detached DOM tree组件 Unmount 后 DOM 节点没被回收频繁出现的自定义组件实例、闭包变量。第三步在 console 里手动触发垃圾回收确认是真实泄漏还是延迟回收。# 命令行里启动 Chrome 时开启预备调试端口或直接使用 --js-flags--expose-gc 启动浏览器启动项如果手动 GC 后内存回落到正常水平说明只是回收不及时如果内存纹丝不动那基本坐实泄漏。第四步用 React DevTools Profiler 查看组件是否被重复挂载。每条记录的组件挂载次数远超预期说明路由切换时旧实例没被销毁或者 key 不一致导致重新挂载。这套路径在我的项目里已经帮团队定位过不下 5 起内存问题最快一次只用了 20 分钟就锁定了罪魁祸首一个在useMemo里创建的ResizeObserver。4.2 白屏问题的三类常见触发路径白屏并不总是渲染代码的 bug也可能跟卸载副作用有关。第一类异步回包导致的状态错乱。卸载后的setState虽然不再警告但如果组件是通过key复用在同一位置重新挂载的旧请求的返回可能更新到新组件上直接把新组件的数据线包打乱页面 UI 全部是空数据看起来就是白屏。第二类内存泄漏导致的浏览器页面僵死。这属于“间接白屏”——Chrome 在内存压力过大时主动冻结后台标签页恢复切回时白屏甚至崩溃。用户感知是“切了一下回来白屏了”实际上内存早就爆了。第三类第三方库实例二次挂载冲突。比如 ECharts 实例没dispose就重新初始化图表 DOM 上残留旧实例的状态渲染异常导致画布空白。这时控制台往往有报错重点看Error: Initialize failed: invalid dom这类提示。排查白屏的正确思路是先分优先级查看 console 有无报错 → 看 Network 有无请求失败 → 看 Memory 是否异常 → 最后才怀疑 CSS 和渲染逻辑。其中前三个都和处理副作用的代码强相关。4.3 用 StrictMode 和代码走查提前拦截问题与其等着线上白屏不如在开发阶段就用工具逼迫问题显形。我的个人习惯是项目开环境强制套StrictMode不要把它当噪音关掉。StrictMode 的双重执行机制会让副作用清理不干净的代码立刻出现双倍副作用行为开发时多留意就没问题。另外代码走查时需要专门检查三个高危位置所有useEffect是否都有对应的释放逻辑。一条一条对着“申请什么、释放什么”来检查。依赖数组是否存在可变的函数引用/对象引用。如果依赖项变化频繁effect 会反复重启定时器和监听器也跟着反复重建。第三方库实例是否有对应的dispose或destroy方法。有就一定要在清理函数里调用。5. React Native 和现代框架里的同款坑5.1 RN 里的原生事件订阅清理React Native 端最常见的是原生模块事件订阅。NativeEventEmitter的addListener返回的订阅对象必须 remove 掉否则原生事件回调会持续触发 JavaScript 端逻辑导致内存上涨。AppState、DeviceEventEmitter、推送通知都是重灾区。useEffect(() { const subscription AppState.addEventListener(change, handleAppStateChange); return () { subscription.remove(); }; }, []);RN 0.65 之前AppState.addEventListener的返回值是AppStateSubscription0.65 之后直接返回AppState实例的句柄同样有remove方法。RN 端排查内存问题时用 Xcode Instruments 的 Leaks 模板或者 Android Studio 的 Memory Profiler 能看到原生层面的引用关系。5.2 React 并发特性与 Suspense 对清理的影响React 18 的并发渲染和 Suspense 对副作用清理提出了更严格的要求。在并发更新中组件可能被 React 临时挂起、丢弃、重试effect 的挂载和卸载顺序不再严格同步。Suspense 下如果请求是挂在组件内部触发的组件被挂起时 effect 可能还没跑完就被中断清理逻辑更容易漏。这个领域新的实践是倾向于把异步数据请求移到框架层或路由层做统一管理组件内只负责消费数据。这样组件卸载时不需要关注请求是否被子进程持有由外层统一取消。React 19 里的useHook 以及社区流行的 TanStack Query、useSWR 都是这个思路——把请求和组件生命周期解绑从根上绕过卸载副作用问题。6. 常见问题速查表问题可能原因快速排查方法切页面后内存只涨不降定时器/监听器未清理Performance 录内存曲线重复操作挂载卸载看是否回升页面偶发白屏异步请求回包更新已卸载组件加 ignore 标志用 AbortController 取消请求同一行为触发多次请求事件监听重复注册检查 addEventListener 是否有对称 remove避免监听器写在渲染循环里文档/表格操作卡顿且内存升高Observer 未 disconnect检查 ResizeObserver/IntersectionObserver 清理路切换后再次进入组件报错第三方库实例未销毁确认 dispose/destroy 方法是否被调用开发环境下 StrictMode 请求发两次正常现象保留清理函数确认第二次挂载时资源正常重新建立函数闭包导致旧状态被捕获依赖数组不完整按 exhaustive-deps 补充依赖或重写 effect 结构下面这个表是给团队 code review 用的自查清单我贴了三年效果非常显著资源类型申请方式释放方式定时器setInterval / setTimeoutclearInterval / clearTimeout事件监听addEventListenerremoveEventListener同引用外部订阅EventEmitter / store.subscribeunsubscribe / remove网络请求fetch / axiosAbortController / CancelToken观察器ResizeObserver 等disconnect / unobserve媒体流getUserMediagetTracks().forEach(stop)第三方实例ECharts / 地图 / 编辑器dispose / destroy / unmount写在最后我在这个领域踩过的坑远不止上面这些。每次带新人看代码我都会让他们先回答三个问题这个组件卸载后它申请的资源去哪了它还活着吗谁负责让它死答得出来的基本不会搞出内存泄漏答不出来的白屏和内存飙升就只是时间问题。顺手再分享一个小技巧写完 effect 后养成一个强迫症动作在代码里搜一下addEventListener、setInterval、setTimeout、fetch(这几个关键字看附近 15 行内有没有对称的释放逻辑。这个习惯救过我无数次比任何框架能力都靠谱。最后说一句关于项目管理的体会。处理这类问题的难点从来不是代码写不出来而是问题不在本地复现、测试又没覆盖到。所以我现在习惯把副作用清理写成文档里的一页规范新需求评审时专门过一遍这条 check list比出事后熬夜查内存曲线效率高太多了。希望这篇能帮你在下次碰见白屏加内存飙红时少走两三个小时的弯路。