代码分割与懒加载
问题
什么是代码分割?Webpack 中如何实现代码分割和懒加载?
答案
什么是代码分割
代码分割(Code Splitting)是一种将打包产物拆分为多个较小 bundle 的优化技术。它的核心目标是按需加载 —— 用户只加载当前页面所需的代码,而不是一次性下载整个应用的所有代码。
现代 SPA 应用动辄数百个组件和数十个依赖库,如果全部打包到一个 JS 文件中:
- 首屏加载慢:用户必须等待整个 bundle 下载完毕才能看到页面
- 缓存失效范围大:修改任何一行代码,整个 bundle 的 hash 都会变化,用户需要重新下载全部内容
- 资源浪费:用户可能永远不会访问某些路由/功能,但对应代码已被加载
代码分割解决的核心问题可以概括为:
| 问题 | 代码分割的解决方案 |
|---|---|
| 首屏加载慢 | 只加载首屏必要代码,其他模块延迟加载 |
| 缓存利用率低 | 将第三方库和业务代码分开,第三方库很少变化可长期缓存 |
| 带宽浪费 | 按需加载,用户只下载真正使用的代码 |
三种代码分割方式
1. 入口起点(Entry Points)
最简单的分割方式,通过配置多个入口来产生多个 bundle:
import type { Configuration } from 'webpack';
const config: Configuration = {
entry: {
app: './src/app.ts',
admin: './src/admin.ts',
},
output: {
filename: '[name].[contenthash].js',
path: __dirname + '/dist',
},
};
export default config;
- 如果两个入口都引用了
lodash,它会被重复打包到两个 bundle 中 - 不够灵活,无法实现动态按需加载
- 需要手动维护入口配置
因此,入口起点通常需要配合 splitChunks 来去重公共模块。
2. 动态导入(Dynamic Imports)
动态导入是最推荐的代码分割方式,使用 import() 语法在运行时按需加载模块:
// 静态导入 —— 会被打包到主 bundle
import Home from './pages/Home';
// 动态导入 —— 会产生单独的 chunk,访问时才加载
const About = () => import('./pages/About');
const Dashboard = () => import('./pages/Dashboard');
import() 返回一个 Promise,resolve 的值就是模块对象:
async function loadModule(): Promise<void> {
const { default: Chart } = await import('chart.js');
const chart = new Chart(/* ... */);
}
通过 Magic Comments 可以控制 chunk 名称和加载行为:
// 指定 chunk 名称
const module = await import(
/* webpackChunkName: "feature-chart" */
'./features/Chart'
);
// 预获取:浏览器空闲时加载
const settings = await import(
/* webpackChunkName: "settings", webpackPrefetch: true */
'./pages/Settings'
);
// 预加载:与父 chunk 并行加载
const modal = await import(
/* webpackChunkName: "modal", webpackPreload: true */
'./components/Modal'
);
3. SplitChunks 插件
Webpack 内置的 SplitChunksPlugin 可以自动识别和抽取公共模块,是代码分割的核心配置。
splitChunks 详细配置
基础配置参数
import type { Configuration } from 'webpack';
const config: Configuration = {
optimization: {
splitChunks: {
// 'all': 同步 + 异步模块都会被分割(推荐)
// 'async': 只分割异步模块(默认)
// 'initial': 只分割同步模块
chunks: 'all',
// 生成 chunk 的最小体积(字节),小于此值不分割
minSize: 20000, // 20KB
// 拆分后 chunk 的最大体积,超过则继续拆分
maxSize: 250000, // 250KB
// 一个模块被至少 N 个 chunk 引用才会被抽取
minChunks: 1,
// 按需加载时的最大并行请求数
maxAsyncRequests: 30,
// 入口点的最大并行请求数
maxInitialRequests: 30,
// 自动命名分隔符
automaticNameDelimiter: '~',
},
},
};
export default config;
chunks 参数的区别:
| 值 | 分割范围 | 适用场景 |
|---|---|---|
'async' | 仅异步导入的模块 | 默认值,保守策略 |
'initial' | 仅同步导入的模块 | 多入口共享代码 |
'all' | 同步 + 异步模块 | 推荐,最大化复用 |
cacheGroups 配置策略
cacheGroups 是 splitChunks 的核心,用于定义分包规则。每个 cache group 会继承 splitChunks 的顶层配置,并可覆盖:
import type { Configuration } from 'webpack';
const config: Configuration = {
optimization: {
splitChunks: {
chunks: 'all',
minSize: 20000,
maxSize: 250000,
minChunks: 1,
cacheGroups: {
// 1. 第三方库单独打包(优先级最高)
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 20,
reuseExistingChunk: true,
},
// 2. 将大型库单独拆分(按需细化)
react: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/,
name: 'react-vendor',
priority: 30, // 优先级高于 vendors
},
antd: {
test: /[\\/]node_modules[\\/](antd|@ant-design)[\\/]/,
name: 'antd-vendor',
priority: 30,
},
// 3. 公共业务代码
commons: {
name: 'commons',
minChunks: 2, // 被 2 个以上 chunk 引用才提取
priority: 10,
reuseExistingChunk: true,
},
// 4. 默认分割规则(兜底)
default: {
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
};
export default config;
test:匹配规则,支持正则、函数、字符串name:chunk 名称,设为固定字符串可以将匹配到的模块合并为一个 chunkpriority:优先级,一个模块可能匹配多个 group,取优先级最高的reuseExistingChunk:如果当前 chunk 已包含目标模块,直接复用,不重复打包enforce:设为true则忽略minSize、minChunks等条件,强制分割
常见分包策略
根据项目类型,以下是几种典型的分包方案:
方案一:基础分包(中小型项目)
// 将所有 node_modules 打包为 vendors
// 业务代码打包为 app
// 公共代码打包为 commons
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 10,
},
},
}
方案二:精细分包(大型项目)
// 框架 → react-vendor
// UI 组件库 → ui-vendor
// 工具库 → utils-vendor
// 业务公共 → commons
// 各路由 → 异步 chunk
splitChunks: {
chunks: 'all',
maxSize: 250000,
cacheGroups: {
framework: {
test: /[\\/]node_modules[\\/](react|react-dom|react-router-dom)[\\/]/,
name: 'framework',
priority: 40,
},
ui: {
test: /[\\/]node_modules[\\/](antd|@ant-design)[\\/]/,
name: 'ui-vendor',
priority: 30,
},
utils: {
test: /[\\/]node_modules[\\/](lodash|dayjs|axios)[\\/]/,
name: 'utils-vendor',
priority: 30,
},
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 20,
},
commons: {
name: 'commons',
minChunks: 2,
priority: 10,
reuseExistingChunk: true,
},
},
}
动态导入与懒加载
React.lazy + Suspense
React 提供了原生的懒加载支持:
import React, { Suspense, lazy } from 'react';
import type { FC } from 'react';
// React.lazy 接受一个返回 import() 的函数
const Home = lazy(() => import('./pages/Home'));
const About = lazy(() => import('./pages/About'));
const Dashboard = lazy(() => import(
/* webpackChunkName: "dashboard" */
'./pages/Dashboard'
));
const Loading: FC = () => <div>加载中...</div>;
const App: FC = () => {
return (
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
);
};
Vue 异步组件
Vue 通过 defineAsyncComponent 实现组件级懒加载:
import { createRouter, createWebHistory } from 'vue-router';
import type { RouteRecordRaw } from 'vue-router';
const routes: RouteRecordRaw[] = [
{
path: '/',
component: () => import('../views/Home.vue'),
},
{
path: '/about',
component: () => import('../views/About.vue'),
},
];
const router = createRouter({
history: createWebHistory(),
routes,
});
export default router;
import { defineAsyncComponent } from 'vue';
import type { Component } from 'vue';
const AsyncChart: Component = defineAsyncComponent({
loader: () => import('./components/Chart.vue'),
loadingComponent: LoadingSpinner,
errorComponent: ErrorDisplay,
delay: 200, // 200ms 后才显示 loading
timeout: 10000, // 10s 超时
});
路由级别懒加载
路由级别的懒加载是最常见也是收益最大的分割方式,因为每个路由页面通常是独立的业务模块:
import { lazy } from 'react';
import type { RouteObject } from 'react-router-dom';
const routes: RouteObject[] = [
{
path: '/',
Component: lazy(() => import('./layouts/MainLayout')),
children: [
{
index: true,
Component: lazy(() => import('./pages/Home')),
},
{
path: 'users',
Component: lazy(() => import('./pages/UserList')),
},
{
path: 'users/:id',
Component: lazy(() => import('./pages/UserDetail')),
},
],
},
];
预加载与预获取
webpackPrefetch vs webpackPreload
这两种策略的触发时机和优先级完全不同:
| 特性 | webpackPrefetch | webpackPreload |
|---|---|---|
| HTML 标签 | <link rel="prefetch"> | <link rel="preload"> |
| 加载时机 | 父 chunk 加载完成后,浏览器空闲时加载 | 与父 chunk 并行加载 |
| 优先级 | 低优先级 | 高优先级 |
| 适用场景 | 用户将来可能访问的页面 | 当前页面一定会用到的资源 |
| 浏览器支持 | 良好(除 Safari) | 良好 |
| 典型用法 | 下一个路由页面 | 首屏关键依赖、弹窗组件 |
// Prefetch:用户可能会进入设置页,空闲时预获取
const Settings = () => import(
/* webpackPrefetch: true */
'./pages/Settings'
);
// Preload:当前页面的弹窗组件,点击按钮时需要立即显示
const EditModal = () => import(
/* webpackPreload: true */
'./components/EditModal'
);
Webpack 会在 HTML 中自动注入对应的 <link> 标签:
<!-- Prefetch: 空闲时下载 -->
<link rel="prefetch" href="/static/js/settings.chunk.js">
<!-- Preload: 立即高优先级下载 -->
<link rel="preload" as="script" href="/static/js/edit-modal.chunk.js">
- Preload 的资源如果 3 秒内没有被使用,浏览器会在控制台输出警告
- 过度使用 Preload 会与当前页面资源争夺带宽,反而拖慢首屏
- 建议优先使用 Prefetch,只在确实需要并行加载时使用 Preload
Chunk 类型
Webpack 将产出的 chunk 分为三种类型:
| Chunk 类型 | 说明 | 示例 |
|---|---|---|
| Initial Chunk | 入口起点对应的 chunk,包含所有同步依赖 | main.js、vendors.js |
| Async Chunk | 通过 import() 动态导入产生的 chunk,按需加载 | dashboard.chunk.js |
| Runtime Chunk | Webpack 运行时代码,负责模块加载和 chunk 管理 | runtime.js |
将 Runtime 单独抽取可以避免业务代码 hash 因 runtime 变化而失效:
const config: Configuration = {
optimization: {
// 将 runtime 代码提取为单独的 chunk
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
},
},
};
Runtime 代码包含模块加载器(__webpack_require__)和 chunk 映射关系。将其单独抽取后:
- 业务代码变化不会影响 runtime 的 hash
- vendor 代码变化不会影响 runtime 的 hash
- runtime 文件很小(通常 < 5KB),可以直接内联到 HTML 中减少一次网络请求
Vite 中的代码分割
Vite 底层使用 Rollup 进行打包,代码分割的配置方式与 Webpack 不同。
Vite 默认会自动进行代码分割:
- 动态
import()会自动生成独立 chunk - CSS 代码自动按组件分割
- 公共依赖自动提取
通过 manualChunks 可以自定义分包策略:
import { defineConfig } from 'vite';
import type { UserConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// 将 React 相关库打包在一起
'react-vendor': ['react', 'react-dom', 'react-router-dom'],
// 将 UI 库单独打包
'ui-vendor': ['antd', '@ant-design/icons'],
// 工具库
'utils': ['lodash-es', 'dayjs', 'axios'],
},
},
},
},
} satisfies UserConfig);
也可以使用函数形式进行更灵活的控制:
import { defineConfig } from 'vite';
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id: string): string | undefined {
// node_modules 中的依赖按包名分割
if (id.includes('node_modules')) {
// React 生态
if (/react|react-dom|react-router/.test(id)) {
return 'react-vendor';
}
// UI 组件库
if (/antd|@ant-design/.test(id)) {
return 'ui-vendor';
}
// 其他第三方库统一打包
return 'vendor';
}
// 公共组件
if (id.includes('src/components/')) {
return 'components';
}
},
},
},
},
});
| 特性 | Webpack | Vite(Rollup) |
|---|---|---|
| 配置方式 | splitChunks + cacheGroups | manualChunks |
| 默认行为 | 只分割异步 chunk | 自动分割异步 chunk + CSS |
| 灵活度 | 更高(支持复杂条件) | 较简洁 |
| 配置复杂度 | 较高 | 较低 |
| Tree Shaking | 需配置 sideEffects | 默认更激进的 Tree Shaking |
代码分割策略最佳实践
分割策略决策流程
关键原则
- 路由级别分割是基础:每个路由页面应该是独立的 async chunk
- 第三方库与业务代码分离:利用浏览器的长期缓存能力
- 大型库单独抽取:超过 50KB 的库考虑独立为一个 chunk
- 避免过度分割:chunk 数量太多会导致 HTTP 请求增多,反而降低性能
- 配合 Prefetch 使用:可预测的用户行为路径提前加载资源
- 持续监控产物体积:使用
webpack-bundle-analyzer或rollup-plugin-visualizer分析
常见面试问题
Q1: Webpack 中代码分割有哪几种方式?各自适用场景?
答案:
Webpack 提供三种代码分割方式:
1. 入口起点(Entry Points)
通过配置多个 entry 来生成多个 bundle:
entry: {
app: './src/index.ts',
admin: './src/admin.ts',
}
- 适用场景:多页应用(MPA),每个页面有独立入口
- 缺点:无法处理重复依赖,需配合
splitChunks使用
2. 动态导入(Dynamic Imports)
使用 import() 语法按需加载模块:
// 路由懒加载
const Home = lazy(() => import('./pages/Home'));
// 条件加载
if (needChart) {
const { Chart } = await import('chart.js');
}
- 适用场景:路由懒加载、条件加载、大型组件延迟加载
- 优点:最灵活,与框架的懒加载机制无缝配合
3. SplitChunks 插件
Webpack 内置插件,自动提取公共模块:
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendors: { test: /node_modules/, priority: 10 },
commons: { minChunks: 2, priority: 5 },
},
},
}
- 适用场景:提取公共依赖、第三方库分包、优化缓存
- 优点:自动化程度高,可细粒度控制分包规则
在实际项目中,三种方式通常组合使用:
- 用入口起点区分应用和管理后台
- 用动态导入实现路由懒加载
- 用 splitChunks 抽取公共模块和第三方库
Q2: splitChunks 的配置策略是怎样的?如何设计合理的分包方案?
答案:
合理的分包方案需要平衡缓存效率和请求数量两个因素。
核心参数解读:
| 参数 | 作用 | 推荐值 |
|---|---|---|
chunks | 分割范围 | 'all'(同步+异步) |
minSize | 最小 chunk 体积 | 20000(20KB) |
maxSize | 最大 chunk 体积 | 250000(250KB) |
minChunks | 被引用次数阈值 | 1(vendors)/ 2(commons) |
priority | cacheGroup 优先级 | 数值越大优先级越高 |
推荐的分包方案:
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
maxSize: 250000,
cacheGroups: {
// 第一层:框架核心(极少变化,长期缓存)
framework: {
test: /[\\/]node_modules[\\/](react|react-dom|vue)[\\/]/,
name: 'framework',
priority: 40,
enforce: true,
},
// 第二层:UI 库(较少变化)
ui: {
test: /[\\/]node_modules[\\/](antd|@ant-design|element-plus)[\\/]/,
name: 'ui-lib',
priority: 30,
},
// 第三层:其他第三方库
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: 20,
},
// 第四层:业务公共代码
commons: {
minChunks: 2,
name: 'commons',
priority: 10,
reuseExistingChunk: true,
},
},
},
}
设计原则:
- 按变更频率分层:变化越少的代码越适合独立为 chunk,利用浏览器缓存
- 控制 chunk 数量:初始加载的 chunk 建议不超过 5 个,避免过多并行请求
- 设置合理的体积范围:单个 chunk 建议在 20KB ~ 250KB 之间
- 优先级层级清晰:确保更精确的 cacheGroup 优先级高于宽泛的匹配规则
Q3: prefetch 和 preload 的区别是什么?
答案:
Prefetch 和 Preload 都是资源加载的优化手段,但在加载时机、优先级和适用场景上有本质区别:
| 对比项 | prefetch | preload |
|---|---|---|
| HTML 语法 | <link rel="prefetch"> | <link rel="preload"> |
| 加载时机 | 浏览器空闲时 | 与当前页面资源并行 |
| 网络优先级 | 最低(Lowest) | 高(High) |
| 是否阻塞渲染 | 不阻塞 | 不阻塞,但会占用带宽 |
| 缓存行为 | 存入 HTTP Cache 或 prefetch cache | 存入内存缓存(memory cache) |
| 适用场景 | 下一页面可能用到的资源 | 当前页面一定需要的资源 |
| Webpack 注释 | /* webpackPrefetch: true */ | /* webpackPreload: true */ |
具体使用场景举例:
// 用户在首页,可能会进入文章详情页
const ArticleDetail = () => import(
/* webpackPrefetch: true */
'./pages/ArticleDetail'
);
// 用户在列表页,可能会打开编辑弹窗
const EditDialog = () => import(
/* webpackPrefetch: true */
'./components/EditDialog'
);
// 字体文件:当前页面渲染必需
// <link rel="preload" href="/fonts/main.woff2" as="font" crossorigin>
// 首屏图表组件:页面加载后立即需要展示
const HeroChart = () => import(
/* webpackPreload: true */
'./components/HeroChart'
);
- 不要把所有路由都设为 Preload:Preload 会争夺当前页面的网络带宽,滥用会适得其反
- Prefetch 不保证一定会加载:浏览器会根据网络状况、电量等因素决定是否执行
- Safari 对 Prefetch 支持有限:在 Safari 中
<link rel="prefetch">不会被执行
Q4: React.lazy 和 import() 动态导入的关系?Suspense 在其中的作用?
答案:
React.lazy 是 React 对 import() 动态导入的封装,它接受一个返回 Promise<{ default: Component }> 的函数(即 import() 的返回值),将其转换为 React 可以渲染的懒加载组件。Suspense 则负责在组件加载过程中展示 fallback UI。
三者的关系:
完整使用示例:
import React, { Suspense, lazy } from 'react';
import type { FC, ReactNode } from 'react';
// 1. React.lazy 包装 import() 返回的 Promise
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Settings = lazy(() => import('./pages/Settings'));
// 2. 错误边界处理加载失败
class ErrorBoundary extends React.Component<
{ children: ReactNode; fallback: ReactNode },
{ hasError: boolean }
> {
state = { hasError: false };
static getDerivedStateFromError(): { hasError: boolean } {
return { hasError: true };
}
render(): ReactNode {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
// 3. Suspense 提供 fallback 展示加载状态
const App: FC = () => {
return (
<ErrorBoundary fallback={<div>加载失败,请刷新重试</div>}>
<Suspense fallback={<div>加载中...</div>}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</ErrorBoundary>
);
};
底层原理:
// React.lazy 的简化实现原理
function lazy<T extends React.ComponentType>(
factory: () => Promise<{ default: T }>
): React.LazyExoticComponent<T> {
let status: 'pending' | 'fulfilled' | 'rejected' = 'pending';
let result: T;
let error: Error;
const promise = factory().then(
(module) => {
status = 'fulfilled';
result = module.default; // 注意:必须是 default 导出
},
(err) => {
status = 'rejected';
error = err;
}
);
// 返回一个特殊组件,Suspense 通过 throw promise 机制感知加载状态
return {
$$typeof: Symbol.for('react.lazy'),
_payload: { status, result, error, promise },
_init: /* ... */,
} as any;
}
import()返回的模块必须有default导出,否则React.lazy无法获取组件Suspense通过 React 的 throw promise 机制感知子组件的加载状态- 加载失败时
Suspense不会捕获错误,必须配合ErrorBoundary处理 - 嵌套的
Suspense可以实现更细粒度的加载状态控制
Q5: Prefetch 和 Preload 有什么区别?在代码分割中如何使用?
答案:
Prefetch 和 Preload 是两种浏览器资源提示(Resource Hints),在 Webpack 代码分割中通过 Magic Comments 使用。
核心区别:
| 对比项 | Prefetch | Preload |
|---|---|---|
| HTML 标签 | <link rel="prefetch"> | <link rel="preload"> |
| 加载时机 | 父 chunk 加载完成后,浏览器空闲时 | 与父 chunk 并行加载 |
| 网络优先级 | 最低(Lowest) | 高(High) |
| 适用场景 | 未来可能需要的资源 | 当前页面一定需要的资源 |
| 缓存位置 | prefetch cache / HTTP cache | memory cache |
| 未使用警告 | 无 | 3 秒内未使用会有控制台警告 |
在 Webpack 中使用 Magic Comments:
// 用户在首页,可能会点击"关于"页面
const About = () => import(
/* webpackPrefetch: true */
'./pages/About'
);
// Webpack 会在父 chunk 加载完成后注入:
// <link rel="prefetch" href="/static/js/about.chunk.js">
// 当前页面一定会用到的弹窗组件,需要与主 chunk 并行加载
const Modal = () => import(
/* webpackPreload: true */
'./components/Modal'
);
// Webpack 会注入:
// <link rel="preload" as="script" href="/static/js/modal.chunk.js">
实际项目中的使用策略:
import { lazy } from 'react';
// 首页 — 不需要 Prefetch/Preload(入口 chunk 直接包含)
const Home = lazy(() => import('./pages/Home'));
// 用户大概率会访问的页面 — Prefetch
const ProductList = lazy(
() => import(/* webpackPrefetch: true */ './pages/ProductList')
);
// 用户可能会访问的页面 — Prefetch
const UserProfile = lazy(
() => import(/* webpackPrefetch: true */ './pages/UserProfile')
);
// 管理后台 — 不 Prefetch(大多数用户不会访问)
const AdminPanel = lazy(() => import('./pages/AdminPanel'));
- 优先用 Prefetch,谨慎用 Preload:Prefetch 在空闲时加载不影响首屏,Preload 会抢占带宽
- 只对高概率访问的路由使用 Prefetch:不要给所有路由都加 Prefetch
- Preload 仅用于当前页面确定需要的资源:如首屏 Hero 组件依赖的大型库
- 移动端网络差时考虑禁用 Prefetch:可通过
navigator.connection.saveData判断
Q6: 如何分析和优化代码分割的效果?
答案:
分析代码分割效果的核心工具是 webpack-bundle-analyzer,它能将打包产物以可视化的方式呈现,帮助你发现问题并优化。
1. 安装和配置 webpack-bundle-analyzer
- npm
- Yarn
- pnpm
- Bun
npm install webpack-bundle-analyzer --save-dev
yarn add webpack-bundle-analyzer --dev
pnpm add webpack-bundle-analyzer --save-dev
bun add webpack-bundle-analyzer --dev
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
const config: Configuration = {
plugins: [
// 按需开启分析
...(process.env.ANALYZE
? [new BundleAnalyzerPlugin({
analyzerMode: 'static', // 生成静态 HTML 报告
reportFilename: 'report.html',
openAnalyzer: false,
})]
: []),
],
};
// 运行: ANALYZE=true npx webpack --mode production
2. 分析报告中要关注的问题
| 问题 | 表现 | 解决方案 |
|---|---|---|
| chunk 过大 | 单个 chunk > 300KB(gzip 前) | 拆分 cacheGroups 或增加动态 import() |
| 重复依赖 | 同一个库出现在多个 chunk 中 | 调整 splitChunks 的 priority 和 test |
| 无用代码 | 整个大库被打包但只用了少量功能 | 按需导入(如 lodash-es)或 sideEffects |
| chunk 过多 | 初始加载 > 10 个请求 | 合并小 chunk,调大 minSize |
| vendor 过大 | node_modules 打包体积 > 1MB | 拆分大型库、使用 CDN externals |
3. 合理的 chunk 大小范围
- 理想范围:100KB ~ 300KB
- 初始加载的 chunk 总数:控制在 3~6 个
- 初始 JS 总体积:< 200KB(gzip 后),确保首屏 < 3 秒
4. 优化流程实操
{
"scripts": {
"analyze": "ANALYZE=true webpack --mode production",
"build:stats": "webpack --mode production --json=stats.json"
}
}
优化步骤:
- 运行
npm run analyze生成可视化报告 - 找出过大的 chunk:检查是否有单个 chunk 超过 300KB
- 识别重复依赖:同一个库是否出现在多个 chunk 中
- 检查按需导入:大库(如 lodash、moment)是否整包引入
- 优化 splitChunks:调整
cacheGroups、minSize、maxSize - 重新分析:对比优化前后的体积变化
// 优化前:lodash 整包引入(71KB gzip)
import _ from 'lodash';
const result = _.get(obj, 'a.b.c');
// 优化后方案一:按需导入(仅 2KB gzip)
import get from 'lodash/get';
const result = get(obj, 'a.b.c');
// 优化后方案二:使用 lodash-es + Tree Shaking
import { get } from 'lodash-es';
const result = get(obj, 'a.b.c');
Vite(基于 Rollup)使用 rollup-plugin-visualizer:
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({ open: true, gzipSize: true }),
],
});