前端性能优化全景:从 Performance 定位到场景化方案
面试题:你做过哪些前端性能优化?遇到页面卡顿,怎么用 Performance 定位问题?
这道题很容易答成一串名词:懒加载、缓存、防抖节流、虚拟列表、Web Worker。
但面试官继续问一句"你怎么知道该用虚拟列表",就需要回到证据了。页面慢在哪里,用什么方式定位,改完以后怎么确认有效,这几个环节要能连起来。
这篇文章分两半:前一半讲怎么定位——把 Performance 面板的使用整理成一套固定的排查顺序;后一半讲定位到之后能做什么——按场景把常见优化方案归拢成几张表。中间的衔接点只有一个:方案跟着证据走,不跟着名词走。
第五节的表格筛选案例是假设场景,不代表个人项目实测,也没有虚构优化百分比。
一、先说清楚是哪一种慢
"前端性能不好"太宽泛。先把我这些年实际遇到过的现象摆出来:
- 页面首次加载慢
- 大屏滚动卡顿、动画延迟、数据出现得慢
- 超大数据量的表格渲染问题
- 前端地图渲染慢、卡顿
- 图片加载慢
- 提交按钮操作完后,页面卡顿转圈无法操作
- 页面越用越卡
- 路由切换卡顿明显
- 某些操作无限 loading
分类下来主要是四个场景:加载与网络、渲染与绘制、交互与响应、内存与长期运行。
| 分类 | 典型现象 | 首先想确认的问题 | 优先使用的工具 |
|---|---|---|---|
| 加载与网络 | 首次打开慢、图片加载慢 | 等接口、下载资源,还是资源到了但页面没画出来? | Network + Performance |
| 渲染与绘制 | 滚动/动画卡顿、大表格、地图卡 | 哪些帧来不及完成,主线程在做什么? | Performance + Rendering |
| 交互与响应 | 提交后转圈、路由切换卡、无限 loading | 事件在排队,还是事件处理和后续渲染太重?请求有没有悬挂? | Performance + Network |
| 内存与长期运行 | 越用越卡 | 工作量在增加,还是对象、监听、定时器没有释放? | Performance monitor + Memory |
Performance 适合把一次操作放到时间线上分析,Network 适合拆请求耗时,Memory 则用于追踪对象为什么还活着。工具可以配合,不必强迫一个面板解释所有问题。
面试里常提到的指标
| 指标 | 关注什么 | "良好"的参考阈值 |
|---|---|---|
| LCP | 主要内容的加载表现 | ≤ 2.5 秒 |
| INP | 页面交互到下一次绘制的响应表现 | ≤ 200 毫秒 |
| CLS | 页面布局的意外偏移 | ≤ 0.1 |
这些是 Core Web Vitals 的参考标准。评价真实用户体验时通常看第 75 百分位,并区分移动端和桌面端;本地录制的一次操作不能直接代表线上整体表现。INP 也不是接口响应时间或所有交互耗时的简单平均值。Web Vitals 官方说明
二、先录一段能回答问题的 trace
分析表格筛选卡顿,我会先等页面稳定,开始录制,只做一次筛选,等结果展示后停止。分析首屏,则使用 Record and reload 录制加载过程。
录制前尽量固定条件:同一个构建版本、同一批数据、同样的窗口尺寸、缓存状态与节流设置。优先使用生产构建,减少开发工具和扩展造成的干扰。
CPU、网络节流可以帮助复现弱设备或慢网情况,但要记录设置,并在优化前后保持一致。桌面 CPU 降速也不等于真正的低端手机,必要时还要上真机复测。Chrome 运行时性能分析教程
录制时间不必长。把"点下筛选按钮到结果出现"录清楚,比连续录几分钟再找问题更容易分析。
三、Performance 面板按什么顺序看?
我的顺序是:找操作发生的位置 → 框选慢的区间 → 看主线程 → 找函数和调用来源。
先定位用户动作,再读火焰图
可以结合 Screenshots、Interactions、Frames,判断点击发生在哪里、页面何时变化、哪里有掉帧。确认时间段后,再看 Main 轨道中的工作。
火焰图横向是时间,块越宽,表示这次调用跨越的时间越长;纵向展示调用嵌套,不是内存大小。一个父函数很宽,可能只是因为它调用了耗时子函数,不能据此认定父函数本身最慢。
接着用下方视图追问:
| 视图 | 用它回答什么问题 |
|---|---|
| Summary | 选中的事件是什么,持续多久,能否跳到源码? |
| Bottom-up | 当前区间里,哪些函数累计自身耗时最多? |
| Call tree | 耗时工作是沿着哪条调用链触发的? |
| Event log | 各事件按时间顺序发生了什么? |
Self time 是函数自身耗时,Total Time 包含子调用。Bottom-up 里排名靠前可能来自一次重计算,也可能来自大量重复调用,需要结合调用次数和上下文判断。以上视图应围绕选中的区间阅读。Performance 面板参考
再判断主线程在忙什么
可以先按 Scripting、Rendering、Painting 等类别判断方向,再点具体事件核实。颜色只是辅助,不能只凭"黄色很多"就得出结论。
| 主要耗时 | 下一步排查方向 |
|---|---|
| JavaScript 执行 | 数据转换、重复遍历、序列化、事件回调是否集中执行? |
| Recalculate Style / Layout | DOM 更新规模是否过大,有没有频繁读写布局? |
| Paint | 是否频繁重绘大面积内容,绘制效果是否昂贵? |
| 主线程较空,但内容迟迟没出现 | 结合 Network、应用状态和其他轨道,检查还在等待什么 |
一次卡顿可能是"数据处理 → DOM 更新 → 布局 → 绘制"的连续开销。减少其中一段,不一定就能解决整次操作的延迟。
四、看到长任务,下一步怎么查?
主线程中超过 50ms 的任务属于长任务。但 50ms 不是流畅度的安全线:60Hz 屏幕一帧约 16.7ms,浏览器还要完成自己的工作;任务不到 50ms,也可能让动画错过一帧。
定位长任务后,先找它为什么形成,再决定减少工作量还是拆分任务。把代码写成 async,或者套一层 Promise.then,不代表重计算离开了主线程。连续微任务也可能让浏览器迟迟没有机会处理输入和绘制。长任务优化说明
针对一次点击,还要区分三个阶段:
- 输入延迟:点击已经发生,但主线程还在忙之前的工作。
- 处理耗时:事件处理函数及相关工作本身耗时。
- 呈现延迟:事件处理结束后,仍需等待下一帧呈现。
如果主要时间花在第一段,单纯优化点击回调可能没什么效果;如果主要卡在呈现阶段,就要继续看布局、绘制等工作。INP 优化说明
一个容易看懂的例子:循环读写布局
下面是示意代码。每次写入样式后,下一轮又读取布局尺寸,可能迫使浏览器反复同步计算布局:
items.forEach((item) => {
item.style.width = `${container.offsetWidth}px`
})
如果这段逻辑确实只需要同一个宽度,可以先读一次,再集中写入:
const width = container.offsetWidth
items.forEach((item) => {
item.style.width = `${width}px`
})
前提是修改子元素不会改变本次计算所需的容器宽度。不是所有尺寸读取都会强制重排,要结合先前是否发生布局失效,以及 trace 中的相关事件判断。布局抖动说明
五、证据和方案怎么接上:表格筛选案例
假设现象: 接口已经返回,但表格过一会儿才更新,期间页面操作不顺畅。
我会先录制一次筛选,再框选接口完成到表格更新之间的区间。接下来根据看到的证据分情况处理:
| trace 中的证据 | 可能的优化动作 | 改完要验证什么 |
|---|---|---|
| 同一份数据反复筛选、排序 | 合并计算,避免无关更新重复触发 | 函数调用次数和自身耗时是否减少 |
| 一次插入大量行,布局开销明显 | 分页或虚拟列表,减少同时存在的节点 | DOM 数量、布局耗时、滚动体验 |
| 可独立的数据计算占用主线程 | 评估拆分任务或交给 Worker | 主线程阻塞是否下降,数据传输成本是否抵消收益 |
| 同一操作触发多次请求或刷新 | 排查监听、状态联动和重复触发 | 一次操作是否只产生必要工作 |
这里不能先说"列表卡,所以加虚拟列表"。如果真正耗时的是收到数据后的转换过程,减少可见行数未必能解决转换开销。
也不能仅凭一次请求很快,就认定整体体验很好。请求完成和用户看到新内容,中间还有一段前端工作。
"越用越卡"需要另一种证据
反复进入和离开同一页面,观察回收后的内存基线、DOM 数量或监听数量是否持续增长,再通过堆快照和保留路径追踪对象。单次内存上涨,甚至正常的锯齿状曲线,都不能独立证明泄漏。
定时器、事件监听、图表实例和缓存都可以作为排查对象,但要查清谁在持有引用,再判断是否需要释放。Chrome 内存问题排查
六、场景化方案:定位到之后能做什么
前面反复强调"先证据后方案"。这一节反过来:假设证据已经指向了某个方向,常见的做法有哪些,各自的代价是什么。分类沿用第一节那四类——加载与网络、渲染与绘制、交互与响应、内存与长期运行,每张表三列:证据 → 方案 → 代价。不是拿这一节去对号入座地"开药"。
1. 加载与网络
| 证据 | 方案 | 代价 / 注意 |
|---|---|---|
| 关键请求串行,瀑布图一竖列(含 N+1) | 接口并行、合并请求、预连接(dns-prefetch、preconnect)、SSR 一次带出首屏数据 | 并行会加重弱网拥塞;合并接口要处理失败粒度 |
| 大图阻塞 LCP、图片整体加载慢 | 压缩、WebP/AVIF、<img> 的 width/height 防抖动、关键图 preload、非视口图懒加载 |
懒加载别用在 LCP 元素上,会反而推迟它 |
| 地图瓦片、图片类资源数量大 | 瓦片缓存、CDN、按视野和 zoom 分级加载 | 分级加载要处理快速缩放时的请求取消 |
| JS 包大,评估里下载/解析久 | 代码分割(路由级 split)、按需引入组件和工具函数、删掉没用的 polyfill | 分包太多会增多请求数,找平衡点 |
| 渲染阻塞的 CSS/JS、第三方脚本 | JS 加 defer/async、第三方脚本延后加载、关键 CSS 内联 | 第三方脚本延后要理清初始化时序 |
| 字体阻塞渲染 | font-display: swap、内联关键字体子集 | swap 有闪烁(FOUT) |
| 同类请求重复发起、接口响应慢 | HTTP 缓存(强缓存/协商缓存)、CDN、接口层去重(in-flight 合并)、请求超时与降级 | 缓存要设计失效策略,别让用户看到旧数据还以为是对的 |
2. 渲染与绘制
| 证据 | 方案 | 代价 / 注意 |
|---|---|---|
| Layout 高、循环读写布局 | 批量读写分离(先读后写)、transform/opacity 代替几何属性动画、contain/content-visibility 隔离布局范围 |
transform 动画会跳到合成层,层太多吃内存 |
| Paint 高、大面积重绘 | 减少重绘面积、避免昂贵效果(blur、shadow 大范围使用)、GPU 友好的属性 | 层爆炸比 paint 高更难查,任务管理器看内存 |
| DOM 节点过多(超大数据量表格、长列表) | 分页、游标加载、虚拟列表 | 虚拟列表要处理动态行高、锚点滚动、选择状态 |
| 地图点位/图形过多导致渲染慢 | 点位聚合、抽稀、按视野加载、canvas/WebGL 渲染代替 DOM 标注 | 聚合会损失细节,点击交互要单独处理 |
| 动画掉帧、未走合成层 | 动画只用 transform/opacity、谨慎使用 will-change |
层爆炸比动画卡顿更难排查 |
| Recalculate Style 高,选择器复杂 | 减少选择器复杂度、减少 DOM 规模、扁平类名 | 收益取决于 DOM 规模,小页面感知不到 |
| 频繁小更新堆出长帧 | requestAnimationFrame 对齐帧、合并状态更新(批处理) | 别用 setTimeout 模拟动画节奏 |
3. 交互与响应
| 证据 | 方案 | 代价 / 注意 |
|---|---|---|
| 提交后整页转圈、期间无法操作 | 先查阻塞点:同步计算拆分;loading 收敛成局部 loading;按钮防重复提交 | 全局 loading 会掩盖真实耗时,别只图省事 |
| 路由切换卡顿明显 | 路由级代码分割、切换前取消旧页面请求、销毁图表等重组件、预加载下一页 | 预加载耗流量;旧页面清理见下面内存那节 |
| 某些操作无限 loading | 请求超时、AbortController 取掉竞态请求、错误兜底、重试设上限 | 别做无限自动重试,会雪上加霜 |
| 长任务阻塞主线程(纯计算) | 算法/数据结构优化(索引、Map 换数组查找)、Web Worker、scheduler.yield() / isInputPending 拆分长任务 |
Worker 有序列化成本,小数据不如不拆 |
| 高频事件触发重计算 | 防抖(停止输入才算)、节流(滚动中限频) | 防抖让"过程反馈"消失,看交互是否需要即时回显 |
| 同一数据被多方重复计算 | 缓存 / memo(Vue computed、React useMemo、全局 selector) | 缓存失效和内存占用是另一半工作量 |
| 启动时同步初始化太多 | 延迟初始化、按路由分包预加载、idle 时机做非关键事 | 注意别把首屏需要的事也延后了 |
4. 内存与长期运行
| 证据 | 方案 | 代价 / 注意 |
|---|---|---|
| 堆快照显示对象持续增长、有保留链 | 解开引用(null、WeakMap、WeakRef)、组件卸载时清理监听/定时器/Observer | 先确认是真泄漏,不是缓存正常增长 |
| 组件销毁后回调还在跑 | AbortController 取消请求、清理 Promise 链 | 见我另一条:组件销毁了请求还在跑 |
| 图表实例、订阅、事件监听未清理 | 卸载钩子里统一销毁;监听器优先事件委托,少逐个绑定 | 委托过度也会让单个处理函数变大 |
| 缓存与历史数据无上限累积 | 缓存加 LRU 上限、历史数据过期淘汰 | 上限值要按内存预算定,不是拍脑袋 |
| 大数据常驻内存 | 分页拉取、虚拟列表只持有可见数据、离屏数据用完释放 | 分页与"全量导出"类需求冲突时要单独设计 |
一张嘴把这些全说出去并不加分。面试时值得说的是:我知道方案有代价,所以每个方案我都问过"证据是什么、代价能不能接受"。
七、改完怎么证明有效?
我会保存修改前后的录制结果,用相同操作和相同测试条件重复测几次,比较耗时分布及异常波动,不只挑最快的一次。
记录可以很简单:
| 项目 | 需要留下的内容 |
|---|---|
| 复现场景 | 操作步骤、数据规模、设备和浏览器环境 |
| 问题证据 | 哪个区间、哪段调用或哪类浏览器工作最耗时 |
| 修改内容 | 针对什么原因,减少了哪部分工作 |
| 复测结果 | 同条件多次结果,剩余瓶颈,以及功能是否正常 |
例如做了缓存,还要检查过期和失效;用了虚拟列表,还要检查滚动、选择和动态行高;拆分了任务,还要确认数据顺序和页面离开后的处理。
没有实测数据时,面试中就讲定位过程和验证方式。比起说"性能提升了 80%",能解释这个百分比原本该怎么测,更经得起追问。
八、面试里可以这样回答
下面是一段方法型回答,实际使用时补上自己确实做过的项目和数据:
我会先区分是加载、渲染、交互还是内存的问题:首次打开慢、图片慢属于加载和网络;滚动、动画、大表格、地图卡属于渲染和绘制;提交后转圈、路由切换卡、无限 loading 属于交互响应;越用越卡属于内存和长期运行。先把问题稳定复现,再选工具,不会直接套某个优化方案。
对交互卡顿,我会用 Performance 录制一次具体操作,定位交互发生到页面反馈之间的时间段。先看 Main 里的长任务和布局绘制,再用 Bottom-up 找自身耗时高的函数,用 Call tree 追到调用来源。
定位到之后按证据选方案:瓶颈是重复计算就缓存或 memo;是大量节点更新布局就分页或虚拟列表;是纯计算就考虑 Worker 或拆分任务;是请求等待就结合 Network 继续拆。方案都有代价,比如 Worker 有序列化成本、虚拟列表要处理动态行高,所以我会先确认收益大于代价再动手。对于越用越卡的情况,还会用 Memory 检查对象保留和资源清理。
修改后用相同数据、设备和操作重新录制,重复比较,同时验证功能正确性。最终要能解释:哪里慢、为什么慢、改了什么,以及什么证据证明它有效。
真正要准备的不是更多优化名词,而是一个你能从现象一路讲到复测结果的例子。
路漫漫其修远兮,吾将上下而求索。