一次完整的 React + Vite 專案性能優化之旅,將首屏加載時間從 4 分鐘降到 13 秒,節省 95% 流量成本。
最近團隊同事比較忙,正好公司要做新的官網,我之前開發專案結尾就開始了官網的開發,這部分的一些首屏優化之前並沒有做過,寫程式的過程中也沒有太考慮這方面的優化,開發進度一大半的時候,注意到 network 面板的請求資料大小,後就看到了這樣的資料:
總請求:75 個
傳輸大小:58 MB
首屏加載時間(3G):4 分 17 秒 😱
4 分鐘! 這意味著一個使用移動網路的用戶需要等待超過 4 分鐘才能看到我們的首頁。
這不可接受。於是開始了一場性能優化之旅。
優化後的資料:
總請求:54 個 (-28%)
傳輸大小:2.57 MB (-95.6%)
首屏加載時間(3G):13.5 秒 ⚡ (快了 94.7%)
這篇文章將完整記錄這次優化的全過程,包括遇到的問題、解決方案、技術原理,以及可以直接重用的工具和程式碼。
使用 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 (一個背景動圖!)所有頁面同步加載 💥
沒有加載優先級 💥
制定明確的優化目標:
| 指標 | 當前 | 目標 | 達成標準 |
|---|---|---|---|
| 首屏資源 | 58 MB | < 5 MB | Good |
| 網路請求 | 75 | < 60 | Good |
| 3G 加載時間 | 257 秒 | < 20 秒 | Excellent |
| 圖片格式 | PNG | WebP | Modern |
| 路由策略 | 全同步 | 懶加載 | Optimized |
WebP 是 Google 開發的現代圖片格式,相比 PNG:
技術原理:
pnpm add -D sharp
創建 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 // 詳細輸出
};
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');
node scripts/convert-images-to-webp.mjs
輸出結果:
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%)
使用 <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?
| 檔案 | 優化前 | 優化後 | 壓縮率 |
|---|---|---|---|
| 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% |
我們的背景動圖 placeholder.gif 有 7.7 MB,分析後發現:
實際影響:
GIF 檔案: 7.7 MB
18 幀 × 400 KB/幀 = 7.2 MB
傳輸時間 (3G): 60 秒
對於一個只有 1.8 秒的背景動畫,7.7 MB 的體積是完全不可接受的。
解決方案:轉換為現代視頻格式(MP4/WebM)
brew install ffmpeg
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: 編碼速度與品質的平衡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 MB
MP4: 162 KB (-97.8%)
WebM: 170 KB (-97.7%)
關鍵發現:不要在 <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" />
{/* 不添加 <img> fallback */}
</video>
為什麼不需要 GIF fallback?
<img> fallback 會導致瀏覽器預加載 GIFGIF 的工作原理:
幀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>
問題:
使用 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 也懶加載
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 (懶加載)
... 其他頁面按需加載
效果:
瀏覽器默認會平等對待所有圖片,但實際上:
// 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>
為什麼?
fetchPriority="high" 提升加載優先級,改善 LCP 指標loading="lazy",會延遲 LCP// 輪播圖片 - 非首屏
<picture>
<source type="image/webp" srcSet={image.replace('.png', '.webp')} />
<img src={image} alt="Carousel" loading="lazy" decoding="async" /> // ✅ 延遲加載
</picture>
效果:
// 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?
| 指標 | 優化前 | 優化後 | 改善 | 狀態 |
|---|---|---|---|---|
| 總請求數 | 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 個請求)
頁面組件 (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 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
問題總結:
優化後的網路請求瀑布流 (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 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
改善總結:
| 網路環境 | 優化前 | 優化後 | 提升 |
|---|---|---|---|
| 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% |
PNG 的壓縮過程:
原始像素數據
↓
預測編碼 (簡單)
↓
DEFLATE 壓縮 (1996 年技術)
↓
PNG 檔案 (壓縮率: 30-50%)
WebP 的壓縮過程:
原始像素數據
↓
預測編碼 (高級 - 16 種預測模式)
↓
DCT 變換編碼 (頻域壓縮)
↓
量化 (丟弃不重要的信息)
↓
熵編碼 (算術編碼)
↓
WebP 檔案 (壓縮率: 70-95%)
關鍵技術對比:
| 特性 | GIF | H.264 | VP9 |
|---|---|---|---|
| 發明年代 | 1987 | 2003 | 2013 |
| 幀間壓縮 | ❌ 無 | ✅ I/P/B 幀 | ✅ I/P/B 幀 |
| 運動補償 | ❌ 無 | ✅ 有 | ✅ 有 |
| 色彩空間 | 256 色 | 1670 萬色 | 1670 萬色 |
| 比特率控制 | ❌ 固定 | ✅ 可變 | ✅ 可變 |
| 壓縮效率 | 1x | 20-30x | 30-50x |
I/P/B 幀解釋:
// 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 後渲染實際組件
在實際優化過程中,我們遇到了幾個關鍵問題。這些經驗教訓比成功案例更有價值。
錯誤做法:
一開始,我將所有頁面都改成了懶加載,包括 HomePage:
// ❌ 錯誤:所有頁面都懶加載
const HomePage = lazy(() => import('@/pages/HomePage'));
const InniesPage = lazy(() => import('@/pages/InniesPage'));
const FoundryPage = lazy(() => import('@/pages/FoundryPage'));
// ... 其他頁面
問題表現:
原因分析:
用戶訪問首頁流程:
正確做法:
// ✅ 正確:首頁同步,其他懶加載
import { HomePage } from '@/pages/HomePage'; // 同步加載
const InniesPage = lazy(() => import('@/pages/InniesPage'));
const FoundryPage = lazy(() => import('@/pages/FoundryPage'));
// ... 其他頁面懶加載
為什麼這樣做?
驗證方法:
# 檢查 HomePage 不應該在懶加載的頁面中
grep -n "const HomePage = lazy" src/App.tsx
# 應該返回空結果
# 檢查 HomePage 應該是同步導入
grep -n "import { HomePage }" src/App.tsx
# 應該有結果
錯誤做法:
優化時,我給所有圖片都加了 loading="lazy",包括首屏的 Hero 背景圖:
// ❌ 錯誤:Hero 背景圖也懶加載
<picture>
<source type="image/webp" srcSet="/images/innies/hero-background.webp" />
<img src="/images/innies/hero-background.png" loading="lazy" /> // ❌ 這是錯的!
</picture>
問題表現:
原因分析:
loading="lazy" 的工作原理:
// 瀏覽器邏輯
if (圖片距離視口 < 1000-2000px) {
開始加載圖片;
} else {
等待滾動到接近位置;
}
Hero 背景圖是首屏可見的,但 loading="lazy" 會:
正確做法:
// ✅ 正確:Hero 圖片高優先級
<picture>
<source type="image/webp" srcSet="/images/innies/hero-background.webp" />
<img src="/images/innies/hero-background.png" fetchPriority="high" /> // ✅ 高優先級
</picture>