先说个真实经历。前阵子我维护的一个看板项目准备发版,测试反馈说切换功能模块时偶发白屏,我打开任务管理器一看,内存曲线像心电图一样一路往上飙,切到第三个页面的时候标签页直接卡死。当时第一反应是数据量太大,后来排查了一圈,发现根子不在数据渲染,而在组件卸载时压根没清副作用——定时器还在跑、事件监听还挂着、异步请求的回包还在往已经卸载的组件里塞数据。这个坑,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 及以前,这个操作会在控制台报警告Can't perform a React state update on an unmounted component,React 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 清理函数的三种标准写法
第一种,最基础的资源对称关闭。适用定时器、事件监听、Observer:
useEffect(() => { 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,回调一律被忽略。团队成员只要统一用这个 Hook,class 级别的高频泄漏入口就被堵了一半。
注意:
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的返回值是AppStateSubscription,0.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 / setTimeout | clearInterval / clearTimeout |
| 事件监听 | addEventListener | removeEventListener(同引用) |
| 外部订阅 | EventEmitter / store.subscribe | unsubscribe / remove |
| 网络请求 | fetch / axios | AbortController / CancelToken |
| 观察器 | ResizeObserver 等 | disconnect / unobserve |
| 媒体流 | getUserMedia | getTracks().forEach(stop) |
| 第三方实例 | ECharts / 地图 / 编辑器 | dispose / destroy / unmount |
写在最后
我在这个领域踩过的坑远不止上面这些。每次带新人看代码,我都会让他们先回答三个问题:这个组件卸载后,它申请的资源去哪了?它还活着吗?谁负责让它死?答得出来的,基本不会搞出内存泄漏;答不出来的,白屏和内存飙升就只是时间问题。
顺手再分享一个小技巧:写完 effect 后,养成一个强迫症动作,在代码里搜一下addEventListener、setInterval、setTimeout、fetch(这几个关键字,看附近 15 行内有没有对称的释放逻辑。这个习惯救过我无数次,比任何框架能力都靠谱。
最后说一句关于项目管理的体会。处理这类问题的难点从来不是代码写不出来,而是问题不在本地复现、测试又没覆盖到。所以我现在习惯把副作用清理写成文档里的一页规范,新需求评审时专门过一遍这条 check list,比出事后熬夜查内存曲线效率高太多了。希望这篇能帮你在下次碰见白屏加内存飙红时,少走两三个小时的弯路。