webpack SplitChunksPlugin vue-cli 4 拆包实战
https://www.jianshu.com/p/b07efb7a76a6
https://www.cnblogs.com/vvjiang/p/9327903.html
https://v4.webpack.docschina.org/plugins/split-chunks-plugin/
干货篇:
【webpack SplitChunksPlugin 配置详解】
【前端性能优化探讨及浏览器缓存机制】文末已经厘清,项目打包时要合理地合并/拆分 js,旨在控制单个资源体积的同时保证尽量少的请求次数( js 个数),避免请求高并发和资源过大导致阻塞加载。
然而光整js
拆包还不够,最终输出的静态资源文件 (js
、css
、img
等),需采用内容摘要算法命名,以开启长期时效的强缓存。那就先以文件名配置作铺垫。
文件以内容摘要 hash 值命名以实现持久缓存
通过对output.filename
和output.chunkFilename
的配置,利用[contenthash]
占位符,为js
文件名加上根据其内容生成的唯一 hash 值,轻松实现资源的长效缓存。也就是说,无论是第几次打包,内容没有变化的资源 (如js
、css
) 文件名永远不会变,而那些有修改的文件就会生成新的文件名 (hash 值) 。
module.exports = {
output: {
path: __dirname + '/dist',
filename: '[name].[contenthash:6].js',
chunkFilename: '[name].[contenthash:8].js',
},
}
如果是 webpack 4,还需要分别固定
moduleId
和chunkId
,以保持名称的稳定性。
因为 webpack 内部维护了一个自增的数字 id,每个 module 都有一个 id。当增加或删除 module 的时候,id 就会变化,导致其它 module 虽然内容没有变化,但由于 id 被强占,只能自增或者自减,导致整个项目的 module id 的顺序都错乱了。
也就是说,如果引入了一个新模块或删掉一个模块,都可能导致其它文件的 moduleId 发生改变,相应地文件内容也就改变,缓存便失效了。
同样地,chunk 的新增/减少也会导致 chunk id 顺序发生错乱,那么原本的缓存就不作数了。
解决办法:
-
moduleId:
HashedModuleIdsPlugin
插件 (webpack 4) →optimization.moduleIds: 'deterministic'
(webpack 5)
在 webpack 5 无需额外配置,使用默认值就好。 -
chunkId:
[NamedChunksPlugin]()
插件 (webpack 4) →optimization.chunkIds
(webpack 5)
但这个方法只对命名 chunk 有效,我们的懒加载页面生成的 chunk 还需要额外设置,如vue-cli 4
的处理:
// node_modules/@vue/cli-service/lib/config/app.js
chainWebpack: config => {
config
.plugin('named-chunks')
.use(require('webpack/lib/NamedChunksPlugin'), [chunk => {
if (chunk.name) {
return chunk.name
}
const hash = require('hash-sum')
const joinedHash = hash(
Array.from(chunk.modulesIterable, m => m.id).join('_')
)
return `chunk-` + joinedHash
}])
}
在 webpack 5 optimization.chunkIds
默认开发环境'named'
,生产环境'deterministic'
,因此我们无需设置该配置项。而且 webpack 5 更改了 id 生成算法,异步 chunk 也能轻松拥有固定的 id 了。
至于图片和 CSS 文件
- CSS 是通过 mini-css-extract-plugin 插件的
filename
和chunkFilename
定义文件名,值用 hash 占位符如[contenthash:8]
实现缓存配置的。 - 而图片文件,是在 file-loader 的 name 配置项用
[contenthash]
处理的。
注 ⚠️:webpack 5 废弃了 file-loader,改用output.assetModuleFilename
定义图片字体等资源文件的名称,如assetModuleFilename: 'images/[contenthash][ext][query]'
。
可以去看看 vue-cli 4 源码 @vue/cli-service/lib/config/
下的配置处理,或者瞅【file-loader 配置详解以及资源相对路径处理】这篇,这里不详述。
SplitChunksPlugin 拆包实战
回归正题来讲代码分包。
用 SplitChunksPlugin 插件控制 webpack 打包输出的精髓就在于,提取公共代码,防止模块被重复打包、拆分过大的 js 文件、合并零散的 js 文件。但 js 体积和数量都要小这俩目标是相矛盾的,因此并没有标准的方案,需运用中庸之道,结合项目的实际情况去找到最合适的拆包策略。
vue-cli 4 默认处理
结合我用 vue-cli 4 搭的项目,来看下 vue-cli 通过 chainWebpack 覆盖掉 SplitChunksPlugin cacheGroups
项默认值的配置(整理后):
(vue-cli chainWebpack
配置处大致是node_modules/@vue/cli-service/lib/config/app.js:38
)
module.exports = {
entry: {
app: './src/main',
},
output: {
path: __dirname + '/dist',
filename: 'static/js/[name].[contenthash:8].js',
chunkFilename: 'static/js/[name].[contenthash:8].js',
},
optimization: {
splitChunks: {
chunks: 'async', // 只处理异步 chunk,这里两个缓存组都另配了 chunks,那么就被无视了
minSize: 30000, // 允许新拆出 chunk 的最小体积
maxSize: 0, // 旨在与 HTTP/2 和长期缓存一起使用。它增加了请求数量以实现更好的缓存。它还可以用于减小文件大小,以加快二次构建速度。
minChunks: 1, // 拆分前被 chunk 公用的最小次数
maxAsyncRequests: 5, // 每个异步加载模块最多能被拆分的数量
maxInitialRequests: 3, // 每个入口和它的同步依赖最多能被拆分的数量
automaticNameDelimiter: '~',
cacheGroups: { // 缓存组
vendors: {
name: `chunk-vendors`,
test: /[\\/]node_modules[\\/]/,
priority: -10, // 缓存组权重,数字越大优先级越高
chunks: 'initial' // 只处理初始 chunk
},
common: {
name: `chunk-common`,
minChunks: 2, // common 组的模块必须至少被 2 个 chunk 共用 (本次分割前)
priority: -20,
chunks: 'initial', // 只针对同步 chunk
reuseExistingChunk: true // 复用已被拆出的依赖模块,而不是继续包含在该组一起生成
}
},
},
},
};
我们配置了 webpack-bundle-analyzer 插件,便于观察和分析打包结果。
运行打包后,发现入口文件依赖的第三方包被全数拆出放进了chunk-vendors.js
,剩下的同步依赖都被打包进了app.js
,而其他都是懒加载组件生成的异步 chunk。并没有打包出所谓的公共模块合集chunk-common.js
。
解读下此配置的拆分实现:
- 入口来自 node_modules 文件夹的同步依赖放入
chunk-vendors
; - 被至少 2 个 同步 chunk 共享的模块放入
chunk-common
; - 符合每个缓存组其他条件的情况下,能拆出的模块整合后的体积必须大于
30kb
(在进行 min+gz 之前的体积)。小了不生成新 chunk。 - 每个异步引入模块并行请求的数量 (即它本身和它的同步依赖被拆分成的 js 个数)不能多于
5
个;每个入口文件和它的同步依赖最多能被拆成3
个 js。 - 即使不匹配任何一个缓存组,splitChunks.* 级别的最小 chunk 属性
minSize
也会影响所有异步 chunk,效果是体积大于minSize
值的公共模块会被拆出。(除非 splitChunks.*chunks: 'initial'
)
公共模块即>= 2
个异步 chunk 共享的模块,同minChunks: 2
。
针对 3、4 两点作特别说明:vue-cli 4 内置 webpack 4,而 webpack 5 的 SplitChunksPlugin 的默认配置是不同的,如
minSize: 20000, maxAsyncRequests: 30, maxInitialRequests: 30, enforceSizeThreshold: 50000
。而maxSize
默认值即为 0,不用像 webpack 4 这样额外设置。enforceSizeThreshold
的用途是体积大于该值就对 chunk 进行强制拆分 (默认值约50kb
)。
体积大于 maxSize 的 chunk 便能被拆分,为 0 表示不设限。因此只是作为一个提示存在,在 webpack 5 便被弱化了。同时需要满足的是 chunk 能拆出的模块不小于minSize
值。
综上,webpack 5 能让 chunk 在合理的范围更细粒度地拆分,以便更好地支持和利用HTTP/2
来进行长缓存。 故 3、4 两点我们会根据当下标准重新配置。
所以查 Api 的时候切记要弄清版本。
同时我们发现,部分 node_modules 包被重复打包进了一些异步加载的 js 中 (如下)。
这个 js 是根据上面第 5 点生成的,另如果对异步 chunk 名字有疑问,是我在动态引入的时候用了 webpackChunkName magic comment(魔术注释)。此处为两个异步 chunk 名用'~'
分隔符连接是为了说明模块来源,也是 webpack 的自行处理。
【SplitChunksPlugin 干货篇】已经讲得很详尽,这里不再重复。
它其实是两个异步模块guide-add
、guide-edit
共同引用的组件,由于体积过大 (超过minSize
) 被 webpack 单独拆分出来。而且据观察其实大部分懒加载组件都未引入第三包,那这个code-js
的重复就更显得突兀和没有必要了。
这和没有打包出任何公共模块(chunk-common
) ,都是chunks: 'initial'
的锅。这俩缓存组都只负责拆入口 (entry point) 和其同步依赖的模块,异步 chunk 里的第三方自然拆不出来。而且单入口的情况默认生成的 initial chunk 只有一个,上哪和其他同步 chunk 共享模块呀 (minChunks: 2
的意思是至少 2 个 chunk 共同引入的同步模块) 。
必须清楚
minChunks
的共用是面向 chunk 的,有些文章会误写成模块之间共享。同时了解 SplitChunksPlugin 拆包前 webpack 对于 chunk 的初始分包状态也至关重要。不清楚可以 ➡️ 【webpack SplitChunksPlugin 配置详解】 开篇处)。
还有chunk-vendors.js
和app.js
的体积都太大了,特别是初始第三方包竟有 841kb。非常不利于首屏加载的响应速度。以上说明 vue-cli 4 的处理还是有些不尽人意,那我们来自行优化看看吧。
拆包优化
再回顾下这张图:
-
基础类库 chunk-libs
构成项目必不可少的一些基础类库,如vue+vue-router+vuex+axios
这种标准的全家桶,它们的升级频率都不高,但每个页面都需要它们。(一些全局被共用的,体积不大的第三方库也可以放在其中:比如nprogress
、js-cookie
等) -
UI 组件库
理论上 UI 组件库也可以放入 libs 中,但它实在是过大,不管是Element-UI
还是Ant Design
gzip 压缩完都要 200kb 左右,可能比 libs 里所有的包加起来还要大不少,而且 UI 组件库的更新频率也相对比 libs 要更高一点。我们会及时更新它来解决一些现有的 bugs 或使用一些新功能。所以建议将 UI 组件库单独拆成一个包。 -
自定义组件/函数 chunk-commons
这里的 commons 分为 必要和非必要。
必要组件是指那些项目里必须加载它们才能正常运行的组件或者函数。比如你的路由表、全局 state、全局侧边栏/Header/Footer 等组件、自定义 Svg 图标等等。这些其实就是你在入口文件中依赖的东西,它们都会默认打包到app.js
中。
非必要组件是指被大部分懒加载页面使用,但在入口文件 entry 中未被引入的模块。比如:一个管理后台,你封装了很多select
或者table
组件,由于它们的体积不会很大,它们都会被默认打包到到每一个懒加载页面的 chunk 中,这样会造成不少的浪费。你有十个页面引用了它,就会包重复打包十次。所以应该将那些被大量共用的组件单独打包成chunk-commons
。
不过还是要结合具体情况来看。一般情况下,你也可以将那些非必要组件/函数也在入口文件 entry 中引入,和必要组件/函数一同打包到app.js
之中也是没什么问题的。 -
低频组件
低频组件和上面的自定义公共组件chunk-commons
最大的区别是,它们只会在一些特定业务场景下使用,比如富文本编辑器、js-xlsx
前端 excel 处理库等。一般这些库都是第三方的且大于30kb
(缓存组外的默认minSize
值),也不会在初始页加载,所以 webpack 4 会默认打包成一个独立的 js。一般无需特别处理。小于minSize
的情况会被打包到具体使用它的页面 js (异步 chunk) 中。 -
业务代码
就是我们平时经常写的业务代码。一般都是按照页面的划分来打包,比如在 vue 中,使用路由懒加载的方式加载页面component: () => import('./Guide.vue')
webpack 默认会将它打包成一个独立的异步加载的 js。
再回观我们之前的app.js
和chunk-vendors.js
。它们都是初始加载的 js,由于体积太大需要在合理范围内拆分成更小一些的 js,以利用浏览器的并发请求,优化首页加载体验。
- 为了缩减初始代码体积,通常只抽入口依赖的第三方、另行处理懒加载页面的库依赖更为合理,但我们的项目中除了重复的一个,异步模块并无其他第三方引入。那么
chunk-libs
面向的chunks: "all"
即可。vue 我通过 webpack 的 externals 配了 CDN,故没有打包进来。 -
chunk-vendors.js
的Element-UI
组件库应单独分出为chunk-elementUI.js
,由于它包含在第三方包的缓存组内,要给它设置比libs
更高的优先级。 -
app.js
中图标占了大头可以单独抽出来,把自定义 svg 都放到chunk-svgIcon.js
中; - 备一个优先级最低的
chunk-commons.js
,用于处理其他公共组件
splitChunks: {
chunks: "all",
minSize: 20000, // 允许新拆出 chunk 的最小体积,也是异步 chunk 公共模块的强制拆分体积
maxAsyncRequests: 6, // 每个异步加载模块最多能被拆分的数量
maxInitialRequests: 6, // 每个入口和它的同步依赖最多能被拆分的数量
enforceSizeThreshold: 50000, // 强制执行拆分的体积阈值并忽略其他限制
cacheGroups: {
libs: { // 第三方库
name: "chunk-libs",
test: /[\\/]node_modules[\\/]/,
priority: 10,
// chunks: "initial" // 只打包初始时依赖的第三方
},
elementUI: { // elementUI 单独拆包
name: "chunk-elementUI",
test: /[\\/]node_modules[\\/]element-ui[\\/]/,
priority: 20 // 权重要大于 libs
},
svgIcon: { // svg 图标
name: 'chunk-svgIcon',
test(module) {
// `module.resource` 是文件的绝对路径
// 用`path.sep` 代替 / or \,以便跨平台兼容
// const path = require('path') // path 一般会在配置文件引入,此处只是说明 path 的来源,实际并不用加上
return (
module.resource &&
module.resource.endsWith('.svg') &&
module.resource.includes(`${path.sep}icons${path.sep}`)
)
},
priority: 30
},
commons: { // 公共模块包
name: `chunk-commons`,
minChunks: 2,
priority: 0,
reuseExistingChunk: true
}
},
};
格式美化后的index.html
引入的 js 如下:
当然还可以更细化地拆分,比如拆出全局组件、第三方里再拆出个较大的包/或者直接用 CDN 引入。其实优化就是一个博弈的过程,抉择让 a bundle 大一点还是 b bundle? 是让首次加载快一点还是让 cache 的利用率高一点?不要过度追求颗粒化的前提下,尽量利用浏览器缓存就可以啦。
转发:https://www.jianshu.com/p/b07efb7a76a6