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

4,879 阅读22分钟

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

一次完整的 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%)

这篇文章将完整记录这次优化的全过程,包括遇到的问题、解决方案、技术原理,以及可以直接复用的工具和代码。


项目背景

技术栈

  • 前端框架: 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" → 刷新页面

image.png

发现的问题

  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 MBGood
网络请求75< 60Good
3G 加载时间257 秒< 20 秒Excellent
图片格式PNGWebPModern
路由策略全同步懒加载Optimized

第二步:图片优化 - 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          // 详细输出
};

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. 运行转换
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%)
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
  • 渐进增强策略,确保所有用户都能看到内容

成果

文件优化前优化后压缩率
hero-bg598 KB79 KB-86.8%
case-aerospace6.4 MB202 KB-96.8%
case-fusion6.0 MB99 KB-98.3%
industry-fusion5.8 MB196 KB-96.6%
总计50.7 MB2.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
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 MB
MP4:  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
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 失效
  • 按库类型分组,更精细的缓存控制

最终成果

性能指标对比

指标优化前优化后改善状态
总请求数7554-28%
首屏资源58 MB2.57 MB-95.6%
图片视频31 MB (PNG/GIF)996 KB (WebP/Video)-96.8%
JavaScript~1.2 MB~690 KB-42.5%
CLS0.000.00完美

实际网络请求对比

优化前的网络请求瀑布流 (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 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个懒加载):

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

改善总结

  • ✅ 只加载首页需要的组件(减少 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 的压缩过程

原始像素数据
    ↓
预测编码 (简单)
    ↓
DEFLATE 压缩 (1996 年技术)
    ↓
PNG 文件 (压缩率: 30-50%)

WebP 的压缩过程

原始像素数据
    ↓
预测编码 (高级 - 16 种预测模式)
    ↓
DCT 变换编码 (频域压缩)
    ↓
量化 (丢弃不重要的信息)
    ↓
熵编码 (算术编码)
    ↓
WebP 文件 (压缩率: 70-95%)

为什么视频比 GIF 高效?

关键技术对比

特性GIFH.264VP9
发明年代198720032013
帧间压缩❌ 无✅ I/P/B 帧✅ I/P/B 帧
运动补偿❌ 无✅ 有✅ 有
色彩空间256 色1670 万色1670 万色
比特率控制❌ 固定✅ 可变✅ 可变
压缩效率1x20-30x30-50x

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
<Suspense fallback={<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 chunk
5. ⬛⬛⬛ 黑屏等待 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" 会:

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

正确做法

// ✅ 正确: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"用户可能看不到

验证方法

# 检查 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>
  <source src="video.webm" /> // 解析到这里,标记需要加载
  <img src="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
export default 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> 检查依赖是否被使用

经验总结

关键经验

  1. 测量先于优化

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

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

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

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

常见陷阱

  1. ❌ HomePage 懒加载

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

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

    // 错误:会同时下载 GIF 和 Video
    <video>
      <source src="video.webm" />
      <img src="video.gif" /> {/* ❌ */}
    </video>
    
  4. ❌ 删除 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" 的触发机制:

// 浏览器内部逻辑(简化)
function shouldLoadImage(img) {
  const distanceFromViewport = img.getBoundingClientRect().top - window.innerHeight;

  // 距离视口 1000-2000px 内才开始加载
  if (distanceFromViewport < 1000) {
    loadImage(img);
  } else {
    observeScrollPosition(img); // 继续观察滚动
  }
}

decoding="async" 的好处:

  • 图片解码在后台线程进行
  • 不阻塞主线程渲染
  • 避免页面卡顿
效果对比
优化措施首屏加载图片带宽节省用户体验
优化前18 张0%等待时间长
优化后3 张~80%即时响应 ⚡

验证方法:

// 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
export default 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%
  • 用户操作流畅度提升
  • 避免"点不中"的挫败感