项目发展到一定程度,bundle.js 会越来越大。用户打开首页,却要下载整个应用(包括还没进的路由、点开的弹窗、用不到的第三方库),首屏自然快不起来。**Code Splitting(代码分割)**就是把一个巨大的 bundle 拆成多个小包,让浏览器按需、并行地加载。
一、为什么需要代码分割
假设整个应用被打成一个包:
bundle.js 2.3 MB
问题随之而来:
- 首屏慢:用户只想看首页,却要下载包括「设置页」「详情页」「编辑器」在内的全部代码。
- 缓存效率低:改任何一处代码,整个 bundle 的 hash 都会变化,用户缓存全部失效,必须重新下载 2.3 MB。
- 加载与执行成本高:未用到的代码也要解析执行,白白占用主线程。
理想情况是:
- 首屏只加载「现在就要用」的代码。
- 用户点击某个路由时,再加载对应页面的代码。
- 第三方库变化少,单独拆出来长期缓存,改业务代码时它不受影响。
这正是 Code Splitting 要达成的目标。
二、Code Splitting 是什么
Code Splitting 指的是:
把代码拆分成多个 chunk(代码块),让 webpack 分别输出成独立的文件,在运行时按需加载,而不是把所有模块合并成一个 bundle。
拆分出来的 chunk 主要有两类:
- 初始 chunk(initial):页面一打开就需要加载的,比如入口和框架运行时。
- 异步 chunk(async):通过动态
import()触发的,只有执行到那一行才会去请求。
配合 splitChunks,还能把多个 chunk 共享的模块(尤其是 node_modules 里的第三方库)提取成公共包,避免重复打包。
三、怎么用
webpack 提供了三种代码分割的方式,从手动到自动。
1. 多入口(手动分割)
最原始的方式:配置多个 entry,适合多页面应用。
module.exports = {
entry: {
home: './src/home.js',
admin: './src/admin.js',
},
output: {
filename: 'js/[name].[contenthash:8].js',
},
};
缺点是「拆多少」全靠人工,且公共依赖容易重复。适合页面之间天然独立的场景。
2. 动态 import()(按需加载)
这是最常用、也是收益最直接的方式。把 import 从静态改成动态:
// 静态导入:会被打进同一个 chunk
import { heavyFn } from './heavy.js';
// 动态导入:单独成一个 chunk,执行到这行才加载
button.addEventListener('click', () => {
import('./heavy.js').then(({ heavyFn }) => {
heavyFn();
});
});
webpack 遇到 import() 时,会自动把目标模块及其依赖拆成一个独立 chunk,并在需要时通过 __webpack_require__.e 去请求它。因为返回的是 Promise,所以可以配合 async/await:
async function handleClick() {
const { heavyFn } = await import('./heavy.js');
heavyFn();
}
路由级分割是它的典型应用——React 用 React.lazy,Vue 用异步组件:
// React
const Detail = React.lazy(() => import('./pages/Detail.jsx'));
// Vue Router
const routes = [
{ path: '/detail', component: () => import('./pages/Detail.vue') },
];
这样每个路由页面的代码都会被拆出来,用户访问哪个页面才加载哪个。
3. 魔法注释:给 chunk 命名与控制加载
import() 支持通过魔法注释控制拆分行为:
import(
/* webpackChunkName: "detail-page" */
/* webpackPrefetch: true */
'./pages/Detail.jsx'
);
| 注释 | 作用 |
|---|---|
webpackChunkName | 指定 chunk 名称,产物文件名更可读 |
webpackPrefetch | 空闲时预取,用户后续可能用到 |
webpackPreload | 与父 chunk 并行预加载,优先级更高 |
webpackMode | 控制拆分模式(lazy / eager / weak 等) |
webpackInclude / webpackExclude | 动态导入目录时限定或排除匹配文件 |
其中 prefetch 与 preload 的区别值得记住:
- prefetch:浏览器空闲时下载,适合「下一步很可能要用」的代码,不抢当前资源带宽。
- preload:和当前 chunk 并行下载,适合「当前页面很快就要用」的代码,优先级高。
4. splitChunks:抽取公共依赖
动态导入解决「按需加载」,splitChunks 解决「避免重复、便于缓存」。它会把多个 chunk 共享的模块提取成公共包:
module.exports = {
optimization: {
splitChunks: {
chunks: 'all', // 对同步和异步 chunk 都生效
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
priority: -10,
chunks: 'all',
},
common: {
name: 'common',
minChunks: 2, // 至少被 2 个 chunk 引用才提取
priority: -20,
reuseExistingChunk: true,
},
},
},
},
};
几个关键配置:
| 配置 | 含义 |
|---|---|
chunks | 作用范围:'all' / 'async' / 'initial' |
minSize | 超过该体积才拆分(默认 20000 字节) |
minChunks | 被引用几次才提取 |
maxSize | 超过该体积尝试进一步拆分 |
cacheGroups | 分组规则,按 test / priority 决定归属 |
reuseExistingChunk | 可复用已有 chunk 时不再重复打包 |
最常见的收益:把 node_modules 拆成 vendors。业务代码更新时,vendors 内容不变、hash 不变,用户无需重新下载。
5. runtimeChunk:分离运行时代码
webpack 的运行时代码(模块加载、缓存等)默认打进入口文件。一旦有异步 chunk 的内容变化,运行时代码可能跟着变,导致入口 hash 变化,连锁使缓存失效。把它单独抽出来即可:
module.exports = {
optimization: {
runtimeChunk: 'single', // 所有入口共用一个 runtime chunk
},
};
四、产物命名与目录
分割后会有多个文件,建议通过 filename / chunkFilename 规划清楚:
module.exports = {
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'js/[name].[contenthash:8].js', // 初始 chunk
chunkFilename: 'js/[name].[contenthash:8].chunk.js', // 异步 chunk
},
};
配合 splitChunks 的 name,产物结构大致是:
dist/js/
├── runtime.c9d0e1f2.js
├── vendors.e5f6a7b8.js
├── main.a1b2c3d4.js
├── detail-page.11223344.chunk.js
└── 42.99887766.chunk.js
五、注意事项
- 不要过度分割。chunk 太碎会带来大量 HTTP 请求和调度开销,尤其 HTTP/1.1 下适得其反。用
minSize/minChunks控制粒度。 chunks: 'all'抽公共包时留意体积和请求数。提取过度可能让首屏反而要加载更多文件。- 区分 prefetch 与 preload。
prefetch可能浪费带宽(用户未必会访问),preload会抢占关键资源,都要按场景选。 - 动态导入的代码会延迟执行。如果某段代码「首屏必须用」,就别拆成异步,否则会多一次请求往返。
- 老浏览器兼容。动态
import()需要 polyfill,可用@babel/plugin-syntax-dynamic-import(Babel 7 已内置语法支持)或构建时转换。
六、完整配置示例
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'production',
entry: './src/index.js',
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'js/[name].[contenthash:8].js',
chunkFilename: 'js/[name].[contenthash:8].chunk.js',
publicPath: '/',
clean: true,
},
plugins: [
new HtmlWebpackPlugin({ template: './public/index.html' }),
],
optimization: {
runtimeChunk: 'single',
splitChunks: {
chunks: 'all',
minSize: 20000,
cacheGroups: {
vendors: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
priority: -10,
},
common: {
name: 'common',
minChunks: 2,
priority: -20,
reuseExistingChunk: true,
},
},
},
},
};
业务代码中的动态导入:
// src/index.js
document.querySelector('#detail').addEventListener('click', () => {
import(
/* webpackChunkName: "detail-page" */
/* webpackPrefetch: true */
'./pages/detail.js'
).then(({ renderDetail }) => renderDetail());
});
小结
Code Splitting 的三种手段分工明确:
| 方式 | 解决的问题 | 适用场景 |
|---|---|---|
多入口 entry | 页面级手动拆分 | 多页面应用 |
动态 import() | 运行时代码按需加载 | 路由、弹窗、重型功能 |
splitChunks | 提取公共依赖、优化缓存 | 所有中大型项目 |
runtimeChunk | 稳定入口 hash | 配合长期缓存 |
一句话:用动态 import() 把「暂时用不到」的代码拆出去,用 splitChunks 把「大家都用」的代码抽出来,再用 runtimeChunk 稳住缓存。 三者配合,才能兼顾首屏体积、加载体验和缓存效率。