你以为DOM没了就没事了?内存泄漏排查指南
虚拟滚动让页面不卡了,但半小时后浏览器标签页还是崩了。本文教你用Chrome Memory面板揪出闭包引用、失控监听器和幽灵定时器。
01. 场景还原:静默的杀手
用户并没有抱怨卡顿,而是直接反馈"浏览器闪退了"。打开Chrome任务管理器(Shift+Esc),发现该标签页内存占用从启动时的120MB,在持续对话1小时后飙升到了870MB,随后触发OOM(Out of Memory)被系统杀掉。
为什么会这样? 虚拟滚动只回收了DOM节点,但JS堆中的消息对象、DOM引用、回调函数从未释放。
02. 元凶一:闭包引用与大对象常驻
很多开发者喜欢在消息对象里存imageBase64(可能几MB)或超长文本。即使这条消息早就不在可视区了,只要它在messages数组里,垃圾回收器(GC)永远不会回收它。
排查工具实操:
- 打开Chrome DevTools → Memory面板。
- 选择"Heap snapshot",拍第一张快照。
- 聊天工具运行10分钟,再拍第二张快照。
- 视图切换为"Comparison",过滤"Array"和"Object"。
- 查找**Delta(变化量)**始终为正的构造函数,如
Message、Array。
你会发现,即使新消息不断进来,旧消息的content字符串依然占据着内存。
03. 元凶二:事件监听幽灵
每条消息如果都有"复制按钮"、"语音播放按钮",并各自绑定了addEventListener:
tsx
// ❌ 错误写法(每条消息独立绑定)
useEffect(() => {
const btn = ref.current.querySelector('.copy-btn');
const handler = () => navigator.clipboard.writeText(msg.content);
btn.addEventListener('click', handler);
// 没有 cleanup!
}, []);
当消息被移出虚拟滚动(DOM被删除)时,如果没有removeEventListener,DOM节点虽然从视觉上消失了,但由于事件回调函数引用了该DOM节点,GC无法回收这个DOM对象,造成**分离DOM节点(Detached DOM)**泄漏。
排查 :在Elements面板中,搜索detached关键字,或者看Memory快照中的Detached HTMLElement数量。
04. 元凶三:失控的定时器与SSE连接
打字机效果常用setInterval,语音播报也有轮询。如果组件卸载或消息发送完毕忘记clearInterval:
tsx
// ❌ 致命错误
useEffect(() => {
const timer = setInterval(() => {
setTypingText(prev => prev + nextChar);
}, 30);
// 没有 return clearInterval
}, []);
即使当前页面跳转了,这个setInterval依然在运行,持有组件实例的引用(闭包),导致整个组件树无法被回收。
05. 破局三板斧
① 强制限制队列长度(兜底策略) 不管虚拟滚动怎么变,内存数组必须截断 。设置最大长度500条,超出时使用shift()移除最早消息,并手动切断引用:
typescript
const MAX_MESSAGES = 500;
function addMessage(newMsg: Message) {
messages.push(newMsg);
if (messages.length > MAX_MESSAGES) {
const removed = messages.shift();
// 关键:清理大对象引用,帮助GC
if (removed?.imageBase64) removed.imageBase64 = '';
if (removed?.content) removed.content = removed.content.slice(0, 100) + '...(truncated)';
}
}
② 统一清理钩子(事件委托替代独立绑定) 采用事件委托 ,只在容器上绑定一次click,通过data-*属性判断点击了哪条消息的哪个按钮:
tsx
// ✅ 正确写法
<div onClick={(e) => {
const target = e.target as HTMLElement;
const msgId = target.dataset.msgId;
const action = target.dataset.action;
if (action === 'copy') handleCopy(msgId);
}}>
{/* 子组件只渲染UI,不绑任何监听器 */}
</div>
对于必须绑定的(如滚动监听、定时器),在useEffect的Cleanup中统一清除:
tsx
useEffect(() => {
const timer = setInterval(...);
window.addEventListener('scroll', handler);
return () => {
clearInterval(timer);
window.removeEventListener('scroll', handler);
};
}, []);
③ 大图使用Blob URL并手动revoke 对于AI生成的图片,用URL.createObjectURL生成临时链接,组件卸载时调用URL.revokeObjectURL(url)释放内存。
06. 性能数据
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 持续对话1小时内存 | 120MB → 870MB (OOM) | 120MB → 210MB (稳定) |
| Detached DOM节点 | 数百个 | 0 |
| 定时器残留 | 多个 | 全部清除 |
📖 下一篇预告(第03篇)
AI的"记忆"是有代价的------Token管理艺术
场景预告:内存终于不崩了,但接口开始报错
400 context_length_exceeded------用户AI突然"失忆"了。核心看点:大模型上下文窗口硬限制原理 / 滑动窗口 vs 摘要压缩 vs 结构化存储对比决策 / Prompt模板设计 / 前端上下文截断透明化UI
👋 关注我,继续深挖AI对话的下一层命门!
- 点击「关注」获取实时更新提醒
- 收藏本系列专栏,全集内容持续解锁中
你的「点赞 👍 + 收藏 ⭐」能让这篇文章被更多需要它的人看到!