ARTICLE DETAIL

资讯详情

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

页面无响应问题排查与性能优化实战指南

页面无响应问题排查与性能优化实战指南 “页面无响应”这个事做前端久了基本都会碰到。最气人的不是它偶发一次而是你根本没法稳定复现用户那边隔三差五来一句“页面又卡死了”你这边打开DevTools怎么操作都好好的。一旦出现这种情况整个项目组都会陷入被动运营催、测试催、领导也催最后压力全堆到前端头上。我前阵子刚处理完一个线上的类似问题从接到反馈到最终修复前后折腾了差不多一周今天把整个排查思路、工具用法和优化方案整理出来希望能帮遇到同样问题的人少走点弯路。这篇文章我会围绕“Web页面频繁无响应”这个主题讲清楚三个层面的东西第一怎么区分不同类型的无响应建立自己的排查框架第二用什么工具、在什么时机去采集证据别靠肉眼猜第三定位到根因之后有哪些行之有效的优化手段。内容偏实战适合有一定前端基础、但遇到性能问题还不太知道怎么下手的同学也适合准备前端面试时想拿“性能优化”这个点讲出深度的人。1. 先搞清楚“页面无响应”到底属于哪一类很多人一听到“页面无响应”第一反应就是“主线程被阻塞了”然后赶紧打开Performance面板去录一段结果录了半天什么也没抓到。这是因为“无响应”其实是一个很笼统的说法它可能对应好几种完全不同的底层现象而不同现象对应的排查路径是完全不一样的。1.1 把现象拆开看白屏、卡顿、假死和崩溃我习惯把“无响应”拆成四类来看第一类是白屏。页面打开后一片空白或者某个路由跳转后内容区域空白用户点哪里都没反应。这种情况通常是JS执行报错、资源加载失败、或者渲染链路断裂导致的。第二类是卡顿。用户能操作但点击之后要等很久才有反馈滚动页面像幻灯片一样一顿一顿的。这种情况一般是主线程上连续执行了太多同步任务浏览器来不及渲染每一帧。第三类是假死。页面还停留在上一个画面但鼠标点击、键盘输入全部失效标签页标题变成“无响应”过几秒又恢复或者一直恢复不了。这种情况是主线程被一个超长任务完全占住了浏览器的事件循环转不过来连渲染都没机会执行。第四类是崩溃。标签页直接变成“页面崩溃”提示刷新都没用。这种情况往往是内存占用过高、渲染进程直接被杀掉。从排查难度来讲卡顿最容易录到证据白屏其次假死最坑的是那种“偶发几秒又恢复”的情况崩溃则需要重点看内存曲线。1.2 建立自己的排查框架先看现象再定方向我在处理这类问题的时候会先跟反馈人确认几个关键信息发生频率次/天、发生场景首屏打开还是操作中、持续时长秒级还是永久、浏览器和系统版本、是否必现。这些信息看起来简单但如果第一个反馈人说“经常无响应”就埋头开干很容易被偶发性带到沟里去。以前我用过一个笨办法让用户下次卡住的时候按一下刷新键问“刷新后能不能恢复”再问“卡住之前最后做的一个操作是什么”。这两个问题的答案往往能直接缩小排查范围。如果是刷新后恢复正常基本可以排除硬件和浏览器层面的问题重点考虑代码里的资源占用或请求挂起如果是某个特定操作后必现那就直接审查那个操作的实现代码。判断完现象和触发场景之后再去选工具就会很有针对性。比如卡顿类问题重点看Long Tasks和FPS假死类问题重点看主线程时间轴上的长任务崩溃类问题重点看Memory面板的内存曲线和堆快照对比。2. 定位工具链别靠肉眼猜让数据说话定位“页面无响应”最忌讳的就是凭空猜。这里先分享一个真实例子我之前有个同事排查页面卡死他怀疑是某个第三方库的Bug花了三天时间翻那个库的源码最后发现根因根本不是这个库而是自己业务代码里一个无意间写出的无限循环。所以工具和数据永远比感觉靠谱。2.1 Performance面板的正确打开方式用Performance面板录制的核心操作其实不复杂打开DevTools切到Performance面板点击录制按钮然后让页面执行出那个“无响应”的操作再点停止。录制完成后主线程的时间轴图会清晰地展示每一帧的工作状态。我自己的习惯是这样的录制前清一下浏览器缓存控制变量如果页面是首次打开就出问题就从刷新前开始录制如果是操作中出问题就先把页面恢复到出问题前的状态再开始录。录制时间控制在10到20秒太短抓不到长任务太长数据冗余反而难分析。停止录制后我优先看三个东西第一个是红色的“长任务”标记。Chrome会直接把超过50毫秒的任务标红这些就是阻塞用户交互和渲染的元凶。点开标红的任务块能直接看到这个任务在哪个脚本、哪个函数上消耗了最多时间。第二个是FPS曲线。如果录制期间FPS频繁掉到20以下说明渲染链路有问题。FPS低不一定是JS的问题也可能是样式计算、图层合并导致的。第三个是“Summary”板块里各类型时间占比。如果Scripting占比超过60%那JS计算就是瓶颈如果Rendering和Painting占比高就要去检查CSS和图层策略。为了抓偶发的假死问题我还会开Performance Monitor面板它是实时监控CPU占用、JS堆大小和DOM节点数量的。设置成常驻窗口等页面卡住的那一瞬间赶紧切过去截图能看到卡死前CPU是不是100%、堆内存是不是一直在涨。2.2 用Performance API做自动监控手动录制只能解决“能复现”的问题线上偶发问题靠人工盯着根本不现实所以我把Long Tasks的监控代码直接埋到了项目里。核心是利用浏览器的PerformanceObserver接口去监听长任务事件代码很简单const observer new PerformanceObserver((list) { for (const entry of list.getEntries()) { if (entry.duration 100) { // 上报到日志平台记录时长和来源脚本 report({ type: long-task, duration: entry.duration, startTime: entry.startTime, name: entry.name || , attribution: entry.attribution || [] }); } } }); observer.observe({ entryTypes: [longtask] });这里有一个细节Long Task API返回的attribution里包含TASKS.SCRIPT_URL这样的属性能直接告诉你是哪个脚本文件执行超过了阈值这对定位第三方脚本导致的主线程阻塞非常有帮助。我在实际项目里就把好几个广告SDK的脚本录出来了实锤是他们的问题。除了Long Task我还会用performance.getEntriesByType(navigation)去采集页面加载阶段的各阶段耗时把DNS查询、TCP连接、请求响应、DOM解析、脚本执行这几个关键耗时数据上报到监控平台。这样每次页面打开耗时多少、哪一段耗时异常都有数据支撑不用等用户反馈。另外我强烈推荐所有前端团队都去用一下浏览器自带的“任务管理器”。它不是DevTools里的那个Memory面板而是浏览器右上角菜单里的“更多工具-任务管理器”。它能实时显示每个标签页的CPU占用率和内存占用页面卡住的时候这个标签页的CPU如果飙到100%以上那你基本可以确定问题出在主线程计算密集或者有死循环如果CPU不高但内存一直涨那方向就要转向内存泄漏。这个工具在真机模拟和线上问题排查时效率特别高很多新手都不知道它的存在。3. 深挖根因常见导致页面无响应的五类元凶排查工具掌握之后下一步就是识别根因。我接手过不少“无响应”类问题总结下来绝大多数都跑不出下面五类原因对号入座就能省下一大半时间。3.1 主线程被长任务霸占同步计算和大数据处理这是最常见的一类。页面主线程本来就要承担事件处理、样式计算、渲染、布局、垃圾回收这些活一旦一个同步任务占了超过某个时间片段浏览器的整体响应就会变得不跟手。如果这个任务超过一两秒页面就直接进入“无响应”状态。我处理过一个数据可视化的项目每次筛选数据都会卡死。最后定位到问题出在一段对两三万条数据进行多层filter和sort操作上。每次筛选都同步处理还要同时生成若干个图表配置对象主线程直接被打满。这种场景的优化思路我后面会详细讲核心就是两条把大任务拆成小任务或者把重计算挪到Worker线程。判断一段代码是不是长任务可以手动看执行时间但更推荐用Performance面板录制直接看标红的区块和对应的函数名。如果函数名是被压缩过的比如上线后的代码记得在Sources面板里开启Source Map才能定位到源码。3.2 渲染层频繁重排重绘布局抖动和样式风暴页面“无响应”和“卡顿”经常是叠加出现的其中渲染层的频繁重排重绘是很重要的一环。有些操作本身不算重但如果在一个高频触发的流程里反复触发强制同步布局整个页面就会变得非常卡。最典型的反模式是在循环里不停地读offsetWidth、clientTop这样的布局属性然后立刻修改样式。浏览器为了返回正确的布局值只能中止当前的样式计算和布局流程强制做一次同步重排这就是强制同步布局也叫布局抖动。一两个还好循环里调几千次渲染线程直接崩溃页面表现为滚动卡成PPT、点击延迟几秒。3.3 内存泄漏堆内存越涨越高最终引发渲染进程崩溃内存泄漏导致的“无响应”有一个显著特征页面刚打开的时候是好的用着用着越来越卡最后点什么都没反应严重时标签页直接崩溃。这种问题你录Performance面板还不够得结合Memory面板的堆快照对比来排查。常见的泄漏源头有几个没有清理的setInterval或setTimeout定时器、没有被解绑的DOM事件监听器、全局变量引用、闭包意外持有的大对象、以及Vue或React里没有正确销毁的组件实例和watcher。我见过一个真实案例项目里用addEventListener注册了滚动监听组件销毁时忘了removeEventListener导致每次进出页面都多一份监听器DOM节点和监听器越来越多最终页面在低端机上彻底卡死。排查内存泄漏我的建议是在Memory面板里连续采集三次堆快照加载完成时取一次、操作一段时间后取一次、触发垃圾回收后再取一次。对比这三份快照如果一些对象的实例数只增不减那就是泄漏点。另外Chrome的Heap Snapshot里还支持按“Detached”来筛选被分离的DOM节点这些节点从文档中摘除了但依然被JS引用着是最典型的泄漏对象。3.4 网络层挂起有响应框但请求永远在pending还有一种“无响应”比较迷惑页面本身没死按钮也能点但所有请求都pending界面一直处于加载状态。用户感知就是“点哪儿都没反应”。这类问题最常见的原因是后端接口过慢或者某个接口被并发请求拖垮前端没有做超时控制。比如页面上同时挂了十几个接口其中有一个耗时30秒而这个接口阻塞了后续的关键内容渲染用户看到的就是一大片空白转圈圈转到怀疑人生。前端要做的优化一方面是把必要的请求和可延后的请求分开核心链路先加载非核心模块异步加载另一方面要给所有请求设置合理的超时时间配合统一的错误处理和重试策略。还有一个容易被忽视的坑是Service Worker如果你项目里注册了Service Worker一旦它的缓存策略有问题会让某些请求永远处于pending状态表现上跟“页面无响应”一样。我查过一个项目就是因为Service Worker的回调里Promise一直没resolve导致页面请求全部挂起。3.5 无限循环和异常递归代码级Bug瞬间压垮页面最后这一类属于硬核Bug型不是数据大也不是渲染复杂而是代码写错了。最常见的是while循环条件写反、递归没有出口、或者一些不自知的高频调用。比如我在项目里遇到过一个问题某个watch里监听了数据变化然后又修改了同一份数据触发下一次watch形成了一个无限循环Vue还给了警告“You may have an infinite update loop in a component render function”但很多人忽略了。这种循环往往在几秒内就能让CPU冲到极限页面直接假死。排查这种问题最快的办法是看Performance面板里长任务的调用栈如果在某个函数里反复循环跳不出来调用栈上看得很清楚。也可以用Node.js的--inspect调试远程定位但前端场景还是DevTools性能录制更直接。4. 优化方案落地从一个长任务到一个顺滑页面通过前面几步找到根因之后就到了真正动手优化的阶段。这一章我会把高频用到的优化方案拆开讲每一类都可以直接“抄作业”式落地。4.1 拆解大任务时间切片和时间窗口“时间切片”的核心思想是把一个超过50毫秒的同步长任务拆成多个小于50毫秒的短任务让浏览器有机会在每个任务间隙去响应事件和渲染页面。这样用户的点击不会等待太久页面看起来就“有响应”。最简单的一种拆分方式是用requestAnimationFrame分档处理。比如要循环处理两万条数据可以每帧只处理一小部分代码大致是这样的function processLargeArray(items, processFn, batchSize 500) { let index 0; function nextBatch() { const end Math.min(index batchSize, items.length); for (; index end; index) { processFn(items[index]); } if (index items.length) { requestAnimationFrame(nextBatch); } } requestAnimationFrame(nextBatch); }这里用frames作为时间窗口每执行完一批就让出主线程让页面能处理用户事件和渲染。如果任务不那么紧急可以用requestIdleCallback来执行专门利用浏览器的空闲时间片避免阻塞关键交互。requestIdleCallback还带一个超时参数可以控制它最多延迟多久“必要时可以等但不能无限等”。4.2 计算密集型任务交给Web Worker如果数据处理逻辑非常重比如解析大JSON、复杂计算、图片处理、数据转换前端的主线程真的不合适。这种场景最好把计算任务放到Web Worker里Worker运行在独立的线程不会阻塞UI渲染。一个完整的Worker用法是这样的主线程里通过new Worker(...)建一个子线程用postMessage传参用onmessage接收结果。Worker内部跑完计算后再把结果postMessage回主线程。核心代码如下// main.js const worker new Worker(/workers/dataProcessor.js); worker.onmessage (e) { const result e.data; // 拿到计算结果再去更新渲染 renderList(result); }; worker.onerror (error) { console.error(Worker error:, error); }; // 把原始数据发给Worker worker.postMessage({ rawData, config }); // dataProcessor.js self.onmessage (e) { const { rawData, config } e.data; const result heavyProcess(rawData, config); self.postMessage(result); };这里要提醒两件事第一Worker里拿不到DOM和window只能做纯计算第二postMessage传递数据时会有结构化克隆的开销如果数据特别大几十MB级别能传引用而不是全部拷贝就传引用比如用Transferable对象。另外需要长驻的Worker记得在某个时机terminate()不然内存管理也是个问题。4.3 大数据列表虚拟滚动是刚需列表渲染是前端性能问题的高发区。如果一个页面要展示上千条、上万条数据最简单的v-for或者map生成DOM都会导致DOM节点爆炸首屏渲染就要很久滚动起来更新也很吃力。用户操作起来就会明显感觉“页面无响应”。虚拟滚动的核心思路是不管总数据量有多大只渲染可视区域内的那几条DOM。监听滚动事件动态计算当前应该显示的数据片段用一个上下偏移把真实滚动高度模拟出来。市面上现成的库有vue-virtual-scroller、react-window等但我更推荐理解原理后自己写一个轻量的因为在复杂业务里往往需要定制。核心思路很简单容器固定高度内容区的高度等于总条目数乘以每条目高度用来撑出滚动条根据容器的scrollTop计算出可视区域起始索引startIndex Math.floor(scrollTop / itemHeight)再根据容器高度算出endIndex真正渲染的只有startIndex到endIndex之间的那部分数据再绝对定位到正确位置。几百行代码就能实现一个够用的版本性能提升是数量级的从几万个DOM节点直接降到几十个。滚动的时候要注意配合防抖或requestAnimationFrame降低计算频率不然监听器本身又是一个新的性能负担。4.4 防抖和节流高频事件的统一解法scroll、resize、mousemove、input输入这类高频事件如果handler里做的事情比较重都会给主线程增加大量压力。这里防抖和节流的选用有一条经验法则如果目标事件是“用户停止操作后再做一次”用防抖如果目标操作是“即使高频触发也要按固定频率执行”用节流。一个简单的节流函数示例function throttle(fn, limit 100) { let inThrottle false; return function(...args) { if (!inThrottle) { fn.apply(this, args); inThrottle true; setTimeout(() { inThrottle false; }, limit); } }; } window.addEventListener(scroll, throttle(onScroll, 100));防抖函数核心是clearTimeout然后再setTimeout也是十几行就能实现。不过我想强调的是不要为了用防抖而防抖而是要先量化一下handler到底消耗了多少资源。如果只是一个简单的样式切换防抖反而会降低交互响应速度得不偿失。4.5 渲染优化避免强制同步布局和长链式样式更新要想页面不卡渲染层的优化同样重要。首先要规避在布局信息读取和样式修改之间反复切换的问题。正确的做法是先批量读取布局信息再批量修改样式把读写分离。用一个例子说明// 不良写法循环里读一次改一次每次都触发同步布局 for (let i 0; i items.length; i) { const width item.clientWidth; // 读 item.style.width width 10 px; // 写 } // 优化写法先统一读再统一写 const widths items.map(item item.clientWidth); items.forEach((item, i) { item.style.width widths[i] 10 px; });另外引起大面积重排的样式属性尽量避开。比如修改width、height、left、top这些会触发布局的属性如果动画只需要视觉变化尽量用transform和opacity代替它们能走合成线程不触发重排和重绘。如果页面里有复杂的固定背景或遮罩层可以设置will-change属性让浏览器提前优化但不要滥用否则内存占用也会增加反而弄巧成拙。4.6 缓存与资源加载让页面最快进入可交互状态从资源加载角度说页面无响应也经常和首屏加载时间过长有关。用户在页面白屏的那几秒里反复点击页面还没有绑定事件用户的感觉就是“没反应”。针对资源加载我会从三个层面去优化第一代码分拆把首屏不需要的代码放到动态import里减少初始JS体积第二利用浏览器缓存策略对不常变的静态资源和接口CDN做合理缓存第三关键渲染链路上的CSS同步加载非关键的CSS、JS全部异步或者延迟加载。这部分要特别注意一个反向优化的坑缓存设置太久用户更新不到新版页面会反馈“页面做这么烂点了都没反应”。这种情况不是性能问题而是缓存策略和前端资源版本管理脱节了。我在nginx部署多个前端项目时就专门为不同项目设置了不同的资源路径前缀配合文件指纹文件名加hash更新保证用户既能享受缓存提速又能及时拿到新版本。这里可以看几个核心的nginx配置思路location /project-a/ { alias /data/www/project-a/; try_files $uri $uri/ /project-a/index.html; location ~* \.(js|css|png|jpg|svg|woff2?)$ { expires 30d; # 带hash的静态资源可以放心长缓存 add_header Cache-Control public, immutable; } } location /project-b/ { alias /data/www/project-b/; try_files $uri $uri/ /project-b/index.html; location ~* \.html$ { add_header Cache-Control no-cache; # html入口不做长缓存 } }不明白Cache-Control的人很容易把“缓存”和“无响应”搞混。拿到一个“页面无响应”的反馈先确认一下是渲染问题、数据加载问题还是缓存策略问题这一步很关键。5. 一次完整实战线上偶发卡死的定位与修复记录理论讲了不少我再用一个实际处理过的案例把从接需求到一次修复的全流程串一遍。这个案例是一个后台管理系统技术栈是Vue Element Plus用户反馈在某个低配Windows电脑上打开某条列表数据详情页之后页面频繁进入“未响应”状态每次持续几秒到十几秒不定运气好能自己缓过来运气差直接白屏。5.1 第一轮采集信息确认触发条件我接到反馈后没有直接动代码先远程到用户的电脑上看了一下现场。排查后确认了几个关键信息只在Windows上的Chrome低版本复现打开详情页之后点击左侧菜单切换路由时必卡页面停留越久卡死概率越高刷新后能恢复正常但再点进详情页又会复发。这个信息里有几个关键词给了我方向“切换路由时必卡”意味着路由切换这个动作触发了某个重操作“停留越久概率越高”意味着可能有状态在累积或者内存占用越来越高“刷新能恢复”说明不是浏览器层面的彻底崩溃而是运行时资源耗尽。当时我顺手打开了浏览器自带的任务管理器页面卡住的时候看到Chrome的CPU占用率在120%到300%之间疯狂波动。到这里我可以基本锁定问题是主线程任务过重。5.2 第二轮性能录制找到具体长任务接着我在本机开始复现。先在DevTools里打开Performance面板开始录制然后模拟用户一路进入详情页再切菜单10秒后停止录制。时间轴上一片红色长任务最长的几个任务耗时在1200毫秒到3000毫秒不等。点开最长的那个任务调用栈指向了一个组件内的computed属性函数名字被压缩过但顺着Source Map就能定位到源码。这个computed做的事情是把详情页里的一个几千条元素的数组按若干维度做筛选、排序、再组装成树形结构。按设计它只在数据变化时重算一次但实际上详情页里还有一个定时器每5秒去轮询一次后端接口刷新数据而轮询回来的新对象会被直接赋值给响应式数据于是computed频繁触发每次都要重新算一遍几千条数据的树形结构主线程被彻底拖垮。定位到问题之后我给这次问题定了性业务定时轮询触发了全量重算复杂度是O(N^3)级别的多循环嵌套加上数据量本身不小导致单次计算超过1秒。路由切换时又把旧的组件销毁、新组件渲染叠加在一起长任务就更加明显。5.3 第三轮分步优化最终稳定平滑修复分了三步走。第一步把轮询数据的更新方式从“整体覆盖”改成“按需合并”。后端返回的新列表先跟当前列表做差异对比只有真正变化的字段和条目才触发响应式更新。这里用一个Map以ID为键做映射判断前后数据是否相同相同就跳过赋值。这个改动直接让computed的触发频率降低了95%以上。第二步把树形结构的组装从computed里拿出来。因为这个计算确实是页面展示依赖的不能省但计算成本太高所以我把它改成了懒计算加缓存只有用户真正展开某棵子树的时候才去计算那一层的结构并且用缓存存储已展开的节点避免重复计算。这一步把单次计算时间从1000毫秒降到了180毫秒左右。第三步给渲染层加上虚拟滚动。详情页里展示树形数据的区域一次最多只渲染可视区域内的节点配合展开状态做局部更新。这一步看着是渲染优化其实是给整体交互“松绑”即使数据再多一些也不至于重新回到卡顿状态。优化完之后我又回到用户那台低配电脑上实测。优化前打开详情页然后点菜单平均等待3到5秒才有反应优化后点击菜单到新的页面渲染出来基本稳定在200到300毫秒CPU占用率从峰值300%降到60%以下。后来再也没收到过这个页面的“无响应”反馈。整个过程走下来我的体感是定位这类问题核心不是会多少API而是能否一步一步缩小范围用数据做决策。Performance录制定位长任务、任务管理器看CPU、Memory对比堆快照这三板斧足够应付90%的页面无响应问题。现在哪怕接到的反馈只有一句“页面卡了”我也会先把这三个工具顺手开起来等下次再犯的时候就能抓到现场后面的事情就好办多了。再分享一个小技巧解决完一个问题之后不要急着关掉DevTools。顺手把这次的复现路径、长任务截图、优化前后数据对比整理到团队的wiki或文档里。以后再遇到类似问题先搜文档很多坑前人已经替你踩过了。另外如果有余力可以把5.2里那个PerformanceObserver的长任务监控埋到生产环境里设置合理的上报阈值这样线上问题就不再完全依赖用户反馈了你甚至能在用户感知之前就发现隐患。
返回列表