从 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 秒以内就够了,重复访问应该做到"秒开"。

相关推荐
mldong3 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排4 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60614 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角5 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!5 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰6 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi7 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen8 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马8 小时前
AI源码分析:拆解模型底层实现逻辑
前端
IT_陈寒9 小时前
React子组件莫名其妙重渲染?你可能漏了这个Hook
前端·人工智能·后端