前言
上个月接手了一个内部运营后台项目,技术栈是 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 秒以内就够了,重复访问应该做到"秒开"。