从 58MB 到 2.6MB:我是如何将 React 官网性能提升 95% 的

从 58MB 到 2.6MB:React 官网性能优化实战全记录

一次完整的 React + Vite 项目性能优化之旅,将首屏加载时间从 4 分钟降到 13 秒,节省 95% 流量成本。

📖 目录


引言:一个触目惊心的发现

最近团队同事比较忙,正好公司要做新的官网,我之前开发项目结尾就开始了官网的开发,这部分的一些首屏优化之前并没有做过,写代码的过程中也没有太考虑这方面的优化,开发进度一大半的时候,注意到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" → 刷新页面

发现的问题

  1. 巨大的图片文件 💥

    • case-aerospace.png: 6.4 MB
    • case-fusion.png: 6.0 MB
    • industry-fusion.png: 5.8 MB
    • placeholder.gif: 7.7 MB (一个背景动图!)
  2. 所有页面同步加载 💥

    • 即使用户只访问首页,也要下载所有 8 个页面的代码
    • 首屏加载了 CareersPage, CommunityPage, CompanyPage 等不需要的组件
  3. 没有加载优先级 💥

    • 关键首屏图片和非首屏图片一视同仁
    • 没有使用 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" 会:

  1. 延迟发起网络请求
  2. 等待 JavaScript 执行完成
  3. 然后才开始加载

正确做法

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> 检查依赖是否被使用

经验总结

关键经验

  1. 测量先于优化

    • 不要凭感觉优化,用数据说话
    • Chrome DevTools 是最好的朋友
    • 建立 baseline,量化改善
  2. 优先级排序

    • 图片优化收益最大(通常占 80-90% 流量)
    • 路由懒加载收益次之
    • 代码优化收益较小,但必不可少
  3. 用户体验至上

    • 首屏必须快(< 2 秒)
    • 避免黑屏和布局偏移
    • 移动端优先
  4. 渐进增强

    • 使用现代技术(WebP, Video)
    • 提供降级方案(PNG, GIF)
    • 确保所有用户都能访问

常见陷阱

  1. ❌ HomePage 懒加载

    tsx 复制代码
    // 错误:会导致首屏黑屏
    const HomePage = lazy(() => import('@/pages/HomePage'));
  2. ❌ Hero 图片懒加载

    tsx 复制代码
    // 错误:会延迟 LCP
    <img src="hero-bg.png" loading="lazy" />
  3. ❌ Video 添加 img fallback

    tsx 复制代码
    // 错误:会同时下载 GIF 和 Video
    <video>
      <source src="video.webm" />
      <img src="video.gif" /> {/* ❌ */}
    </video>
  4. ❌ 删除 PNG 原图

    bash 复制代码
    # 错误:老旧浏览器会无法显示
    rm public/images/*.png

结语

这次优化之旅让我深刻理解了前端性能优化的重要性。从 58MB 到 2.6MB,从 4 分钟到 13 秒,真切感受到了优化的重要性。

关键启示

  1. 测量才能改进
    • 没有数据就没有优化的基础
    • Chrome DevTools 是最强大的性能分析工具
    • 建立 baseline,每次优化后对比验证
  2. 优先级很重要
    • 图片/视频优化:收益 80-90%(最重要)
    • 路由懒加载:收益 10-15%(重要)
    • 代码优化:收益 5-10%(必要)
    • 先做收益最大的,再做细节优化
  3. 避免过度优化
    • 不要为了技术纯粹性牺牲用户体验
    • HomePage 必须同步加载,即使违背"全部懒加载"的原则
    • 99% 浏览器支持的功能不需要 fallback
  4. 用户体验至上
    • 首屏必须快(FCP < 1.8s)
    • 避免黑屏和布局偏移
    • 移动端用户优先考虑
    • 渐进增强,不遗漏任何用户
相关推荐
该用户已不存在3 小时前
7个让全栈开发效率起飞的 Bun 工作流
前端·javascript·后端
芙蓉王真的好13 小时前
Angular CDK 响应式工具指南:从基础到自适应布局应用
前端·javascript·angular.js
Boale_H3 小时前
如何获取npm的认证令牌token
前端·npm·node.js
qq_339191143 小时前
vue3 npm run dev局域网可以访问,vue启动设置局域网访问,
前端·vue.js·npm
帅气的花泽类3 小时前
npm error code ERR_SSL_TLSV1_UNRECOGNIZED_NAME
前端·npm·node.js
明仔的阳光午后4 小时前
React 入门 01:快速写一个React的HelloWorld项目
前端·javascript·react.js·前端框架·reactjs·react
sorryhc5 小时前
Webpack中的插件流程是怎么实现的?
前端·webpack·架构
残冬醉离殇5 小时前
原来dom树就是AST!!!
前端
~无忧花开~5 小时前
掌握Axios:前端HTTP请求全攻略
开发语言·前端·学习·js