《前端性能优化实战手册》系列 · 第 1 篇
本篇将用一份真实的 Lighthouse 审计报告为起点,带你走完从诊断到优化的全流程。每一个优化都有前后对比数据。
前言:性能优化的正确打开方式
很多团队的"性能优化"是这么做的:产品说要优化了,开发看看 Lighthouse 报告,做一轮图片压缩、加个懒加载、开个 gzip,分数涨了 10 分,收工。
但真正的性能优化应该是:量化诊断 → 确定瓶颈 → 逐项优化 → 数据验证 → 建立护栏。
这个系列,我会带你用这套方法论,从零开始把一个 60 分的项目优化到 95 分。本篇是第 1 篇:聚焦核心指标诊断和最快见效的 5 大优化。
一、先读懂你的 Lighthouse 报告
1.1 五大核心指标(2026 版权重)
| 指标 | 全称 | 含义 | 你的目标 | 权重 |
|---|---|---|---|---|
| LCP | Largest Contentful Paint | 最大内容绘制时间 | ≤ 2.5s | 25% |
| TBT | Total Blocking Time | 主线程阻塞总时间 | ≤ 200ms | 30% |
| CLS | Cumulative Layout Shift | 累计布局偏移 | ≤ 0.1 | 25% |
| SI | Speed Index | 视觉速度指数 | ≤ 3.4s | 10% |
| FCP | First Contentful Paint | 首次内容绘制 | ≤ 1.8s | 10% |
2026 年更新:INP(Interaction to Next Paint)已在 2024 年 3 月正式替代 FID 成为 Core Web Vital,但在 Lighthouse 综合评分中权重通过 TBT 体现。TBT 高的页面 INP 几乎必然差。
1.2 典型 60 分项目的「病历」
这里模拟一个真实的中型 React 项目初始 Lighthouse 报告:
╔══════════════════════════════════╗
║ Lighthouse Performance: 60 ║
╠══════════════════════════╤═══════╣
║ FCP │ 3.2s ║
║ LCP │ 5.8s ║
║ TBT │ 820ms ║
║ CLS │ 0.35 ║
║ SI │ 6.1s ║
╚══════════════════════════╧═══════╝
问题清单:
- 首屏加载了一个 1.2MB 的
vendor.js(包含 moment.js 全量 + lodash 全量) - Hero Banner 是一张 2.3MB 的 PNG 图片,没有任何压缩和格式优化
- 谷歌分析和第三方客服 SDK 同步加载,阻塞渲染 800ms+
- 自定义字体
Noto Sans SC全量加载,FOIT(Flash of Invisible Text)体验极差 - 首页渲染后布局抖动:图片加载完成前没有预留空间,CLS 爆炸
二、优化一:图片 ------ 投入产出比最高的优化
「先动刀子砍图片」是性能优化的第一铁律。因为在大多数项目中,图片占页面总大小的 60-70%。
2.1 现状诊断
❌ Banner 图片: hero.png → 2.3MB
❌ 产品图片 × 8: 每张约 400KB → 共 3.2MB
❌ 图标: SVG 内联但未优化,重复路径
📊 图片总大小: 5.5MB,占页面总请求的 68%
2.2 优化措施
第一刀:格式转换(PNG → WebP + AVIF 降级)
html
<!-- 优化前:一张 2.3MB 的 PNG -->
<img src="/images/hero.png" alt="Hero Banner" />
<!-- 优化后:AVIF 优先,WebP 降级,PNG 兜底 -->
<picture>
<source srcset="/images/hero.avif" type="image/avif" />
<source srcset="/images/hero.webp" type="image/webp" />
<img
src="/images/hero.png"
alt="Hero Banner"
width="1200"
height="600"
loading="eager"
fetchpriority="high"
/>
</picture>
第二刀:响应式图片
不要给移动端加载桌面端的 2x 图:
html
<img
src="/images/product.jpg"
srcset="
/images/product-400.webp 400w,
/images/product-800.webp 800w,
/images/product-1200.webp 1200w
"
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
alt="产品图"
loading="lazy"
decoding="async"
width="400"
height="300"
/>
decoding="async"让浏览器在空闲时解码图片,不被图片解码阻塞主线程渲染。这个属性经常被忽略,但对 TBT 有直接改善。
第三刀:构建时自动处理
手工转格式不现实。在构建流程中自动化:
js
// vite.config.js --- vite-plugin-imagemin 配置
import viteImagemin from 'vite-plugin-imagemin';
export default defineConfig({
plugins: [
viteImagemin({
gifsicle: { optimizationLevel: 3 },
mozjpeg: { quality: 80 },
pngquant: { quality: [0.7, 0.9] },
webp: { quality: 80 },
}),
],
});
2.3 效果验证
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 图片总大小 | 5.5MB | 1.1MB ↓ 80% |
| LCP | 5.8s | 3.2s ↓ 45% |
| Lighthouse 分 | 60 | 72 ↑ 12 |
三、优化二:JavaScript ------ 砍掉最大的那块肉
3.1 现状诊断:分析你的 Bundle
第一步永远是打开 bundle analyzer,看清你在加载什么:
bash
# Vite 项目
npx vite-bundle-analyzer
# Webpack 项目
npx webpack-bundle-analyzer dist/stats.json
典型发现:
vendor.js: 1.2MB
├── moment.js 380KB (已废弃,换成 dayjs 直接省掉)
├── lodash 270KB (只用了 debounce 和 cloneDeep)
├── antd icons 220KB (全量引入)
├── react-dom 130KB (合理)
└── core-js 90KB (合理)
3.2 优化措施
第一刀:moment.js → day.js
js
// 优化前:全量引入 moment
import moment from 'moment';
moment().format('YYYY-MM-DD');
// 优化后:dayjs 按需引入
import dayjs from 'dayjs';
dayjs().format('YYYY-MM-DD');
// 省下 380KB,API 几乎完全兼容
第二刀:lodash → 按需引入 / 原生替代
js
// 优化前:全量引入
import _ from 'lodash';
_.debounce(fn, 300);
// 优化后:按需引入
import debounce from 'lodash-es/debounce';
// 或直接用原生实现常见工具
// 省下 250KB
第三刀:路由级代码分割
jsx
// 优化前:静态 import,所有路由代码打进一个 bundle
import Dashboard from './pages/Dashboard';
import Analytics from './pages/Analytics';
import Settings from './pages/Settings';
// 优化后:动态 import,每个路由独立 chunk
const Dashboard = lazy(() => import(
/* webpackChunkName: "dashboard" */ './pages/Dashboard'
));
const Analytics = lazy(() => import(
/* webpackChunkName: "analytics" */ './pages/Analytics'
));
const Settings = lazy(() => import(
/* webpackChunkName: "settings" */ './pages/Settings'
));
// 配合 Suspense 边界
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/analytics" element={<Analytics />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
第四刀:第三方脚本异步化
html
<!-- 优化前:同步加载,阻塞渲染 -->
<script src="https://www.googletagmanager.com/gtag/js"></script>
<script src="https://customer-service-sdk.com/widget.js"></script>
<!-- 优化后:异步加载,不阻塞 -->
<script async src="https://www.googletagmanager.com/gtag/js"></script>
<script defer src="https://customer-service-sdk.com/widget.js"></script>
对于非关键的第三方脚本,更好的做法是延迟到空闲时加载:
js
// 在浏览器空闲时才加载客服 SDK
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
const script = document.createElement('script');
script.src = 'https://customer-service-sdk.com/widget.js';
document.body.appendChild(script);
});
} else {
// 降级:延迟 3 秒
setTimeout(() => {
const script = document.createElement('script');
script.src = 'https://customer-service-sdk.com/widget.js';
document.body.appendChild(script);
}, 3000);
}
3.3 Vite / Webpack 自动分块配置
js
// vite.config.js --- Vite 6 的分块策略
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// React 生态单独打一个 chunk(稳定,缓存友好)
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
// UI 组件库单独打包
'ui-vendor': ['antd', '@ant-design/icons'],
// 工具库单独打包
'utils-vendor': ['dayjs', 'lodash-es'],
},
},
},
},
});
3.4 效果验证
| 指标 | 优化前 | 优化后 |
|---|---|---|
| vendor.js 大小 | 1.2MB | 240KB ↓ 80% |
| TBT | 820ms | 340ms ↓ 58% |
| FCP | 3.2s | 1.9s ↓ 41% |
| Lighthouse 分 | 72 | 82 ↑ 10 |
四、优化三:CSS ------ 别让样式阻止首屏渲染
4.1 问题诊断
❌ 全局 CSS 文件 180KB,包含所有未使用样式
❌ Noto Sans SC 字体 CSS 在 <head> 中同步加载
❌ Ant Design 全量样式被打包
❌ 首屏不需要的页面 CSS 也被加载
4.2 优化措施
Critical CSS 内联
首屏渲染需要的 CSS 直接内联到 HTML,其余异步加载:
js
// vite.config.js --- 使用 vite-plugin-critical
import critical from 'vite-plugin-critical';
export default defineConfig({
plugins: [
critical({
inline: true, // 内联
dimensions: [
{ width: 375, height: 812 }, // 移动端优先
{ width: 1440, height: 900 }, // 桌面端
],
}),
],
});
移除未使用的 CSS(PurgeCSS)
js
// postcss.config.js
export default {
plugins: [
require('@fullhuman/postcss-purgecss')({
content: ['./src/**/*.{js,jsx,ts,tsx}', './index.html'],
safelist: ['html', 'body', '#root', /^ant-/], // 保留 Ant Design 动态类名
}),
],
};
按需引入组件库样式
js
// vite.config.js --- antd 按需加载
import Components from 'unplugin-vue-components/vite'; // Vue
// 或 import Components from 'unplugin-antd'; // React
export default defineConfig({
plugins: [
Components({
resolvers: [AntDesignResolver()],
}),
],
});
4.3 效果验证
| 指标 | 优化前 | 优化后 |
|---|---|---|
| CSS 总大小 | 180KB | 45KB ↓ 75% |
| FCP | 1.9s | 1.2s ↓ 37% |
| Lighthouse 分 | 82 | 88 ↑ 6 |
五、优化四:字体 ------ 你的用户看到的可能是一片空白
5.1 问题诊断
css
/* 问题1:FOIT --- 字体加载完成前文字不可见 */
@font-face {
font-family: 'Noto Sans SC';
src: url('/fonts/NotoSansSC-Regular.woff2');
/* 缺少 font-display,浏览器默认行为是 FOIT */
}
/* 问题2:全量字体,Noto Sans SC 完整版约 6MB */
/* 问题3:字体文件没有预加载,等 CSSOM 构建完才开始下载 */
5.2 优化措施
css
/* 优化后的 @font-face */
@font-face {
font-family: 'Noto Sans SC';
src: url('/fonts/NotoSansSC-Regular-subset.woff2') format('woff2');
font-display: swap; /* FOFT 策略:先用系统字体,加载后切换 */
font-weight: 400;
unicode-range: U+4E00-9FFF, U+3000-303F, U+FF00-FFEF; /* 只加载中文 */
}
font-display: swap是性价比最高的 Web 字体策略。FOIT(不可见文本闪烁)比 FOUT(无样式文本闪烁)对用户体验的伤害大得多------用户对着白屏等 3 秒和看着字变一下,心理感受完全不同。
字体子集化
不要给用户加载整个字体文件:
bash
# 使用 glyphhanger 工具生成子集
npx glyphhanger ./dist/index.html --subset=*.woff2
# 或用 Google Fonts 的 subset 参数
# https://fonts.googleapis.com/css2?family=Noto+Sans+SC&text=你的页面所有文字
预加载关键字体
html
<head>
<!-- 预加载:让字体文件在 CSS 解析前就开始下载 -->
<link
rel="preload"
href="/fonts/NotoSansSC-Regular-subset.woff2"
as="font"
type="font/woff2"
crossorigin
/>
</head>
5.3 效果验证
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 字体文件大小 | 6MB | 46KB ↓ 99% |
| 文字可见时间 | 3.1s | 0.6s ↓ 80% |
| CLS(字体导致) | 0.15 | 0.02 ↓ 87% |
六、优化五:CLS ------ 消灭布局抖动
6.1 CLS 是什么,为什么是 60→95 的决胜点
很多项目把 LCP 和 TBT 优化完就卡在 88-90 分,差的那几分几乎全是 CLS。
CLS = 元素在视口中的位移 × 位移距离占视口的比例。换句话说:页面加载过程中,任何你看到的元素位置发生意外移动,都在扣 CLS 分。
6.2 最常见 CLS 元凶及修复
元凶一:图片没有宽高
html
<!-- ❌ 图片加载完成后撑开布局,下方内容被挤下去 -->
<img src="banner.jpg" alt="Banner" />
<!-- ✅ 显式设置宽高或 aspect-ratio -->
<img
src="banner.jpg"
alt="Banner"
width="1200"
height="600"
style="max-width: 100%; height: auto;"
/>
元凶二:动态注入的内容顶开布局
css
/* ❌ Cookie 弹窗/通知栏插入时,整个页面下移 */
/* ✅ 预留固定空间 */
.announcement-bar {
min-height: 48px; /* 即使内容为空也保留高度 */
}
.page-wrapper {
/* 不要让 cookie banner 的插入改变文档流 */
/* 使用 fixed 定位或 overlay 方式 */
}
元凶三:Web 字体导致的文字跳动
css
/* ✅ 给 fallback 字体匹配相近的 size-adjust */
@font-face {
font-family: 'Custom Font';
src: url('/fonts/custom.woff2');
font-display: swap;
size-adjust: 105%; /* 系统字体和自定义字体的尺寸差异补偿 */
}
元凶四:广告/嵌入式 iframe
css
/* ✅ 为广告预留固定空间 */
.ad-slot {
min-width: 300px;
min-height: 250px;
background: #f5f5f5;
}
.ad-slot:empty::after {
content: 'Advertisement';
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: #999;
font-size: 12px;
}
6.3 效果验证
| 指标 | 优化前 | 优化后 |
|---|---|---|
| CLS | 0.35 | 0.06 ↓ 83% |
| Lighthouse 分 | 88 | 95 ↑ 7 |
七、优化全景对比
优化路线图: 60 → 72 → 82 → 88 → 95
┌──────────────────────────────────────────────────┐
│ 步骤 问题 分数提升 │
├──────────────────────────────────────────────────┤
│ ① 图片优化 5.5MB→1.1MB 60 → 72 (+12) │
│ ② JS优化 1.2MB→240KB 72 → 82 (+10) │
│ ③ CSS优化 180KB→45KB 82 → 88 (+6) │
│ ④ 字体优化 6MB→46KB 88 → 91 (+3) │
│ ⑤ CLS消除 0.35→0.06 91 → 95 (+4) │
└──────────────────────────────────────────────────┘
关键指标最终对比:
FCP: 3.2s → 1.1s ↓ 66%
LCP: 5.8s → 2.1s ↓ 64%
TBT: 820ms → 150ms ↓ 82%
CLS: 0.35 → 0.06 ↓ 83%
SI: 6.1s → 2.8s ↓ 54%
八、把优化结果锁死:接入 Lighthouse CI
辛苦优化完,最怕的就是下次发版被同事一个不经意的改动毁掉。用 Lighthouse CI 建立自动化护栏:
yaml
# .github/workflows/lighthouse.yml
name: Lighthouse Performance Check
on: [pull_request]
jobs:
lighthouse:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci && npm run build
- name: Run Lighthouse
uses: treosh/lighthouse-ci-action@v12
with:
urls: |
http://localhost:4173/
http://localhost:4173/products
budgetPath: .github/lighthouse/budget.json
uploadArtifacts: true
- name: Check thresholds
run: |
npx @lhci/cli autorun --config=.github/lighthouse/lighthouserc.js
性能预算配置(budget.json) --- 任何超过预算的 PR 直接拦截:
json
{
"performance": 90,
"accessibility": 90,
"best-practices": 90,
"seo": 85,
"resource-summary": [
{ "resourceType": "script", "budget": 300 },
{ "resourceType": "stylesheet", "budget": 50 },
{ "resourceType": "image", "budget": 500 },
{ "resourceType": "font", "budget": 80 }
]
}
本篇总结 & 下篇预告
本篇核心要点
- 先量化,后动手 --- 不看 Lighthouse 就做优化 = 盲目健身不称体重
- 图片永远是第一刀 --- 占 60%+ 体积,砍得最快
- JS 按路由切割、按需引入 --- moment.js → dayjs 这种替换 ROI 极高
- CSS 只给首屏需要的 --- Critical CSS 内联 + PurgeCSS
- 字体子集化 + font-display: swap --- 6MB 变 46KB
- CLS 是 90→95 的决胜点 --- 图片宽高、动态内容、字体 size-adjust
- 用 Lighthouse CI 锁死你的优化成果
🔜 下篇预告
第 2 篇:《资源加载策略全解》
- HTTP/2 多路复用 vs HTTP/3 QUIC
- 预加载 / 预连接 / 预获取的最佳实践
- CDN 缓存策略和边缘计算(Edge Functions)
- Service Worker 缓存策略
- 资源优先级(fetchpriority)实战