Skip to content

性能优化

性能优化的第一课是先定义指标,再谈优化——没有度量就动手,只会陷入"自我感觉变快了"。本文从指标模型讲起,依次覆盖渲染性能、加载与体积、再到测量与监控闭环。所有做法遵循一条主线:让关键资源更快到达、让关键渲染更早完成、让主线程永不空闲太久。

一、指标模型:先知道该看什么

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 首屏脚本瘦身三板斧

  1. 路由/组件懒加载(见构建工具篇 §3):首屏外代码不进主包;
  2. 第三方库按需引入 + 换轻量替代:dayjs → date-fns/temporal;moment → 弃;大型图标库只引用到的 svg;
  3. 压缩与指纹: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 打包体积体检清单

  1. vite-bundle-visualizer / webpack-bundle-analyzer 看图找"体积王";
  2. 检查:重复依赖版本(pnpm why)、整包引入 vs 子路径引入、未用代码是否被 tree-shake;
  3. 产物大小 ≠ 加载时间:还要看主包是否包含首屏不需要的代码(拆分后每路由只拉自己)。

四、渲染与应用层优化

框架/场景手段
Reactmemo + 稳定的 props 引用;useDeferredValue/useTransition 让大型更新可中断;列表 key 稳定
Vue计算属性缓存;v-memo 细粒度跳过;<script setup> 编译期优化
AngularOnPush 变更检测;信号驱动更新收敛;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 ...

六、常见坑速查

  1. 只看首页快:首页缓存/直出好但用户路径慢——现场数据(P75 INP)才是真相;
  2. 过度 preload:把 10 个资源都 preload 等于没优先级,还可能抢关键带宽;
  3. 懒加载过头:把 5KB 的组件也懒加载 → 额外请求 + 空白闪烁,净收益为负;
  4. 图片优化只做压缩不做占位:CLS 依然暴雷,宽高必须占位;
  5. 数值型动画每秒都在改 DOM:rAF + transform 才是动画的正道;
  6. 把 console.log 留在循环里:控制台输出会拖慢主线程(尤其移动端);
  7. 无视 DevTools Performance 就调优:先录一段再动手,别猜。

参考链接

基于 VitePress 构建 · 内容以知识共享方式沉淀