性能优化
性能优化的第一课是先定义指标,再谈优化——没有度量就动手,只会陷入"自我感觉变快了"。本文从指标模型讲起,依次覆盖渲染性能、加载与体积、再到测量与监控闭环。所有做法遵循一条主线:让关键资源更快到达、让关键渲染更早完成、让主线程永不空闲太久。
一、指标模型:先知道该看什么
1.1 Core Web Vitals(2026 生效版本)
| 指标 | 度量什么 | 良好阈值 | 出现阶段 |
|---|---|---|---|
| LCP(Largest Contentful Paint) | 首屏最大内容渲染时间 | ≤ 2.5s | 加载 |
| INP(Interaction to Next Paint) | 用户交互到下一次绘制的延迟(P75) | ≤ 200ms | 交互 |
| CLS(Cumulative Layout Shift) | 布局意外偏移累计分 | ≤ 0.1 | 全周期 |
INP 自 2024 年 3 月起取代 FID 成为官方 Core Web Vitals。FID 只度量"第一次交互的延迟",INP 采样整个会话里最差(P75)的交互,反映的是"持续可交互性"——长任务排查从此不是锦上添花而是必修。
1.2 加载时间线(哪些阶段可优化)
text
DNS 查找 → TCP/TLS 连接 → TTFB(首字节)→ 下载 HTML → 解析 → 子资源下载
→ FCP(首帧内容)→ LCP(最大内容)→ 可交互(脚本执行完)→ 稳定每段的优化杠杆:
| 阶段 | 主要手段 |
|---|---|
| 网络(TTFB/DNS/TLS) | CDN、就近节点、HTTP/2/3、预连接 preconnect |
| HTML 下载 | 压缩(gzip/br)、SSR/预渲染减少空壳 |
| 关键子资源 | preload 关键 CSS/字体/首屏 JS;移除 render-blocking |
| 脚本执行 | 代码分割、defer、瘦身依赖 |
| 渲染 | 见 §2 |
二、渲染性能:把主线程当稀缺资源
2.1 帧预算与长任务
浏览器一帧约 16.7ms(60Hz)。留给 JS + 样式 + 布局 + 绘制的时间只要 10ms 左右。超过 50ms 的同步任务即"长任务(Long Task)",会阻塞输入、推迟 INP。
js
// 把长同步任务切成片段(让出主线程,保证可交互)
const jobs = [...getHugeTaskList()]
function runNext() {
const deadline = performance.now() + 5
do {
processOne(jobs.shift())
} while (jobs.length && performance.now() < deadline)
if (jobs.length) requestAnimationFrame(runNext) // 或 scheduler.postTask
}2.2 避免布局抖动(layout thrash)
读样式 → 写样式 → 又读样式:浏览器每读一次就可能强制同步布局(forced reflow)。批量读写是基本功:
js
// ❌ 抖动:读 offsetHeight 强制 reflow 后再写,循环 N 次 = N 次布局
for (const el of cards) {
el.style.height = el.offsetHeight * 1.2 + 'px' // 每轮都触发同步布局
}
// ✅ 先统一读(浏览器可复用上一次布局),再统一写
const heights = cards.map((el) => el.offsetHeight)
cards.forEach((el, i) => { el.style.height = heights[i] * 1.2 + 'px' })2.3 动画优先走合成器
css
/* ✅ 合成器属性:transform / opacity —— 不触发 layout/paint,走 GPU 合成线程 */
.box { transition: transform .3s ease, opacity .3s ease; }
/* ❌ 触发整棵子树重排:width / height / top / left / margin / font-size */css
/* 对"确定要频繁变换"的子树,显式提升为独立图层 */
.panels { will-change: transform; contain: layout; }
contain: layout还能告诉浏览器"此元素子树不影响外部布局",让布局范围局部化——长列表、大型图表容器都适用。现代浏览器对transform/opacity动画本就自动优化,will-change只在确有需要时加,滥用会吃内存。
2.4 列表与虚拟滚动
上万行表格/瀑布流:只渲染视口内条目。框架侧统一思路——容器高度固定 + 视口裁剪 + 计算起始索引。也可以直接用 content-visibility: auto(CSS 级懒渲染):
css
.list-item { content-visibility: auto; contain-intrinsic-size: 96px; }三、加载与体积:把 payload 做小、做对顺序
3.1 首屏脚本瘦身三板斧
- 路由/组件懒加载(见构建工具篇 §3):首屏外代码不进主包;
- 第三方库按需引入 + 换轻量替代:dayjs → date-fns/temporal;moment → 弃;大型图标库只引用到的 svg;
- 压缩与指纹:gzip/brotli、
contenthash长缓存、immutable缓存头。
html
<!-- 资源提示(loading 顺序工程) -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<link rel="preload" href="/assets/screen.css" as="style">
<link rel="prefetch" href="/assets/next-page.hash.js">对比:
preload= 本次导航的关键资源,尽早下载(用在 LCP 首屏字体/首屏 CSS);prefetch= 下一步可能用到,空闲时下载;preconnect/dns-prefetch= 提前建立跨域连接。
3.2 图片是体积大头
- 格式:AVIF(新一代,体积最小)/ WebP 兜底;
loading="lazy"视口外懒加载; - 响应式:
srcset+sizes让浏览器按视口选尺寸; - 提示宽高(或 CSS
aspect-ratio)→ 防 CLS 最有效的一招; - 图标级用 SVG / 字体子集,不用大图。
html
<img
src="photo-640.webp"
srcset="photo-640.webp 640w, photo-1280.webp 1280w"
sizes="(max-width: 640px) 100vw, 640px"
width="640" height="427" loading="lazy" decoding="async"
alt="示例图片"
>3.3 字体与 LCP
font-display: swap保证文字先以回退字体渲染(FCP 不被卡);preload关键 woff2;用unicode-range子集化(中文场景尤其重要);- 可变字体/只引一个 weight 文件,减少 HTTP 次数。
3.4 打包体积体检清单
vite-bundle-visualizer/webpack-bundle-analyzer看图找"体积王";- 检查:重复依赖版本(pnpm
why)、整包引入 vs 子路径引入、未用代码是否被 tree-shake; - 产物大小 ≠ 加载时间:还要看主包是否包含首屏不需要的代码(拆分后每路由只拉自己)。
四、渲染与应用层优化
| 框架/场景 | 手段 |
|---|---|
| React | memo + 稳定的 props 引用;useDeferredValue/useTransition 让大型更新可中断;列表 key 稳定 |
| Vue | 计算属性缓存;v-memo 细粒度跳过;<script setup> 编译期优化 |
| Angular | OnPush 变更检测;信号驱动更新收敛;trackBy |
| 通用 | 事件委托;passive: true 滚动监听;长列表虚拟滚动;ResizeObserver 替代 window resize 轮询 |
| 交互 | 点击后立刻响应(乐观 UI)→ 微任务里做重活 → 长任务切片 → scheduler.postTask |
js
// 滚动/输入频繁回调,用 rAF 合并写入(代替 setInterval 节流)
let ticking = false
window.addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => { updateUI(); ticking = false })
ticking = true
}
}, { passive: true })五、测量与持续监控(闭环的最后一步)
5.1 实验室 vs 现场
| 实验室(Lighthouse / CI) | 现场(RUM:web-vitals + 上报) | |
|---|---|---|
| 数据 | 模拟环境快照 | 真实用户分布(P75/P95) |
| 用途 | 回归门禁、定位瓶颈 | 长期趋势、慢机/弱网洞察 |
| 坑 | 一次快照 ≠ 常态 | 需要阈值告警与采样策略 |
5.2 页面内自测
js
import { onLCP, onINP, onCLS } from 'web-vitals'
onLCP((m) => console.log('LCP', m.value))
onINP((m) => console.log('INP', m.value))
onCLS((m) => console.log('CLS', m.value))5.3 CI 门禁
sh
# 在 CI 里跑 Lighthouse 并卡阈值(预算超了发不过)
npx lighthouse https://staging.example.com --budget-file=budget.json
npx vite build && npx lighthouse-ci collect --url ...六、常见坑速查
- 只看首页快:首页缓存/直出好但用户路径慢——现场数据(P75 INP)才是真相;
- 过度 preload:把 10 个资源都
preload等于没优先级,还可能抢关键带宽; - 懒加载过头:把 5KB 的组件也懒加载 → 额外请求 + 空白闪烁,净收益为负;
- 图片优化只做压缩不做占位:CLS 依然暴雷,宽高必须占位;
- 数值型动画每秒都在改 DOM:rAF + transform 才是动画的正道;
- 把 console.log 留在循环里:控制台输出会拖慢主线程(尤其移动端);
- 无视 DevTools Performance 就调优:先录一段再动手,别猜。