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

前言

上个月接手了一个内部运营后台项目,技术栈是 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 面板的水瀑布图,定位到三个核心瓶颈:

xml 复制代码
瓶颈一: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 跑了一次分析:

ini 复制代码
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;

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

分析结果触目惊心:

bash 复制代码
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 是全量引入的,改为按需加载:

javascript 复制代码
// ❌ 之前:全量引入
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 这种重量级库。

javascript 复制代码
// ❌ 之前
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 按需引入
javascript 复制代码
// ❌ 之前
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:

javascript 复制代码
// 路由配置
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) 520KB 180KB -65%
main.js (gzip) 240KB 95KB -60%
总计 (gzip) 760KB 275KB -64%
TTI 3.8s 2.1s -45%

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


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

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

3.1 CSS 加载优化

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

javascript 复制代码
// ❌ 之前: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> 中,消除一次网络请求:

xml 复制代码
<!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 加载策略优化
xml 复制代码
<!-- ❌ 之前:同步加载,阻塞 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 字体加载优化

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

css 复制代码
/* ❌ 之前: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"> 预加载字体文件:

bash 复制代码
<link rel="preload" href="/fonts/custom.woff2" as="font" type="font/woff2" crossorigin>

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

3.5 第二轮优化结果
指标 第一轮后 第二轮后 变化
FCP 1.6s 0.8s -50%
LCP 2.5s 1.2s -52%
TTI 2.1s 1.3s -38%
CLS 0.25 0.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 组件级代码分割

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

javascript 复制代码
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 在空闲时再处理:

javascript 复制代码
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 节点数量巨大。改用虚拟滚动,只渲染可视区域内的行:

javascript 复制代码
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 图表不需要在首屏立即渲染,延迟到用户滚动到可视区域时再初始化:

ini 复制代码
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 第三轮优化结果
指标 第二轮后 第三轮后 变化
FCP 0.8s 0.6s -25%
LCP 1.2s 0.9s -25%
TTI 1.3s 0.8s -38%
TBT 600ms 120ms -80%

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

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

5.1 开启 Brotli 压缩

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

bash 复制代码
# 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 主动推送,省去一次请求往返:

arduino 复制代码
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 的缓存收益非常大:

ini 复制代码
// 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 秒


六、最终成果

指标 初始值 最终值 总提升
FCP 2.1s 0.3s(缓存命中) -86%
LCP 3.2s 0.6s(首次) / 0.3s(缓存) -81%
TTI 3.8s 0.3s(缓存命中) -92%
TBT 1200ms 80ms -93%
CLS 0.25 0.02 -92%
Bundle (gzip) 760KB 230KB -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 秒以内就够了,重复访问应该做到"秒开"。

相关推荐
whn19771 小时前
dm9安装初体验
前端
阿黎梨梨1 小时前
从零搞懂 JWT 登录鉴权:一张“电子通行证”的诞生记
前端
用户921080262861 小时前
ChatMessageList 消息列表组件:基于 BubbleList 搭建 AI 对话展示区
前端
HjhIron1 小时前
实战 React + JWT + Zustand:从零搭建登录鉴权系统
前端
晴天161 小时前
DeepSeek Harness 全景技术解析-Day23
前端·deepseek
风月说与山鬼1 小时前
三、uni-app页面配置(pages.json)
前端·uni-app
daols882 小时前
vue 实现基于 vxe-table 构建多维度产品对比表
前端·javascript·vue.js
前端 贾公子2 小时前
第08章:中间件(5)
服务器·前端·javascript
tech_zjf2 小时前
当 AI 把 Next.js Route 越写越快:我为什么做了 next-route-kit
前端·后端