前言
做过大型 SPA 项目的同学一定遇到过这样的场景:首屏白屏时间超过 3 秒,用户还没看到内容就已经走了。打开 DevTools 一看,一个 main.js 直接干到 2MB,里面塞满了 ECharts、Monaco Editor、Moment.js 和各种没用到的页面逻辑。
问题的根源很简单------我们把所有代码打成了一个包。用户访问首页时,却要下载整个应用的全部代码,包括他可能永远不会打开的管理后台页面。
这篇文章从三个维度来解决这个问题:代码分割 减小单次加载的 JS 体积,路由懒加载 让页面按需加载,字体子集化砍掉字体文件中 90% 的无用字符。三板斧下去,首屏加载体积可以降低 60% 以上。
JS 体积为什么重要
很多人知道图片优化,但忽略了 JS 体积对性能的影响远大于同等大小的图片。原因在于 JS 不仅需要下载,还需要解析、编译和执行。
| 资源类型 | 200KB 的处理耗时(中端手机) | 对交互的影响 |
|---|---|---|
| 图片 | ~50ms(解码) | 不阻塞交互 |
| CSS | ~30ms(解析+CSSOM) | 阻塞渲染但不阻塞交互 |
| JavaScript | ~300-500ms(解析+编译+执行) | 阻塞渲染和交互 |
Google 的研究数据表明,每增加 100KB 的 JS(压缩后),在中端 Android 设备上大约增加 150-300ms 的 TTI(Time to Interactive)。当你的 bundle 从 500KB 膨胀到 1.5MB 时,用户感知到的可交互时间可能从 2 秒变成 5 秒。
更关键的是,JS 体积有一个"隐性成本":即使代码被缓存了,每次页面加载仍然需要解析和编译。这不像图片缓存那样直接从磁盘读取渲染------V8 虽然有字节码缓存机制,但首次访问时的解析成本是实实在在的。
Dynamic import() 的工作原理
ES2020 引入的 import() 是代码分割的基础。与静态 import 不同,它返回一个 Promise,在运行时动态加载模块:
typescript
// 静态导入:打包时就会被包含进 bundle
import { heavyFunction } from './heavyModule';
// 动态导入:运行时按需加载,返回 Promise
const module = await import('./heavyModule');
module.heavyFunction();
Webpack 遇到 import() 时,会自动将目标模块及其依赖抽成单独的 chunk 文件。构建产物从一个大文件变成了主文件 + 若干异步 chunk。
Webpack Magic Comments
Webpack 提供了几个魔法注释来控制动态导入的行为:
typescript
// 1. webpackChunkName:给 chunk 指定可读的名字
const AdminPage = () => import(
/* webpackChunkName: "admin" */
'./pages/Admin'
);
// 产物:admin.abc123.js(而不是 chunk-7.abc123.js)
// 2. webpackPrefetch:在浏览器空闲时预加载
const SettingsPage = () => import(
/* webpackChunkName: "settings" */
/* webpackPrefetch: true */
'./pages/Settings'
);
// 会在 <head> 中注入 <link rel="prefetch" href="settings.abc123.js">
// 3. webpackPreload:与父 chunk 并行加载
const HeroChart = () => import(
/* webpackChunkName: "hero-chart" */
/* webpackPreload: true */
'./components/HeroChart'
);
// 会在 <head> 中注入 <link rel="preload" href="hero-chart.abc123.js">
prefetch 和 preload 的区别需要理解清楚:
| 策略 | 加载时机 | 优先级 | 适用场景 |
|---|---|---|---|
| 无注释 | 代码执行到时 | - | 很少访问的页面 |
| webpackPrefetch | 父 chunk 加载完后,浏览器空闲时 | 低 | 用户很可能接下来访问的页面 |
| webpackPreload | 与父 chunk 并行 | 高 | 当前页面一定会用到的模块 |
一个常见的误用是把所有异步 chunk 都加上 prefetch。当你有 30 个路由时,浏览器空闲后会同时发起 30 个请求去下载所有 chunk,这在移动端网络下反而会影响体验。只对用户大概率会访问的下一页使用 prefetch。
React.lazy + Suspense 路由级代码分割
React 16.6 引入了 React.lazy 和 Suspense,让路由级别的代码分割变得非常简单。下面是一个完整的 React Router v6 示例:
tsx
// src/App.tsx
import React, { Suspense, lazy } from 'react';
import {
BrowserRouter,
Routes,
Route,
Navigate,
} from 'react-router-dom';
import { MainLayout } from './layouts/MainLayout';
import { LoadingSpinner } from './components/LoadingSpinner';
import { ErrorBoundary } from './components/ErrorBoundary';
// 路由级别的懒加载
const HomePage = lazy(() => import(
/* webpackChunkName: "home" */
'./pages/Home'
));
const ProductListPage = lazy(() => import(
/* webpackChunkName: "product-list" */
/* webpackPrefetch: true */
'./pages/ProductList'
));
const ProductDetailPage = lazy(() => import(
/* webpackChunkName: "product-detail" */
'./pages/ProductDetail'
));
const AdminDashboard = lazy(() => import(
/* webpackChunkName: "admin" */
'./pages/admin/Dashboard'
));
const UserSettings = lazy(() => import(
/* webpackChunkName: "settings" */
'./pages/UserSettings'
));
function App() {
return (
<BrowserRouter>
<ErrorBoundary>
<MainLayout>
<Suspense fallback={<LoadingSpinner />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/products" element={<ProductListPage />} />
<Route path="/products/:id" element={<ProductDetailPage />} />
<Route path="/settings" element={<UserSettings />} />
<Route path="/admin/*" element={<AdminDashboard />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Suspense>
</MainLayout>
</ErrorBoundary>
</BrowserRouter>
);
}
export default App;
几个关键实践:
第一,ErrorBoundary 必须包在 Suspense 外面。 当网络异常导致 chunk 加载失败时,React.lazy 会抛出错误。如果没有 ErrorBoundary 兜底,整个应用会白屏。一个基本的 ErrorBoundary 实现:
tsx
// src/components/ErrorBoundary.tsx
import React, { Component, ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false, error: null };
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
handleRetry = () => {
this.setState({ hasError: false, error: null });
};
render() {
if (this.state.hasError) {
return (
<div className="error-fallback">
<h2>页面加载失败</h2>
<p>请检查网络连接后重试</p>
<button onClick={this.handleRetry}>重新加载</button>
</div>
);
}
return this.props.children;
}
}
export { ErrorBoundary };
第二,fallback 组件要轻量。 Suspense 的 fallback 不应该依赖任何异步模块,否则会陷入死循环。一个简单的 CSS 动画 spinner 就够了,不要在 fallback 里用 Ant Design 的 Spin 组件------那玩意儿自己也不小。
第三,合理划分 Suspense 边界。 不要只在最外层放一个 Suspense,也不要每个组件都包一个。推荐按"独立功能区域"划分:
tsx
function AdminDashboard() {
return (
<div className="admin-layout">
{/* 侧边栏始终显示 */}
<AdminSidebar />
<div className="admin-content">
{/* 内容区域独立的 Suspense 边界 */}
<Suspense fallback={<ContentSkeleton />}>
<Routes>
<Route path="overview" element={<AdminOverview />} />
<Route path="users" element={<AdminUsers />} />
<Route path="analytics" element={<AdminAnalytics />} />
</Routes>
</Suspense>
</div>
</div>
);
}
这样切换管理后台的子页面时,侧边栏不会闪烁,只有内容区域显示 loading 状态。
组件级代码分割
路由级分割是最基本的,但很多时候真正的"大胖子"藏在组件里。典型的重量级组件包括:图表库(ECharts ~800KB)、富文本编辑器(TinyMCE ~500KB)、代码编辑器(Monaco ~2MB)、PDF 预览等。
对这些组件做按需加载的方式如下:
tsx
// src/components/LazyChart.tsx
import React, { Suspense, lazy, useState } from 'react';
const EChartsReact = lazy(() => import(
/* webpackChunkName: "echarts" */
'echarts-for-react'
));
interface ChartProps {
data: number[];
title: string;
}
function LazyChart({ data, title }: ChartProps) {
const option = {
title: { text: title },
xAxis: { type: 'category' as const },
yAxis: { type: 'value' as const },
series: [{ data, type: 'bar' as const }],
};
return (
<Suspense fallback={<div className="chart-skeleton" />}>
<EChartsReact option={option} style={{ height: 400 }} />
</Suspense>
);
}
对于弹窗类组件,可以在用户触发操作时才加载:
tsx
// src/components/ExportDialog.tsx
import React, { Suspense, lazy, useState, useCallback } from 'react';
const HeavyExportDialog = lazy(() => import(
/* webpackChunkName: "export-dialog" */
'./HeavyExportDialog'
));
function ExportButton() {
const [showDialog, setShowDialog] = useState(false);
const handleClick = useCallback(() => {
setShowDialog(true);
}, []);
// 鼠标悬停时预加载,点击时秒开
const handleMouseEnter = useCallback(() => {
import(/* webpackChunkName: "export-dialog" */ './HeavyExportDialog');
}, []);
return (
<>
<button
onClick={handleClick}
onMouseEnter={handleMouseEnter}
>
导出报告
</button>
{showDialog && (
<Suspense fallback={<div>加载中...</div>}>
<HeavyExportDialog onClose={() => setShowDialog(false)} />
</Suspense>
)}
</>
);
}
注意 onMouseEnter 中的 import() 调用------当用户把鼠标移到按钮上时就开始加载 chunk,等到真正点击时通常已经加载完毕。这个技巧在用户意图明确的场景下(比如"导出"按钮、"高级设置"入口)非常有效。
Vendor Chunk 拆分策略
光做路由和组件级分割还不够,还需要合理配置 splitChunks 来拆分第三方依赖。一个经过实践验证的配置如下:
typescript
// webpack.config.ts(webpack 5)
import type { Configuration } from 'webpack';
const config: Configuration = {
optimization: {
splitChunks: {
chunks: 'all',
maxInitialRequests: 20,
maxAsyncRequests: 20,
minSize: 20_000, // 20KB 以下不拆
maxSize: 244_000, // 超过 244KB 的 chunk 尝试继续拆分
cacheGroups: {
// React 全家桶,变动频率低,单独缓存
reactVendor: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom)[\\/]/,
name: 'vendor-react',
priority: 40,
reuseExistingChunk: true,
},
// UI 组件库
uiVendor: {
test: /[\\/]node_modules[\\/](antd|@ant-design)[\\/]/,
name: 'vendor-ui',
priority: 30,
reuseExistingChunk: true,
},
// 工具库
utilVendor: {
test: /[\\/]node_modules[\\/](lodash|dayjs|axios)[\\/]/,
name: 'vendor-utils',
priority: 20,
reuseExistingChunk: true,
},
// 其他第三方依赖兜底
defaultVendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor-common',
priority: 10,
reuseExistingChunk: true,
minChunks: 2, // 被至少 2 个 chunk 引用才拆出来
},
// 项目内公共模块
common: {
name: 'common',
minChunks: 3,
priority: 5,
reuseExistingChunk: true,
},
},
},
},
};
export default config;
这套配置的核心思路:
-
按更新频率分组。React 可能半年升一次,antd 可能每月升一次,工具库可能每次发版都会更新。分开打包后,React 的 chunk 可以长期走浏览器缓存,用户只需要重新下载变化的部分。
-
minChunks控制公共模块提取 。如果一个模块只被一个路由页面引用,没必要拆出来做公共 chunk,因为拆出来反而多了一次 HTTP 请求。minChunks: 2或minChunks: 3是比较合理的阈值。 -
maxSize防止单个 chunk 过大。设为 244KB(gzip 后约 60-80KB)是一个经验值,兼顾了 HTTP/2 多路复用的优势和浏览器解析效率。
如果你使用 Vite,对应的配置方式如下:
typescript
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
build: {
rollupOptions: {
output: {
manualChunks: (id: string) => {
if (id.includes('node_modules')) {
if (/react|react-dom|react-router/.test(id)) {
return 'vendor-react';
}
if (/antd|@ant-design/.test(id)) {
return 'vendor-ui';
}
return 'vendor-common';
}
},
},
},
},
});
字体优化
字体文件是另一个容易被忽略的性能黑洞。一个中文字体文件动辄 5-10MB(包含 2 万多个汉字),即使是英文字体,多个字重加起来也有几百 KB。
font-display 策略对比
font-display 控制浏览器在字体加载期间的渲染行为。不同值的表现差异很大:
css
@font-face {
font-family: 'CustomFont';
src: url('/fonts/custom.woff2') format('woff2');
font-display: swap; /* 根据场景选择合适的值 */
}
| 值 | 阻塞期 | 交换期 | 行为描述 | 适用场景 |
|---|---|---|---|---|
| auto | 由浏览器决定 | 由浏览器决定 | 通常等同于 block | 不推荐使用 |
| block | 3秒 | 无限 | 先隐藏文字,加载完后替换 | 图标字体 |
| swap | 无 | 无限 | 先用后备字体,加载完后替换 | 正文文字(推荐) |
| fallback | 100ms | 3秒 | 短暂隐藏,3秒内加载完就替换,否则放弃 | 重要但非关键的字体 |
| optional | 100ms | 无 | 极短隐藏,由浏览器决定是否替换 | 装饰性字体 |
大多数场景推荐 swap------用户先看到后备字体渲染的文字,字体加载完成后再平滑替换。唯一的缺点是可能出现 FOUT(Flash of Unstyled Text),但比 3 秒白屏强太多了。
对于首屏标题等关键文字,配合 preload 可以进一步减少 FOUT 的持续时间:
html
<link
rel="preload"
href="/fonts/title-font.woff2"
as="font"
type="font/woff2"
crossorigin
/>
注意 crossorigin 属性不能省略,即使字体文件和页面同源。这是因为字体请求默认使用匿名 CORS 模式,如果 preload 没加 crossorigin,浏览器会发起两次请求------一次是 preload 的非 CORS 请求,一次是 @font-face 触发的 CORS 请求。
字体子集化
中文字体优化的核心手段是子集化------从完整字体中只提取页面实际用到的字符。一个完整的思源黑体约 8MB,而一个典型的企业官网可能只用到 800 个汉字,子集化后可以压缩到 100KB 以内。
使用 fonttools(Python 工具)进行子集化:
bash
# 安装工具
pip install fonttools brotli
# 从文本文件提取字符并生成子集字体
pyftsubset NotoSansSC-Regular.otf \
--text-file=chars.txt \
--output-file=NotoSansSC-subset.woff2 \
--flavor=woff2 \
--layout-features='kern,liga,calt' \
--no-hinting \
--desubroutinize
# 也可以直接指定 Unicode 范围
pyftsubset NotoSansSC-Regular.otf \
--unicodes="U+0000-00FF,U+4E00-9FFF" \
--output-file=NotoSansSC-common.woff2 \
--flavor=woff2
在实际项目中,可以写一个构建脚本自动从源码中提取用到的中文字符:
typescript
// scripts/extract-chars.ts
import * as fs from 'fs';
import * as path from 'path';
import { execSync } from 'child_process';
function extractChineseChars(dir: string): Set<string> {
const chars = new Set<string>();
const chineseRegex = /[一-鿿㐀-䶿]/g;
function walk(currentDir: string) {
const files = fs.readdirSync(currentDir);
for (const file of files) {
const fullPath = path.join(currentDir, file);
const stat = fs.statSync(fullPath);
if (stat.isDirectory() && !file.startsWith('.') && file !== 'node_modules') {
walk(fullPath);
} else if (/\.(tsx?|jsx?|json|md)$/.test(file)) {
const content = fs.readFileSync(fullPath, 'utf-8');
const matches = content.match(chineseRegex);
if (matches) {
matches.forEach((char) => chars.add(char));
}
}
}
}
walk(dir);
return chars;
}
const chars = extractChineseChars('./src');
const charList = Array.from(chars).sort().join('');
fs.writeFileSync('./fonts/chars.txt', charList, 'utf-8');
console.log(`提取到 ${chars.size} 个中文字符`);
// 调用 pyftsubset 生成子集
execSync(
`pyftsubset fonts/NotoSansSC-Regular.otf ` +
`--text-file=fonts/chars.txt ` +
`--output-file=public/fonts/NotoSansSC-subset.woff2 ` +
`--flavor=woff2 --no-hinting --desubroutinize`
);
可变字体替代多字重文件
传统做法需要为每个字重单独引入一个字体文件:
css
/* 传统方式:3 个字重 = 3 个文件 */
@font-face {
font-family: 'Inter';
font-weight: 400;
src: url('/fonts/Inter-Regular.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-weight: 600;
src: url('/fonts/Inter-SemiBold.woff2') format('woff2');
}
@font-face {
font-family: 'Inter';
font-weight: 700;
src: url('/fonts/Inter-Bold.woff2') format('woff2');
}
/* 总计: ~300KB */
使用可变字体(Variable Font)可以用一个文件覆盖所有字重:
css
/* 可变字体:1 个文件覆盖所有字重 */
@font-face {
font-family: 'Inter';
font-weight: 100 900;
font-display: swap;
src: url('/fonts/Inter-Variable.woff2') format('woff2-variations');
}
/* 使用时可以指定任意字重值 */
h1 { font-weight: 750; }
.subtitle { font-weight: 350; }
/* 总计: ~200KB,且支持更灵活的字重 */
可变字体的文件体积通常比 2-3 个静态字体文件的总和更小,同时提供了连续的字重选择空间。对于需要多个字重的项目,可变字体几乎总是更优的选择。
预加载关键字体
综合以上策略,一个完整的字体加载方案如下:
html
<!DOCTYPE html>
<html>
<head>
<!-- 预加载首屏关键字体 -->
<link
rel="preload"
href="/fonts/Inter-Variable.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<link
rel="preload"
href="/fonts/NotoSansSC-subset.woff2"
as="font"
type="font/woff2"
crossorigin
/>
<style>
@font-face {
font-family: 'Inter';
font-weight: 100 900;
font-display: swap;
src: url('/fonts/Inter-Variable.woff2') format('woff2-variations');
}
@font-face {
font-family: 'Noto Sans SC';
font-weight: 400;
font-display: swap;
src: url('/fonts/NotoSansSC-subset.woff2') format('woff2');
}
body {
font-family: 'Inter', 'Noto Sans SC', -apple-system, BlinkMacSystemFont,
'Segoe UI', Roboto, sans-serif;
}
</style>
</head>
</html>
只预加载首屏一定会用到的字体文件(通常 1-2 个),其他字体交给浏览器按需加载即可。预加载过多字体文件会占用带宽,反而拖慢其他关键资源的加载。
效果度量:优化前后对比
说了这么多优化手段,落地效果如何?以一个真实的中后台管理系统项目为例,展示优化前后的 bundle 分析数据。
优化前
less
主包 main.js: 1,847 KB (gzip: 512 KB)
vendor.js: 923 KB (gzip: 287 KB)
字体文件总计: 8,200 KB
────────────────────────────────
首屏加载总计: ~9,970 KB
首屏 LCP (4G): 4.2s
首屏 TTI (4G): 5.8s
优化后
less
主包 main.js: 187 KB (gzip: 58 KB)
vendor-react.js: 142 KB (gzip: 45 KB)
vendor-ui.js: 按需加载
vendor-utils.js: 38 KB (gzip: 12 KB)
路由 chunk (首页): 23 KB (gzip: 7 KB)
字体文件总计: 186 KB
────────────────────────────────
首屏加载总计: ~555 KB
首屏 LCP (4G): 1.4s
首屏 TTI (4G): 2.1s
| 指标 | 优化前 | 优化后 | 降幅 |
|---|---|---|---|
| 首屏 JS 体积 | 2,770 KB | 390 KB | -86% |
| 字体体积 | 8,200 KB | 186 KB | -98% |
| 首屏总加载 | 9,970 KB | 555 KB | -94% |
| LCP | 4.2s | 1.4s | -67% |
| TTI | 5.8s | 2.1s | -64% |
用 webpack-bundle-analyzer 可以直观地看到优化效果:
bash
# 安装分析工具
npm install --save-dev webpack-bundle-analyzer
# 生成分析报告
npx webpack --profile --json > stats.json
npx webpack-bundle-analyzer stats.json
如果使用 Vite,可以用 rollup-plugin-visualizer:
bash
npm install --save-dev rollup-plugin-visualizer
typescript
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
react(),
visualizer({
open: true,
gzipSize: true,
brotliSize: true,
filename: 'dist/stats.html',
}),
],
});
决策树:什么该拆、什么不该拆
代码分割不是越多越好。每拆一个 chunk 就多一次 HTTP 请求(虽然 HTTP/2 缓解了这个问题),也多了 webpack runtime 的管理开销。以下是一个实用的决策框架:
应该拆分的:
- 路由页面:每个路由页面独立成 chunk,这是最基本的分割
- 大型第三方库(超过 100KB):如 ECharts、Monaco Editor、PDF.js
- 条件渲染的重型组件:弹窗、抽屉中的复杂表单、管理后台模块
- 低频使用的功能:导出报表、数据迁移工具、高级设置
- A/B 测试的变体:不同实验分支的组件
不应该拆分的:
- 首屏直接渲染的组件:拆了反而多一次请求延迟
- 体积小于 20KB 的模块:拆分的开销大于收益
- 几乎每个页面都用到的公共组件:Header、Footer、Toast
- 紧密耦合的模块组:两个模块总是一起使用,拆开毫无意义
需要根据情况判断的:
- 用户大概率会访问的下一页:用 prefetch 而非拆分
- 首屏组件的非关键状态:比如表格的"列设置"面板,可以做组件级分割
- 国际化资源文件:如果每种语言包超过 50KB,考虑按语言拆分
一个简化的判断流程:
markdown
模块体积 > 100KB?
├── 是 → 首屏必需?
│ ├── 是 → 不拆,但做 vendor 分组缓存
│ └── 否 → 拆分为异步 chunk
└── 否 → 被多个路由共享?
├── 是 → 放入 common chunk
└── 否 → 保留在所属路由 chunk 中
总结
前端瘦身的核心思路是"用户当前需要什么,就只加载什么"。代码分割解决的是"不要一次加载所有 JS"的问题,路由懒加载解决的是"不要加载用户没访问的页面"的问题,字体子集化解决的是"不要加载用户看不到的字符"的问题。
三板斧的实施建议按以下优先级推进:
- 先用 bundle analyzer 看清现状,找到最大的优化空间
- 实施路由级代码分割,这是投入产出比最高的优化
- 配置 splitChunks 做 vendor 分组,提高缓存命中率
- 对重型组件做按需加载
- 字体子集化 + preload 关键字体
- 持续监控,在 CI 中加入 bundle size 检查,防止体积回涨
每一步都要量化效果,用数据驱动优化决策,而不是凭感觉把所有东西都拆一遍。
如果觉得有帮助,欢迎点赞收藏关注,后续会继续分享前端性能优化相关的实践。