Code Splitting
代码拆分
不要把整本百科全书塞进一页纸 ——只给用户当前需要的那一章。
📦 没有 Code Splitting
去餐厅吃饭,服务员把整本菜谱(包括所有季节菜单、酒水单、厨师笔记) 全部端给你 —— 你只想点一份炒饭,却要等 10 分钟翻完整本菜谱。
🍽️ 有 Code Splitting
服务员只递上一页今日推荐,你需要哪道菜就点哪道 —— 后厨接到单后现做。想看更多?下一页按需翻阅。
为什么你的 SPA 需要它?
白屏时间过长
用户在 3G 网络下,一个 487KB 的 JS bundle 解析 + 执行需要 3.2 秒。 Google 数据显示:加载每延迟 1 秒,转化率下降 20%。
带宽浪费严重
统计显示,用户平均只访问一个网站的 3~4 个页面。 但全量打包意味着用户下载了 100% 的代码,实际只用到 ~30%。 剩余 70% 是带宽浪费。
解析成本不可忽略
JS 不仅要下载,还要解析 + 编译 + 执行。 V8 引擎解析 1MB JS 约需 150~300ms(桌面端), 移动端可达 1 秒+。 拆分 = 减少单次解析量。
实测性能对比 — 电商首页(200+ 组件,47 个 npm 依赖)
| 指标 | ❌ 无拆分 | ⚡ 路由拆分 | 🚀 精细拆分 + 预加载 |
|---|---|---|---|
| 初始 JS 总量 (gzip) | 487KB | 142KB ↓71% | 98KB ↓80% |
| 首次内容绘制 (FCP) | 3.2s | 1.4s | 1.2s |
| 最大内容绘制 (LCP) | 4.8s | 2.1s | 1.8s |
| 可交互时间 (TTI) | 6.1s | 2.8s | 2.3s |
| 后续路由导航 | 0ms (已全加载) | 200~400ms | ~0ms (已预加载) |
import() 到底做了什么?
动态 import() 并不是魔法 —— 它是一个返回 Promise 的函数表达式,告诉打包器: "这个模块别打包进来,运行时我再来取。"
编译器处理 import() 的完整流程
① 源码编写
await import('./Chart')② 打包器分析
识别为拆分点(split point)③ 生成独立 Chunk
384.chunk.js (67KB)运行时(浏览器)
④ 执行到 import()
发起 HTTP 请求加载 chunk 文件
⑤ Chunk 下载完成
Promise resolve,返回模块对象
// 引擎层面:动态 import() 被编译器如何处理
// 1️⃣ 编译阶段 — Webpack 识别到 import() 表达式
const module = await import('./heavy-module')
// 2️⃣ Webpack 将其转换为运行时调用 (简化版)
const module = __webpack_require__.e(/* import() */ "heavy-module-chunk")
.then(__webpack_require__.bind(null, './heavy-module'))
// 3️⃣ __webpack_require__.e 的核心逻辑
function ensure(chunkId) {
return installedChunks[chunkId] = new Promise((resolve, reject) => {
// 动态创建 <script> 标签
var script = document.createElement('script');
script.src = __webpack_require__.p + chunkId + ".chunk.js";
// 注册回调,chunk 加载完毕后 resolve
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}两大打包器的分包策略
Webpack 和 Vite(生产构建基于 Rollup)的分包哲学截然不同: Webpack 走的是"自动拆 + 可配置", Vite 走的是"语义化 + 手动控制"。
Webpack — splitChunks
namedChunks: true)Vite — Rollup manualChunks
// webpack.config.js — Webpack 分包策略
module.exports = {
optimization: {
splitChunks: {
chunks: 'all', // 对同步 + 异步模块都生效
maxSize: 244000, // 单 chunk 不超过 244KB
cacheGroups: {
// 🔹 策略一:提取 node_modules 为 vendor chunk
vendor: {
test: /[\/]node_modules[\/]/,
name: 'vendors',
priority: 10,
chunks: 'all',
enforce: true, // 即使很小也要拆
},
// 🔹 策略二:提取公共模块为 common chunk
common: {
minChunks: 2, // 被 2+ 个 chunk 引用时提取
priority: 5,
reuseExistingChunk: true,
},
// 🔹 策略三:特定大库单独拆包
chart: {
test: /[\/]node_modules[\/](chart\.js|d3)/,
name: 'chart-vendor',
priority: 20, // 优先级高于 vendor
chunks: 'all',
},
},
},
},
};// vite.config.ts — Vite 分包策略 (基于 Rollup)
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
// 🔹 手动分包映射 — 精确控制每个 chunk
manualChunks: {
// React 生态 → 单独 vendor chunk
'vendor-react': [
'react',
'react-dom',
'react-router-dom',
],
// 图表库 → 独立 chunk(按需加载时不会拖慢首屏)
'vendor-charts': [
'chart.js',
'react-chartjs-2',
],
// 工具库 → 公共 chunk
'vendor-utils': [
'lodash-es',
'date-fns',
],
},
// 🔹 也可以用函数式手动分包(更灵活)
// manualChunks(id) {
// if (id.includes('node_modules/react')) return 'vendor-react';
// if (id.includes('node_modules/chart')) return 'vendor-charts';
// if (id.includes('node_modules')) return 'vendor-other';
// },
},
},
// Vite 默认 chunk 大小警告阈值 (500KB)
chunkSizeWarningLimit: 500,
},
});// 📊 编译产物对比 — 同一项目,两种打包器
// Webpack 产物结构 (production)
dist/
├── main.4f2a8b1c.js (89KB gzipped) ← 入口 chunk
├── vendors.e5d7c3b2.js (142KB gzipped) ← node_modules
├── charts.7a9f1e4d.chunk.js (67KB gzipped) ← 按需加载的图表
├── dashboard.a1b2c3d4.chunk.js (23KB gzipped) ← 路由 chunk
└── 883.chunk.js (12KB gzipped) ← Webpack 自动命名
// Vite 产物结构 (production)
dist/
├── assets/main-bK4x2d.js (82KB gzipped) ← 入口 chunk
├── assets/vendor-react-P3x1.js (45KB gzipped) ← React 独立
├── assets/vendor-charts-mN7k.js (64KB gzipped) ← 图表独立
├── assets/dashboard-Qr8w.js (21KB gzipped) ← 路由 chunk
└── assets/vendor-utils-fL2p.js (18KB gzipped) ← 工具库
// 🔑 关键差异:
// 1. Webpack 默认用数字 ID 命名 chunk,Vite 用语义化命名
// 2. Vite 生产构建基于 Rollup,tree-shaking 通常更彻底
// 3. Webpack 的 splitChunks.cacheGroups 更灵活,Vite 用 manualChunksNext.js 路由级拆分实战
Next.js 14+ App Router 已经自动对每个路由进行代码拆分 —— 每个 page.tsx 及其依赖成为一个独立 chunk。 但真正的工程化还需要 组件级懒加载 + 预加载策略。
// app/dashboard/page.tsx — Next.js App Router 路由级代码拆分
// Next.js 14+ 自动对每个路由进行代码拆分 ✅
// 📦 此文件及依赖 → 自动成为独立 chunk
// 用户未访问 /dashboard 时,此代码不会被加载
import { AnalyticsChart } from './analytics-chart';
import { RecentOrders } from './recent-orders';
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* 组件内再拆分重量级子组件 */}
<AnalyticsChart /> {/* ← 这个组件自身也是独立 chunk */}
<RecentOrders />
</div>
);
}// app/dashboard/analytics-chart.tsx
'use client'; // ← Client Component 自动成为可拆分边界
import dynamic from 'next/dynamic';
// 🚀 进一步拆分:Chart.js 整体延迟加载
const Chart = dynamic(() => import('./HeavyChart'), {
loading: () => <ChartSkeleton />, // 加载占位符
ssr: false, // 禁止 SSR(纯客户端库)
});
// 💡 对比:如果用普通 import,Chart.js (250KB) 会在首屏加载
// import { Chart } from './HeavyChart' // ❌ 增大初始 bundle
export function AnalyticsChart() {
return (
<div className="p-6 border-2 border-[var(--foreground)] rounded-2xl">
<h2>流量趋势</h2>
<Chart data={useAnalyticsData()} />
</div>
);
}拆分层级金字塔
L3: 组件级 lazy()
重型子组件按需加载 → 一个页面内再拆
L2: 路由级拆分 (Next.js 自动)
每个 page.tsx → 独立 chunk → 访问时加载
L1: splitChunks / manualChunks (打包器配置)
vendor / common / 特定库 → 全局共享 chunk
预加载 —— 让拆分的代价趋近于零
代码拆分的最大痛点是"冷启动" —— 用户点击后等待 chunk 下载。 预加载通过提前获取将这个延迟从 300~600ms 降至 ~0ms。
// 🔮 预加载策略 — 在用户"快要"需要时提前加载
// 方案一:Hover 预加载(最常用)
function NavLink({ href, children }: { href: string; children: React.ReactNode }) {
const router = useRouter();
const handleMouseEnter = () => {
// 鼠标悬停时预加载目标路由的 chunk
router.prefetch(href); // ← Next.js 内置
};
return (
<a
href={href}
onMouseEnter={handleMouseEnter}
className="hover:underline"
>
{children}
</a>
);
}
// 方案二:基于视口的预加载
function useViewportPreload(href: string) {
const ref = useRef<HTMLElement>(null);
const router = useRouter();
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
router.prefetch(href); // ← 元素进入视口时预加载
observer.disconnect();
}
},
{ rootMargin: '200px' } // 提前 200px 开始加载
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, [href, router]);
return ref;
}
// 方案三:空闲时预加载(非关键路由)
function useIdlePreload(href: string) {
const router = useRouter();
useEffect(() => {
if ('requestIdleCallback' in window) {
requestIdleCallback(() => router.prefetch(href)); // ← 浏览器空闲时
} else {
setTimeout(() => router.prefetch(href), 2000); // 降级方案
}
}, [href, router]);
}Hover 预加载
用户鼠标悬停导航链接时触发。命中率 ~60%(用户确实会点击), 实现最简单。配合 onMouseEnter 即可。
视口预加载
当链接元素进入可视区域(或提前 200px)时触发。命中率 ~80%。 用 IntersectionObserver 实现, 适合长列表中的下一页入口。
空闲预加载
在浏览器空闲时预加载所有高频路由。命中率 ~100%(全量下载), 适合小型应用。用 requestIdleCallback 实现, 不阻塞主线程。
🧪 Bundle 拆分模拟器
实时观察拆分策略对加载时间的影响
全量打包(无 Code Splitting)
⚠️ 首屏加载全部代码,FCP 3.2s
代码拆分 + 懒加载
✅ 首屏仅加载 87KB,其余按需获取,FCP 1.2s
⏱️ 用户点击 /dashboard 后的时间线
页面已在内存中
渲染 /dashboard
导航完成(但首屏被全量 JS 阻塞了 3.2s)
用户 hover 时已预加载 chunk
chunk 已在浏览器缓存中
渲染 /dashboard(无额外网络请求)
⚠️ 常见陷阱与反模式
// 🚫 Anti-Pattern 1: 动态路径导致全目录打包
// ❌ 错误 — 变量路径无法在编译时静态分析
const modules = await import(`./modules/${name}.js`);
// Webpack 警告: Critical dependency: the request of a dependency is an expression
// 结果: ./modules/ 目录下所有文件都被打包
// ✅ 正确 — 使用 webpack 魔法注释限制范围
const modules = await import(
/* webpackInclude: /\.[jt]sx?$/ */ // 只包含 JS/TS
/* webpackExclude: /\.test\./ */ // 排除测试文件
/* webpackMode: "lazy-once" */ // 所有匹配模块打成一个 chunk
`./modules/${name}.js`
);
// 🚫 Anti-Pattern 2: 过度拆分导致瀑布请求
// ❌ 每个小组件都拆成独立 chunk
const Button = lazy(() => import('./Button')); // 2KB
const Input = lazy(() => import('./Input')); // 3KB
const Badge = lazy(() => import('./Badge')); // 1.5KB
// → 用户看到 3 次 loading 状态,总请求数膨胀
// ✅ 正确 — 按功能/路由聚合拆分
const AdminPanel = lazy(() => import('./admin/AdminPanel'));
// → AdminPanel 内部包含 Button、Input、Badge,一次加载陷阱:动态路径 + 变量
import(`./${name}`) 中的变量路径让打包器无法静态分析,Webpack 会将整个目录打包。 用 webpackInclude/Exclude 注释限制范围,或改用显式映射表。
陷阱:过度拆分的瀑布请求
拆分粒度过细(每个 2KB 组件都 lazy import)会导致: ① HTTP 请求数爆炸 ② 用户多次看到 loading 态 ③ 浏览器并行限制(6个域名)导致排队。经验法则:单 chunk 不低于 20KB,按功能聚合。
陷阱:SSR + 动态导入的兼容
React.lazy() 在服务端渲染时会报错 —— SSR 没有 import() 的运行时。 Next.js 的 dynamic() 已内置 SSR 降级, 但自定义 SSR 方案需要手动处理 loadable-components。
💡 最佳实践:Suspense + 骨架屏
永远不要让用户看到空白 —— 配合 <Suspense fallback=>提供骨架屏(Skeleton)。骨架屏让感知加载时间降低 30~50%(Nielsen Norman Group 研究数据)。
📋 Code Splitting 速查清单
import() 动态导入
运行时按需加载模块,返回 Promise
const m = await import('./module')React.lazy()
React 原生的组件懒加载,配合 Suspense
const Comp = lazy(() => import('./Comp'))Next.js dynamic()
支持 SSR 降级、loading 组件、禁用 SSR
const D = dynamic(() => import('./D'), { ssr: false })router.prefetch()
Next.js 预加载路由 chunk
router.prefetch('/dashboard')Webpack splitChunks
cacheGroups 配置 vendor / common 拆分
splitChunks: { chunks: 'all', cacheGroups: {...} }Vite manualChunks
Rollup 手动分包,对象式或函数式
manualChunks: { 'vendor-react': ['react'] }webpackMagicComment
在 import() 内注入打包指令
import(/* webpackChunkName: 'chart' */ './chart')Suspense + Skeleton
加载态骨架屏,降低感知延迟 30~50%
<Suspense fallback={<Skeleton />} />IntersectionObserver
视口内预加载,命中率 ~80%
new IntersectionObserver(cb, { rootMargin: '200px' })拆分决策流程图 — 该不该拆?
这个模块 > 100KB 吗?
YES → 必须拆分(vendor / 大库单独 chunk)
用户首屏是否需要它?
YES → 放入 main chunk
NO → 路由级 / 组件级 lazy import
被 2+ 个页面共用?
YES → 提取为 shared chunk (splitChunks / manualChunks)
NO → 放入对应路由 chunk
用户有 60%+ 概率访问?
YES → 启用预加载 (prefetch / preload)
NO → 纯懒加载即可
Code Splitting = 按需加载 + 预加载 + 缓存策略
拆分是手段,不是目的。目标始终是:更少的初始 JS + 更快的可交互时间。