Skip to content
概述
当一个项目包含多个独立页面时,webpack 默认的单入口配置就不够用了。每个页面通常有自己的入口模块,也需要独立的 HTML 文档来加载对应的脚本和样式。如果继续使用单个入口,所有页面的逻辑会打包进同一个 bundle,这意味着一个页面被迫加载了所有其它页面的代码——既拖慢首屏,也让代码间的依赖边界变得模糊。
代码分割(Code Splitting)就是用来解决这类问题的机制。它允许将代码拆成多个 chunk,按需加载或让不同页面只引入各自关心的 chunk。在 webpack 中,多页面项目的代码分割通常包含两件事:
- 从构建层面定义多个入口,为每个入口生成独立的 HTML
- 利用 splitChunks 把跨页面重复的公共模块提取出来,避免每个页面都打包一份
多页应用的项目结构与入口定义
构建多页面应用前,通常会按照页面维度组织源码目录。一个典型的目录结构如下:
src/
├── home/
│ └── index.js
├── about/
│ └── index.js
└── index.html # HTML 模板入口对象 entry 需要采用对象形式,每个 key 对应一个页面入口,同一 key 下的模块会被打包进同一个 chunk。对象的 key 会作为 output.filename 中 [name] 的占位值。
js
const path = require('path');
module.exports = {
entry: {
home: './src/home/index.js',
about: './src/about/index.js',
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'js/[name].[contenthash:8].js',
clean: true,
},
};构建后 dist/js/ 下会得到 home.xxxxxxxx.js 和 about.xxxxxxxx.js 两个 chunk。
为每个入口生成独立的 HTML
只生成 JS 还不够,还需要让每个页面引用正确的脚本。HtmlWebpackPlugin 默认只产出一份 HTML,多页面场景下需要创建多个插件实例,并通过 chunks 选项控制引入的 chunk。
js
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
// ... entry、output 同上
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
filename: 'home.html',
chunks: ['home'],
}),
new HtmlWebpackPlugin({
template: './src/index.html',
filename: 'about.html',
chunks: ['about'],
}),
],
};chunks 的值就是 entry 中定义的 key。如果不指定 chunks,插件会把所有打包出来的 JS 都插入 HTML,这样 home 页面也会加载 about 的脚本,破坏了代码分割的意义。另外,如果要引入公共 chunk(例如通过 splitChunks 提取的 vendor 和 common),也需要在此处显式加入,例如 chunks: ['home', 'vendor', 'common']。
自定义模板与变量注入
template 可以指定 HTML 模板文件,支持 EJS 语法插入变量:
html
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title><%= htmlWebpackPlugin.options.title %></title>
</head>
<body>
<div id="app"></div>
</body>
</html>在插件选项中传入 title:
js
new HtmlWebpackPlugin({
template: './src/index.html',
filename: 'home.html',
chunks: ['home', 'vendor', 'common'],
title: '首页',
})这样每个页面可以拥有不同的文档标题,也可以注入自定义 meta 或初始化脚本。minify 选项可以在构建时对 HTML 进行压缩,hash 选项可以给引入的资源 URL 追加查询参数时间戳——不过配合 contenthash 时一般不需要开启。
理解 splitChunks:公共依赖的自动提取
即便给每个页面生成了独立的 HTML 并引入了各自的入口 chunk,如果多个页面引用了相同的第三方库(例如 lodash)或共享业务模块,这些模块仍然会被重复打包进各自的 chunk 中。splitChunks 的作用就是把这些满足条件的模块抽取出来,形成一个或多个新的 chunk,然后让入口 chunk 以依赖的方式引用它们。
webpack 在生产模式 (production) 下内置了一套 splitChunks 的默认配置,大致行为是:
chunks默认值为async,即只对异步 chunk 进行拆分minSize默认为 20000(字节),小于这个体积的模块不会单独提取minChunks默认为 1,表示至少被 1 个 chunk 引用即可拆分cacheGroups中存在一个vendors组,会把node_modules下的模块提取到vendorschunk 中
由于默认 chunks 为 async,同步入口模块即使被多个页面共享,也不会被这些规则自动拆分;只有动态导入 (import()) 产生的异步 chunk 会被处理。需要拆分同步公共模块时,必须将 splitChunks.chunks 显式设置为 'all' 或 'initial'。
配置 cacheGroups 分离 vendor 与业务公共代码
splitChunks.cacheGroups 是定义分包策略的核心。每个缓存组可以继承或覆盖 splitChunks 层的默认值,同时拥有 test、priority、reuseExistingChunk 等专属选项。
js
optimization: {
splitChunks: {
chunks: 'all',
minSize: 20000,
minChunks: 1,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'all',
priority: 10,
},
common: {
name: 'common',
minChunks: 2,
chunks: 'all',
priority: 5,
reuseExistingChunk: true,
},
},
},
},这套配置的意图是:
vendor组通过test匹配所有来自node_modules的模块,将其聚合到名为vendor的 chunk 中common组要求某个模块至少被两个 chunk 共享(minChunks: 2)才会抽取,生成commonchunk,用于承载业务公共代码priority决定当模块同时匹配多个缓存组时优先进入哪个组。vendor 的优先级设为 10 比 common 的 5 更高,保证第三方模块一定会进入 vendor,而不会被 common 误收chunks: 'all'表示同时处理同步和异步引入的模块
如果项目里还有更细粒度的需求,可以继续添加缓存组,并调整各自的 priority 和 test。reuseExistingChunk 为 true 时,如果当前 chunk 已经包含了该模块,就不再重新创建。
文件名哈希与长期缓存
部署到服务器时,为了让浏览器缓存更有效,通常会让 chunk 文件名带上内容哈希(contenthash)。这样只有内容发生变化时文件名才会改变,未修改的 chunk 可以继续使用缓存。
示例 output 配置:
js
output: {
filename: 'js/[name].[contenthash:8].js',
chunkFilename: 'js/[name].[contenthash:8].chunk.js',
path: path.resolve(__dirname, 'dist'),
clean: true,
},filename用于初始 chunk(entry 和 splitChunks 产生的 sync chunk)chunkFilename用于动态导入产生的异步 chunk
结合上面的 splitChunks 配置,vendor 和 common chunk 也会带上哈希值。只要依赖的库版本不更新,vendor 的哈希不变,上线后用户仍然可以使用磁盘缓存中的 vendor 脚本。
动态 import 与代码分割的配合
除了通过 entry 拆分页面,动态 import() 是另一种代码分割的入口。webpack 碰到 import() 表达式时,会将其引用的模块单独拆出一个 chunk,并且仅在运行时真正执行到这条语句时才加载。
js
button.addEventListener('click', () => {
import(/* webpackChunkName: 'chart' */ './chart').then(({ default: renderChart }) => {
renderChart();
});
});- 注释
webpackChunkName: 'chart'可以指定 chunk 名称,输出文件会命名为chart.[contenthash:8].chunk.js - 如果缺省,webpack 会按数字编号命名
import() 返回 Promise,模块加载完毕后,其内部代码会在 then 回调触发前自动执行——也就是说模块的顶层语句会立即运行。这一点与 require.ensure 不同,后者需要手动执行模块代码。
在多页应用中,动态导入可以用来按需加载页面中某块交互才需要的组件(例如图表、富文本编辑器),不至于让全部用户都承受这部分体积。
实战:从单页改造为多页应用并完成分包
下面是一个完整的配置文件,演示如何将单页改造为多页应用并完成分包。
js
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = {
mode: 'production',
entry: {
home: './src/home/index.js',
about: './src/about/index.js',
},
output: {
path: path.resolve(__dirname, 'dist'),
filename: 'js/[name].[contenthash:8].js',
chunkFilename: 'js/[name].[contenthash:8].chunk.js',
clean: true,
},
optimization: {
splitChunks: {
chunks: 'all',
minSize: 20000,
minChunks: 1,
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendor',
chunks: 'all',
priority: 10,
},
common: {
name: 'common',
minChunks: 2,
chunks: 'all',
priority: 5,
reuseExistingChunk: true,
},
},
},
},
plugins: [
new HtmlWebpackPlugin({
template: './src/index.html',
filename: 'home.html',
chunks: ['home', 'vendor', 'common'],
title: '首页',
}),
new HtmlWebpackPlugin({
template: './src/index.html',
filename: 'about.html',
chunks: ['about', 'vendor', 'common'],
title: '关于我们',
}),
],
};在这份配置下,最终 dist 目录中会产出:
home.html、about.html两份 HTMLhome.xx.js、about.xx.js两个页面入口 chunkvendor.xx.js包含所有node_modules中被引用到的模块common.xx.js包含两个页面共用的业务模块
注意点
- 多个 HtmlWebpackPlugin 实例且未指定
chunks:容易导致所有 JS 被引入,使得代码分割失去意义。务必为每个实例明确chunks列表。 - 缓存组的优先级:如果 vendor 的
priority没有高于 common,公共的node_modules模块有概率被 common 组捕获,导致 vendor 体积缩水而 common 反而变大。 - 动态
import()会自动执行模块顶层代码。如果模块内部有副作用(如注册全局事件、修改 prototype),需要注意执行时机。另外,加载失败不会自动重试,需要业务方自行捕获异常。 chunks参数的默认值:在 splitChunks 层和每个缓存组中均可设置,默认分别是async(顶层)和继承splitChunks.chunks。如果顶层已经设为all,不是每个缓存组都需要再显式指定,除非希望某个组仅处理异步 chunk。- 哈希值的干扰:当使用
contenthash时,如果一个公共模块的内容发生变化,所有引用它的 chunk 的哈希值也会变化。这不是 bug,但会降低缓存命中率。此时可以考虑使用runtimeChunk: 'single'将 webpack 运行时分离,以稳定 chunk 哈希。
