性能优化:先量,再改
性能这块最容易背成一份清单:"用 CDN、压缩图片、懒加载、虚拟列表"。但面试官真正想看的是——你为什么认为这里是瓶颈。所以下面每一节都先给机制,再给手段:知道它卡在哪一步,才知道该动什么。
01机制先于手段每条优化都对应一个渲染阶段或网络环节,答不上机制就只是背清单。
02会看数据能说清用 Lighthouse 还是 Performance 面板、看 p75 还是平均值,比记住分数有用。
03动手试第 6 节有个可点的演示:同步阻塞时页面心跳会停,分片执行时不会。差别一眼就能看出来。
1
三个核心指标:LCP、INP、CLS★
FID 已经被换掉了,别再答它
Core Web Vitals 现在是三个:LCP 看加载(最大内容什么时候画出来)、INP 看交互(点击到下一帧之间的延迟)、CLS 看稳定(布局有没有跳)。FID 在 2024 年被 INP 取代。
三个指标各管什么
- LCP:视口内最大的图片或文本块出现的时间。好 < 2.5s
- INP:所有交互的延迟取一个高分位值。好 < 200ms
- CLS:布局偏移分数。好 < 0.1
FID 为什么被淘汰
- FID 只量了"输入延迟",不量处理时间
- 它不知道点击之后页面多久才更新
- INP 覆盖了完整的交互生命周期
- LCP 的候选元素只有几种:
<img>、<image>(SVG)、<video>封面、背景图、块级文本节点。它的时间是"开始渲染"而不是"加载完成"。 - CLS 只算"非预期"偏移:用户主动交互(比如点击展开)引起的偏移不计入。图片没写宽高、字体切换、动态插入广告是三大来源。
- INP 不是平均值:它取的是接近最差的那一档(高百分位),因为用户体验由最卡的那次决定。
面试怎么答核心指标是 LCP 衡量加载、INP 衡量交互响应、CLS 衡量视觉稳定。FID 因为只测输入延迟、不覆盖处理过程,已被 INP 取代。三个指标都要看现场数据,不能只看本地跑分。
2
怎么量:实验室数据 vs 现场数据★
两者结论经常不一样
实验室(Lighthouse、Performance 面板)可复现、能定位问题,但用的是模拟网络和你的设备;现场(RUM)是真实用户,但只能告诉你"有问题",说不清为什么。两者要配合用。
现场数据只能靠 PerformanceObserver 收集// LCP
new PerformanceObserver((list) => {
const entries = list.getEntries();
const last = entries[entries.length - 1];
report('LCP', last.startTime, last.element);
}).observe({ type: 'largest-contentful-paint', buffered: true });
// CLS:累加,注意只在页面隐藏时上报最终值
let cls = 0;
new PerformanceObserver((list) => {
for (const e of list.getEntries()) {
if (!e.hadRecentInput) cls += e.value;
}
}).observe({ type: 'layout-shift', buffered: true });
// INP:记录每次交互的时长,取高百分位
new PerformanceObserver((list) => {
for (const e of list.getEntries()) report('interaction', e.duration);
}).observe({ type: 'event', buffered: true, durationThreshold: 40 });
- 指标要在页面隐藏时上报:LCP 和 CLS 在页面生命周期里会持续变化,中途上报拿到的都是中间值。用
visibilitychange加sendBeacon。 - Lighthouse 的分数波动很大:同一份代码跑两次可能差十几分。看趋势别看绝对值,跑三次取中位数。
- Performance 面板看的是"为什么":主线程有没有长任务、哪个函数占了大头、有没有强制同步布局——这些是 RUM 给不了的。
面试怎么答实验室数据用 Lighthouse 和 Performance 面板,可复现能定位,但环境和设备是模拟的。现场数据用 RUM 加 PerformanceObserver 收集真实用户的 LCP、INP、CLS,要注意在 visibilitychange 时用 sendBeacon 上报最终值。两者结合才能既知道有没有问题,又知道问题在哪。
3
关键渲染路径:什么在阻塞首屏★
CSS 阻塞渲染,JS 阻塞解析
首屏慢,先看有没有阻塞资源。CSS 没下完就不会渲染(避免样式闪烁),JS 没执行完就不会继续解析 DOM(因为脚本可能改 DOM)。两句话解释了 90% 的首屏优化。
- defer 和 async 的区别就一句话:
defer保证顺序、在 DOMContentLoaded 前执行;async谁先下完谁先跑、不保证顺序。有依赖关系的脚本只能用 defer。 - 字体也会阻塞文本渲染:
font-display: swap让文字先用系统字体显示,避免"白屏等字体"。代价是会有一次字形切换(这也是 CLS 来源)。 - 不是所有 CSS 都要内联:内联会把 HTML 变大、失去缓存。只内联首屏关键路径那部分,其余的异步加载。
面试怎么答CSS 阻塞渲染是因为浏览器要避免无样式闪烁,JS 阻塞 DOM 解析是因为脚本可能修改 DOM。优化手段是给脚本加 defer 或 async、把关键 CSS 内联、非关键样式异步加载、字体用 font-display: swap。
4
五个资源提示,别用混
preload 用错等于多下一次
preload 是"这个页面马上要用,优先下";prefetch 是"下个页面可能要用,闲时下";preconnect 只建连接不下载;dns-prefetch 只做 DNS。用错会出现"下载了但没用上"的警告。
常见误用
- preload 了但当前页面没用 → 控制台警告,白占带宽
- 漏写
as→ 类型不明,可能重复下载 - preload 字体忘了
crossorigin→ 下两次 - prefetch 太多 → 抢当前页面的带宽
正确姿势
- 只 preload 首屏必备的那 1–3 个资源
- preload 必须和实际请求的 as、crossorigin 一致
- preconnect 控制在 4 个以内(每个都要握手)
- prefetch 只用于明确的下一个页面
面试怎么答preload 用于当前页面的关键资源,必须正确声明 as 和 crossorigin,否则会重复下载。prefetch 是空闲时预取下一个页面的资源。preconnect 提前完成 DNS 加 TCP 加 TLS,适合第三方域名,但数量要控制。dns-prefetch 只做域名解析,开销最小。
5
图片优化:格式、响应式与懒加载的矛盾★
懒加载别把 LCP 图也懒掉
图片优化的顺序是:先选对格式(AVIF/WebP)→ 再对尺寸(srcset + sizes)→ 最后才懒加载。而且首屏那张图绝对不能懒加载,否则 LCP 会明显变差。
- loading="lazy" 用错会拖慢 LCP:它的判断依据是"是否接近视口",首屏图本来就在视口里,加上它反而让浏览器延迟加载。LCP 图应该用
fetchpriority="high"。 - 宽高必须写:不写
width/height,图片加载完会撑开布局,这是 CLS 的头号来源。写了浏览器就能提前按比例占位。 - AVIF 通常比 WebP 再小 20–30%,但编码慢、老浏览器不支持,所以要
<picture>做降级,不能只给一种格式。 - 别用 CSS 背景图放内容图:背景图不能被 preload 正常识别,也不会被 LCP 算法同等对待,还失去 alt 和无障碍语义。
面试怎么答图片先选格式再做响应式,用 picture 做 AVIF/WebP 降级,用 srcset 和 sizes 按视口选尺寸。必须写 width 和 height 防止 CLS。首屏图不能加 loading=lazy,应该用 fetchpriority=high 提升优先级。
6
长任务与时间切片★
50ms 是那条线
主线程上一个超过 50ms 的任务就会让交互变迟钝——因为浏览器要等它跑完才能响应输入。解决办法不是"跑得更快",而是拆成小片,中间让出主线程。
亲眼看到主线程被独占
页面心跳(每个动画帧 +1)
0
点上面的按钮,盯着数字看它停不停。
- 让出主线程的三种方式:
setTimeout(0)让出到下一个宏任务;await Promise.resolve()让到微任务队列(但微任务会在渲染前被清空,所以不能让渲染插入);scheduler.yield()是新的原生方案,优先级更合理。 - 为什么是 5ms:留出余量,让浏览器有机会在 16.7ms 的一帧里完成渲染。切片太大等于没切。
- 不只是 JS 长任务:大规模 DOM 插入、复杂 CSS 选择器匹配、频繁的重排都会让主线程变忙,本质一样。
- 能挪走的就别留在主线程:解析大 JSON、加解密、图片处理可以放 Web Worker,这才是真正的并行。
面试怎么答长任务指占用主线程超过 50ms 的任务,会让输入和渲染排队。优化方式是把任务切片,每 5ms 左右让出主线程,用 setTimeout 或 scheduler.yield,让浏览器有机会处理输入和渲染。计算密集型任务应该放到 Web Worker。
7
布局抖动:读写分离★
为什么一个循环能卡到掉帧
浏览器为了性能会攒着样式改动、批量计算。但只要你读一个依赖布局的属性,它就必须立刻把攒的改动全算完。循环里读一次写一次,就变成每轮一次强制同步布局。
触发强制布局的读操作
- offsetTop / offsetLeft / offsetWidth / offsetHeight
- scrollTop / scrollHeight
- clientWidth / clientHeight
- getComputedStyle()
- getBoundingClientRect()
只写不读就不会抖
- 把读集中在前一轮
- 把写集中到后一轮
- 需要动画就用 transform
- 用 requestAnimationFrame 包住写操作
面试怎么答布局抖动的根因是读写交替:读布局属性会强制浏览器立刻完成之前所有待处理的样式计算。解决办法是把读操作和写操作分批,先全部读出来再统一写,或者用 requestAnimationFrame 把读写分到不同帧。
8
动画:只动 transform 和 opacity★
这两个属性可以完全绕开布局和绘制
动画卡不卡,取决于它触发了哪一步。改
width 要走布局和绘制,改 transform 只需要合成——在 GPU 上挪一下图层,几乎不占主线程。改 width/topLayout → Paint → Composite
→
改 colorPaint → Composite
→
改 transformComposite
→
改 opacityComposite
- will-change 是"提前提层",不是"加速开关":它会常驻占用显存。动画结束后应移除,否则大量图层反而拖慢合成。
- 别用 setInterval 做动画:它和屏幕刷新不同步,会掉帧或堆积。必须用
requestAnimationFrame,它在页面不可见时会自动暂停。 - CSS 动画优先:能用 CSS transition/animation 就别用 JS 逐帧算,前者可以跑在合成线程上,主线程忙的时候也不卡。
面试怎么答动画性能取决于触发的渲染阶段。改几何属性触发重排最贵,改颜色触发重绘,改 transform 和 opacity 只触发合成,可以跑在 GPU 上。所以动画优先用这两个属性,用 requestAnimationFrame 驱动,will-change 要谨慎使用并及时移除。
9
虚拟列表:只渲染看得见的那几行
一万条数据不该产生一万个 DOM
虚拟列表的核心就三步:只渲染可视区 + 上下缓冲、用总高度撑出滚动条、用 transform 偏移到正确位置。定高和不定高是两种实现难度。
最小实现的关键计算// 已知每行高 40,容器高 600,滚动到 scrollTop
const start = Math.floor(scrollTop / ROW_H) - BUFFER; // 可视区第一行
const visibleCount = Math.ceil(VIEW_H / ROW_H) + BUFFER * 2;
const end = start + visibleCount;
// 只渲染 [start, end) 这一段
// 外层容器高度 = total * ROW_H,撑出滚动条
// 内层偏移 = start * ROW_H,用 transform 而不是 top
- 为什么用 transform 而不是 top:改 top 会触发重排,改 transform 只触发合成。列表滚动时这个差别非常明显。
- 不定高怎么办:要么先估算、渲染后回填真实高度(会导致滚动条跳动),要么用
ResizeObserver动态测量并维护位置缓存。 - 别忘了一致性:虚拟列表会让 Ctrl+F 搜不到屏幕外的内容、也会影响无障碍朗读,所以更适合后台表格这类场景,不适合正文。
- 简单场景有更轻的方案:只加
content-visibility: auto,浏览器会自动跳过屏幕外元素的渲染,代码量是零。
面试怎么答虚拟列表只渲染可视区和缓冲区内的元素,用总高度撑出滚动条,用 transform 偏移内容位置。关键是避免改 top 触发重排。不定高的场景需要动态测量并缓存位置,实现复杂度会明显上升。
10
缓存分层:从内存到 CDN
同一个请求可能被五层挡下来
浏览器请求一个资源,会依次经过内存缓存 → Service Worker → 磁盘 HTTP 缓存 → CDN → 源站。优化缓存不是"加个 Cache-Control",而是想清楚每一层该放什么。
内存缓存当前页面会话,关掉就没
→
Service Worker你写的策略,可离线
→
HTTP 缓存max-age / ETag
→
CDN边缘节点,按地域命中
→
源站最后一道
- 内存缓存是隐式的:同一个页面里再次请求同一资源,可能直接命中内存,连 HTTP 缓存都不查。所以你改了配置却发现"没生效",可能只是内存缓存。
- CDN 的命中和回源:命中率高才是省钱又快的关键。缓存键要包含所有影响内容的维度(比如 Vary、query 参数),否则会串内容。
- 分层的目的不同:HTTP 缓存求"快",CDN 求"近",SW 求"可控 + 离线"。它们不是互相替代,而是叠加。
面试怎么答缓存是分层的:内存缓存、Service Worker、HTTP 缓存、CDN、源站依次兜底。静态资源用内容 hash 配长 max-age 走 HTTP 缓存和 CDN,HTML 用 no-cache 保证入口及时更新,需要离线能力时再加 Service Worker。
11
打包层面:能删的删,能拆的拆
性能优化里性价比最高的一段
打包优化只有两件事:让代码变小(tree-shaking、按需引入、替代大依赖)和让首屏少加载(代码分割、路由懒加载)。前者省带宽,后者省时间。
让体积变小
- 按需引入(
import { x } from lodash-es) - 用轻量替代(dayjs 替 moment)
- 检查有没有整包引入图标库
- production 构建 + 压缩 + brotli
让首屏变少
- 路由懒加载
- 把不关键的第三方脚本延后加载
- 抽出公共依赖,利用缓存
- 分析包体,找最大的那几块
- 先测量再动手:用
webpack-bundle-analyzer或rollup-plugin-visualizer看包体构成。多数项目最大的那块往往是一个没注意到的依赖。 - 压缩算法有实际差别:Brotli 比 gzip 通常再小 15–20%,静态资源应该开 brotli 并保留 gzip 兜底。
- 被忽略的大头常常是字体和图片:JS 优化半天不如字体子集化、图片换格式来得直接。
面试怎么答打包优化分两条线:减小体积靠 tree-shaking、按需引入、替换大依赖和 brotli 压缩;减少首屏加载靠路由懒加载和代码分割。动手前先用包体分析工具看最大的那几块,通常收益比逐项优化大得多。
12
前端内存泄漏的四种样子★
SPA 里特别容易中招
单页应用不刷新页面,所以泄漏会一直累积。四种典型:忘清定时器、忘解绑监听、闭包留住大对象、缓存无限增长。
- 怎么发现:DevTools → Memory → Heap Snapshot,在路由来回切换十次前后各拍一张,做 Comparison,看 Detached DOM 和某个类实例是不是在涨。
- 框架通常有统一出口:Vue 的
onBeforeUnmount、React 的useEffect清理函数。所有"启动"的动作都要有对应的"停止"。 - 缓存就该有淘汰策略:Map 做缓存要限长,或者直接用
WeakMap(键没引用就自动回收)。
面试怎么答前端泄漏主要是忘清定时器和事件监听、闭包持有大对象、缓存无上限增长、异步回调持有已卸载组件的引用。排查用堆快照做前后对比看增长,修复靠把所有启动动作和清理动作配对,缓存加容量上限或改用 WeakMap。
13
性能预算与线上监控
没有预算的优化永远做不完
性能不是"越优化越好",而是定一个能接受的上限,然后守住它。预算要写进 CI,监控要看分位数而不是平均值。
预算写成可执行的规则// 体积预算:超过就构建失败
budgets: [
{ type: 'initial', maximumWarning: '200kb', maximumError: '300kb' },
{ type: 'anyComponentStyle', maximumWarning: '6kb' },
]
// 指标预算:写在 CI 里比对
LCP p75 <= 2500ms
INP p75 <= 200ms
CLS p75 <= 0.1
- 看 p75,不看平均:平均值会被快的那部分用户拉漂亮,而体验差的是那 25%——他们才决定真实口碑。
- 按设备/网络分层看:低端安卓上的表现和你的开发机差一个数量级。切分维度才能找到真问题。
- 要有回归检测:每次合并前跑一次 Lighthouse 或 bundler 体积对比,涨了就要说明理由。否则半年后没人知道是哪次改动拖慢的。
- 先优化影响最大的那一段:首屏慢就先解决 LCP,交互卡就先解决长任务,别看哪个指标分数低就瞎改。
面试怎么答性能预算就是把体积和指标的阈值写进 CI,超标直接失败。监控要看 p75 分位而不是平均值,按低端设备和弱网分层分析。每次合入都做回归比对,避免性能随时间悄悄退化。
14
速答清单
考前十分钟只看这页
01三个核心指标是什么?
LCP 加载、INP 交互、CLS 稳定。FID 已被 INP 取代,因为它不覆盖处理时间。
02CSS 和 JS 分别阻塞什么?
CSS 阻塞渲染(怕样式闪烁),JS 阻塞 DOM 解析(因为可能改 DOM)。
03defer 和 async 的区别?
defer 保序且在 DOMContentLoaded 前执行;async 谁先下完谁跑,不保序。有依赖用 defer。
04preload 和 prefetch?
preload 是当前页关键资源,必须写对 as 和 crossorigin;prefetch 是空闲时预取下一个页面。
05图片怎么优化?
AVIF/WebP 加 picture 降级、srcset 响应式、必须写宽高防 CLS、首屏图不能 lazy。
06什么是长任务?
占用主线程超过 50ms。切片处理,每 5ms 左右让出主线程。
07布局抖动怎么解决?
读写分离。读 offsetWidth 等属性会强制同步布局,把读和写分批就好。
08为什么动画只用 transform?
只触发合成,不经过布局和绘制,可以跑在 GPU 上。
09虚拟列表的原理?
只渲染可视区和缓冲,用总高度撑滚动条,用 transform 偏移内容。
10性能预算怎么做?
体积和指标阈值写进 CI,超标就失败;监控看 p75 不看平均值,按设备分层。