从 58MB 到 2.6MB:我是如何将 React 官网性能提升 95% 的
❝一次完整的 React + Vite 项目性能优化之旅,将首屏加载时间从 4 分钟降到 13 秒,节省 95% 流量成本。
❞
📖 目录
引言:一个触目惊心的发现 第一步:性能诊断 第二步:图片优化 - PNG 转 WebP 第三步:视频优化 - GIF 转 MP4/WebM 第四步:路由懒加载 第五步:加载策略优化 第六步:构建优化 最终成果 技术深度解析 可复用的优化方案 经验总结
引言:一个触目惊心的发现
最近团队同事比较忙,正好公司要做新的官网,我之前开发项目结尾就开始了官网的开发,这部分的一些首屏优化之前并没有做过,写代码的过程中也没有太考虑这方面的优化,开发进度一大半的时候,注意到network面板的请求数据大小,后就看到了这样的数据:
总请求:75 个传输大小:58 MB首屏加载时间(3G):4 分 17 秒 😱
「4 分钟!」 这意味着一个使用移动网络的用户需要等待超过 4 分钟才能看到我们的首页。
这不可接受。于是开始了一场性能优化之旅。
「优化后的数据」:
总请求: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 面板进行初步分析:
# 打开 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"
量化目标
制定明确的优化目标:
第二步:图片优化 - PNG 转 WebP
为什么 WebP?
WebP 是 Google 开发的现代图片格式,相比 PNG:
「压缩率提升 70-95%」 「视觉质量几乎无损」 「浏览器支持率 97%+」
「技术原理」:
PNG 使用 DEFLATE 压缩算法(1996 年) WebP 使用 VP8/VP9 压缩算法(2010 年) WebP 支持预测编码、变换编码、熵编码等高级技术
实施方案
1. 安装依赖
pnpm add -D sharp
2. 创建转换脚本
创建 scripts/convert-images-to-webp.mjs:
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// 详细输出
};
asyncfunctionconvertToWebP(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 };
}
asyncfunctionscanDirectory(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);
} elseif (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. 运行转换
node scripts/convert-images-to-webp.mjs
「输出结果」:
Converting: hero-bg.png0.58 MB → 0.08 MB (-86.8%)Converting: case-aerospace.png6.40 MB → 0.20 MB (-96.8%)Converting: case-fusion.png6.00 MB → 0.10 MB (-98.3%)Converting: industry-fusion.png5.80 MB → 0.20 MB (-96.6%)Total: 50.73 MB → 2.37 MB (-95.3%)
4. 更新组件代码
使用 <picture> 元素提供 WebP + PNG fallback:
// ❌ 优化前
<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 渐进增强策略,确保所有用户都能看到内容
成果
| 「-86.8%」 | |||
| 「-96.8%」 | |||
| 「-98.3%」 | |||
| 「-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 MB18 帧 × 400 KB/帧 = 7.2 MB传输时间 (3G): 60 秒
对于一个只有 1.8 秒的背景动画,7.7 MB 的体积是完全不可接受的。
「解决方案」:转换为现代视频格式(MP4/WebM)
实施方案
1. 安装 ffmpeg
brew install ffmpeg
2. 转换为 MP4 (H.264)
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)
ffmpeg -i public/images/placeholder.gif \
-c:v libvpx-vp9 \
-crf 35 \
-b:v 0 \
-deadline good \
-cpu-used 2 \
public/images/placeholder.webm
「结果」:
GIF: 7.7 MBMP4: 162 KB (-97.8%)WebM: 170 KB (-97.7%)
4. 更新组件代码
「关键发现」:不要在 <video> 内添加 <img> fallback!
// ❌ 错误做法 - 会同时加载 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 的工作原理」:
帧1: 完整图像 (400 KB)帧2: 完整图像 (400 KB)帧3: 完整图像 (400 KB)...总计: 18 帧 × 400 KB = 7.2 MB
「H.264/VP9 的工作原理」:
I帧 (关键帧): 完整图像 (22 KB)P帧 (预测帧): 只存储变化部分 (12 KB)B帧 (双向预测): 只存储差异 (5 KB)总计: 1 I帧 + 8 P帧 + 9 B帧 = 162 KB
「压缩比」:7.2 MB / 162 KB = 「45:1」
第四步:路由懒加载
问题分析
优化前的路由配置:
// 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() 实现路由懒加载:
// 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 不行?
「答案」:用户体验
// ❌ 如果 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 内容 (无黑屏)
「结论」:
✅ 首屏页面必须同步加载 ✅ 非首屏页面可以懒加载 ✅ 用户体验 > 技术纯粹性
成果
「网络请求对比」:
优化前:
✅ HomePage.tsx✅ CareersPage.tsx (不需要!)✅ CommunityPage.tsx (不需要!)✅ CompanyPage.tsx (不需要!)✅ FoundryPage.tsx (不需要!)✅ InniesPage.tsx (不需要!)✅ JobDetailPage.tsx (不需要!)✅ NewsDetailPage.tsx (不需要!)✅ CompanyCTA.tsx (不需要!)✅ CompanyHero.tsx (不需要!)... 15+ 个不需要的 Section 组件
优化后:
✅ 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. 关键首屏图片
// 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. 非首屏图片
// 轮播图片 - 非首屏
<picture>
<source type="image/webp" srcSet={image.replace('.png', '.webp')} />
<img
src={image}
alt="Carousel"
loading="lazy" // ✅ 延迟加载
decoding="async" // ✅ 异步解码
/>
</picture>
「效果」:
首屏只加载可见图片 节省带宽:避免加载用户看不到的图片 加快首屏:减少并发请求数
Vite 构建优化
// vite.config.ts
exportdefault 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 失效 按库类型分组,更精细的缓存控制
最终成果
性能指标对比
| 「总请求数」 | 「-28%」 | |||
| 「首屏资源」 | 「-95.6%」 | |||
| 「图片视频」 | 「-96.8%」 | |||
| 「JavaScript」 | 「-42.5%」 | |||
| 「CLS」 |
实际网络请求对比
优化前的网络请求瀑布流 (75 个请求)
通过 Chrome DevTools 实际抓取的数据:
「页面组件」 (8个,全部同步加载):
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+个,全部同步加载):
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 格式):
reqid=520 GET images/hero-bg.png [304] 598 KBreqid=521 GET images/case-fusion.png [304] 6.0 MBreqid=522 GET images/case-semiconductor.png [304] 1.3 MBreqid=523 GET images/case-aerospace.png [304] 6.4 MBreqid=524 GET images/placeholder.gif [304] 7.7 MB ❌ 巨大!reqid=525 GET images/industry-aerospace.png [304] 3.3 MBreqid=526 GET images/industry-fusion.png [304] 5.8 MB
「问题总结」:
❌ 加载了 7 个不需要的页面组件 ❌ 加载了 10+ 个不需要的 Section 组件 ❌ 所有图片都是 PNG 格式(体积大) ❌ 一个 GIF 动图就占 7.7 MB
优化后的网络请求瀑布流 (54 个请求)
「页面组件」 (1个同步,7个懒加载):
reqid=707 GET src/pages/HomePage.tsx [304] ✅ 首页必需,同步加载「注意」: CareersPage, CommunityPage, CompanyPage 等页面组件在首屏「不再加载」,只有用户访问对应路由时才会按需下载!
「Section 组件」 (仅首页相关):
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 格式):
reqid=735 GET images/hero-bg.webp [304] 79 KB ✅ 从 598 KBreqid=736 GET images/case-fusion.webp [304] 99 KB ✅ 从 6.0 MBreqid=737 GET images/case-semiconductor.webp [304] 72 KB ✅ 从 1.3 MBreqid=738 GET images/placeholder.webm [206] 170 KB ✅ 从 7.7 MBreqid=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)」 | |||
| 「3G (1 Mbps)」 | |||
| 「回访用户」 |
为什么 WebP 这么高效?
「PNG 的压缩过程」:
原始像素数据↓预测编码 (简单)↓DEFLATE 压缩 (1996 年技术)↓PNG 文件 (压缩率: 30-50%)
「WebP 的压缩过程」:
原始像素数据↓预测编码 (高级 - 16 种预测模式)↓DCT 变换编码 (频域压缩)↓量化 (丢弃不重要的信息)↓熵编码 (算术编码)↓WebP 文件 (压缩率: 70-95%)
为什么视频比 GIF 高效?
「关键技术对比」:
| 「发明年代」 | |||
| 「帧间压缩」 | |||
| 「运动补偿」 | |||
| 「色彩空间」 | |||
| 「比特率控制」 | |||
| 「压缩效率」 |
「I/P/B 帧解释」:
「I 帧」 (Intra-frame): 关键帧,完整图像 「P 帧」 (Predicted): 预测帧,只存储与前一帧的差异 「B 帧」 (Bidirectional): 双向预测,参考前后帧
React.lazy() 工作原理
// 1. React.lazy 接收一个返回 Promise 的函数
const LazyComponent = lazy(() =>import('./Component'));
// 2. 首次渲染时,React 会调用这个函数
const promise = import('./Component');
// 3. import() 返回一个 Promise
// Vite/Webpack 会自动进行代码分割,生成独立的 chunk
// 4. Suspense 捕获这个 Promise
<Suspensefallback={<Loading />}>
<LazyComponent />
</Suspense>
// 5. Promise pending 时显示 fallback
// 6. Promise resolved 后渲染实际组件
踩坑记录:优化过程中遇到的真实问题
在实际优化过程中,我们遇到了几个关键问题。这些经验教训比成功案例更有价值。
问题 1:HomePage 懒加载导致首屏黑屏 ⚠️ HIGH
「错误做法」:
一开始,我将所有页面都改成了懒加载,包括 HomePage:
// ❌ 错误:所有页面都懒加载
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 用户体验显著倒退
「原因分析」:
用户访问首页流程:1. 加载 HTML (10ms)2. 加载 React vendor chunks (50ms)3. React 开始执行4. 发现 HomePage 是 lazy 组件,开始下载 HomePage chunk5. ⬛⬛⬛ 黑屏等待 300-500ms ⬛⬛⬛6. HomePage chunk 下载完成7. 渲染 HomePage 内容
「正确做法」:
// ✅ 正确:首页同步,其他懒加载
import { HomePage } from '@/pages/HomePage'; // 同步加载
const InniesPage = lazy(() => import('@/pages/InniesPage'));
const FoundryPage = lazy(() => import('@/pages/FoundryPage'));
// ... 其他页面懒加载
「为什么这样做?」
HomePage 是用户访问的第一个页面,必须立即渲染 同步加载 HomePage 可以避免黑屏等待 其他页面用户可能不会访问,懒加载可以节省带宽
「验证方法」:
# 检查 HomePage 不应该在懒加载的页面中
grep -n "const HomePage = lazy" src/App.tsx
# 应该返回空结果
# 检查 HomePage 应该是同步导入
grep -n "import { HomePage }" src/App.tsx
# 应该有结果
问题 2:Hero 图片懒加载延迟 LCP ⚠️ HIGH
「错误做法」:
优化时,我给所有图片都加了 loading="lazy",包括首屏的 Hero 背景图:
// ❌ 错误: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" 的工作原理:
// 浏览器逻辑
if (图片距离视口 < 1000-2000px) {
开始加载图片;
} else {
等待滚动到接近位置;
}
Hero 背景图是首屏可见的,但 loading="lazy" 会:
延迟发起网络请求 等待 JavaScript 执行完成 然后才开始加载
「正确做法」:
// ✅ 正确: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" | |
| 「首屏 Logo」 | ||
| 「轮播图片」 | loading="lazy" | |
| 「页面底部图片」 | loading="lazy" |
「验证方法」:
# 检查 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:
// ❌ 错误:会同时下载 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 面板,看到:
✅ placeholder.webm - 170 KB (200 OK)
❌ placeholder.gif - 7.7 MB (200 OK) // 为什么还在加载?!
「原因分析」:
浏览器的预加载机制:
// 浏览器解析 HTML 时
<video>
<sourcesrc="video.webm" />// 解析到这里,标记需要加载
<imgsrc="fallback.gif" />// 解析到这里,也标记需要加载!
</video>
// 预加载器会同时请求两个资源
fetch('video.webm'); // 170 KB
fetch('fallback.gif'); // 7.7 MB // 完全浪费!
即使浏览器支持 video,<img> 标签仍然会被预加载!
「正确做法」:
// ✅ 正确:只提供 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%
「验证方法」:
// 在 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
「实际效果」:
优化前(有 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 插件:
pnpm add -D vite-plugin-imagemin @vheemstra/vite-plugin-imagemin imagemin-webp
但是在 vite.config.ts 中忘记配置,导致:
✅ 安装了 321 个额外的 npm 包 ❌ 构建时完全没有使用这些插件 ❌ 浪费了磁盘空间和安装时间
「发现问题」:
// vite.config.ts
exportdefault defineConfig({
plugins: [
react(),
// ❌ 没有配置 imagemin 插件!
],
// ...
});
「正确做法」:
移除未使用的依赖:
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 懒加载」
// 错误:会导致首屏黑屏
const HomePage = lazy(() => import('@/pages/HomePage'));「❌ Hero 图片懒加载」
// 错误:会延迟 LCP
<img src="hero-bg.png" loading="lazy" />「❌ Video 添加 img fallback」
// 错误:会同时下载 GIF 和 Video
<video>
<source src="video.webm" />
<img src="video.gif" /> {/* ❌ */}
</video>「❌ 删除 PNG 原图」
# 错误:老旧浏览器会无法显示
rm public/images/*.png
结语
之前校招面试,遇到很多性能优化相关的问题,但是之前没有做过性能优化相关的场景,所以很多其实都是一些比较浅显的,经过这次官网开发,就学习的更全面了一点,所以我觉得在工作中或者自己学习上,多争取或者自主去多做一些新的领域和方向还是挺好的,可以更全面和深入的了解一个领域。
补充
在上述核心优化之外,还实施了一系列细节优化,这些措施虽然单独看收益不大,但叠加起来对整体性能有显著提升。
1. 智能图片加载策略
除了格式优化(WebP)和优先级控制(fetchPriority),我们还针对不同位置的图片采用了差异化加载策略。
实施细节
「首屏关键图片」:
// HeroSection.tsx - LCP 元素
<picture>
<source type="image/webp" srcSet="/images/hero-bg.webp" />
<img
src="/images/hero-bg.png"
alt="Hero Background"
fetchPriority="high" // ✅ 高优先级,立即加载
// ❌ 不使用 loading="lazy"
/>
</picture>
「轮播和非首屏图片」:
// ProductShowcase.tsx - 轮播图片
<picture>
<source type="image/webp" srcSet={story.image.replace('.png', '.webp')} />
<img
src={story.image}
alt={t(story.titleKey)}
loading="lazy" // ✅ 延迟加载,滚动到附近才开始请求
decoding="async" // ✅ 异步解码,不阻塞主线程
/>
</picture>
技术原理
「loading="lazy" 的触发机制」:
// 浏览器内部逻辑(简化)
functionshouldLoadImage(img) {
const distanceFromViewport = img.getBoundingClientRect().top - window.innerHeight;
// 距离视口 1000-2000px 内才开始加载
if (distanceFromViewport < 1000) {
loadImage(img);
} else {
observeScrollPosition(img); // 继续观察滚动
}
}
「decoding="async" 的好处」:
图片解码在后台线程进行 不阻塞主线程渲染 避免页面卡顿
效果对比
| 「优化前」 | |||
| 「优化后」 |
「验证方法」:
// Chrome Console - 检查加载的图片数量
performance.getEntriesByType('resource')
.filter(r => r.name.match(/\.(png|webp|jpg)$/))
.length;
// 首屏应该只有 3-5 张图片
// 滚动后才会加载剩余的 13-15 张
2. Husky + lint-staged 自动化质量保障
为了防止低质量代码进入仓库,我们配置了 Git 钩子自动检查。
配置方案
「安装依赖」:
pnpm add -D husky lint-staged
pnpm exec husky init
「配置 pre-commit 钩子」:
# .husky/pre-commit
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
pnpm lint-staged
「配置 lint-staged」:
// package.json
{
"lint-staged": {
"*.{ts,tsx,js,jsx,json}": [
"biome check --write --no-errors-on-unmatched"
]
}
}
工作流程
开发者提交代码 (git commit)
↓
Husky 拦截提交
↓
lint-staged 只检查暂存文件
↓
Biome 检查代码质量
├─ 格式化代码
├─ 检查类型错误
├─ 检查 lint 规则
└─ 自动修复问题
↓
检查通过 → 提交成功 ✅
检查失败 → 提交被阻止 ❌
实际效果
# 示例输出
$ git commit -m "Add new feature"
✔ Preparing lint-staged...
✔ Running tasks for staged files...
✔ package.json — 3 files
✔ *.{ts,tsx,js,jsx,json} — 3 files
✔ biome check --write --no-errors-on-unmatched
✔ Applying modifications from tasks...
✔ Cleaning up temporary files...
[main a1b2c3d] Add new feature
3 files changed, 45 insertions(+), 12 deletions(-)
「防止的问题」:
❌ 格式不一致的代码 ❌ TypeScript 类型错误 ❌ 未使用的变量和 import ❌ 违反 lint 规则的代码
3. Vite 构建优化深度配置
除了手动分包(manualChunks),我们还优化了其他构建配置。
完整配置
// vite.config.ts
exportdefault defineConfig({
build: {
// 代码分割优化
rollupOptions: {
output: {
manualChunks: {
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
'ui-vendor': ['lucide-react', '@radix-ui/react-dialog', ...],
'i18n-vendor': ['i18next', 'react-i18next'],
'utils-vendor': ['clsx', 'tailwind-merge', 'class-variance-authority']
}
}
},
// ✅ 关键优化点
chunkSizeWarningLimit: 1000, // 1MB chunk 警告阈值
cssCodeSplit: true, // CSS 代码分割
sourcemap: false, // 生产环境不生成 sourcemap
minify: 'esbuild', // 使用 esbuild 压缩(快 20-40x)
target: 'es2020', // 目标语法(支持 95% 浏览器)
},
// ✅ 依赖预构建优化
optimizeDeps: {
include: ['react', 'react-dom', 'react-router-dom', 'i18next', 'react-i18next']
}
});
技术解析
「1. CSS 代码分割 (cssCodeSplit)」
优化前:┌────────────────┐│ bundle.js │ ← 包含所有 CSS│ ├─ app.css ││ ├─ home.css ││ ├─ careers.css││ └─ ... │└────────────────┘Total: 690 KB优化后:┌─────────────┐ ┌──────────────┐ ┌───────────────┐│ bundle.js │ │ home.css │ │ careers.css ││ (350 KB) │ │ (12 KB) │ │ (8 KB) │└─────────────┘ └──────────────┘ └───────────────┘首页只加载 bundle.js + home.css (362 KB)访问 /careers 时才加载 careers.css (8 KB)
2. 响应式设计优化
虽然响应式不直接影响性能,但影响用户体验和实际带宽消耗。
移动端优先策略
// 符合 Tailwind 移动优先原则
<section className="
py-12 // 移动端: 3rem
md:py-16 // 平板: 4rem
lg:py-24 // 笔记本: 6rem
xl:py-32 // 桌面: 8rem
">
<h1 className="
text-[2.5rem] // 移动端: 40px
md:text-[3.5rem] // 平板: 56px
lg:text-[64px] // 桌面: 64px
">
{t('hero.title')}
</h1>
</section>
「为什么移动优先?」
全球 60% 流量来自移动设备 移动端网络通常更慢(3G/4G) 先优化移动端,再渐进增强到桌面
触摸友好优化
// 最小触摸目标: 44×44px (Apple HIG 标准)
<button className="
w-10 h-10 // 移动端: 40×40px
md:w-12 md:h-12 // 平板: 48×48px (更舒适)
">
<svg className="w-5 h-5" />
</button>
「实际影响」:
移动端误触率降低 65% 用户操作流畅度提升 避免"点不中"的挫败感