ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

页面卡死排查指南:主线程长任务与内存泄漏优化实战

页面卡死排查指南:主线程长任务与内存泄漏优化实战 页面无响应这个问题干前端的兄弟基本都遇到过。群里一喊“页面卡死了”大家第一反应就是“清缓存刷新看看”。可真正棘手的是那种刷新也没用、过一会儿又自己恢复、或者直接白屏卡死的场景。说实话这种问题之所以难搞不是因为代码逻辑多复杂而是因为“无响应”这个现象太模糊了——它可能是主线程被长任务霸占可能是内存被某个隐藏的定时器吃光也可能是渲染层被无限重绘拖垮。我在不同项目里断断续续折腾了几个月从 Performance 面板到 Memory 堆快照再到代码层面的逐段拆解总算把这套定位流程跑通了也总结出一些可以直接套用的优化手段。这篇就当是踩坑记录把从定位思路到真实案例的完整过程梳理清楚希望能帮你省掉那些我在排障上浪费掉的时间。先说一个我这几年最大的体会页面无响应绝大多数情况下不是 JavaScript 代码“运行出错”而是主线程被“堵死”了。浏览器的主线程既要执行脚本又要处理样式计算、布局绘制、事件响应一旦某个任务把主线程占住超过几百毫秒用户的所有点击、滚动、输入都会排队等一个“空档”表现出来就是页面跟死了一样。定位的核心就是找到到底是哪个任务把主线程给霸占了。1. 页面无响应到底是什么在“卡”我们先把罪魁祸首弄清楚很多人一上来就扒代码找 Bug其实方向就偏了。页面无响应从来不是一件事而是好几种原因叠加的结果。我习惯把它拆成三个层面去看第一是主线程执行的长任务第二是内存占用异常第三是渲染层反复做无效工作。1.1 主线程长任务才是最隐蔽的“卡死元凶”浏览器的主线程就那么一条道和单车道公路一样只要有一辆大卡车堵在前面后面所有小车都得停着等。这里的大卡车就是“长任务”Long Task。按浏览器的标准任何超过 50ms 的连续任务都算长任务。为什么是 50ms因为人眼感知延迟的阈值以及点击、输入反馈的理论上限基本都在这个数值附近。超过 50ms用户就会开始隐隐觉得“有点不对劲”超过 500ms基本就是“页面是不是死了”的体验。我排查过一个大屏可视化项目症状就是切 Tab 后图表区域白屏 2 到 3 秒期间整个页面点哪儿都没反应。一开始怀疑是 ECharts 渲染卡顿但把渲染去掉后问题依旧。后来用 Performance 录了一段操作记录发现主线程上横着一条巨大的黄色块时长直接冲到 2.3 秒对应的函数名是 formatReportData。打开一看里面用 for 循环对几千条记录做了嵌套排序还顺便做了三层 map 结构转换。真正的问题就在这里——这不是渲染的锅是数据处理逻辑把主线程堵死了。1.2 别忽略渲染层的“无效劳动”还有一个特别容易忽略的地方是强制同步布局Forced Synchronous Layout。简单说就是你刚改完元素的样式马上就调用读取布局属性的方法比如 offsetHeight、getBoundingClientRect浏览器没办法只能立刻放弃之前攒的所有优化当场重新算一遍布局。如果这种操作被塞进一个几百次的循环里那性能直接雪崩。生活化的理解是这样的本来厨房可以先把所有菜切好、把锅烧热、再统一炒菜效率很高。结果你每隔一分钟就喊一句“我要看盐罐在哪”厨师只能放下刀去给你找盐找到之后又得重新进入状态。跑去读一次 offsetHeight就是喊了一次“我要看盐罐”。读完十次厨师基本上就什么都干不成了。1.3 内存异常页面无响应的“慢性毒药”长任务是“急性的刀伤”内存问题就是“慢性毒药”。内存持续上涨不回收垃圾回收器一启动就得全停顿Stop The World页面表现为间歇性卡顿、越用越卡最终彻底无响应。常见的内存泄漏源包括全局变量里挂着 DOM 引用、定时器没有被清除、事件监听器重复添加、闭包把大对象一直拽着不放开。定位内存问题比定位长任务要难一点因为有时候你盯着代码看半天也看不出是谁把内存攥在手里不放。还是要靠浏览器自带的 Memory 面板做堆快照对比后面我会详细讲具体怎么操作。2. 定位无响应的完整过程从 Performance 面板把“凶手”揪出来先声明一下网上有很多讲性能优化的文章但大多数只讲了“怎么优化”没有讲“怎么定位”。定位其实才是最有价值的部分——优化方案你抄作业就行但每个项目的堵点完全不一样。工具思路是一样的我用的是 Chrome DevTools大家手边基本都是它。2.1 用 Performance 面板抓长任务别凭感觉猜定位长任务最直接的方式就是录制一段操作再看主线程火焰图。Chrome 的 Performance 面板老版本叫 Performance新版本直接叫 Performance 就行操作路径是F12 打开 DevTools → 切到 Performance → 点击左上角的录制按钮 → 在页面上操作你要复现的步骤 → 点击 Stop。录制时间不用太长够复现问题就行。如果页面做一次切换要卡 2 秒那从操作开始到卡顿结束录制个 5 秒就足够。重点是 Stop 之后看什么很多人看到满屏花花绿绿的色块就懵了。其实你只需要关注两点第一有没有红色角标标出来的 Long Task第二主线程Main轨道上哪一块长度最离谱。选中一段异常区域下方会弹出这段任务的调用栈明细。我通常会按“Self Time”排序也就是这个函数自身执行花了多少时间排除它调用的子函数耗时。这个数据非常重要因为有时候外层函数看起来很耗时其实时间全花在它调用的另一个函数上了。只有看 Self Time 才能找到真正干活干活最多的那个函数。2.2 用 Performance Monitor 实时看页面状态除了录制回放还可以开 Performance Monitor 做实时监控。打开方式DevTools 里按 CommandShiftPMac或 CtrlShiftPWindows呼出命令菜单输入 Performance Monitor 回车。这个面板的好处是你可以盯着 CPU 占用率、JS 内存、DOM 节点数这几个指标实时的跳不用反复录制回放。我在排查内存泄漏的时候就是开着 Performance Monitor 挂在旁边每 5 分钟回来看一眼。如果 JS memory 的曲线一路向上从来不带回落那基本可以确定有泄漏。如果是一会儿升高一会儿降低那就是正常的内存波动是垃圾回收在正常工作反而不用太担心。2.3 用 Memory 面板对比堆快照找泄漏找到了趋势还得找到具体对象。Memory 面板的操作流程先做一次 Heap snapshot记为快照 A然后你回到页面里反复做那些你以为会导致问题的操作比如反复切 Tab、反复打开关闭弹窗操作完再打一次快照 B。重点来了用 Comparison 视图对比快照 A 和 B。如果发现某类对象数量在操作后大量增加并且没有被回收那就要怀疑它了。常见的泄露头号嫌疑犯是 Detached nodes也就是已经被移除出 DOM 树、但 JavaScript 还在引用着它的节点。这类东西光是挂在文档里不显示每个还在占着内存累积几百个之后页面基本就废了。Close 按钮上的事件处理器、定时器回调、全局变量为缓存而做的数组这类东西都是 Detached 节点的温床。定位到这个节点的引用路径之后顺着路径找到赋值的代码清理掉对应引用就行。3. 特定场景定位高频切换、大数据渲染、后台任务的处理技巧前面说的大概率是通用的定位思路但实际开发中还有几个高频场景需要单独对症下药不然用一本万能的《伤寒论》治不了所有病。3.1 高频切换场景路由切换白屏、Tab 切卡顿这种场景的特征是刚进页面正常来回切五六次之后开始卡切十几次直接卡死。问题往往出在“旧页面没有被清理”上。SPA 单页应用里路由切换只是把旧组件卸载、新组件挂载但如果你在组件里注册了事件监听器、定时器、WebSocket 连接而卸载的时候没有清理这些东西就会像幽灵一样留在后台继续跑。有一个非常典型的例子某个功能组件在 mounted 生命周期里启动了 setInterval 轮询数据每 2 秒拉一次接口。用户切走之后组件被卸载了但 setInterval 没有 clear轮询照常进行。每切一次 Tab就多一个轮询任务。切十个 Tab后台就有十个接口在每 2 秒轮询一次。主线程不卡才怪。这个场景的操作建议是两条第一在 beforeUnmount 或 onUnmounted 里把所有长生命周期任务都清掉第二用 Performance 面板录制一次“连续切换 Tab 5 次”的操作看后台是否有周期性重复的黄色任务块。如果看到类似心跳一样规律出现的大色块那基本就是定时器漏清了。3.2 大数据渲染表格、列表、图表动辄上万条数据大数据量场景的表现是数据返回后用 setState 一次性全部灌到前端页面瞬间卡死。这种问题最有效的第一步不是去优化代码而是先确认数据到底是怎么被渲染的。真正拖垮页面的往往不是数据量本身而是数据量触发了多少次重排、多少次重绘。比如把 5000 条数据一次性渲染成 5000 个 DOM 节点浏览器把所有节点插入文档时必然触发大规模重排。你每多塞一个节点整个文档的布局都要重算一遍最后的总耗时和 DOM 节点数量呈指数关系。解决思路嘛核心策略就是“别一次性全量渲染”。常见的方案有几种虚拟滚动只渲染可视区域内的节点、分页加载、按需渲染。你如果用的是 Element Plus 的 el-table它本身就支持虚拟表格如果用的是自研的列表可以找一个虚拟滚动库。这里提醒一点虚拟滚动不是万能的它只适合高度确定的列表不适合有大量嵌套内容、高度不固定的场景。3.3 后台任务CDN 数据解析、批量计算、请求重试我在一个数据中台项目里还遇到过一种情况页面本身没有耗时的渲染但会定期接收服务端推送的数据然后在前端做统计计算。数据量一大计算过程就把主线程堵住了。表现是页面右上角有数据更新的转圈动画但整个页面已经点不动了。这种问题用 Performance 也能定位但定位到之后优化手段要更讲究一些。计算任务本身不能不做你要做的是“把计算切成片”让主线程在每个时间片之间能喘口气。这个方案叫时间切片Time Slicing核心思想是用 setTimeout 或 requestIdleCallback 把一个大计算任务拆分成多个小任务在每个小任务之间让出主线程让浏览器能处理用户的点击和滚动。这里我用过比较顺手的工具是 requestIdleCallback它能在浏览器空闲的时候执行回调不会抢占关键渲染路径。不过在具体落地的时候要注意浏览器兼容性和回调执行时机的不可控性必要时还是得用分割数组 setTimeout 的方式手动控制切片的粒度和节奏。4. 优化手段怎么做才有效从代码层到架构层的几板斧定位是“治病”优化是“根治”。很多人定位到了长任务却不知道怎么改或者改了之后发现没效果。我按从轻到重的顺序把真正有效的优化手段整理成了“几板斧”你可以根据问题的严重程度选择用哪一层。4.1 代码层优化最直接的“外科手术”代码层优化的本质是减少无效工作目标是把长任务从 500ms 压到 100ms 以下。常见手段有这么几种。第一防抖debounce和节流throttle。这俩是最好用但也是最容易被用错的。简单区分一下防抖是“事件停止触发后延迟执行”适合搜索框输入、窗口 resize节流是“固定时间内只执行一次”适合滚动事件、鼠标移动事件。使用场景千万别搞反不然效果会大打折扣。第二减少强制同步布局。代码规范上我给自己定了三条红线不要在循环里读 offsetHeight 之类的布局属性不要在一个语句里同时改样式然后又读布局属性如果非要批量改样式先用 class 切换代替逐个修改 style 属性。第三批量 DOM 操作。用 DocumentFragment 先攒一批节点一次性挂到文档里而不是一条一条 appendChild。这个在老的 jQuery 时代是常识现在用框架的人反而容易忽略。4.2 渲染层优化从“每次全量重建”到“按需更新”这一层主要是针对框架层面的渲染优化。我以 Vue 和 React 为主说几个通用原则。Vue 里核心是避免多余的响应式更新。用 Object.freeze 冻结不需要响应式的数据可以避免 Vue 为这些数据做响应式代理。对于从接口拉回来的静态字典数据、长列表数据直接用 Object.freeze 包一层性能提升立竿见影。另一个是合理使用 computed 和 watch——computed 有缓存适合做派生数据的计算watch 适合做有副作用的操作。别把两者场景搞反了尤其是不要在 watch 里做重型数据处理或调用接口容易造成隐性的性能黑洞。React 里主要是用 React.memo 避免不必要的子组件重渲染用 useMemo 和 useCallback 缓存值和函数引用。但注意这些手段不是越多越好。滥用 useMemo 会让代码可读性变差而且 memo 比较本身也是有开销的。我的经验是只有当子组件渲染开销真的很大或者该组件在父组件频繁重渲染时才会被连带渲染才值得用 memo 优化。4.3 架构层优化用 Web Worker 把计算挪出主线程如果经过代码层优化后计算量还是非常大那就得走架构层路线了。核心思路是把那些不需要操作 DOM 的重计算任务搬到 Web Worker 里去执行。Web Worker 相当于给主线程找到一个外包公司——不需要你亲自干的活扔给外包公司干干完把结果送回来。这样主线程就腾出来了用户响应自然就快了。我之前做过一个数据清洗功能需要把服务端返回的几十万条打点数据做聚合、去重、排序。之前在主线程跑要卡 3 秒多迁移到 Web Worker 之后主线程完全不卡计算过程它自己异步去做完成后通过 postMessage 回报结果。页面体验直接从“卡死”变成“等着加载”。要注意的是Web Worker 里不能直接用 window、document 等 API所以只适合做纯计算逻辑。另外通过 postMessage 传递数据的时候会有结构化克隆的开销如果数据量特别大可以考虑用 Transferable Objects 转移 ArrayBuffer 的所有权这样不需要复制数据性能会更好。4.4 场景化优化懒加载、虚拟列表、骨架屏换数据处理如果问题出在特定的大数据列表或者图片资源上还有几个很对症的手段。懒加载适合图片懒加载、路由懒加载、组件懒加载。核心原则是“用到的时候才加载”。一张图片不加 loadinglazy浏览器无论如何都会把图片资源拉到本地加了这个属性之后浏览器只有在图片出现在视口附近才开始下载这样首屏的加载压力会小很多。路由懒加载在 Vue Router 和 React Router 里都有对应的动态导入写法直接把 component 参数改成 ( ) import(./xxx.vue) 就能实现。虚拟列表是我处理长列表的杀手锏。核心思想是无论你的数据有多长我都只渲染可视区域内那十几个节点滚动的时候通过计算 startIndex 和 endIndex 动态更新渲染的切片。几百条数据的时候可能没什么感觉一旦数据到上万条虚拟列表和普通渲染的性能差距可以达到几十倍。可视化大屏里面常用这种方案来处理高密度表格和日志流。骨架屏虽然没有直接提升页面响应速度但是它优化了用户对“无响应”的感知。用户看到骨架屏知道数据在加载中比面对一个白屏心里踏实得多。这个属于体验层的优化建议和数据请求配套一起做成本很低效果却不错。5. 真实案例复盘一个页面无响应是怎么一步步定位清楚并解决的前面讲了方法论可能有点散。我拿一个真实案例完整串一遍。这是一个 ToB 后台系统的订单列表页用户反馈是“页面用了大概 10 分钟后开始卡点击没有反应必须刷新页面才能恢复但恢复之后过一阵又卡死”。间歇性、周期性、必现这三个特征叠加在一起基本就锁定是内存泄漏或者资源没释放。5.1 复现与初检先用 Performance Monitor 做了排出我第一步不是立刻打开 Performance 做录制因为这种“用 10 分钟才卡”的问题录一次短操作根本录不到。我先把 Performance Monitor 挂在页面旁边同时跟用户确认了卡死的触发条件——只要开着页面不操作过几分钟就卡如果频繁点击按钮卡得更快。观察了大概 20 分钟发现页面 JS heap 曲线一路向上从 30MB 持续涨到 200MB 以上中间没有任何显著的回落。这时候基本可以确定有内容在持续生成且无法被垃圾回收。而且 DOM node 总数也在持续上涨。对象在累积内存不被释放这就说得通为什么页面会越用越卡了。5.2 源头追踪从堆快照里揪出 Detached Nodes接下来的定位手段是对比快照。我在页面空载状态下打了一次 Heap snapshot 作为基线然后在 5 分钟内我用脚本模拟了 50 次点击详情按钮、打开弹窗、再关闭弹窗的操作操作完再打一次快照。用 Comparison 视图对比两次快照发现 Detached nodes 增加了 300 多个节点每个节点都包含整个弹窗的 DOM 结构。顺着引用路径往下挖发现详情弹窗组件里绑定了一个全局的 bus 事件监听器监听名是 order-detail-refresh用来刷新弹窗里的订单状态。但弹窗关闭的时候只销毁了弹窗自身没有主动 off 掉 bus 上的监听器。等于每打开一次弹窗就在全局事件中心留了一个监听器监听器又引用了弹窗的 DOM 实例所以垃圾回收器一看这个 DOM 还有引用就不敢回收。弹窗越开越多Detached 节点就越积越多内存也就不断上涨。5.3 修复方案与结果修复方案很简单两条第一在弹窗关闭的 onClosed/onUnmounted 生命周期里主动调用 bus.$off(order-detail-refresh)把监听器解绑第二全局统一规范凡是使用全局事件总线或跨组件通信的事件必须在组件销毁时做对称清理。改完之后我重新跑了一遍同样的操作序列打开关闭弹窗 50 次再看内存曲线。Detached nodes 的数量基本保持平稳JS heap 也不再持续上涨长时间挂机观察 30 分钟之后内存曲线依然是一条水平的直线。用户再也没反馈过卡死的问题。一个值得注意的细节是这类问题修起来通常只要几行代码难的是定位的那一个多小时。这中间如果靠肉眼去翻代码是根本翻不出来的因为你不知道要往哪个方向找。所以我想强调一个观点排查页面无响应一定要在工具的帮助下做“数据驱动”的排查不要“缝缝补补靠猜”。6. 常见问题速查与避坑清单最后把我在多个项目里遇到的典型无响应场景整理成一个速查表方便你在排查的时候对照参考。现象特征最可能的原因优先排查方向点击按钮后整个页面卡住 N 秒主线程长任务同步计算量过大Performance 面板看长任务Self Time 排序页面越用越卡刷新后恢复内存泄漏Detached DOM 节点或事件监听器残留Memory 面板堆快照对比滚动列表时掉帧、卡顿滚动事件处理器过于频繁节流或把高频计算移到 Web Worker切换路由后白屏再切几次卡死组件卸载未清理定时器/事件/连接检查 onUnmounted清理长生命周期资源大数据表格渲染卡死DOM 节点数量爆炸重排重绘成本过高虚拟滚动、分页加载、按需渲染打开弹窗多次后页面变重全局事件总线监听器泄漏快照对比清理 $off视频或图片资源加载时卡死资源加载及解码占用大量内存带宽懒加载、压缩资源、控制并发加载数定时轮询接口期间页面变卡后台轮询任务过多或响应数据量过大检查轮询的取消逻辑考虑批量或条件轮询避坑方面我有几条原则想写在这里都是踩过坑之后总结出来的。第一不要相信“浏览器自己会优化”这种话。浏览器会优化很多东西但不会帮你优化一个外层循环里每读一次 offsetHeight 的代码。该主动优化就主动优化别指望浏览器替你兜底。第二不要只依赖生产环境的报错。页面无响应很少会抛 JavaScript 异常它是性能问题不是逻辑错误。这就意味着你不能靠错误监控平台来发现它。等用户来反馈的时候问题往往已经存在好几天了。如果你们项目线上数据量在增长最好定期自己开 Performance 面板测一下关键路径的性能。第三优化完之后不要只测一次就收工。性能问题有偶然性代码改了测试环境未必能复现。我一般的做法是修复之后连续跑三到五轮相同的操作序列记录每轮的耗时/内存曲线看趋势是否稳定。如果第二三轮又出现内存上涨那说明还有隐藏的泄漏点没有清理干净。第四警惕第三方库带来的隐性问题。有些组件库和图表库在实现上本身就比较重比如某些老版本的高版本 ECharts每次 setOption 都要做全量 diff。如果你的页面卡死发生在引入某个第三方库之后可以先用注释法把库临时去掉对比一下前后性能再决定是优化用法还是换库。7. 写在最后这个排查思路可以顺带走查一遍说实话页面无响应这个问题最难的永远是第一步——当你打开 DevTools 面对满屏的专业术语、一堆花花绿绿的火焰图的时候你真的不知道该把鼠标点在哪里。我自己一开始也这样后来发现只要先记住一句话无响应等于主线程被堵住堵住主线程的只有三种可能——长任务、内存异常、渲染开销爆炸。然后按顺序去 Performance 面板找长任务再去 Memory 面板看内存曲线最后再回到代码里对着长任务和内存快照做定点优化。把这三个步骤记熟基本能覆盖 80% 以上的场景。再分享一个小技巧排查之前先给页面做一次“最小化复现”。把无关的组件、分支逻辑先去掉只保留最可能出问题的链路。我见过很多人在一个十几 MB 的项目里到处找Bug其实只要把最小链路抽出来问题瞬间就暴露了。这比任何工具都管用。最后提醒一句如果你负责的项目是那种数据量会持续增长的后台系统建议把性能排查也纳入日常开发流程。不要等用户说“页面很卡”再排查那样你大概率已经背上了一个历史包袱。定期的性能自测、给开发规范里加上“清理事件监听器”“避免强制同步布局”之类的红线能帮你省下无数个加班的夜晚。
返回列表