前言
从上一篇面试篇知道,目前入职所从事的方向是底层代码的重构以及下一代产品的架构设计。入职不久后主管丢给我一个表格,上面列了四五条,都是当前项目存在的一些问题,让我看看有哪些可以去解决掉,一眼就瞅到了一个语言包优化,心想终于可以实践一下自己的优化知识储备了,当我准备大展拳脚时点开了webpack.config.js,看到提示:xxxx, 4 years ago · 初始化,.....,恍然大悟,原来我是铲💩官。
问题大概是这样的,通过打包工具分析,同个语言包被打进每一个被依赖的模块中,导致多个模块传输体积增加。关键是这个语言包体积挺大的,几个大文件我几乎占大头的都是它(为什么到现在才发现?💩)。出于安全问题,就不直接使用业务代码做例子了,我准备了一个类似的demo来还原当时的场景。牢骚发到这,开始我们的优化之路~
正文
代码仓库
使用的webpack为4.x版本,实现起来与5.x版本大同小异(5.x有些插件被内置,比如clean-webpack-plugin),但不影响总体思路。
目录结构非常简单,两个业务模块中都用到了第三方模块jquery和lodash。但是如果不做优化处理的话那么最后的打包结果会出现两份jquery和lodash,随着业务模块的引入增多那么最终就会多出对应份的公共代码。
|-- node_modules
| |-- jquery
| |__ lodash
|-- src
| |-- handler.js //入口文件(业务代码),引入juqery和lodash
| |__ index.js //入口文件(业务代码),引入juqery和lodash
解决思路:利用分包提取大量公共代码,从而减少总体积并充分利用浏览器缓存。
先把环境安装好,使用webpack-bundle-analyzer来进行性能分析,两个入口文件,简单配置下webpack.config.js
//webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
entry: {
main: "./src/index.js", //入口文件
handler: "./src/handler.js", //其他入口文件
},
output: {
filename: "[name].[hash:4].js", //打包后的文件名用chunk name和hash来组合命名
},
plugins: [
new BundleAnalyzerPlugin(), //注册打包分析plugins
]
}
不优化正常打包
使用npm run prod进行生产环境打包
厚礼谢,可以看到我们的业务代码里并没有写什么东西,但是两个文件里都包含了包含了jquery和lodash的代码,所以导致两个文件都达到了160kb的体积。这只是两个文件,如果在一个复杂的大型系统里,不知道会有多少公共依赖,会增加多少传输量。
优化后打包
从打包分析中可以看到两个文件体积明显减少,解析后仅有1.1kb,依赖的jquery和lodash都变成了dll-reference表示引用,再看network,可以发现两个库都被单独的抽离了出来。
优化前:main(160kb)+ handler(160kb) = 320kb
优化后:main + handler(2kb)+ jquery(91kb) + lodash(74kb) = 167kb
优化后体积缩小了接近50%,斯国一!(杠精:垃圾,多增加了两次传输!! 呵呵,分包另一个好处就是因为第三方库不会频繁变动文件内容,所以可以充分利用浏览器缓存~) 接下来就开始精解两种分包的原理和实践。
手动分包
原理
顾名思义,就是要先手动的将公共文件先单独打包出来,成为动态链接库dll(Dynamic Link Library),生成一个资源清单(manifest.json)。至于什么是dll还是在上学时接触.net网站开发了解的,在分包中简单的理解为一个代码仓库,你要哪些东西直接去里面拿,没了解过的同学自行百度哈,这里就不过多解释了。
怎么做?该如何配置?在实践中会详细讲解,有了动态链接库和资源清单后,我们就可以正常进行打包了,在正常打包的过程中,如果发现导入的路径跟资源清单中记录的模块名称相同,那么就会使用动态链接库中的文件,就不会将依赖打包进自己的文件中。
这里有一个小问题,这里为什么打包出来的jquery和lodash要直接使用var暴露全局变量出来?
我们直接来分析下webpack的打包结果就一目了然了,使用mode=dev开发模式打包,看的更清楚一些。
// dist/dll/main.6321.js
// ....其他代码....
"dll-reference jquery":
(function (module, exports) {
eval("module.exports = jquery;\n\n//# sourceURL=webpack:///external_%22jquery%22?");
}),
"dll-reference lodash":
(function (module, exports) {
eval("module.exports = lodash;\n\n//# sourceURL=webpack:///external_%22lodash%22?");
})
可以发现并没有将jquery和lodash一大堆源码打入到main.js中。而是使用了module.exports的方式导出一个全局变量。
为什么? 因为在前面生成的资源清单中有关于jquery或lodash的描述。打包过程中分析依赖凡是看到依赖的名称为jquery的,都会去资源清单的content的路径进行匹配,也就是./node_modules/jquery/dist/jquery.js。
// 我们平时写的引入路径
import $ from "jquery" === import $ from "node_modules/jquery/dist/jquery.js"
node_modules/jquery/dist/jquery.js正好匹配到了jquery.manifest.json资源清单文件的content路径,所以在打包结果中jquery的源码变成了:module.exports = 该资源清单的name。
// jquery清单文件
// jquery.manifest.json
{
"name": "jquery",
"content": {
"./node_modules/jquery/dist/jquery.js": {
"id": 1,
"buildMeta": {
"providedExports": true
}
}
}
}
之后直接在模版index.html中直接引入公共代码dist/dll/jquery.js就可以完成手动分包的目的。有些懵?没事,走一遍实践就明白了。
实践
前面提到的资源清单和动态链接库如何生成呢?新建webpack.dll.config.js,配置如下:
// webpack.dll.config.js
const webpack = require("webpack");
const path = require("path");
module.exports = {
entry: {
//有几个公共文件就写几个入口
jquery: ["jquery"],
lodash: ["lodash"]
},
output: {
//打包到dist/dll目录下
filename: "dll/[name].js",
library: "[name]"
},
plugins: [
new webpack.DllPlugin({
//资源清单的保存位置
path: path.resolve(__dirname, "dll", "[name].manifest.json"),
//资源清单中,暴露的变量名
name: "[name]"
}),
]
};
然后在packages.json中添加script脚本dll: "webpack --mode=production --config webpack.dll.config.js"指明配置文件。运行npm run dll。
可以看到我们想要的文件都被打包出来了,用来描述清单的manifest文件。以及打包结果中的dll文件夹。我们在来看一下打包后的jquery.js和lodash.js是不是导出了一个全局变量。
哦♂ yeah~ 我们的配置生效了,公共代码分出来了,前置工作已经做好了,接下来就可以正常进行打包了。进一步完善webpack.config.js。
这里安装两个新插件html-webpack-plugin和clean-webpack-plugin(5.x版本已内置),在plugins中新增两个webpack.DllReferencePlugin,配置一下manifest指明清单文件。
//webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const HtmlWebpackPlugin = require("html-webpack-plugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const webpack = require("webpack");
module.exports = {
mode: "production",
entry: {
main: "./src/index.js",
handler: "./src/handler.js",
},
output: {
filename: "[name].[hash:4].js"
},
plugins: [
// 打包分析工具
new BundleAnalyzerPlugin(),
new CleanWebpackPlugin({
// 不删除手动打包出来dll文件,这样每次打包就不会删除dist/dll文件了
cleanOnceBeforeBuildPatterns: ["**/*", "!dll", "!dll/*"],
}),
new HtmlWebpackPlugin({
template: "./index.html"
}),
// 指明清单文件
new webpack.DllReferencePlugin({
manifest: require("./dll/jquery.manifest.json"),
}),
// 指明清单文件
new webpack.DllReferencePlugin({
manifest: require("./dll/lodash.manifest.json")
})
]
}
然后在模版文件index.html中手动引入dist/dll下的公共代码。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>webpack sep packages</title>
</head>
<body>
<script src="./dll/jquery.js"></script>
<script src="./dll/lodash.js"></script>
</body>
</html>
然后敲下npm run prod,可以看到打包分析工具启动端口打开页面,呈现在我们眼前的就是文章前面提到的优化后打包的样子啦。至此,手动分包的原理及使用就全部ok了。但是有没有觉得有一丝繁琐?还有一个问题就是如果第三方库依赖其他的第三方库,那我是不是要先把依赖的依赖先打包,然后再打包依赖?这些就是手动分包的一些缺点,如何解决?先看看自动分包怎么做。
自动分包
原理
与手动分包不同的是,自动分包无需每次都要手动先将公共代码先打包一次,它不针对某个具体的包分出去,我们只需要配置好分包策略后webpack每次都会自动的完成分包的流程,更符合我们的开发方式,无需关注以后会新增哪些公共代码,所以我们一般优化基本上都用的是自动分包,一次配置,永久畅享~
事实上,webpack内部完成分包依赖的是SplitChunksPlugin来实现的,可以在官方文档中看见过去是使用CommonsChunkPlugin来实现的分包,从v4版本后就换成了SplitChunksPlugin。所以自动分包的策略实际上是对配置文件webpack.config.js中optimization.splitChunks配置项的修改。
从图中可以看到,经过分包策略后webpack开启了一个新的chunk,对公共代码进行打包,并且在输出文件的时被提取出来形成了一个单独的文件,它是新chunk打包出来的产物。
最后在公共代码打包出来的文件内挂载一个全局变量window.webpackJsonp = [common1、common2、...],然后使用到公共代码的chunk从这个数组中拿,今后有再分出来的包继续添加到数组中。最后把打包出去的模块从原始包中删除,并且修正原始包的代码。
实践
了解到大概原理其实根据分包策略开启若干个新chunk并打包形成一个单独的文件,并且挂载一个全局变量webpackJsonp来存放公共代码。如何配置?最优解是什么?那么就进入到实践环节。
基本配置
这里就不新开一个单独的项目来演示了,在原有项目中新建webpack.auto.config.js专门用来做自动分包(正常项目中直接在默认的config文件中配置就行,不用专门新建文件)。
//webpack.auto.config.js
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin")
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
module.exports = {
mode: "production",
entry: {
main: "./src/index.js",
handler: "./src/handler.js",
},
output: {
filename: "[name].[hash:4].js"
},
optimization: {
splitChunks: {
//优化配置项...
}
},
plugins: [
new BundleAnalyzerPlugin(),
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
template:"./index.html",
})
],
};
除了最基本的配置外,主要就是对webpack提供optimization的优化配置项中的splitChunks进行修改。那么可以写哪些配置呢,我单独列了自动分包可能用到的配置项,想要拓展的朋友可以去英文文档上自行翻阅。
| 名称 | 作用 | 默认值 |
|---|---|---|
| chunks | 哪些chunk需要应用分包策略 | async(异步chunk) |
| maxSize | 分出来的包超过了多少字节需要继续进行拆分 | 0 |
| minChunks | 模块被chunk引用了多少次才会进行分包 | 1 |
| minSize | 模块超过多少字节才会进行拆分 | 20000 |
| automaticNameDelimiter | 公共代码的新chunk名称的分隔符 | ~ |
1. chunks
chunks有三个取值,默认值为async,表示只对异步的chunk开启分包,就是懒加载的模块。所以如果没有懒加载的模块并不配置的话直接打包是看不什么效果的,所以一般使用all来表示对所有的chunk都要应用分包策略。
optimization: {
splitChunks: {
//优化配置项...
chunks: "all",
}
},
2. maxSize
maxSize表示如果某个包(包括经过分包后的包)体积大于了设定的值,那么webpack会尽最大努力继续再次分离,什么意思呢?比如开启了chunks: "all"后的打包结果是这样的。
可以看到开启了all后,jquery和lodash被合并打包成了一个新的文件,而不是像手动分包那样将每个源码都映射一份出来。那么这个新的文件vendors~handler~main.2fca.js是公共代码对应的新chunk的产物。但是如果觉得160kb还是有点大,能不能再继续分一分?这就要用到maxSize
配置项了。
optimization: {
splitChunks: {
//优化配置项...
chunks: "all",
maxSize: 50 * 1024,
}
},
表示如果包大于了50kb那么webpack还会去进一步的继续尝试着分离包。运行npm run auto。
能发现比刚刚多了一个文件,都是vendors开头的。点开进去之后可以看到分别是jquery和lodash的源码。同上所说,打包出来的文件都去定义一个全局变量webpackJsonp,那如果存在多个文件不就覆盖了吗?
其实不然,真正每个verdors文件第一行是(window.webpackJsonp = window.webpackJsonp || []).push(序号索引),按照分出来的顺序,序号索引就是0、1、...,这样就无论分几个包,最后其实还都是会push到同一个window.webpackJsonp数组中。
细心的同学应该会发现一个问题就是明明设置的是50kb呀,为什么拆分出来的两个文件都还是大于设置的值呢?仔细看前面说的如果超过了设定值,webpack会尽最大努力继续分离,但是,是以模块为基础的,再怎么分,都不可能把原来的一个整体的模块直接打乱掉代码分割出去,最小的单元就是一个模块。所以最小也只能jquery和lodash完整的代码再单独各分出去。
走到这大家就会发现有点不对劲,再怎么分总体积是不变的,只不过是拆分了很多份,有些时候反而对性能是负提升。但是如果某个浏览器支持多线程请求的话,可能会对性能有帮助。大家对这个属性仁者见仁,智者见智就好,谨慎使用。
3. minChunks
表示一个chunk被引用了多少次,才会进行分包。默认值是1,只要被引用过就要进行分包优化,这个配置就很简单,可以试一试设置为一个大点的值,那么就不会进行分包了。
optimization: {
splitChunks: {
//优化配置项...
chunks: "all",
maxSize: 60 * 1024,
minChunks: 20,
}
},
注意: 这里设置的minChunks是针对于我们自己写的一些公共模块想要进行分包处理的最小引用数,针对于引用依赖node_modules中的文件是不生效的,因为存在缓存组单独针对node_modules的规则,下面会说到,所以这个配置对第三方库是不生效的!
4. minSize
此配置表示当一个包达到多大体积才会进行分包,默认值为20000。对第三方库同样生效。如果设置一个很大的值,那么就都不会进行分包。
optimization: {
splitChunks: {
//优化配置项...
chunks: "all",
maxSize: 60 * 1024,
minChunks: 20,
minSize: 1000000,
}
},
5. automaticNameDelimiter
这个配置项十分简单,只是修改新chunk生成的文件名中的分隔符,默认值~。比如现在改成---,那么新chunk的文件名分隔符就是vendors---handler---main.b457.js。
optimization: {
splitChunks: {
//优化配置项...
chunks: "all",
// maxSize: 60 * 1024,
// minChunks: 20,
minSize: 20000,
automaticNameDelimiter: "---"
}
},
缓存组
前面提到了minChunks配置对第三方库不生效,是因为有缓存组的存在,那么缓存组到底是什么?在之前设置的配置都是基于全局的,实际上,分包策略是基于缓存组的,每一个缓存组都是一套单独的分包策略,可以设置不同的缓存组来针对不同的文件进行分包。webpack默认开启了两个缓存组,即cacheGroups。
optimization: {
splitChunks: {
//优化配置项...
chunks: "all",
cacheGroups: {
vendors: { //属性名即是缓存的名称,改成common看看
test: /[\\/]node_modules[\\/]/,
priority: 2
},
default: { //默认缓存组设置,会覆盖掉全局设置
minChunks: 2,
priority: 1,
reuseExistingChunk: true
}
}
}
},
在cacheGroups中,每一个对象就是一个缓存组分包策略,属性名便是缓存组的名称,对象中设置的值可以继承全局设置的属性(如minChunks等),也存在只有缓存组独特的属性,比如test(匹配模块名称规则)、priority(缓存组的优先级),reuseExistingChunk(重用已经被分离出的chunk)。
webpack会根据缓存组的优先级(priority)来依次处理每个缓存组,被缓存组处理过的模块不需要再次分包。所以前面为什么minChunks对第三方库没有生效,是因为有默认缓存组的存在,已经针对node_modules定了一套独有的分包策略。
可以把默认的vendors的属性名改成其他的,比如common,那么打包结果中,经过缓存组处理过的node_modules分出来的包就是以common开头了。
正常情况下,缓存组对我们来说没有太多意义,webpack提供的默认缓存组就已经够用了。但是大家可以想一想,它其实还可以用来做对公共样式的抽离,比如两个css文件有相同的样式,那么我们可以用test匹配css文件来设置公共样式的打包。这个就属于拓展了,有兴趣的同学可以试一试找我交流~
总结
两者分包的区别在于手动分包可以极大的提升编译构建速度,但是使用起来比较繁琐,一旦今后有新增的公共代码都需要手动去处理。自动分包的话可以极大的提高开发效率,只要配置好分包策略后就一劳永逸了。深入了解两者分包的原理及优缺点,还有一些比较冷门的点后,就已经基本拿捏了,斯国一!遇到相同的业务场景时,可以选择合适的分包手段来进行优化。
但是分包也是有局限性的,比如已经分到不能再分的时候,就只能通过代码压缩、tree shaking、懒加载等等手段来继续优化了,本文就不过多赘述了,以后可以出个续集。最后,谢谢大家的观看,对本文内容有异议或交流欢迎评论~
我正在参与掘金技术社区创作者签约计划招募活动,点击链接报名投稿。