“前台页面卡顿”这四个字,我在不同团队里听过不下几十遍。用户反馈的原话基本都差不多:点一下要等半天、滚动的时候一抖一抖、输入框打字跟不上手、切个标签页回来整个界面像被冻住。可真让开发去看,打开自己那台内存充足的电脑,页面丝滑得像抹了油,什么问题都复现不出来。卡顿排查最难的地方从来不是“改”,而是先证明它确实卡、再定位它卡在哪一层。这两步走不通,后面所有的优化都是在猜。
这篇内容面向的是需要真正上手排查前端页面卡顿的人:可能是刚接手一个老项目的前端,可能是被临时拉来处理性能问题的全栈,也可能是要给出结论的技术负责人。我会把“卡”这件事拆成可量化的信号、可复现的取证手段、可验证的修复路径,中间夹杂一些我自己踩过的坑和判断经验。不涉及具体业务,方法通用。
1. 用户说“卡”,你得先问出是哪种卡
1.1 四种体感差异,对应四条完全不同的排查路径
很多人拿到“页面卡”这三个字就开始看代码,这是最容易白干一整天的做法。卡顿是一个结果,它的成因至少分成四类,每一类的排查入口完全不同。
第一类是加载卡。表现是打开页面先白屏或者骨架屏挂很久,然后内容“啪”一下全出来。这一类的问题几乎都在网络层和首屏渲染策略上:资源体积、请求瀑布、关键渲染路径阻塞、接口串行。你去翻主线程火焰图,往往看不出太大问题,因为那时候主线程大部分时间在等。
第二类是交互卡。表现是点击、输入、切换标签时有明显延迟,手指按下到界面反馈之间隔了几百毫秒。这类问题的根因是主线程被长任务占住了,事件回调排在队列后面排队。查的方向是长任务、事件处理函数里的同步计算、强制同步布局。
第三类是滚动和动画卡。表现是滑动列表一顿一顿,动画像在跳帧。这类要往渲染流水线里查:是不是某些属性改动触发了布局、是不是合成层太多导致内存压力、是不是滚动事件里绑了重活。
第四类是越用越卡。刚打开很快,开了三五个页面、切了几轮数据之后开始变钝,最后可能直接崩。这一类基本锁定内存问题:游离 DOM、未解绑的监听、只增不减的缓存、定时器泄漏。
把这四类分清楚,排查范围能立刻缩小八成。我习惯在接到反馈时先问三个问题:哪个页面、做了什么操作、用什么设备和网络。这三个答案基本能决定我第一步是开网络面板还是开性能录制。
1.2 把主观感受换算成可采集的数字
体感是模糊的,指标是硬的。排查之前得先约定一套“卡”的判定标准,否则改完之后没法证明有效。
| 体感现象 | 对应指标 | 采集方式 | 参考阈值 |
|---|---|---|---|
| 滑动掉帧、动画不连贯 | 帧间隔、掉帧率 | 逐帧打点计算间隔 | 单帧超过 16.7ms 即为丢帧,连续超过 50ms 体感明显 |
| 点击后无反馈 | 交互延迟 | 事件触发到下一帧绘制的时间差 | 200ms 以内良好,超过 500ms 明显卡 |
| 主线程被占住 | 长任务数量与时长 | 长任务观察接口 | 单个任务超过 50ms 即为长任务 |
| 首屏空白久 | 首次内容绘制、最大内容绘制 | 性能条目采集 | 首屏 2.5s 内为佳 |
| 越用越卡 | JS 堆大小、DOM 节点数 | 采样打点 | 堆持续上升不回落即为可疑 |
这张表的意义在于:它把“我觉得卡”变成了“我在什么条件下掉了多少帧”。后面无论是说服产品经理还是验证优化效果,都靠它。
需要注意一点,阈值不是绝对的。同样掉 3 帧,在低端安卓机上用户感受是“卡死”,在高配笔记本上用户根本无感。所以判定标准必须绑定设备档次,这一点后面会专门说。
1.3 复现不出来,先怀疑自己的环境太干净
90% 的“我这边不卡”,都是因为排查环境和用户环境差太远。你的开发机可能是十几核处理器、三十二G内存、千兆内网、资源全在本地缓存里;用户的设备可能是四年前的入门安卓机、信号两格、第一次访问、缓存全空。
我自己的习惯是,只要涉及卡顿反馈,一律先在开发者工具里把 CPU 降速调到 4 倍或 6 倍,网络限制到 4G 甚至 3G,再关闭缓存,然后才点录制。很多在本地“完全正常”的页面,一降速问题立刻现形,甚至不用看代码就能判断方向。
提示:录性能轨迹之前,先把浏览器装的各种插件临时关掉。我见过不止一次,真正的元凶是一个翻译插件或者去广告插件在页面里注入脚本,导致每个滚动帧都在重排。关掉插件问题消失,白白查了两天。
2. 抓现场:录制、采样、埋点三条取证路径
2.1 能复现的,优先录一段可回放的性能轨迹
性能面板是排查卡顿的第一把武器,但很多人录出来的东西没法用,因为录制方式不对。我总结几个关键动作。
第一,先开降速再开录。降速开关要在录制开始之前打开,否则录制期间切换会影响采样。
第二,录制前把操作脚本想清楚。不要漫无目的地录十几秒,只录你要排查的那一段:从点击到结果出现,或者从开始滚动到停下。理想时长控制在 5 到 10 秒,太长了火焰图密密麻麻根本看不清。
第三,勾上截图和内存。截图能让你把火焰图里的某一段和界面状态对上号,内存曲线能看出有没有峰值和回收。这两个选项会让录制稍微变慢,但换来的是可解释性,非常值。
第四,录两遍:一遍缓存清空,一遍缓存命中。很多页面在缓存命中时飞快,第一次访问却要等好几秒。这两份数据要分开看,否则优化方向会跑偏。
录完之后不要急着看火焰图,先看顶部的概览条:如果大部分时间被标成红色,说明主线程长期繁忙,那问题八成在脚本;如果大片空白但界面就是不出来,那问题在等待,去看网络。
2.2 线上抓不到开发者工具,就用代码自己采样
更多时候问题只发生在用户那边,你的机器上永远复现不了。这种情况得靠自己打点上报。不用打得太复杂,两段代码就能覆盖大部分场景。
第一段是长任务监听,主线程被谁占住了,一眼能看出来:
if ('PerformanceObserver' in window) { try { const observer = new PerformanceObserver((list) => { list.getEntries().forEach((entry) => { if (entry.duration > 80) { report('longtask', { duration: Math.round(entry.duration), startTime: Math.round(entry.startTime), page: location.pathname }); } }); }); observer.observe({ entryTypes: ['longtask'] }); } catch (e) { // 部分浏览器不支持该类型,静默降级 } }第二段是自己算帧间隔,掉帧率是最贴近体感的指标:
let lastFrame = performance.now(); let dropCount = 0; let frameCount = 0; function tick(now) { const gap = now - lastFrame; frameCount++; if (gap > 32) { dropCount++; } lastFrame = now; requestAnimationFrame(tick); } requestAnimationFrame(tick); // 每 10 秒上报一次,采样率控制好,别把上报本身变成负担 setInterval(() => { if (frameCount > 0) { report('fps', { total: frameCount, dropped: dropCount, page: location.pathname }); frameCount = 0; dropCount = 0; } }, 10000);这两段代码有几个必须注意的地方。上报一定要用低优先级的方式发出去,别用同步请求,别在回调里做序列化大对象,否则你测出来的卡顿有一部分是你自己造出来的。另外采样率要有控制,全量上报在用户量大的业务里会变成新的性能隐患,我一般按 5% 到 10% 的比例抽样,或者只在页面停留超过一定时间后才开始采集。
2.3 设备与网络条件必须和用户对齐
有一种卡顿特别坑人:所有指标都正常,但用户就是说卡。这种情况十有八九是设备档次问题——你的指标是在高端机上采的,用户的机器只有两三个 G 内存,主线程随便一个普通任务在它上面就变成长任务。
处理办法是在打点里带上设备信息,按设备档次分组看数据。同一段代码在高配机上平均帧间隔 16ms,在低配机上可能是 40ms,平均值一拉通就什么都看不出来了。分组之后你会发现某些版本的崩溃率、掉帧率集中在特定机型上,这时候排查目标就非常明确。
网络同理。接口响应在办公室内网是 50ms,在弱网环境可能是 3 秒。我在排查顺序上有个固定习惯:先排网络,再排渲染,最后排计算。因为网络问题最快验证,也最容易被误判成前端卡。
3. 火焰图怎么读:主线程上到底谁在挡路
3.1 先认清主轨道上的几类色块
打开录制结果,最上面几行是概览,中间是各个轨道,最下面是详情。真正要盯的是主线程那一条。
主轨道上会出现几类东西:脚本执行、样式计算、布局、绘制、合成、垃圾回收、还有空白的空闲时间。它们的区别很关键。
如果一大块时间被脚本占着,而且是那种一长串嵌套调用的形状,说明是 JS 计算密集,往下钻找具体函数。如果时间花在样式计算和布局上,而且反复出现,那是渲染层面的问题,多半是读写交替或者元素数量太多。如果出现规律的、短促的、间隔均匀的小块,还伴随内存曲线下降,那是垃圾回收,说明对象创建太频繁。如果大片空白但界面不动,那是主线程在等异步结果,去看网络轨道。
我见过最常见的误判是:看到主线程有大块空白就以为没问题,实际上那不是空闲,是主线程在等接口,用户看到的正是这段时间的白屏或转圈。所以空白不等于没事,得结合界面截图一起看。
3.2 从长任务倒推调用栈,用自底向上视图更快
火焰图的自顶向下视图适合看整体结构,但定位具体函数时,自底向上视图效率更高。它会按函数聚合,直接告诉你哪个函数自己消耗的时间最多。
判断标准是看两个值:自己消耗的时间占总耗时的比例,以及被调用的次数。有时候某个函数单次执行只要 2ms,但被调用了三千次,累计就是 6 秒,这种东西在自顶向下视图里根本看不出来,在自底向上视图里一眼就能看到。
我排查过一个列表渲染的问题,最后定位到一个日期格式化函数。单次耗时不到 0.1ms,看起来完全无害,但列表里每一行调用了七八次,一屏三百行就是两千多次,光这一个函数吃掉了一百多毫秒。这种问题只能靠聚合视图发现。
3.3 强制同步布局:最隐蔽也最常见的一类
有一类问题在火焰图上表现为布局和脚本反复交替出现,像斑马纹一样。这叫强制同步布局,也常被叫做读写交替。
原理不复杂:浏览器本来会把样式修改攒起来,等到一帧的最后统一计算布局,这样效率最高。但如果你在修改样式之后立刻去读一个依赖布局的属性,浏览器为了给你准确的返回值,不得不马上把之前攒的所有修改全部结算一遍。你每读一次,它就重算一次。
// 反面写法:每次循环都触发一次布局重算 const items = document.querySelectorAll('.item'); for (let i = 0; i < items.length; i++) { const height = items[i].offsetHeight; // 读,强制结算 items[i].style.height = height + 10 + 'px'; // 写,污染状态 } // 正面写法:先集中读,再集中写 const heights = []; for (let i = 0; i < items.length; i++) { heights.push(items[i].offsetHeight); // 只读 } for (let i = 0; i < items.length; i++) { items[i].style.height = heights[i] + 10 + 'px'; // 只写 }会触发强制布局的读取操作包括读取偏移量、滚动位置、计算后的样式、元素尺寸等。写操作包括改宽高、改内边距、改字体、增删节点、改类名。这两类操作只要在同一个循环里交替出现,性能就会指数级下降。
注意:现代框架的响应式更新有时候会在你不知情的情况下触发读写交替。比如在渲染过程中读取了某个元素的尺寸,框架又紧接着根据这个尺寸去改样式。排查这类问题必须看火焰图,光读代码看不出来。
3.4 别忽略时间轴上的“空隙”
火焰图上经常出现这样的情况:主线程看起来没有被长任务占满,整体很“碎”,但用户还是觉得卡。这时候要看帧的节奏。
理想情况下,每一帧的间隔应该稳定在 16.7ms 左右。如果你看到帧间隔忽长忽短,一会儿 10ms 一会儿 45ms,那就是节奏被打乱了。这种“抖动”对体感的影响往往比持续性的慢更大,人眼对不均匀的运动特别敏感。
造成抖动的原因通常是多个中等长度的任务分散在各帧里,每个都不到 50ms 不构成“长任务”,但加起来把每帧的预算吃掉了。这种情况下长任务监听是抓不到的,必须靠帧间隔打点。
4. 渲染链路上的四道关卡:样式、布局、绘制、合成
4.1 改哪个属性,决定要重走几道关
浏览器把一帧的渲染拆成几个阶段:计算样式、布局、绘制、合成。你改的属性不同,需要重走的阶段数量就不同,代价差好几倍。
| 改动的属性举例 | 重走阶段 | 代价 |
|---|---|---|
| 宽高、内外边距、字体大小、定位偏移 | 布局 + 绘制 + 合成 | 最高,整棵树可能要重排 |
| 背景色、边框色、阴影、可见性 | 绘制 + 合成 | 中等 |
| 变换、透明度 | 合成 | 最低,可交给合成线程 |
这张表是性能优化的常识,但真正落到代码里,很多人还是习惯用定位偏移做动画,用改宽度做进度条。这些写法在低端机上就是灾难。
我自己的原则很简单:只要是每帧都在变的东西,只允许改变换和透明度。用变换做位移和缩放,用透明度做淡入淡出。所有会触发布局的属性,一律不进动画循环。
4.2 合成层不是越多越好
既然变换和透明度最省事,是不是给所有动画元素都提升成合成层就万事大吉?不是。
每个合成层都要占用显存,层数一多,内存压力上来,合成线程自己就开始忙不过来,反而掉帧。更麻烦的是层爆炸很难在火焰图上直接看出来,你会看到主线程很闲,但界面就是卡,因为瓶颈在合成线程那边。
判断办法是看渲染相关的信息面板,观察层的数量。一个普通页面几十层正常,上千层就该查了。常见的层爆炸来源是给大量列表项同时加了提升属性,或者在滚动容器里给每个子元素都做了变换动画。
我的做法是:只给真正在做动画的那几个元素提升层级,动画结束就撤掉。列表项一律不加,靠减少每帧的工作量来保证流畅,而不是靠堆层。
4.3 大面积模糊、阴影和圆角的真实代价
设计稿里一个漂亮的毛玻璃效果,实现起来可能是整页掉帧的元凶。模糊滤镜需要对区域内的像素做卷积运算,面积越大代价越高,而且是每帧都要重算。多层叠加的阴影同理,绘制阶段的开销会成倍增长。
圆角裁剪也有代价,尤其是配合溢出隐藏使用的时候,浏览器需要额外的裁剪计算。单个元素无所谓,一屏几百个卡片全都带圆角和阴影,绘制阶段就会变成瓶颈。
处理办法不是不用,而是控制面积和数量。静态的装饰性模糊可以用预先处理好的图片替代,动态的只在必要时才开。列表里的卡片阴影可以合并成一层统一的背景图,避免每个元素单独绘制。
4.4 把每帧的预算当成硬约束来分配
一帧 16.7ms,这个数字是所有优化的基础。在这 16.7ms 里,浏览器自己要处理输入、跑脚本、算样式、布局、绘制、合成。留给业务代码的安全空间,我一般按 8ms 算。
意思就是:任何要在每帧执行的回调,里面的活必须控制在 8ms 以内,超了就会掉帧。这条约束比任何优化技巧都管用,因为它直接告诉你能不能这么写。
滚动监听里做复杂计算、逐帧读取元素位置、在动画回调里更新整个列表,这些都是典型的超预算写法。改法无非两种:把活拆到多帧去做,或者挪到不需要每帧跑的地方去。
5. 越用越卡:内存水位、GC 抖动与游离 DOM
5.1 堆快照对比法,找出只增不减的东西
“越用越卡”这类问题,光看火焰图看不出来,得上内存工具。方法不复杂:打开页面,做一轮操作,拍一张堆快照;再重复同样的操作三轮,再拍一张;然后对比两张快照里的对象数量。
如果某个类型的对象数量随着操作次数线性增长,而且没有被回收,那基本可以锁定泄漏点。重点看几类对象:DOM 节点、事件监听器、闭包、定时器句柄、缓存对象。
我排查过一个单页应用的内存问题,最后发现是路由切换时,上一个页面的图表实例没有销毁。每次切换都会新建一个图表,旧的还挂在内存里,因为它的定时刷新还在跑。切了二十次之后,内存涨了几百兆,帧率直接腰斩。这类问题在带图表的后台系统里特别常见。
5.2 游离 DOM 是最典型的隐形泄漏
游离 DOM 指的是已经从文档树上移除、但还被 JS 变量引用着的节点。它们不显示,不参与布局,但占着内存,而且如果里面绑了事件监听或者定时器,还会持续消耗 CPU。
// 问题写法:节点移除了,但引用还在,监听还在 let modalCache = null; function openModal() { if (!modalCache) { modalCache = document.createElement('div'); modalCache.addEventListener('scroll', onScroll); document.body.appendChild(modalCache); } } function closeModal() { modalCache.remove(); // 只是从文档树移除,引用和监听都还在 }这段代码的问题在于:节点从页面上消失了,但变量还指着它,事件监听还挂在它身上,只要触发滚动它照样会执行。调用几十次之后,内存里堆着一堆看不见的节点,每一个都在偷偷干活。
正确做法是关闭时把引用置空、把监听解绑、把定时器清掉。在框架里则是在组件销毁的生命周期里做同样的清理。这件事没有捷径,只能靠代码规范和定期用工具检查。
5.3 缓存池和数据堆积的膨胀
另一个常见来源是“为了性能而做的缓存”本身变成了负担。比如接口数据缓存,很多实现是往一个对象里塞,键是请求参数,值是响应数据,从来不清。页面上翻几十页、搜几十个关键词,缓存就膨胀到几百兆。
还有一类是全局状态里堆积的列表数据。分页加载的时候每次都往数组里追加,用户一直往下滑,数组一直长,最后渲染和遍历都变慢。
处理办法是给缓存加容量上限和过期策略,列表数据用虚拟滚动只保留可视区域的节点,历史数据该丢就丢。这类改动往往能立竿见影,因为它解决的是持续增长的问题。
5.4 垃圾回收在火焰图上的样子
浏览器是自动回收内存的,回收的时候会暂停主线程。单次暂停通常很短,但如果创建对象的频率太高,回收就会变得非常频繁,累积起来就是明显的卡顿。
在火焰图上,垃圾回收表现为规律出现的短条,颜色和普通脚本不同。如果它出现得非常密集,说明代码里有大量的临时对象创建。典型场景是循环里创建对象、每帧都新建数组、字符串频繁拼接、以及不必要的深拷贝。
改法是把能复用的对象提到循环外、用数组收集最后统一拼接字符串、避免在热路径里做深拷贝。这些改动单个看起来很小,但在高频执行的路径上,收益非常明显。
6. 五类高频重灾区场景的具体打法
6.1 长列表与大数据表格
这是卡顿反馈里的头号来源。一屏渲染几千行,光创建节点就要几百毫秒,滚动时每一帧都要重排重绘。
判定方法很直接:把列表数据砍到原来的十分之一,如果立刻流畅,那就是渲染量的问题。解决办法是虚拟滚动,只渲染可视区域加上下缓冲区的行,滚动时复用节点。
如果短期内改不了渲染方式,有几个折中手段:用文档片段批量插入减少重排次数、给容器加上内容可见性控制让屏幕外的区域跳过渲染、滚动时先隐藏内容停下再显示。这些手段都只能缓解,根治还是得靠虚拟化。
另外要检查列表项里有没有做重计算:日期格式化、数字千分位、状态映射、正则匹配。这些都应该在数据层做一次,而不是每行每次渲染都算一遍。
6.2 输入框与联想搜索
用户打一个字就发一次请求、算一次结果,打字快了就卡。这类问题的本质是事件触发频率远高于处理能力。
标准做法是防抖,输入停止后 200 到 300 毫秒再触发。同时要加请求竞态处理:后发的请求先回来,不能让它覆盖前面的结果,需要标记序号的机制来保证只取最新一次的结果。
还有一个容易忽略的点:输入框本身的值变化如果绑定了复杂计算,每次按键都会触发整页更新。这种时候要把输入框的值和依赖它的计算结果解耦,输入框保持轻量,重计算延后执行。
6.3 表单联动与条件渲染
大型表单里,一个字段变化触发整张表单重算,字段之间有复杂的联动规则,改一个值要重新判断几十个字段的禁用、显示、校验状态。这种场景在后台系统里特别多。
排查时先看每次输入触发了多少次更新。如果是整个表单重新计算,就要把联动规则拆细,只让受影响的字段更新。如果是校验太重,就把校验改成异步或者延迟触发。
条件渲染也要注意,频繁切换显示隐藏时,用样式控制比反复销毁重建便宜得多。销毁重建会触发组件的完整生命周期,包括数据请求、事件绑定、DOM 创建,代价是样式切换的几十倍。
6.4 弹窗与图表组件的反复创建销毁
图表组件通常初始化很重:解析配置、计算布局、创建画布、绑定交互。每次打开弹窗都重新初始化一遍,打开十次就是十次重活。
做法是把实例缓存起来复用,弹窗关闭时只隐藏不销毁,下次打开直接更新数据。同时要注意在页面真正离开时把实例清理干净,否则就变成了前面说的内存泄漏。
6.5 埋点与第三方脚本
这一块最容易被忽略,因为它不在你的代码里。有些统计脚本会监听所有点击和滚动,有些客服组件会定期拉取数据,有些广告位会在主线程做大量计算。这些东西分散在各处,单看每个都不重,加起来可能就是主线程繁忙的主要来源。
判断方法是把第三方脚本全部屏蔽,再跑一遍性能录制,对比前后差异。如果屏蔽后明显变快,就要考虑延迟加载、按需加载、或者干脆换掉。
7. 三个误判:看着像前端卡,其实不是
7.1 接口慢被当成渲染慢
这是最高频的误判。用户点了按钮,界面转圈两秒才出结果,反馈说“页面卡”。你去看前端代码,发现渲染逻辑毫无问题,因为时间全花在等接口上。
识别方法很简单:打开网络面板,看那次操作的请求耗时。如果接口本身就要一两秒,前端再怎么优化也没用。这时候要做的是接口合并、缓存、骨架屏、分步加载,而不是去优化渲染。
我在这上面浪费过不少时间,后来养成习惯:任何卡顿问题,第一步先看网络瀑布,把等待时间和执行时间分开,再决定查哪头。
7.2 缓存策略导致的白屏
首次访问很慢,第二次飞快,这种情况十有八九是缓存没配对。静态资源没有设置合理的缓存头,每次访问都要重新下载;或者反过来,缓存设得太死,更新后用户拿到的还是旧版本。
排查时对比“禁用缓存”和“正常访问”两种情况的加载耗时,差异巨大就说明缓存策略需要调整。同时要看资源体积,一个没压缩的包可能是压缩后的五六倍大。
7.3 本地环境的干扰
开发者工具开着和关着,性能能差一截,因为录制和面板本身也要消耗资源。浏览器插件、翻译工具、输入法、屏幕录制软件,都可能影响结果。系统层面的电源模式、后台更新、杀毒扫描同样会干扰。
所以做性能对比时,必须保证两边环境一致:同一个浏览器版本、同样的插件状态、同样的电源模式、同样的设备。这一点听起来是废话,但实际排查中因为环境不一致得出错误结论的情况非常多。
7.4 建议的排查顺序:从外到内
我把顺序固定成这么几步,大部分问题能在前三步定位:
- 确认复现条件,对齐设备和网络;
- 看网络瀑布,区分等待时间和执行时间;
- 开降速录一段轨迹,看主线程是被脚本、渲染还是回收占住;
- 有长任务就钻调用栈,有掉帧就看渲染阶段;
- 越用越卡就上内存快照对比;
- 都不明显就屏蔽第三方脚本再测一遍。
这个顺序的价值在于它是排除法:每一步都在排除一整类可能性,避免一上来就扎进代码里。
8. 改完怎么证明有效:验证与防回归
8.1 同条件对比,别拿优化前后不同环境的数据说话
优化做完,最容易犯的错是拿“优化后在高端机上的数据”去对比“优化前在低端机上的数据”,结论当然好看,但没有任何意义。
正确的对比方式是固定条件:同一台设备、同一个网络限速、同样的缓存状态、同样的操作路径,各录一遍,把关键指标列出来对比。我一般会记录三组数字:长任务总时长、平均帧间隔、交互延迟。三个指标同时改善,才算真的有效。
提示:录制时把操作脚本固定下来,比如“进入页面、滚动三屏、点击第一个卡片、返回”,每次执行完全一样的动作。否则两次录制的操作路径不同,数据没法比。
8.2 上线后的观察口径要提前想好
本地验证通过只是第一步,真实用户那边的情况才算数。上线前要想清楚看哪些指标、按什么维度分组、观察多久。
分组维度至少要有设备档次和页面路径两个。设备档次决定了同样的代码在不同机器上的表现差异,页面路径能帮你发现是不是只在某个特定页面出问题。观察周期我一般看一周,因为要覆盖不同的使用时段和网络条件。
还要防一种情况:优化把某处变快了,但把另一处变慢了。比如为了减少渲染量做了懒加载,结果滚动到底部时集中加载造成新的卡顿。这种“按下葫芦浮起瓢”的情况,只有靠全链路的指标对比才能发现。
8.3 把性能预算写进日常流程
靠人肉排查永远是被动的,问题发生了才去查,成本很高。真正有效的做法是设一条红线,超了就报警。
红线的形式可以很简单:主包体积不超过某个值、首屏时间不超过某个值、长任务总时长不超过某个值。把这条线写进构建流程,每次提交自动跑一遍,超了就不让合。这比事后排查省太多力气。
另外还有一个习惯我一直在坚持:新功能上线前,在低端设备上完整走一遍主流程。不用做任何分析,就用最普通的操作方式点一遍,感觉不顺就去看一眼。很多问题在这一步就能被发现,而不是等用户投诉。
9. 我个人在这件事上的几条经验
排查卡顿这件事,技术手段其实就那么些,真正拉开差距的是判断顺序和排除效率。我自己总结下来,最有用的几条反而不是技术细节。
一是永远先质疑环境。我遇到过太多次“我这边不卡”,最后都是环境差异。在开始任何代码层面的分析之前,先花五分钟把自己的环境调到和用户接近,能省掉后面几小时的弯路。
二是不要相信平均值。平均帧率 55 帧听起来还行,但可能是一半时间 60 帧、一半时间 20 帧,体感上就是一顿一顿。要看分布,看最差的那 5% 是什么水平。
三是先解决量最大的那一个。性能问题往往不是一个,而是一堆。不要试图一次全改完,找到占用时间最多的那一项,改掉它,再测一遍,看新的瓶颈在哪。迭代着来,比一次性重构安全得多。
四是留下记录。每次排查把条件、数据、结论记下来。同一个系统里,问题往往是同类的,下次遇到类似现象可以直接翻记录,不用从头再来。我带过的一个团队就是靠一份持续维护的排查记录,把重复问题的定位时间从一天压到了半小时。
最后一个体会:卡顿排查最忌讳的是“我觉得这里可能有问题”就去改。没有数据支撑的优化,改十处可能只有一处有效,剩下九处白白增加了复杂度。先测,再改,改完再测,这个循环没有捷径。