从 3 秒到 300 毫秒:一次真实的前端首屏性能优化全记录

33 阅读10分钟

前言

上个月接手了一个内部运营后台项目,技术栈是 React 18 + Ant Design + Webpack 5。功能上没什么问题,但首屏加载体验极差——从输入 URL 到页面可交互,冷启动要 3.2 秒,弱网环境下甚至超过 6 秒。

运营同事每天都在用这个系统,抱怨声不断。领导给了一个明确的目标:首屏可交互时间(TTI)压到 1 秒以内。

这篇文章记录从 3.2 秒优化到 300 毫秒的完整过程,每一步都有数据支撑,所有优化手段都是可落地的实战经验,不是纸上谈兵。


一、先搞清楚:慢在哪里

优化之前,先用 Chrome DevTools 的 Performance 面板和 Lighthouse 做了一次全面诊断。

1.1 核心指标现状
指标当前值目标值
FCP(首次内容绘制)2.1s< 1s
LCP(最大内容绘制)3.2s< 1.5s
TTI(可交互时间)3.8s< 1s
TBT(总阻塞时间)1200ms< 200ms
CLS(累积布局偏移)0.25< 0.1
1.2 问题定位

通过 Performance 面板的火焰图和 Network 面板的水瀑布图,定位到三个核心瓶颈:

瓶颈一:JS Bundle 体积过大
├── vendor.js: 1.8MB(gzip 后 520KB)
├── main.js: 860KB(gzip 后 240KB)
└── 总计: 2.66MB(gzip 后 760KB)

瓶颈二:关键渲染路径被阻塞
├── <head> 中加载了 3 个同步 <script>
├── 首屏 CSS 在 JS 执行完才开始加载
└── 字体文件阻塞了文本渲染

瓶颈三:主线程长时间被占用
├── 组件初始化时同步执行了大量计算
├── Ant Design 全量引入,Tree Shaking 失效
└── 首屏渲染前执行了 1200ms 的 JS

一句话总结:包太大、加载顺序不对、主线程被堵死了。


二、第一轮优化:给 Bundle 瘦身

2.1 分析 Bundle 构成

用 webpack-bundle-analyzer 跑了一次分析:

// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

module.exports = {
  plugins: [
    new BundleAnalyzerPlugin({ analyzerMode: 'static' })
  ]
};

分析结果触目惊心:

moment.js          310KB    ← 只用了日期格式化
lodash             280KB    ← 只用了 _.debounce 和 _.cloneDeep
antd               620KB    ← 全量引入,实际用了 12 个组件
echarts            450KB    ← 只有首页图表用到了
@ant-design/icons  180KB    ← 全量引入,实际用了 8 个图标

2.66MB 的 Bundle 里,有 1.84MB 是可以优化掉的。

2.2 按需引入 Ant Design

项目里 Ant Design 是全量引入的,改为按需加载:

// ❌ 之前:全量引入
import { Button, Table, Modal, Form, Input, Select, DatePicker } from 'antd';

// ✅ 之后:按需引入 + babel-plugin-import
// .babelrc
{
  "plugins": [
    ["import", { "libraryName": "antd", "style": true }]
  ]
}

效果:antd 相关体积从 620KB → 85KB(gzip 后)。

2.3 替换 moment.js 为 dayjs

项目里只用了日期格式化和简单的日期计算,完全不需要 moment 这种重量级库。

// ❌ 之前
import moment from 'moment';
moment().format('YYYY-MM-DD HH:mm:ss');

// ✅ 之后
import dayjs from 'dayjs';
dayjs().format('YYYY-MM-DD HH:mm:ss');

dayjs 的 API 和 moment 几乎完全兼容,体积只有 2KB(gzip 后),替换后节省了 ~90KB(gzip 后)。

注意:如果项目中使用了 Ant Design 的 DatePicker 组件,它内部依赖 moment.js,需要额外配置替换为 dayjs,可以通过 antd-dayjs-webpack-plugin 自动处理。

2.4 lodash 按需引入
// ❌ 之前
import _ from 'lodash';
_.debounce(fn, 300);

// ✅ 之后:只引入用到的方法
import debounce from 'lodash/debounce';
import cloneDeep from 'lodash/cloneDeep';

效果:lodash 体积从 280KB → 8KB(gzip 后)。

2.5 echarts 按需引入 + 路由级懒加载

echarts 只在首页的 Dashboard 用到,没必要打进主 Bundle:

// 路由配置
const Dashboard = lazy(() => import('./pages/Dashboard'));

// Dashboard 组件内部,echarts 按需引入
import * as echarts from 'echarts/core';
import { BarChart, LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';

echarts.use([BarChart, LineChart, GridComponent, TooltipComponent, CanvasRenderer]);

效果:echarts 从主 Bundle 中完全剥离,主 Bundle 减少 ~130KB(gzip 后)。

2.6 第一轮优化结果
指标优化前优化后变化
vendor.js (gzip)520KB180KB-65%
main.js (gzip)240KB95KB-60%
总计 (gzip)760KB275KB-64%
TTI3.8s2.1s-45%

包体积砍掉了 64%,TTI 从 3.8 秒降到 2.1 秒。 但距离 1 秒的目标还有差距,继续。


三、第二轮优化:优化关键渲染路径

包体积降下来了,但页面渲染还是慢,因为关键渲染路径上有多个阻塞点。

3.1 CSS 加载优化

之前的 CSS 是通过 JS 动态注入的(style-loader),这意味着 CSS 要等 JS 下载执行完才开始加载。

// ❌ 之前:CSS 通过 JS 注入,阻塞渲染
module: {
  rules: [{
    test: /.css$/,
    use: ['style-loader', 'css-loader']  // CSS 在 JS 执行后才注入 DOM
  }]
}

// ✅ 之后:CSS 提取为独立文件,<head> 中直接加载
const MiniCssExtractPlugin = require('mini-css-extract-plugin');

module: {
  rules: [{
    test: /.css$/,
    use: [MiniCssExtractPlugin.loader, 'css-loader']
  }]
},
plugins: [
  new MiniCssExtractPlugin({
    filename: 'css/[name].[contenthash:8].css'
  })
]

效果:CSS 不再被 JS 阻塞,FCP 提前了约 400ms。

3.2 关键 CSS 内联

首屏只有登录页和 Layout 框架,关键 CSS 体积很小(< 4KB)。把这部分 CSS 直接内联到 HTML 的 <head> 中,消除一次网络请求:

<!DOCTYPE html>
<html>
<head>
  <!-- 关键 CSS 内联 -->
  <style>
    /* 首屏关键样式:Loading、Layout 骨架 */
    body { margin: 0; font-family: ... }
    #root { min-height: 100vh; }
    .loading-spinner { ... }
  </style>

  <!-- 非关键 CSS 异步加载 -->
  <link rel="preload" href="/css/main.abc123.css" as="style"
        onload="this.onload=null;this.rel='stylesheet'">
  <noscript><link rel="stylesheet" href="/css/main.abc123.css"></noscript>
</head>

效果:FCP 再提前约 200ms。

3.3 JS 加载策略优化
<!-- ❌ 之前:同步加载,阻塞 HTML 解析 -->
<script src="/js/vendor.js"></script>
<script src="/js/main.js"></script>

<!-- ✅ 之后:预加载 + defer -->
<link rel="preload" href="/js/vendor.abc123.js" as="script">
<link rel="preload" href="/js/main.def456.js" as="script">
<script src="/js/vendor.abc123.js" defer></script>
<script src="/js/main.def456.js" defer></script>

defer 让 JS 在 HTML 解析完成后、DOMContentLoaded 事件触发前执行,不阻塞 HTML 解析。preload 让浏览器尽早开始下载 JS 文件。

效果:HTML 解析不再被 JS 阻塞,页面骨架更早出现。

3.4 字体加载优化

项目用了自定义字体,之前是同步加载,阻塞了文本渲染:

/* ❌ 之前:font-display 默认 auto,字体加载完才显示文字 */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
}

/* ✅ 之后:font-display: swap,先用系统字体显示,字体加载完再替换 */
@font-face {
  font-family: 'CustomFont';
  src: url('/fonts/custom.woff2') format('woff2');
  font-display: swap;
}

同时通过 <link rel="preload"> 预加载字体文件:

<link rel="preload" href="/fonts/custom.woff2" as="font" type="font/woff2" crossorigin>

效果:文本不再被字体加载阻塞,CLS 从 0.25 降到 0.03。

3.5 第二轮优化结果
指标第一轮后第二轮后变化
FCP1.6s0.8s-50%
LCP2.5s1.2s-52%
TTI2.1s1.3s-38%
CLS0.250.03-88%

FCP 已经降到 1 秒以内了,但 TTI 还在 1.3 秒,差最后一步。


四、第三轮优化:释放主线程

TTI 的瓶颈在于主线程被 JS 执行阻塞了。即使 JS 体积已经很小,但首屏组件初始化时仍然有大量的同步计算。

4.1 问题定位

通过 Performance 面板的 Main 线程火焰图,发现首屏渲染前有一段 600ms 的长任务(Long Task) ,主要来自:

  • Ant Design 组件初始化(Table、Form 等组件的挂载开销)
  • 首页数据请求回来后的同步渲染(大量 DOM 节点一次性创建)
  • 图表初始化(echarts 实例创建 + 数据渲染)
4.2 组件级代码分割

把首屏不需要立即渲染的组件延迟加载:

import { Suspense, lazy, startTransition } from 'react';

// 非首屏组件懒加载
const DataTable = lazy(() => import('./components/DataTable'));
const ChartPanel = lazy(() => import('./components/ChartPanel'));
const NotificationPanel = lazy(() => import('./components/NotificationPanel'));

function Dashboard() {
  return (
    <div>
      {/* 首屏关键内容:同步渲染 */}
      <Header />
      <StatsCards />

      {/* 非首屏内容:延迟渲染 */}
      <Suspense fallback={<Skeleton />}>
        <DataTable />
      </Suspense>
      <Suspense fallback={<Skeleton />}>
        <ChartPanel />
      </Suspense>
    </div>
  );
}
4.3 使用 startTransition 降低渲染优先级

对于非紧急的 UI 更新,用 startTransition 标记,让 React 在空闲时再处理:

import { startTransition } from 'react';

function Dashboard() {
  const [data, setData] = useState(null);

  useEffect(() => {
    fetchDashboardData().then(res => {
      // 用 startTransition 包裹,告诉 React 这不是紧急更新
      startTransition(() => {
        setData(res);
      });
    });
  }, []);

  return (
    <div>
      <Header />
      {data ? <DataTable data={data} /> : <Skeleton />}
    </div>
  );
}
4.4 虚拟滚动替代全量渲染

首页的数据表格默认渲染了全部 500 行数据,DOM 节点数量巨大。改用虚拟滚动,只渲染可视区域内的行:

import { FixedSizeList } from 'react-window';

function DataTable({ data }) {
  return (
    <FixedSizeList
      height={600}
      itemCount={data.length}
      itemSize={48}
      width="100%"
    >
      {({ index, style }) => (
        <div style={style}>
          <DataRow data={data[index]} />
        </div>
      )}
    </FixedSizeList>
  );
}

效果:DOM 节点从 5000+ 降到 200 左右,首次渲染时间从 350ms 降到 30ms。

4.5 图表延迟初始化

echarts 图表不需要在首屏立即渲染,延迟到用户滚动到可视区域时再初始化:

import { useEffect, useRef, useState } from 'react';

function LazyChart({ option }) {
  const chartRef = useRef(null);
  const containerRef = useRef(null);
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          setVisible(true);
          observer.disconnect();
        }
      },
      { threshold: 0.1 }
    );
    observer.observe(containerRef.current);
    return () => observer.disconnect();
  }, []);

  useEffect(() => {
    if (visible && containerRef.current) {
      const chart = echarts.init(containerRef.current);
      chart.setOption(option);
      chartRef.current = chart;
    }
  }, [visible, option]);

  return <div ref={containerRef} style={{ height: 400 }} />;
}

效果:首屏 JS 执行时间减少 ~200ms。

4.6 第三轮优化结果
指标第二轮后第三轮后变化
FCP0.8s0.6s-25%
LCP1.2s0.9s-25%
TTI1.3s0.8s-38%
TBT600ms120ms-80%

五、第四轮优化:网络层面的最后压榨

TTI 已经到了 0.8 秒,但还想再压一压。

5.1 开启 Brotli 压缩

之前的静态资源用的 gzip 压缩,切换到 Brotli(br)压缩,压缩率更高:

# Nginx 配置
brotli on;
brotli_comp_level 6;
brotli_types text/plain text/css application/json application/javascript text/xml application/xml;

# 同时保留 gzip 作为降级方案
gzip on;
gzip_comp_level 6;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml;

效果:JS Bundle 体积再减 ~15% (Brotli 比 gzip 压缩率高 15~20%)。

5.2 HTTP/2 Server Push

对于关键资源,通过 HTTP/2 Server Push 主动推送,省去一次请求往返:

location / {
    add_header Link '</js/vendor.abc123.js>; rel=preload; as=script';
    add_header Link '</css/main.def456.css>; rel=preload; as=style';
}
5.3 Service Worker 缓存策略

对于运营后台这种用户每天反复访问的系统,Service Worker 的缓存收益非常大:

// sw.js
const CACHE_NAME = 'app-v2';
const STATIC_ASSETS = [
  '/js/vendor.abc123.js',
  '/js/main.def456.js',
  '/css/main.ghi789.css',
  '/index.html'
];

// 安装时预缓存关键资源
self.addEventListener('install', event => {
  event.waitUntil(
    caches.open(CACHE_NAME).then(cache => cache.addAll(STATIC_ASSETS))
  );
});

// 拦截请求:优先走缓存
self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(cached => {
      return cached || fetch(event.request).then(response => {
        // 新的静态资源也加入缓存
        if (event.request.url.includes('/js/') || event.request.url.includes('/css/')) {
          const clone = response.clone();
          caches.open(CACHE_NAME).then(cache => cache.put(event.request, clone));
        }
        return response;
      });
    })
  );
});

效果:第二次及之后的访问,关键资源全部命中缓存,FCP 降到 0.3 秒,TTI 降到 0.3 秒。


六、最终成果

指标初始值最终值总提升
FCP2.1s0.3s(缓存命中)-86%
LCP3.2s0.6s(首次) / 0.3s(缓存)-81%
TTI3.8s0.3s(缓存命中)-92%
TBT1200ms80ms-93%
CLS0.250.02-92%
Bundle (gzip)760KB230KB-70%

Lighthouse 评分从 32 分 → 97 分。


七、优化手段速查表

把这次用到的所有优化手段整理成一张表,方便查阅:

优化方向具体手段收益量级
Bundle 瘦身按需引入组件库高
Bundle 瘦身替换重量级依赖(moment→dayjs)中
Bundle 瘦身工具库按需引入(lodash)中
Bundle 瘦身路由级代码分割高
关键渲染路径CSS 提取为独立文件中
关键渲染路径关键 CSS 内联中
关键渲染路径JS defer 加载中
关键渲染路径字体 font-display: swap低~中
主线程释放React.lazy + Suspense中
主线程释放startTransition 降低优先级低~中
主线程释放虚拟滚动中~高
主线程释放IntersectionObserver 延迟初始化中
网络优化Brotli 压缩中
网络优化HTTP/2 Server Push低
网络优化Service Worker 缓存高(重复访问)

八、几点经验总结

先测量,再优化。 不要凭直觉猜哪里慢,用 Performance 面板和 Lighthouse 找到真正的瓶颈。这次优化中,最大的收益来自 Bundle 瘦身和主线程释放,而不是我最初以为的图片优化。

优化有优先级。 Bundle 瘦身 > 关键渲染路径优化 > 主线程释放 > 网络层优化。前面的优化收益大、成本低,后面的优化收益递减但能榨出最后一点性能。

不要过度优化。 最终 TTI 0.3 秒对于内部运营系统来说已经远远够用了。如果是一个内容型网站,可能只需要做前两轮优化就够了,没必要追求极致。

缓存是最大的加速器。 对于用户反复访问的系统,Service Worker 的收益是碾压级的。首次访问的优化做到 1 秒以内就够了,重复访问应该做到"秒开"。