前端性能优化全景:从 Performance 定位到场景化方案

面试题:你做过哪些前端性能优化?遇到页面卡顿,怎么用 Performance 定位问题?

这道题很容易答成一串名词:懒加载、缓存、防抖节流、虚拟列表、Web Worker。

但面试官继续问一句"你怎么知道该用虚拟列表",就需要回到证据了。页面慢在哪里,用什么方式定位,改完以后怎么确认有效,这几个环节要能连起来。

这篇文章分两半:前一半讲怎么定位——把 Performance 面板的使用整理成一套固定的排查顺序;后一半讲定位到之后能做什么——按场景把常见优化方案归拢成几张表。中间的衔接点只有一个:方案跟着证据走,不跟着名词走。

第五节的表格筛选案例是假设场景,不代表个人项目实测,也没有虚构优化百分比。

一、先说清楚是哪一种慢

"前端性能不好"太宽泛。先把我这些年实际遇到过的现象摆出来:

  1. 页面首次加载慢
  2. 大屏滚动卡顿、动画延迟、数据出现得慢
  3. 超大数据量的表格渲染问题
  4. 前端地图渲染慢、卡顿
  5. 图片加载慢
  6. 提交按钮操作完后,页面卡顿转圈无法操作
  7. 页面越用越卡
  8. 路由切换卡顿明显
  9. 某些操作无限 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 检查对象保留和资源清理。

修改后用相同数据、设备和操作重新录制,重复比较,同时验证功能正确性。最终要能解释:哪里慢、为什么慢、改了什么,以及什么证据证明它有效。

真正要准备的不是更多优化名词,而是一个你能从现象一路讲到复测结果的例子。

路漫漫其修远兮,吾将上下而求索。
屈原《离骚》
← 返回知识库