从 58MB 到 2.6MB:React 官网性能优化实战全记录
一次完整的 React + Vite 项目性能优化之旅,将首屏加载时间从 4 分钟降到 13 秒,节省 95% 流量成本。
📖 目录
- 引言:一个触目惊心的发现
- 第一步:性能诊断
- [第二步:图片优化 - PNG 转 WebP](#第二步:图片优化 - PNG 转 WebP "#%E7%AC%AC%E4%BA%8C%E6%AD%A5%E5%9B%BE%E7%89%87%E4%BC%98%E5%8C%96---png-%E8%BD%AC-webp")
- [第三步:视频优化 - GIF 转 MP4/WebM](#第三步:视频优化 - GIF 转 MP4/WebM "#%E7%AC%AC%E4%B8%89%E6%AD%A5%E8%A7%86%E9%A2%91%E4%BC%98%E5%8C%96---gif-%E8%BD%AC-mp4webm")
- 第四步:路由懒加载
- 第五步:加载策略优化
- 第六步:构建优化
- 最终成果
- 技术深度解析
- 可复用的优化方案
- 经验总结
引言:一个触目惊心的发现
最近团队同事比较忙,正好公司要做新的官网,我之前开发项目结尾就开始了官网的开发,这部分的一些首屏优化之前并没有做过,写代码的过程中也没有太考虑这方面的优化,开发进度一大半的时候,注意到network面板的请求数据大小,后就看到了这样的数据:
总请求:75 个
传输大小:58 MB
首屏加载时间(3G):4 分 17 秒 😱
4 分钟! 这意味着一个使用移动网络的用户需要等待超过 4 分钟才能看到我们的首页。
这不可接受。于是开始了一场性能优化之旅。
优化后的数据:
scss
总请求:54 个 (-28%)
传输大小:2.57 MB (-95.6%)
首屏加载时间(3G):13.5 秒 ⚡ (快了 94.7%)
这篇文章将完整记录这次优化的全过程,包括遇到的问题、解决方案、技术原理,以及可以直接复用的工具和代码。
项目背景
技术栈
- 前端框架: React 19 + TypeScript 5
- 构建工具: Vite 7
- 路由: React Router v7
- 样式: Tailwind CSS v4
- 组件库: shadcn/ui (基于 Radix UI)
- 国际化: i18next + react-i18next
- 包管理: pnpm
- 代码质量: Biome
项目规模
- 页面数量: 8 个主要页面(Home, Innies, Foundry, Company, Community, Careers, JobDetail, NewsDetail)
- 组件数量: 50+ 个组件
- 图片资源: 18 个 PNG + 1 个 GIF
- 代码行数: ~5000 行 TypeScript/TSX
第一步:性能诊断
发现问题
使用 Chrome DevTools Performance 面板进行初步分析:
bash
# 打开 Chrome DevTools (F12)
# Network 标签 → 勾选 "Disable cache" → 刷新页面

发现的问题:
-
巨大的图片文件 💥
case-aerospace.png: 6.4 MBcase-fusion.png: 6.0 MBindustry-fusion.png: 5.8 MBplaceholder.gif: 7.7 MB (一个背景动图!)
-
所有页面同步加载 💥
- 即使用户只访问首页,也要下载所有 8 个页面的代码
- 首屏加载了 CareersPage, CommunityPage, CompanyPage 等不需要的组件
-
没有加载优先级 💥
- 关键首屏图片和非首屏图片一视同仁
- 没有使用 fetchPriority 或 loading="lazy"
量化目标
制定明确的优化目标:
| 指标 | 当前 | 目标 | 达成标准 |
|---|---|---|---|
| 首屏资源 | 58 MB | < 5 MB | Good |
| 网络请求 | 75 | < 60 | Good |
| 3G 加载时间 | 257 秒 | < 20 秒 | Excellent |
| 图片格式 | PNG | WebP | Modern |
| 路由策略 | 全同步 | 懒加载 | Optimized |
第二步:图片优化 - PNG 转 WebP
为什么 WebP?
WebP 是 Google 开发的现代图片格式,相比 PNG:
- 压缩率提升 70-95%
- 视觉质量几乎无损
- 浏览器支持率 97%+
技术原理:
- PNG 使用 DEFLATE 压缩算法(1996 年)
- WebP 使用 VP8/VP9 压缩算法(2010 年)
- WebP 支持预测编码、变换编码、熵编码等高级技术
实施方案
1. 安装依赖
bash
pnpm add -D sharp
2. 创建转换脚本
创建 scripts/convert-images-to-webp.mjs:
javascript
import sharp from 'sharp';
import { readdir, stat } from 'node:fs/promises';
import { join } from 'node:path';
const CONFIG = {
quality: 80, // WebP 质量 (0-100)
maxWidth: 1920, // 最大宽度
skipIfExists: false, // 强制重新转换
verbose: true // 详细输出
};
async function convertToWebP(inputPath, outputPath) {
const image = sharp(inputPath);
const metadata = await image.metadata();
// 如果图片过大,自动缩放
let resizeOptions = {};
if (metadata.width > CONFIG.maxWidth) {
resizeOptions = {
width: CONFIG.maxWidth,
withoutEnlargement: true
};
}
await image
.resize(resizeOptions)
.webp({ quality: CONFIG.quality })
.toFile(outputPath);
return { width: metadata.width, height: metadata.height };
}
async function scanDirectory(dir) {
const files = await readdir(dir);
for (const file of files) {
const fullPath = join(dir, file);
const stats = await stat(fullPath);
if (stats.isDirectory()) {
await scanDirectory(fullPath);
} else if (file.endsWith('.png')) {
const outputPath = fullPath.replace('.png', '.webp');
console.log(`Converting: ${file}`);
await convertToWebP(fullPath, outputPath);
const originalSize = stats.size;
const newSize = (await stat(outputPath)).size;
const reduction = ((1 - newSize / originalSize) * 100).toFixed(1);
console.log(` ${(originalSize / 1024 / 1024).toFixed(2)} MB → ${(newSize / 1024 / 1024).toFixed(2)} MB (-${reduction}%)`);
}
}
}
// 执行转换
await scanDirectory('public/images');
3. 运行转换
bash
node scripts/convert-images-to-webp.mjs
输出结果:
vbnet
Converting: hero-bg.png
0.58 MB → 0.08 MB (-86.8%)
Converting: case-aerospace.png
6.40 MB → 0.20 MB (-96.8%)
Converting: case-fusion.png
6.00 MB → 0.10 MB (-98.3%)
Converting: industry-fusion.png
5.80 MB → 0.20 MB (-96.6%)
Total: 50.73 MB → 2.37 MB (-95.3%)
4. 更新组件代码
使用 <picture> 元素提供 WebP + PNG fallback:
tsx
// ❌ 优化前
<img src="/images/hero-bg.png" alt="Hero Background" />
// ✅ 优化后
<picture>
<source type="image/webp" srcSet="/images/hero-bg.webp" />
<img src="/images/hero-bg.png" alt="Hero Background" />
</picture>
为什么需要 fallback?
- WebP 浏览器支持率 97%+
- 老旧浏览器(IE11, Safari < 14)会自动降级到 PNG
- 渐进增强策略,确保所有用户都能看到内容
成果
| 文件 | 优化前 | 优化后 | 压缩率 |
|---|---|---|---|
| hero-bg | 598 KB | 79 KB | -86.8% |
| case-aerospace | 6.4 MB | 202 KB | -96.8% |
| case-fusion | 6.0 MB | 99 KB | -98.3% |
| industry-fusion | 5.8 MB | 196 KB | -96.6% |
| 总计 | 50.7 MB | 2.4 MB | -95.3% |
第三步:视频优化 - GIF 转 MP4/WebM
GIF 的问题
我们的背景动图 placeholder.gif 有 7.7 MB,分析后发现:
- GIF 格式过时:发明于 1987 年
- 没有帧间压缩:每一帧都是完整图像
- 256 色限制:需要抖动处理
- 体积巨大:7.7 MB 对于一个 1.8 秒的动画来说太大了
实际影响:
GIF 文件: 7.7 MB
18 帧 × 400 KB/帧 = 7.2 MB
传输时间 (3G): 60 秒
对于一个只有 1.8 秒的背景动画,7.7 MB 的体积是完全不可接受的。
解决方案:转换为现代视频格式(MP4/WebM)
实施方案
1. 安装 ffmpeg
bash
brew install ffmpeg
2. 转换为 MP4 (H.264)
bash
ffmpeg -i public/images/placeholder.gif \
-movflags faststart \
-pix_fmt yuv420p \
-vf "scale=trunc(iw/2)*2:trunc(ih/2)*2" \
-c:v libx264 \
-crf 28 \
-preset medium \
public/images/placeholder.mp4
参数说明:
-movflags faststart: 支持渐进式加载-pix_fmt yuv420p: 兼容性最好的色彩空间-crf 28: 质量因子,28 是质量与体积的最佳平衡-preset medium: 编码速度与质量的平衡
3. 转换为 WebM (VP9)
bash
ffmpeg -i public/images/placeholder.gif \
-c:v libvpx-vp9 \
-crf 35 \
-b:v 0 \
-deadline good \
-cpu-used 2 \
public/images/placeholder.webm
结果:
makefile
GIF: 7.7 MB
MP4: 162 KB (-97.8%)
WebM: 170 KB (-97.7%)
4. 更新组件代码
关键发现 :不要在 <video> 内添加 <img> fallback!
tsx
// ❌ 错误做法 - 会同时加载 GIF 和 Video
<video autoPlay loop muted playsInline>
<source src="/images/placeholder.webm" type="video/webm" />
<source src="/images/placeholder.mp4" type="video/mp4" />
<img src="/images/placeholder.gif" alt="Fallback" />
</video>
// ✅ 正确做法 - 只加载 Video
<video autoPlay loop muted playsInline>
<source src="/images/placeholder.webm" type="video/webm" />
<source src="/images/placeholder.mp4" type="video/mp4" />
</video>
为什么不需要 GIF fallback?
- 99%+ 浏览器支持 HTML5 video
- WebM 支持率:97%+ (Chrome, Firefox, Edge)
- MP4 支持率:99%+ (所有现代浏览器)
- 添加
<img>fallback 会导致浏览器预加载 GIF
技术深度:为什么视频比 GIF 小这么多?
GIF 的工作原理:
makefile
帧1: 完整图像 (400 KB)
帧2: 完整图像 (400 KB)
帧3: 完整图像 (400 KB)
...
总计: 18 帧 × 400 KB = 7.2 MB
H.264/VP9 的工作原理:
less
I帧 (关键帧): 完整图像 (22 KB)
P帧 (预测帧): 只存储变化部分 (12 KB)
B帧 (双向预测): 只存储差异 (5 KB)
总计: 1 I帧 + 8 P帧 + 9 B帧 = 162 KB
压缩比 :7.2 MB / 162 KB = 45:1
第四步:路由懒加载
问题分析
优化前的路由配置:
tsx
// App.tsx
import { HomePage } from '@/pages/HomePage';
import { CareersPage } from '@/pages/CareersPage';
import { CommunityPage } from '@/pages/CommunityPage';
import { CompanyPage } from '@/pages/CompanyPage';
import { FoundryPage } from '@/pages/FoundryPage';
import { InniesPage } from '@/pages/InniesPage';
import { JobDetailPage } from '@/pages/JobDetailPage';
import { NewsDetailPage } from '@/pages/NewsDetailPage';
// 所有页面打包在一起,首次访问就下载所有代码
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/careers" element={<CareersPage />} />
<Route path="/community" element={<CommunityPage />} />
{/* ... */}
</Routes>
问题:
- 首次访问首页,也会下载 CareersPage, CommunityPage 等代码
- 打包后的 bundle 包含所有页面(~1.2 MB)
- 网络请求增加 21 个(所有页面组件 + Section 组件)
优化方案
使用 React.lazy() 实现路由懒加载:
tsx
// App.tsx
import { lazy, Suspense } from 'react';
// ✅ 首页必须同步加载(避免黑屏)
import { HomePage } from '@/pages/HomePage';
// ✅ 其他页面懒加载
const CareersPage = lazy(() => import('@/pages/CareersPage'));
const CommunityPage = lazy(() => import('@/pages/CommunityPage'));
const CompanyPage = lazy(() => import('@/pages/CompanyPage'));
const FoundryPage = lazy(() => import('@/pages/FoundryPage'));
const InniesPage = lazy(() => import('@/pages/InniesPage'));
const JobDetailPage = lazy(() => import('@/pages/JobDetailPage'));
const NewsDetailPage = lazy(() => import('@/pages/NewsDetailPage'));
// Loading 组件
function LoadingSpinner() {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white" />
</div>
);
}
function App() {
return (
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/careers" element={<CareersPage />} />
<Route path="/community" element={<CommunityPage />} />
{/* ... */}
</Routes>
</Suspense>
);
}
关键问题:为什么 HomePage 不能懒加载?
很多人会问:既然其他页面都懒加载了,为什么 HomePage 不行?
答案:用户体验
tsx
// ❌ 如果 HomePage 也懒加载
const HomePage = lazy(() => import('@/pages/HomePage'));
// 用户访问首页时的流程:
// 1. 加载 HTML
// 2. 加载 React vendor chunks
// 3. 开始执行 React 代码
// 4. 发现 HomePage 是懒加载,开始下载 HomePage chunk
// 5. 黑屏 + 加载指示器 (300-500ms)
// 6. HomePage 下载完成
// 7. 渲染 HomePage 内容
// ✅ HomePage 同步加载
import { HomePage } from '@/pages/HomePage';
// 用户访问首页时的流程:
// 1. 加载 HTML
// 2. 加载 React vendor chunks (已包含 HomePage)
// 3. 开始执行 React 代码
// 4. 立即渲染 HomePage 内容 (无黑屏)
结论:
- ✅ 首屏页面必须同步加载
- ✅ 非首屏页面可以懒加载
- ✅ 用户体验 > 技术纯粹性
成果
网络请求对比:
优化前:
scss
✅ HomePage.tsx
✅ CareersPage.tsx (不需要!)
✅ CommunityPage.tsx (不需要!)
✅ CompanyPage.tsx (不需要!)
✅ FoundryPage.tsx (不需要!)
✅ InniesPage.tsx (不需要!)
✅ JobDetailPage.tsx (不需要!)
✅ NewsDetailPage.tsx (不需要!)
✅ CompanyCTA.tsx (不需要!)
✅ CompanyHero.tsx (不需要!)
... 15+ 个不需要的 Section 组件
优化后:
scss
✅ HomePage.tsx (首页需要)
✅ HeroSection.tsx (首页需要)
✅ ProductShowcase.tsx (首页需要)
... 仅首页相关的 Section 组件
💤 CareersPage.tsx (懒加载)
💤 CommunityPage.tsx (懒加载)
... 其他页面按需加载
效果:
- 首屏请求减少:75 → 54 (-28%)
- JavaScript 减少:~1.2 MB → ~690 KB (-42.5%)
第五步:加载策略优化
fetchPriority 和 loading="lazy"
浏览器默认会平等对待所有图片,但实际上:
- 首屏图片(Hero 背景)应该优先加载
- 非首屏图片(轮播图、底部内容)可以延迟加载
1. 关键首屏图片
tsx
// Hero 背景图 - LCP 元素
<picture>
<source type="image/webp" srcSet="/images/hero-bg.webp" />
<img
src="/images/hero-bg.png"
alt="Hero"
fetchPriority="high" // ✅ 高优先级
// ❌ 不要使用 loading="lazy"
/>
</picture>
为什么?
- Hero 背景通常是 LCP (Largest Contentful Paint) 元素
fetchPriority="high"提升加载优先级,改善 LCP 指标- 绝不能使用
loading="lazy",会延迟 LCP
2. 非首屏图片
tsx
// 轮播图片 - 非首屏
<picture>
<source type="image/webp" srcSet={image.replace('.png', '.webp')} />
<img
src={image}
alt="Carousel"
loading="lazy" // ✅ 延迟加载
decoding="async" // ✅ 异步解码
/>
</picture>
效果:
- 首屏只加载可见图片
- 节省带宽:避免加载用户看不到的图片
- 加快首屏:减少并发请求数
Vite 构建优化
typescript
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// React 核心库
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
// UI 组件库
'ui-vendor': [
'lucide-react',
'@radix-ui/react-dialog',
'@radix-ui/react-slot',
],
// 国际化
'i18n-vendor': ['i18next', 'react-i18next'],
// 工具库
'utils-vendor': [
'clsx',
'tailwind-merge',
'class-variance-authority'
]
}
}
},
cssCodeSplit: true,
minify: 'esbuild',
target: 'es2020'
},
optimizeDeps: {
include: [
'react',
'react-dom',
'react-router-dom',
'i18next',
'react-i18next'
]
}
});
为什么要手动分割 chunks?
- 第三方库更新频率低,可以充分利用浏览器缓存
- 修改业务代码不会使整个 vendor bundle 失效
- 按库类型分组,更精细的缓存控制
最终成果
性能指标对比
| 指标 | 优化前 | 优化后 | 改善 | 状态 |
|---|---|---|---|---|
| 总请求数 | 75 | 54 | -28% | ✅ |
| 首屏资源 | 58 MB | 2.57 MB | -95.6% | ✅ |
| 图片视频 | 31 MB (PNG/GIF) | 996 KB (WebP/Video) | -96.8% | ✅ |
| JavaScript | ~1.2 MB | ~690 KB | -42.5% | ✅ |
| CLS | 0.00 | 0.00 | 完美 | ✅ |
实际网络请求对比
优化前的网络请求瀑布流 (75 个请求)
通过 Chrome DevTools 实际抓取的数据:
页面组件 (8个,全部同步加载):
css
reqid=473 GET src/pages/HomePage.tsx [304]
reqid=474 GET src/pages/CareersPage.tsx [304] ❌ 首屏不需要
reqid=475 GET src/pages/CommunityPage.tsx [304] ❌ 首屏不需要
reqid=476 GET src/pages/CompanyPage.tsx [304] ❌ 首屏不需要
reqid=477 GET src/pages/FoundryPage.tsx [304] ❌ 首屏不需要
reqid=478 GET src/pages/InniesPage.tsx [304] ❌ 首屏不需要
reqid=479 GET src/pages/JobDetailPage.tsx [304] ❌ 首屏不需要
reqid=480 GET src/pages/NewsDetailPage.tsx [304] ❌ 首屏不需要
Section 组件 (15+个,全部同步加载):
css
reqid=488 GET src/components/sections/CompanyCTA.tsx [304] ❌ Company 页面用
reqid=489 GET src/components/sections/CompanyHero.tsx [304] ❌ Company 页面用
reqid=490 GET src/components/sections/CompanyImage.tsx [304] ❌ Company 页面用
reqid=491 GET src/components/sections/CompanyMission.tsx [304] ❌ Company 页面用
reqid=492 GET src/components/sections/CompanyValues.tsx [304] ❌ Company 页面用
reqid=493 GET src/components/sections/CompanyVision.tsx [304] ❌ Company 页面用
reqid=494 GET src/components/sections/CommunityHero.tsx [304] ❌ Community 页面用
reqid=495 GET src/components/sections/CommunityMission.tsx [304]❌ Community 页面用
reqid=504 GET src/components/sections/CareersHero.tsx [304] ❌ Careers 页面用
reqid=505 GET src/components/sections/CareersJobs.tsx [304] ❌ Careers 页面用
图片资源 (PNG 格式):
ini
reqid=520 GET images/hero-bg.png [304] 598 KB
reqid=521 GET images/case-fusion.png [304] 6.0 MB
reqid=522 GET images/case-semiconductor.png [304] 1.3 MB
reqid=523 GET images/case-aerospace.png [304] 6.4 MB
reqid=524 GET images/placeholder.gif [304] 7.7 MB ❌ 巨大!
reqid=525 GET images/industry-aerospace.png [304] 3.3 MB
reqid=526 GET images/industry-fusion.png [304] 5.8 MB
问题总结:
- ❌ 加载了 7 个不需要的页面组件
- ❌ 加载了 10+ 个不需要的 Section 组件
- ❌ 所有图片都是 PNG 格式(体积大)
- ❌ 一个 GIF 动图就占 7.7 MB
优化后的网络请求瀑布流 (54 个请求)
页面组件 (1个同步,7个懒加载):
css
reqid=707 GET src/pages/HomePage.tsx [304] ✅ 首页必需,同步加载
注意 : CareersPage, CommunityPage, CompanyPage 等页面组件在首屏不再加载,只有用户访问对应路由时才会按需下载!
Section 组件 (仅首页相关):
css
reqid=715 GET src/components/sections/CTASection.tsx [304] ✅ 首页用
reqid=716 GET src/components/sections/HeroSection.tsx [304] ✅ 首页用
reqid=717 GET src/components/sections/IndustryDetailSection.tsx [304] ✅ 首页用
reqid=718 GET src/components/sections/IndustryFusionSection.tsx [304] ✅ 首页用
reqid=719 GET src/components/sections/PlaceholderSection.tsx [304] ✅ 首页用
reqid=720 GET src/components/sections/ProductListSection.tsx [304] ✅ 首页用
reqid=721 GET src/components/sections/ProductShowcase.tsx [304] ✅ 首页用
reqid=722 GET src/components/sections/TestimonialsSection.tsx [304] ✅ 首页用
注意 : CompanyCTA, CompanyHero 等组件在首屏不再加载,与对应页面一起懒加载!
图片/视频资源 (WebP + Video 格式):
ini
reqid=735 GET images/hero-bg.webp [304] 79 KB ✅ 从 598 KB
reqid=736 GET images/case-fusion.webp [304] 99 KB ✅ 从 6.0 MB
reqid=737 GET images/case-semiconductor.webp [304] 72 KB ✅ 从 1.3 MB
reqid=738 GET images/placeholder.webm [206] 170 KB ✅ 从 7.7 MB
reqid=739 GET images/case-aerospace.webp [304] 202 KB ✅ 从 6.4 MB
改善总结:
- ✅ 只加载首页需要的组件(减少 21 个请求)
- ✅ 所有图片都转换为 WebP(体积减少 95%+)
- ✅ GIF 转换为 WebM 视频(体积减少 97.7%)
- ✅ 资源总大小从 31 MB 降到 996 KB
用户体验改善
| 网络环境 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 4G (10 Mbps) | 25.8 秒 | 1.4 秒 | ⚡ 快 94.6% |
| 3G (1 Mbps) | 4 分 17 秒 | 13.5 秒 | ⚡ 快 94.7% |
| 回访用户 | 5-10 秒 | 0.1-0.2 秒 | ⚡ 快 97-98% |
为什么 WebP 这么高效?
PNG 的压缩过程:
scss
原始像素数据
↓
预测编码 (简单)
↓
DEFLATE 压缩 (1996 年技术)
↓
PNG 文件 (压缩率: 30-50%)
WebP 的压缩过程:
scss
原始像素数据
↓
预测编码 (高级 - 16 种预测模式)
↓
DCT 变换编码 (频域压缩)
↓
量化 (丢弃不重要的信息)
↓
熵编码 (算术编码)
↓
WebP 文件 (压缩率: 70-95%)
为什么视频比 GIF 高效?
关键技术对比:
| 特性 | GIF | H.264 | VP9 |
|---|---|---|---|
| 发明年代 | 1987 | 2003 | 2013 |
| 帧间压缩 | ❌ 无 | ✅ I/P/B 帧 | ✅ I/P/B 帧 |
| 运动补偿 | ❌ 无 | ✅ 有 | ✅ 有 |
| 色彩空间 | 256 色 | 1670 万色 | 1670 万色 |
| 比特率控制 | ❌ 固定 | ✅ 可变 | ✅ 可变 |
| 压缩效率 | 1x | 20-30x | 30-50x |
I/P/B 帧解释:
- I 帧 (Intra-frame): 关键帧,完整图像
- P 帧 (Predicted): 预测帧,只存储与前一帧的差异
- B 帧 (Bidirectional): 双向预测,参考前后帧
React.lazy() 工作原理
javascript
// 1. React.lazy 接收一个返回 Promise 的函数
const LazyComponent = lazy(() => import('./Component'));
// 2. 首次渲染时,React 会调用这个函数
const promise = import('./Component');
// 3. import() 返回一个 Promise
// Vite/Webpack 会自动进行代码分割,生成独立的 chunk
// 4. Suspense 捕获这个 Promise
<Suspense fallback={<Loading />}>
<LazyComponent />
</Suspense>
// 5. Promise pending 时显示 fallback
// 6. Promise resolved 后渲染实际组件
踩坑记录:优化过程中遇到的真实问题
在实际优化过程中,我们遇到了几个关键问题。这些经验教训比成功案例更有价值。
问题 1:HomePage 懒加载导致首屏黑屏 ⚠️ HIGH
错误做法:
一开始,我将所有页面都改成了懒加载,包括 HomePage:
tsx
// ❌ 错误:所有页面都懒加载
const HomePage = lazy(() => import('@/pages/HomePage'));
const InniesPage = lazy(() => import('@/pages/InniesPage'));
const FoundryPage = lazy(() => import('@/pages/FoundryPage'));
// ... 其他页面
问题表现:
- 用户访问首页时出现明显的黑屏
- 可以看到加载指示器(转圈圈)
- FCP (First Contentful Paint) 从 240ms 退化到 500-700ms
- 用户体验显著倒退
原因分析:
markdown
用户访问首页流程:
1. 加载 HTML (10ms)
2. 加载 React vendor chunks (50ms)
3. React 开始执行
4. 发现 HomePage 是 lazy 组件,开始下载 HomePage chunk
5. ⬛⬛⬛ 黑屏等待 300-500ms ⬛⬛⬛
6. HomePage chunk 下载完成
7. 渲染 HomePage 内容
正确做法:
tsx
// ✅ 正确:首页同步,其他懒加载
import { HomePage } from '@/pages/HomePage'; // 同步加载
const InniesPage = lazy(() => import('@/pages/InniesPage'));
const FoundryPage = lazy(() => import('@/pages/FoundryPage'));
// ... 其他页面懒加载
为什么这样做?
- HomePage 是用户访问的第一个页面,必须立即渲染
- 同步加载 HomePage 可以避免黑屏等待
- 其他页面用户可能不会访问,懒加载可以节省带宽
验证方法:
bash
# 检查 HomePage 不应该在懒加载的页面中
grep -n "const HomePage = lazy" src/App.tsx
# 应该返回空结果
# 检查 HomePage 应该是同步导入
grep -n "import { HomePage }" src/App.tsx
# 应该有结果
问题 2:Hero 图片懒加载延迟 LCP ⚠️ HIGH
错误做法:
优化时,我给所有图片都加了 loading="lazy",包括首屏的 Hero 背景图:
tsx
// ❌ 错误:Hero 背景图也懒加载
<picture>
<source type="image/webp" srcSet="/images/innies/hero-background.webp" />
<img
src="/images/innies/hero-background.png"
loading="lazy" // ❌ 这是错的!
decoding="async"
/>
</picture>
问题表现:
- LCP (Largest Contentful Paint) 指标显著恶化
- Hero 背景图延迟加载,用户看到白色空白
- Performance trace 显示 LCP 从 155ms 退化到 500ms+
原因分析:
loading="lazy" 的工作原理:
javascript
// 浏览器逻辑
if (图片距离视口 < 1000-2000px) {
开始加载图片;
} else {
等待滚动到接近位置;
}
Hero 背景图是首屏可见的,但 loading="lazy" 会:
- 延迟发起网络请求
- 等待 JavaScript 执行完成
- 然后才开始加载
正确做法:
tsx
// ✅ 正确:Hero 图片高优先级
<picture>
<source type="image/webp" srcSet="/images/innies/hero-background.webp" />
<img
src="/images/innies/hero-background.png"
fetchPriority="high" // ✅ 高优先级
// ❌ 不使用 loading="lazy"
/>
</picture>
区分原则:
| 图片类型 | 策略 | 原因 |
|---|---|---|
| 首屏 Hero 背景 | fetchPriority="high" |
LCP 元素,必须优先 |
| 首屏 Logo | 默认 | 小文件,不需要特殊处理 |
| 轮播图片 | loading="lazy" |
非首屏,延迟加载 |
| 页面底部图片 | loading="lazy" |
用户可能看不到 |
验证方法:
bash
# 检查 Hero 图片不应该有 loading="lazy"
grep -r "hero.*loading=\"lazy\"" src/
# 应该返回空结果
# 检查 Hero 图片应该有 fetchPriority="high"
grep -r "hero.*fetchPriority=\"high\"" src/
# 应该有结果
问题 3:Video fallback 导致同时加载 GIF 和 Video ⚠️ CRITICAL
错误做法:
转换完视频后,我为了兼容性添加了 <img> fallback:
tsx
// ❌ 错误:会同时下载 GIF 和 Video!
<video autoPlay loop muted playsInline>
<source src="/images/placeholder.webm" type="video/webm" />
<source src="/images/placeholder.mp4" type="video/mp4" />
<img
src="/images/placeholder.gif"
alt="Fallback"
className="w-full h-full object-cover"
/>
</video>
问题表现:
打开 Chrome DevTools Network 面板,看到:
scss
✅ placeholder.webm - 170 KB (200 OK)
❌ placeholder.gif - 7.7 MB (200 OK) // 为什么还在加载?!
原因分析:
浏览器的预加载机制:
javascript
// 浏览器解析 HTML 时
<video>
<source src="video.webm" /> // 解析到这里,标记需要加载
<img src="fallback.gif" /> // 解析到这里,也标记需要加载!
</video>
// 预加载器会同时请求两个资源
fetch('video.webm'); // 170 KB
fetch('fallback.gif'); // 7.7 MB // 完全浪费!
即使浏览器支持 video,<img> 标签仍然会被预加载!
正确做法:
tsx
// ✅ 正确:只提供 WebM + MP4,不要 GIF fallback
<video autoPlay loop muted playsInline>
<source src="/images/placeholder.webm" type="video/webm" />
<source src="/images/placeholder.mp4" type="video/mp4" />
{/* 不添加 <img> fallback */}
</video>
为什么可以不要 fallback?
- HTML5 video 浏览器支持率:99%+
- WebM (VP9) 支持率:97%+ (Chrome, Firefox, Edge)
- MP4 (H.264) 支持率:99%+ (所有现代浏览器 + Safari)
- 不支持 video 的浏览器 (IE11) 市场份额 < 0.5%
验证方法:
javascript
// 在 Chrome Console 运行
performance.getEntriesByType('resource')
.filter(r => r.name.includes('placeholder'))
.map(r => ({
name: r.name.split('/').pop(),
size: Math.round(r.encodedBodySize / 1024) + ' KB'
}));
// 结果应该只有:
// [{name: "placeholder.webm", size: "170 KB"}]
// 不应该有 placeholder.gif
实际效果:
yaml
优化前(有 img fallback):
placeholder.webm: 170 KB
placeholder.gif: 7.7 MB
总计: 7.87 MB
优化后(无 img fallback):
placeholder.webm: 170 KB
总计: 170 KB
节省: 7.7 MB (-97.8%)
问题 4:未使用的 imagemin 依赖 ⚠️ MEDIUM
错误做法:
在优化初期,我安装了 vite-plugin-imagemin 插件:
bash
pnpm add -D vite-plugin-imagemin @vheemstra/vite-plugin-imagemin imagemin-webp
但是在 vite.config.ts 中忘记配置,导致:
- ✅ 安装了 321 个额外的 npm 包
- ❌ 构建时完全没有使用这些插件
- ❌ 浪费了磁盘空间和安装时间
发现问题:
typescript
// vite.config.ts
export default defineConfig({
plugins: [
react(),
// ❌ 没有配置 imagemin 插件!
],
// ...
});
正确做法:
移除未使用的依赖:
bash
pnpm remove vite-plugin-imagemin @vheemstra/vite-plugin-imagemin imagemin-webp
# 移除了 321 个包
为什么不用 vite-plugin-imagemin?
- 我们已经用脚本 (convert-images-to-webp.mjs) 预处理了所有图片
- 构建时压缩图片会大幅增加构建时间
- 预处理一次,永久使用,更高效
经验教训:
- ✅ 定期审查 package.json,移除未使用的依赖
- ✅ 安装依赖后立即配置和使用
- ✅ 使用
pnpm ls <package>检查依赖是否被使用
经验总结
关键经验
-
测量先于优化
- 不要凭感觉优化,用数据说话
- Chrome DevTools 是最好的朋友
- 建立 baseline,量化改善
-
优先级排序
- 图片优化收益最大(通常占 80-90% 流量)
- 路由懒加载收益次之
- 代码优化收益较小,但必不可少
-
用户体验至上
- 首屏必须快(< 2 秒)
- 避免黑屏和布局偏移
- 移动端优先
-
渐进增强
- 使用现代技术(WebP, Video)
- 提供降级方案(PNG, GIF)
- 确保所有用户都能访问
常见陷阱
-
❌ HomePage 懒加载
tsx// 错误:会导致首屏黑屏 const HomePage = lazy(() => import('@/pages/HomePage')); -
❌ Hero 图片懒加载
tsx// 错误:会延迟 LCP <img src="hero-bg.png" loading="lazy" /> -
❌ Video 添加 img fallback
tsx// 错误:会同时下载 GIF 和 Video <video> <source src="video.webm" /> <img src="video.gif" /> {/* ❌ */} </video> -
❌ 删除 PNG 原图
bash# 错误:老旧浏览器会无法显示 rm public/images/*.png
结语
这次优化之旅让我深刻理解了前端性能优化的重要性。从 58MB 到 2.6MB,从 4 分钟到 13 秒,真切感受到了优化的重要性。
关键启示
- 测量才能改进
- 没有数据就没有优化的基础
- Chrome DevTools 是最强大的性能分析工具
- 建立 baseline,每次优化后对比验证
- 优先级很重要
- 图片/视频优化:收益 80-90%(最重要)
- 路由懒加载:收益 10-15%(重要)
- 代码优化:收益 5-10%(必要)
- 先做收益最大的,再做细节优化
- 避免过度优化
- 不要为了技术纯粹性牺牲用户体验
- HomePage 必须同步加载,即使违背"全部懒加载"的原则
- 99% 浏览器支持的功能不需要 fallback
- 用户体验至上
- 首屏必须快(FCP < 1.8s)
- 避免黑屏和布局偏移
- 移动端用户优先考虑
- 渐进增强,不遗漏任何用户