性能优化
你简历上「优化 TBT / FP / FCP / CLS / TTI / SEO」是真实经历,这页要确保被追问时能讲出机制。
:::tip 答这题的框架 不要一上来背清单。 先问 / 明确:优化哪个指标?
- 首屏慢 → LCP → 关键资源加载路径
- 点击没反应 → INP / TBT → 主线程长任务
- 页面跳动 → CLS → 元素尺寸预留
- 动画卡 → 帧率 → 合成层和重排
然后按 测量 → 定位瓶颈 → 针对性优化 → 验证 讲。有数据的优化才算优化。 :::
🔥 Core Web Vitals
| 指标 | 衡量 | 好 | 差 |
|---|---|---|---|
| LCP(Largest Contentful Paint) | 最大内容元素渲染完成 —— 加载性能 | ≤2.5s | >4s |
| INP(Interaction to Next Paint) | 交互到下一帧渲染 —— 响应性(2024 年取代 FID) | ≤200ms | >500ms |
| CLS(Cumulative Layout Shift) | 累积布局偏移 —— 视觉稳定性 | ≤0.1 | >0.25 |
其他相关指标:
- FCP(First Contentful Paint)—— 首次有内容绘制,衡量「白屏时间」
- TTFB(Time to First Byte)—— 服务端响应速度,是 LCP 的前置
- TBT(Total Blocking Time)—— 所有长任务(>50ms)超出 50ms 的部分之和。实验室环境下 INP 的代理指标
- TTI(Time to Interactive)—— 可交互时间
- FID —— 已被 INP 取代(FID 只测「第一次交互的延迟」,INP 测所有交互的整体表现,更真实)
各指标的优化手段
LCP —— 它的构成是 TTFB + 资源加载时间 + 渲染时间:
- 找出 LCP 元素是什么(通常是首屏大图或标题块)—— DevTools Performance 面板直接标注
- 如果是图片:
<link rel="preload" as="image">提前加载、用fetchpriority="high"、首屏图片绝对不要loading="lazy"(这是常见的反向优化)、用 WebP/AVIF、响应式图片(srcset) - 减少 TTFB:CDN、服务端缓存、SSR/SSG
- 消除渲染阻塞资源:关键 CSS 内联、非关键 CSS 异步加载、JS 用
defer/async - 避免 LCP 元素依赖 JS 才能显示(纯 CSR 的 LCP 必然很差)
INP / TBT —— 核心是减少主线程长任务:
- 代码分割 —— 路由级和组件级懒加载,减少首屏 JS 体积
- 长任务切片 —— 把大计算拆成小块,用
scheduler.yield()(新 API)或MessageChannel让出主线程 - Web Worker —— 把纯计算移出主线程
- 减少不必要的重渲染 —— React 的 memo 化、虚拟列表
- 防抖节流 —— 高频事件(输入、滚动、resize)
passive: true—— 滚动相关事件
CLS —— 布局偏移的四个主要来源:
- 图片 / 视频没有尺寸 → 给
width/height属性或aspect-ratio(这样浏览器能提前预留空间) - 动态插入的内容(广告、通知条)→ 预留固定高度的容器
- Web Font 导致的文字重排(FOUT) →
font-display: swap+size-adjust或preload字体 - 骨架屏尺寸和真实内容不一致 → 对齐尺寸
注意:用户交互 500ms 内触发的布局变化不计入 CLS(因为那是预期的)。
资源加载优化
资源提示(Resource Hints):
| 标签 | 作用 | 用在 |
|---|---|---|
dns-prefetch | 提前做 DNS 解析 | 第三方域名 |
preconnect | 提前建立连接(DNS + TCP + TLS) | 确定会用到的关键第三方域(字体、API) |
preload | 提前加载当前页需要的资源,不执行 | 关键字体、首屏图片、关键 CSS |
prefetch | 空闲时加载下一个页面可能用的资源,低优先级 | 预测的下一步导航 |
modulepreload | 预加载 ES 模块(含依赖) | 关键模块 |
preload 用错会变负优化 —— 它是高优先级,会和真正的关键资源抢带宽。只对「确定首屏需要、但发现得晚」的资源用(比如 CSS 里 @font-face 引用的字体,浏览器要等 CSS 解析完才发现它)。
图片优化(通常是页面体积的大头):
<img src="hero.jpg"
srcset="hero-400.webp 400w, hero-800.webp 800w, hero-1600.webp 1600w"
sizes="(max-width: 600px) 100vw, 50vw"
width="1600" height="900"
fetchpriority="high"
alt="...">
- 格式:AVIF(最小)> WebP(兼容性好)> JPEG。用
<picture>做降级 - 响应式:
srcset+sizes让浏览器按屏幕和 DPR 选最合适的 - 懒加载:
loading="lazy",但首屏图片不要加 - 必须写
width/height—— 防 CLS - CDN 实时裁剪 —— 按需生成尺寸,不要前端拿原图缩放
字体优化:font-display: swap(先用后备字体显示,字体到了再换,避免不可见文字)、子集化(中文字体必做,全量字体几 MB)、preload 关键字体、优先用系统字体栈。
渲染层面
虚拟列表 —— 长列表只渲染可见区域的若干项。这是「渲染一万条数据」的标准答案。要点:容器固定高度 + 内部撑开总高度的占位元素 + 根据 scrollTop 算可见范围 + 上下各多渲染几个缓冲项。不定高的情况要动态测量并缓存高度。
合成层(GPU 加速) —— 用 transform/opacity 做动画,它们跳过布局和绘制,直接在合成线程执行,不占主线程。will-change: transform 可以提前提升为独立层,但滥用会导致内存暴涨(每层都要单独的位图),用完要移除。
content-visibility: auto —— 告诉浏览器「这个元素不在视口内时跳过渲染」,对长页面效果显著。配合 contain-intrinsic-size 避免滚动条跳动。
减少重排 —— 见浏览器原理的强制同步布局。
构建与传输
代码分割:
// 路由级(最重要)
const Page = lazy(() => import('./Page'))
// 组件级:弹窗、图表、编辑器这类非首屏必需的
const Chart = lazy(() => import('./Chart'))
// 条件加载:只在需要时才下载 polyfill
if (!window.IntersectionObserver) await import('intersection-observer')
Tree Shaking 生效的前提(高频追问):
- 必须是 ESM(
import/export),CommonJS 的动态性让静态分析无法进行 - 没有副作用 —— 在
package.json里声明"sideEffects": false,或列出有副作用的文件 - 不要用
import * as X后整体传递
常见失效原因:引入了整个库(import _ from 'lodash' 而不是 lodash-es 的按需导入)、Babel 把 ESM 转成了 CJS(要设 modules: false)、类的方法无法被 shake(因为可能被动态访问)。
压缩与传输 —— Brotli 比 gzip 小 15~25%,现代浏览器都支持;静态资源用 CDN;HTTP/2 下不要合并文件和域名分片(见网络与缓存)。
缓存策略 —— 带哈希的静态资源永久缓存 + HTML 不缓存。见网络与缓存。
测量工具
| 工具 | 用途 |
|---|---|
| Lighthouse | 实验室数据,给评分和具体建议。注意是模拟环境,和真实用户体验有差距 |
| DevTools Performance | 火焰图,定位长任务、重排、具体函数耗时。最重要的工具 |
| DevTools Coverage | 找未使用的 CSS/JS 代码 |
| web-vitals 库 | 采集真实用户(RUM)的 CWV 数据上报 |
| CrUX / PageSpeed Insights | Chrome 真实用户数据(有的话比 Lighthouse 可信) |
PerformanceObserver | 自己采集各类性能条目 |
| Bundle Analyzer | 可视化产物体积构成,找出意外引入的大包 |
:::warning 实验室数据 ≠ 真实数据 Lighthouse 跑 95 分不代表用户体验好 —— 它用的是固定的模拟网速和设备。真实用户数据(RUM)才是最终标准,尤其要看 p75 分位而不是平均值(CWV 的官方标准就是 p75)。
面试主动区分这两者,比只会报 Lighthouse 分数专业得多。 :::
怎么讲自己的优化经历
你简历上有「优化 X4 产品页的 TBT/FCP/CLS」,被追问时按这个结构答:
- 当时的问题和数据 —— 「LCP 4.2s,首屏是一张 2MB 的 PNG,且被 GSAP 的初始化阻塞」
- 怎么定位的 —— 「Performance 面板发现主线程有一个 800ms 的长任务,是动画库在解析配置」
- 做了什么,为什么这么选 —— 「图片换 WebP + 响应式 srcset(体积降 70%);动画初始化推迟到
requestIdleCallback;首屏图片加preload和fetchpriority=high」 - 结果 —— 「LCP 从 4.2s 降到 1.8s,TBT 从 800ms 降到 120ms」
- 代价和权衡 —— 「WebP 需要对老 Safari 做
<picture>降级;动画推迟导致首次进入有轻微延迟感,所以加了 CSS 的静态初始态避免闪烁」
第 5 点最加分 —— 能主动说出代价,说明你真的做过而不是背的。