代码分割与懒加载
代码分割与懒加载
代码分割(Code Splitting)和懒加载(Lazy Loading)是减少初始加载时间、提高应用性能的重要策略。它们的核心思想是:不要一次性把所有代码都塞给用户,而是按需分批交付。
一句话理解概念
把打包后的 JavaScript 想象成一本很厚的书。如果不做代码分割,用户打开网页就相当于必须先把整本书搬回家才能读第一页;而代码分割是把书拆成很多小册子,用户先拿到"第一章"就能开始阅读,其他章节等真正翻到的时候再去取。懒加载就是这个"翻到才取"的动作。
为什么重要
现代前端应用(尤其是 SPA)打包体积动辄几 MB,如果全部塞进一个 `bundle.js`:
一个真实的对比数据:某中后台管理系统未分割时首包 3.2MB,Gzip 后约 900KB,4G 网络首屏可交互(TTI)约 6.8s;按路由分割后首包降到 280KB(Gzip 后 95KB),TTI 降到 2.1s,提升约 3 倍。
| 指标 | 未分割 | 按路由分割 | 提升 |
| 首包体积(Gzip) | 900KB | 95KB | -89% |
| TTI (4G) | 6.8s | 2.1s | -69% |
| LCP | 4.2s | 1.8s | -57% |
代码分割的概念
定义:
分割策略:
懒加载技术
动态导入(Dynamic Import):
// 静态导入:构建时确定,打进主包
import { heavyUtil } from './heavy-util';
// 动态导入:运行时按需加载,单独切出一个 chunk
button.addEventListener('click', async () => {
// 只有用户点击按钮,才会去下载这段代码
const { heavyUtil } = await import('./heavy-util');
heavyUtil();
});
// 动态导入配合错误处理与加载态
async function loadEditor() {
try {
showSpinner();
const { createEditor } = await import('./rich-editor');
return createEditor();
} catch (err) {
console.error('富文本编辑器加载失败', err);
showError('编辑器加载失败,请刷新重试');
} finally {
hideSpinner();
}
}魔法注释(Magic Comments):
webpack 支持通过特殊注释控制 chunk 的名字与加载行为。
// 指定 chunk 名字,便于在网络面板中识别
const module = await import(
/* webpackChunkName: "chart" */
'./chart'
);
// 预取:空闲时提前下载(未来可能用到)
import(
/* webpackChunkName: "settings" */
/* webpackPrefetch: true */
'./settings-page'
);
// 预载:与父 chunk 并行下载(当前导航很可能马上用到)
import(
/* webpackChunkName: "login-modal" */
/* webpackPreload: true */
'./login-modal'
);React 中的实现:
import React, { Suspense, lazy } from 'react';
// 组件级懒加载
const HeavyChart = lazy(() => import('./HeavyChart'));
function Dashboard() {
const [showChart, setShowChart] = React.useState(false);
return (
<div>
<button onClick={() => setShowChart(true)}>显示图表</button>
{showChart && (
<Suspense fallback={<div>图表加载中...</div>}>
<HeavyChart />
</Suspense>
)}
</div>
);
}React 路由级代码分割(React Router v6):
import { lazy, Suspense } from 'react';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
const Home = lazy(() => import('./pages/Home'));
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
// 用高阶函数统一包裹 Suspense,避免重复
function withSuspense(Component) {
return (
<Suspense fallback={<PageLoading />}>
<Component />
</Suspense>
);
}
const router = createBrowserRouter([
{ path: '/', element: withSuspense(Home) },
{ path: '/dashboard', element: withSuspense(Dashboard) },
{ path: '/settings', element: withSuspense(Settings) },
]);
function App() {
return <RouterProvider router={router} />;
}处理懒加载失败与重试(生产必备):
网络抖动或新版本发布导致旧 chunk 404 是常见线上问题,需要加重试与兜底。
// 带重试的懒加载封装
function lazyWithRetry(importFn, retries = 3, interval = 500) {
return lazy(() =>
new Promise((resolve, reject) => {
const attempt = (n) => {
importFn()
.then(resolve)
.catch((error) => {
if (n <= 0) {
// 常见于发布后旧 chunk 被清理,强制刷新拿新资源
window.location.reload();
reject(error);
return;
}
setTimeout(() => attempt(n - 1), interval);
});
};
attempt(retries);
})
);
}
const Reports = lazyWithRetry(() => import('./pages/Reports'));Vue 中的实现:
// Vue Router 路由懒加载
const routes = [
{
path: '/dashboard',
component: () => import(/* webpackChunkName: "dashboard" */ './Dashboard.vue'),
},
];
// defineAsyncComponent 完整配置
import { defineAsyncComponent } from 'vue';
const AsyncChart = defineAsyncComponent({
loader: () => import('./Chart.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorFallback,
delay: 200, // 展示 loading 前的延迟,避免闪烁
timeout: 10000, // 超时后展示 errorComponent
});Webpack 配置
SplitChunks 配置:
module.exports = {
optimization: {
// 把 webpack 运行时抽成单独文件,业务代码变化时它不变,缓存更稳定
runtimeChunk: 'single',
splitChunks: {
chunks: 'all', // 同时处理异步和同步导入
minSize: 20000, // 生成 chunk 的最小体积(字节)
maxSize: 244000, // 超过则尝试进一步拆分,利于缓存
minChunks: 1, // 模块被引用多少次才拆分
maxAsyncRequests: 30, // 按需加载时最大并行请求数
maxInitialRequests: 30, // 入口点最大并行请求数
cacheGroups: {
// 把体积大、更新慢的框架单独抽出,长期缓存
framework: {
test: /[\\/]node_modules[\\/](react|react-dom|scheduler)[\\/]/,
name: 'framework',
priority: 40,
},
// 其余第三方库
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
priority: 20,
},
// 被多处复用的公共业务代码
common: {
minChunks: 2,
priority: 10,
reuseExistingChunk: true,
},
},
},
},
};各参数含义速查:
| 参数 | 默认值 | 作用 |
| chunks | async | 处理哪种导入:async 仅异步,initial 仅同步,all 全部 |
| minSize | 20000 | 生成 chunk 的最小字节数,小于则不拆 |
| maxSize | 0 | chunk 最大字节数,超过尝试再拆(0 为不限制) |
| minChunks | 1 | 模块被引用几次才拆分 |
| maxAsyncRequests | 6 | 按需加载的最大并行请求数 |
| maxInitialRequests | 4 | 入口初始加载的最大并行请求数 |
代码分割策略:
Vite / Rollup 的手动分包
Vite 底层用 Rollup,通过 `manualChunks` 控制分包。
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('react')) return 'framework';
if (id.includes('echarts')) return 'charts';
return 'vendor';
}
},
},
},
chunkSizeWarningLimit: 500, // 超过 500KB 警告
},
};预加载与预缓存
资源提示(Resource Hints):
<!-- preload:当前页面关键资源,高优先级,提前下载 -->
<link rel="preload" as="font" href="/fonts/main.woff2" crossorigin>
<link rel="preload" as="image" href="/hero.webp">
<!-- prefetch:未来可能用到的资源,空闲时低优先级下载 -->
<link rel="prefetch" href="/js/next-page.chunk.js">
<!-- preconnect:提前建立连接(DNS + TCP + TLS) -->
<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<!-- dns-prefetch:仅提前做 DNS 解析,兼容性兜底 -->
<link rel="dns-prefetch" href="https://api.example.com">基于用户意图的智能预加载(hover 预取):
// 鼠标悬停链接时,提前加载目标路由的代码
const prefetched = new Set();
function prefetchOnHover(link, importFn) {
link.addEventListener('mouseenter', () => {
if (prefetched.has(link.href)) return;
prefetched.add(link.href);
importFn(); // 触发动态 import,chunk 提前进缓存
}, { once: true });
}
prefetchOnHover(
document.querySelector('a[href="/reports"]'),
() => import('./pages/Reports')
);Service Worker 预缓存:
// 一个最简 Service Worker 预缓存示例
const CACHE = 'app-v1';
const PRECACHE = ['/', '/index.html', '/js/main.chunk.js', '/css/main.css'];
self.addEventListener('install', (event) => {
event.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE)));
});
self.addEventListener('fetch', (event) => {
// 缓存优先,命中直接返回,未命中走网络
event.respondWith(
caches.match(event.request).then((res) => res || fetch(event.request))
);
});Tree Shaking 与按需加载配合
代码分割解决"分批加载",Tree Shaking 解决"删掉没用的代码",两者互补。
// ❌ 全量导入,可能把整个库打进包(取决于库是否支持 ESM)
import _ from 'lodash';
_.debounce(fn, 300);
// ✅ 按需导入,配合 Tree Shaking 只保留用到的部分
import debounce from 'lodash-es/debounce';
debounce(fn, 300);
// package.json 中标记无副作用,帮助打包器安全删除未用导出
// {
// "sideEffects": false
// }真实案例:电商详情页的分割实践
某电商商品详情页包含:商品信息(首屏)、评价列表(滚动到中部)、推荐商品(底部)、分享弹窗(点击才用)。
优化前所有模块打进一个包,首包 620KB。优化方案:
// 滚动进入视口才加载评价模块
const reviewSlot = document.querySelector('#reviews');
const io = new IntersectionObserver((entries) => {
entries.forEach(async (entry) => {
if (entry.isIntersecting) {
const { renderReviews } = await import('./reviews');
renderReviews(entry.target);
io.unobserve(entry.target);
}
});
}, { rootMargin: '200px' }); // 提前 200px 预加载,避免用户看到空白
io.observe(reviewSlot);结果:首包降到 210KB(-66%),LCP 从 3.6s 降到 1.7s,分享弹窗代码(80KB)只有约 12% 的用户会真正加载,节省了大量无效流量。
性能影响
优势:
注意事项与常见坑:
| 坑 | 现象 | 解决 |
| 过度分割 | chunk 太多太碎,HTTP 请求数暴增反而变慢 | 合并小 chunk,设置合理 minSize |
| 懒加载首屏组件 | 首屏内容还要多一次网络往返,LCP 变差 | 首屏组件用静态导入,不要懒加载 |
| 未处理加载失败 | 发布后旧 chunk 404,白屏报错 | 加重试 + 兜底刷新 |
| 无 loading 态 | 切换路由时白屏闪烁 | Suspense / loadingComponent |
| 瀑布式请求 | 一个 chunk 加载完才请求下一个 | prefetch / preload 并行化 |
| vendor 频繁失效 | 业务改动导致 vendor hash 变化,缓存全失效 | 抽 runtimeChunk,稳定 vendor 分组 |
更多框架中的实现
Next.js 动态导入(next/dynamic):
Next.js 在 React.lazy 之上封装了 `next/dynamic`,同时支持关闭 SSR、自定义 loading。
import dynamic from 'next/dynamic';
// 关闭 SSR:某些只在浏览器可用的库(如地图、图表)必须这样
const MapView = dynamic(() => import('../components/MapView'), {
ssr: false,
loading: () => <p>地图加载中...</p>,
});
// 命名导出也可以懒加载
const Chart = dynamic(() =>
import('../components/charts').then((mod) => mod.BarChart)
);
export default function Page() {
return (
<div>
<MapView />
<Chart />
</div>
);
}Nuxt 的自动分割:
Nuxt 会自动对 `pages/` 目录按路由分割,无需手动配置。组件级懒加载用 `Lazy` 前缀即可。
<template>
<!-- 组件名加 Lazy 前缀,Nuxt 自动懒加载 -->
<LazyHeavyChart v-if="show" />
</template>Angular 路由懒加载:
const routes: Routes = [
{
path: 'admin',
// loadChildren 触发独立 chunk,进入路由才下载
loadChildren: () =>
import('./admin/admin.module').then((m) => m.AdminModule),
},
];微前端与模块联邦(Module Federation)
Webpack 5 的模块联邦(Module Federation)让多个独立部署的应用在运行时共享代码,是大型微前端的核心方案。它本质上是一种"跨应用的运行时代码分割"。
// host 应用 webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: 'host',
remotes: {
// 运行时从远程加载子应用暴露的模块
cart: 'cart@https://cdn.example.com/cart/remoteEntry.js',
},
shared: {
// 共享依赖,避免重复加载 React
react: { singleton: true, requiredVersion: '^18.0.0' },
'react-dom': { singleton: true },
},
}),
],
};
// 使用远程模块
const RemoteCart = React.lazy(() => import('cart/Cart'));真实案例:某平台由 6 个团队维护的微前端组成,通过模块联邦共享 React、设计系统组件库,避免了每个子应用各自打包一份框架,整体首屏 JS 从 2.1MB 降到 780KB。
Bundle 分析实操
优化前先测量。用可视化工具找出"到底谁最大、谁被重复打包"。
# webpack
npm i -D webpack-bundle-analyzer
# 在 webpack 配置里加插件后构建,会打开交互式树图
# Vite
npm i -D rollup-plugin-visualizer
# 通用:基于 source map 分析线上产物
npx source-map-explorer dist/js/*.js// webpack.config.js
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
module.exports = {
plugins: [
new BundleAnalyzerPlugin({ analyzerMode: 'static', openAnalyzer: false }),
],
};
// vite.config.js
import { visualizer } from 'rollup-plugin-visualizer';
export default {
plugins: [visualizer({ filename: 'stats.html', gzipSize: true })],
};分析时重点看:有没有 moment/lodash 全量引入、有没有同一个库被打进多个 chunk、有没有本该懒加载的大组件混进了主包。
弱网与移动端的分割策略
网络越差,代码分割的价值越大,但也要避免"碎片化请求"拖累弱网。
// 根据网络状况自适应:弱网时不做激进 prefetch
const conn = navigator.connection;
const saveData = conn && conn.saveData;
const slow = conn && /2g/.test(conn.effectiveType);
function maybePrefetch(importFn) {
if (saveData || slow) return; // 省流量模式/弱网:跳过预取
if ('requestIdleCallback' in window) {
requestIdleCallback(() => importFn());
} else {
setTimeout(importFn, 2000);
}
}
maybePrefetch(() => import('./pages/Reports'));数据对比:在 3G 弱网下,过度分割(30+ 个小 chunk)反而比适度分割(8 个 chunk)慢 40%,因为每个请求都有连接与调度开销。经验值:单个异步 chunk 控制在 20-150KB(Gzip 前)较合理。
真实案例:SaaS 后台首屏优化全过程
某 SaaS 后台优化前 bundle 分析发现三个问题:整包引入了 `echarts`(全量 1MB)、`moment` + 全部 locale(230KB)、以及所有路由打进主包。
优化动作:
// 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]);| 阶段 | 主包(Gzip) | TTI(4G) | 说明 |
| 优化前 | 900KB | 6.8s | 全量打包 |
| echarts 按需+懒加载 | 560KB | 4.2s | 图表页才下载 |
| 换 dayjs | 490KB | 3.6s | 省掉 moment locale |
| 路由分割 | 180KB | 2.3s | 首屏只加载首页 |
| antd 按需 | 95KB | 2.1s | Tree Shaking 生效 |
常见面试问答
Q:动态 import 和静态 import 的本质区别?
静态 import 在编译时确定依赖关系、被打进引用它的 chunk;动态 import 是运行时按需加载、会被打包器切成独立 chunk,返回 Promise。
Q:preload 和 prefetch 的区别?
preload 是当前页面必需的高优先级资源,立即下载;prefetch 是未来可能用到的资源,浏览器空闲时低优先级下载。
Q:为什么要抽 runtimeChunk?
webpack 运行时代码(模块映射等)每次构建都可能变化,若混在 vendor 里会导致 vendor 的哈希频繁变化、长缓存失效。单独抽出后 vendor 更稳定。
Q:代码分割一定能提速吗?
不一定。首屏组件被懒加载、过度分割导致请求过多、瀑布式加载等都会适得其反。要用数据验证。
性能预算与 CI 监控
分割做完不是终点,要防止体积在迭代中悄悄反弹。设定"性能预算"并在 CI 中卡住。
// webpack 性能预算:超限则构建告警/失败
module.exports = {
performance: {
hints: 'error', // 超限直接报错,阻断合并
maxEntrypointSize: 250000, // 入口点最大 250KB
maxAssetSize: 250000, // 单资源最大 250KB
},
};也可以在 CI 里用 size-limit 精确到每个 chunk 的 Gzip/Brotli 体积:
// .size-limit.js
module.exports = [
{ path: 'dist/main.*.js', limit: '100 KB', gzip: true },
{ path: 'dist/framework.*.js', limit: '45 KB', gzip: true },
];
// package.json scripts: "size": "size-limit"
// CI 中运行 npm run size,超限退出码非 0,阻断发布SSR 流式渲染下的分割
在 React 18 的流式 SSR 中,`Suspense` 不只是客户端 loading,它还能让服务端"先发送已就绪的部分 HTML,未就绪的部分用占位符,稍后流式补齐",配合 `renderToPipeableStream` 实现渐进式呈现。
import { Suspense } from 'react';
function Page() {
return (
<>
<Header /> {/* 立即渲染 */}
<Suspense fallback={<Skeleton />}>
<SlowComments /> {/* 数据就绪后流式补齐,不阻塞首屏 */}
</Suspense>
</>
);
}这样即便某块数据慢,用户也能先看到首屏骨架,显著改善感知性能。分割在这里从"减少下载"延伸到了"渐进式呈现"。
分割粒度与 HTTP/2 的关系
HTTP/1.1 时代浏览器每个域名并发有限(约 6 个),所以倾向"合并请求、少而大";HTTP/2 多路复用后并发不再是瓶颈,可以更细粒度分割、更好地利用缓存。但细分仍有上限——每个 chunk 都有解析、执行、调度成本,且过多小文件的 Gzip 压缩率反而下降。
| 场景 | 分割倾向 |
| HTTP/1.1 | 少而大,减少请求数 |
| HTTP/2/3 | 可更细,利用多路复用与缓存 |
| 弱网(2G/3G) | 适度,避免过多往返 |
| 强缓存命中率优先 | 稳定 vendor + 内容哈希 |
最佳实践
总结
| 维度 | 要点 |
| 核心目标 | 按需分批交付代码,缩短首屏可交互时间 |
| 主要手段 | 动态 import、React.lazy/Suspense、Vue 异步组件 |
| 分割维度 | 路由 > 组件 > 功能 > 第三方库 |
| 配置工具 | webpack splitChunks、Vite manualChunks |
| 加速补充 | preload / prefetch / preconnect、Service Worker |
| 常见收益 | 首包体积降 60-90%,TTI/LCP 提升 2-3 倍 |
| 关键红线 | 不懒加载首屏、处理加载失败、避免过度分割 |
代码分割不是"分得越细越好",而是"该分的分、该合的合"。始终以真实的用户访问路径和网络环境为依据,用数据(bundle 分析 + 真实性能指标)驱动优化决策。