背景
一个 antd 项目打包时间太长,竟然快二十分钟了,有时还会导致内存溢出,查了一些资料(thanks funfish),解决方法如下
roadhog.js问题
roadhog.js 是类似可配置的 react-create-app,只是这个可配置,也只是部分可配置的,木有办法,只能从源码开始看 webpack 配置。
在进行 npm run build
的时候发现终端有提示 Creating an optimized production build...
的字样,而且出现的时间也挺晚的,以前其他项目上面从未见过,难道是 roadhog 自己的?这个时候 webpack 居然还没有开始构建?抱着疑惑,从 roadhog 的bin/roadhog.js
就开始打印当前时间,再在到开始webpack构建的时候再打印一次时间。
结果这个过程要花上2931ms,还是可以接受的,只是明明第一次的时候记得等了很久的,为什么这次只要3s不到?后面又试了几次,耗时均3s左右,后来想起了Webpack 构建性能优化探索里面提到的初次构建和再次构建的问题,一般再次构建耗时都要比初次构建的要少。会不会第一次比较慢是初次构建,后面都是再次构建呢?初次构建和再次构建有什么区别?百度和谷歌都没有查询到答案,只有该博客提到比较多。为了再现问题,well,重启电脑,再次 npm run build
不就是初次构建吗?结果还正如此。
优化webpack
按照Webpack 构建性能优化探索里面给出的思路,对于webpack的优化,可以从四个维度考量:
- 从环境着手,提升下载依赖速度;
- 从项目自身着手,代码组织是否合理,依赖使用是否合理,反面提升效率;
- 从 webpack 自身优化手段着手,优化配置,提升 webpack 效率;
- 从 webpack 可能存在的不足着手,优化不足,进一步提升效率。
从环境出发这一点,是因为不同的nodejs版本和npm版本,有着显著的性能差异来的。可以这么认为最新版本的nodejs/npm自然有更优秀的性能。由于项目本身用的就是最新版本的环境,所以这里也不加以分析了。
从项目中出发
首先用比较常规的方法,通过 webpack-bundle-analyzer
来查看 webpack 体积过大问题,结果如下图所示:
图挺好看的,乍一看没有什么特别的地方,好像每个打包文件都是由诸多细文件组成的。并从文件大小来看压缩过后都在1M以下,无可厚非。但是细心对比下,还是有不少发现。
案例1:为了实现小功能而引用大型 lib
这里用 webpack-bundle-analyzer
来查看打包过大问题,但是在引用的时候,却发现roadhog原本自身就用了 webpack-visualizer-plugin
插件,只是在analyze指令下才能进入分析,整理之后webpack配置如下:
1 var BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 2 var Visualizer = require('webpack-visualizer-plugin'); 3 4 plugins: [ 5 new Visualizer({ 6 filename: './statistics.html' 7 })], 8 new BundleAnalyzerPlugin() 9 ]
这里 webpack-bundle-analyzer
可以给予直观的整体感受,而 webpack-visualizer-plugin
则细化到每个文件中,每个模块的百分比。
首先看到的是:最大的文件打包压缩后是815.9kb,相对于其他较大的文件大出了整整400kb,这里肯定是有什么问题,细看之后,发现用了支付宝的 G2,在代码中的体现是:
import G2 from 'g2'; // 将整个G2都引入进来了,导致文件过大
遗憾的是目前G2没有实现按需加载的功能,在issue里面也只是表示正在讨论而已(庆幸这里只是用了G2,没有用到Data-set)。
仔细看了每个js文件打包构造后,发现有个文件也用了 moment 模块,在印象中是基本没有用到的。moment 模块大小为53.2kb,而在总的打包文件中占 131.6kb。正同 Webpack 构建性能优化探索所说的,如果不想简单实现,就采用 fecha 库来代替 moment,fecha 要比 moment 小很多。只是替换后,发现 moment 体积并没有降低多少,由于出处是在 index.js 文件里面,可能的地方只有 dva 了。只是 dva 怎么可能用到moment?完全不可能的,他的package.json里面也同样没有用到。通过排除法最后定位到如下:
1 // @src/index.js 2 import 'moment/locale/zh-cn'; 3 4 // @src/router.js 5 import { LocaleProvider } from 'antd';
第一个行代码是直接使用了 moment 模块,该代码看着作用不大,而且查阅Ant-Design-Pro的历史版本,均没有发现在index.js里面使用 moment/locale/zh-cn
。细心观察,发现在 index.js里面使用了 moment/locale/zh-cn
之后,其他几处用到moment的地方,生成文件都没有明显 moment 包,这些文件的体积基本上要减少一个 moment 的大小。这个moment/locale/zh-cn
,还能降低其他文件体积。
第二行代码,是在 router.js 文件里面,由于使用了 LocaleProvider 组件,这个组件通过源码可以发现直接引用了 moment 模块 import * as moment from 'moment'
。当然同样也起到了 moment/locale/zh-cn
的效果,能降低其他原本含 moment 文件的体积。
案例2:废弃依赖没有及时删除
项目中用的是 Ant Design,import 的时候,组件是按需加载的,并不会整个引入 Ant Design,但是由于敏捷开发周期较短,新建页面不会从零开始写,基本都是移植相似的页面,由此导致了Ant Design组件的乱引用。
由于 G2 的不可按需加载,以及 moment 在 Ant-Design 中的作用,工程的打包体积和打包时间没有较大的减少。
从 webpack 自身优化点出发
webpack 本身也提供了许多优化的插件,但是由于经常接触不多,许久后容易遗漏,导致再次学习的成本高。一个好的脚手架,是相当重要的。
webpack 自带的优化
webpack 就有不少内置的插件。
- CommonsChunkPlugin
CommonsChunkPlugin 可以从 module 提取公共 chunk,实现降低模块大小,有利于整体工程打包后的瘦身。
CommonsChunkPlugin 这个插件在Vue-cli中也有用到,如下:
1 // split vendor js into its own file 2 new webpack.optimize.CommonsChunkPlugin({ 3 name: 'vendor', 4 minChunks: function (module, count) { 5 // any required modules inside node_modules are extracted to vendor 6 return ( 7 module.resource && 8 /.js$/.test(module.resource) && 9 module.resource.indexOf( 10 path.join(__dirname, '../node_modules') 11 ) === 0 12 ) 13 } 14 }), 15 new webpack.optimize.CommonsChunkPlugin({ 16 name: 'manifest', 17 chunks: ['vendor'] 18 })
把相同的 chunk 提取出来,命名 vendor 与 manifest,前者是常说的公共 chunk 部分,后者是由于代码变动导致 chunk 的 hash 值变化,导致公共部分在每次打包时都会有不一样的 hash 值,使得客户端无法缓存 vendor。**由于代码变动导致 hash 变化,而生成的代码,自然而然的会落在最后配置的 commonschunk 上面,**所以这部分可以单独提取,命名为 manifest。
在roadhog里面,刚开始看以后没有CommonsChunkPlugin的配置,想着赶紧提个issue,但是后面发现,是通过common.js
引入,只有在roadhog里面配置了multipage选项为true的时候,才执行CommonsChunkPlugin插件。其代码如下:
1 var name = config.hash ? 'common.[hash]' : 'common'; 2 ret.push(new _webpack2.default.optimize.CommonsChunkPlugin({ 3 name: 'common', 4 filename: name + '.js' 5 }));
通过 CommonsChunkPlugin 插件,node.js 在打包的时候,峰值内存增加了40M,就是约5.4%的内存,打包时间延长了大约6s,而构建后项目体积基本不变,what?有点震惊。只有负面效果。。。。看构建文件,只提取了一个公共文件,大小1kb,而且内容为一句普通的错误打印。为什么人与人之间没有相互的chunk可以提取呢?
通过反复查 roadhog/ant-design/ant-design-pro 的 issue 都没有类似的问题,似乎用了 babel-plugin-antd
对 antd 进行按需加载,没有办法将其提取到 vendor 里面了。如若不想不想按需加载,直接用 cdn 不就好了。但是现在想的是只要单独的提取antd里面几个涉及CRUD的重要组件:表格,form,日历这几个组件能否实现单独打包到vendor?难道是我打开方式不对吗?大神里面少 7s,我还多了 6s。。。。
在这个issue里面看到了这种写法,顿时觉得没错,就是她了。entry 里面设置多入口,CommonsChunkPlugin里面再提取。
1 entry: { 2 //... 3 antd: [ //build the mostly used components into a independent chunk,avoid of total package over size. 4 'antd/lib/button', 5 'antd/lib/icon', 6 'antd/lib/breadcrumb', 7 'antd/lib/form', 8 'antd/lib/menu', 9 'antd/lib/input', 10 'antd/lib/input-number', 11 'antd/lib/dropdown', 12 'antd/lib/table', 13 'antd/lib/tabs', 14 'antd/lib/modal', 15 'antd/lib/row', 16 'antd/lib/col' 17 ] 18 }, 19 //... 20 new webpack.optimize.CommonsChunkPlugin({ 21 names: ['antd'], 22 minChunks: Infinity 23 }),
咦?见证奇迹的时候到了,构建后的项目大小居然小了,整整3M,少了36.64%,厉害了。更惊讶的是,峰值内存减少了180M,减少了24.3%,打包时间减少了26s,直接下降到59196ms,减少25%;这牛逼了。
仔细对比一下,发现原来减少的部分并不是我以为的 antd 组件,antd 组件反而在每个打包文件里面的体积都要更大了,大概多了几kb,而减少的部分却是一些 _rc
开头的组件,这 CommonsChunkPlugin 也是厉害,按需加载部分没有单独打包起来,反而打包了这些组件背后的引用,如 rc-table
。为什么这些组件最后还是没有完整的打包在antd里面呢?难道每次用的都不同?
1 import { DatePicker } from 'antd'; 2 // / babel-plugin-import 会帮助你加载 JS 和 CSS 转变成下面内容 3 import DatePicker from 'antd/lib/date-picker'; // 加载 JS 4 import 'antd/lib/date-picker/style/css'; // 加载 CSS
这没看来,只是典型的引入组件,以及引入css模块而已。这是必然会被打包到公共模块的呀。看了未丑化的代码,发现用同一个组件的话,生成的不同文件 antd 的组件内容是一样的,不存在组件内部不一样导致没有打包在一起的情况。折腾许久后尚未解决,不晓得有没有大神知道。
而且 roadhog.js 的方式不允许添加新的入口,只能直接改源代码。。。这项目要怎么上线呢?难道每次都要自己改一遍?这就是约定和可配置的问题所在了,后面大神的博客也有讨论到,最后的思想还是约定为若干模块,可自选配置,来适合不同的场景。
- DedupePlugin/OccurrenceOrderPlugin
这两个功能在webpack里面很常见,以至于已经被移除了,默认加载包含在 webpack 2 里面了。
CommonsChunkPlugin对项目的优化还是很实在的,能减少不必要的打包,不仅是体积,更多的是从内存和时间上。
webpack外引入的优化
前面提到的 webpack-bundle-analyzer
和 webpack-visualizer-plugin
插件就是从 webapck 外部引入的,可以很直观的看。
externals 的设置在 Vue 项目里面用的比较多,其中主要 externals 的是 axios, Vue, Vonic, Vue-router
这些。本身体积也不大,而且作为单页面应用还是很需要的。
但是到了 Ant Design Pro 项目,由于 Ant Desgin 项目本身 CSS + JS
就要1.5M,对于首屏的影响是显著的。虽然可以通过浏览器缓存/cdn缓存的方式来自然优化,但是首次体验还是不行,还是按照官网上的介绍来吧。
- DllPlugin 和 DLLReferencePlugin
按照官网上的介绍:DLLPlugin 和 DLLReferencePlugin 用某种方法实现了拆分 bundles,同时还大大提升了构建的速度。具体原理则是将特定的第三方 NPM 包模块提前构建再引入就好了。通过在 webpack 外进行配置,DllPlugin 负责配置输出引用文件 manifest.json,而 DLLReferencePlugin 在webpack的正常配置里面用 manifest.json 就好了。可以避免每次都对 npm 包打包,明明它们就不会改动,直接引用不是更好吗。
在 roadhog.js 里面实现就有点那个了,按照 sorrycc 作者的意思,在生产环境使用 DllPlugin 是不合适,打包大量的 npm 包后,会延长首屏时间,与按需加载矛盾。这点就和 CommonsChunkPlugin 是相同,都是提取第三方库,而且 DllPlugin 是一次打包即可,以后重复用引用,而 CommonsChunkPlugin 是每次打包都要重复提取公共部分,那这两个又有什么区别?
一般 DllPlugin 打的包会包含很多 npm 包,导致体积很大,首次加载自然不好,而且若以后更新某个包,会导致客户端重新下载整个 DllPlugin 的生成文件,对于产品迭代是不友善的。反观 CommonsChunkPlugin,一般提取的公共部分体积较小,例如antd主要组件提取,不到500kb,除非大版本升级,否则客户端是不会重新请求 vendor.js 文件的。
基于上面的观点 DllPlugin 一般用于提升本地开发的编译速度,就是启动项目开发的时候能够快点。只是一天能够启动多少次项目呢,基本都是热更新为主吧。。。。。这么看好像意义不大,就是开发人员的自 hight 而已。
发现原来roadhog自己也有 DllPlugin 的配置,只要在 config 里面添加 dllPlugin: true
就可以了,当然也是仅仅限于开发环境,肯定不是生产环境。很是方便,这里就不详细介绍了,感兴趣的可以自行看看这个issue。
从 webpack 不足出发
- HappyPack
使用 HappyPack,可以利用 node.js 的多进程能力,来提升构建速度。在 webpack 打包的时候,经常可以看到 CPU 和内存飚的非常高,内存可以理解,但是 CPU 为何会如此之高呢?只能说明有大量计算,而 node.js 的单进程在大量计算面前是单薄的。可以在 webpack 中定义 loader 规则来转换文件,让HappyPack来处理这些文件,实现多进程处理转换。
设置如下:
1 new HappyPack({ 2 threads: 4, 3 loaders: [{ 4 loader: 'babel-loader', 5 options: babelOptions 6 }], 7 }) 8 { 9 test: /.(js|jsx)$/, 10 include: paths.appSrc, 11 // loader: 'babel', 12 loader: 'happypack/loader', 13 options: babelOptions 14 }
只是运行结果却不让人满意,打包时间/内存什么都和原先的数据几乎相当。难道和 CommonsChunkPlugin 的时候一样,又是打开方式不正确?于是按照官网说的加个 id 试试,结果立马报错,提示AssertionError: HappyPack: plugin for the loader '1' could not be found! Did you forget to add it to the plugin list?
,看到有 issue 提出将 loader 里面的 options 改为 query 就可以了,只是官方提示 webpack 2+ 需要使用 options 来代替query ,最后试了一下也是报错,报错的根由是 happyloader 没有获取到查询的识别 id。回头看了下源码,query = loaderUtils.getOptions(this) || {}
这句话不就是获取 loader 的 option 配置吗,里面怎么可能有 id 呢?里面就是 babelOptions,不可能有 id 的。接着看 loader-utils 的源码,这个就是简单的获取查询到的 query,没有毛病,难道是 HappyPack 用错了?
折腾好久后,差不多都要放弃了,我定了定神,重新理一遍,看到了 rules 里面的配置:
1 loaders: [{ 2 loader: 'happypack/loader?id=js', 3 options: babelOptions 4 }],
options 选项是 roadhog 原先就有的,而 laoder 原先是 babel
,后面改为了 happypack 的设置。这个时候眼睛一亮 loader 设置里面有个问号 ?
,这个不就是 query 吗?那 options 呢?loader-utils 里面获取的是这个 query 还是 option?注释掉试一试?完美成功了。。。。原来如此简单。
用了 happypack 之后,不能在 rules 里面的相关 loader 中配置 options,相反只能在 happypack 插件中配置 options!
well, 然而什么都没有变呀,设置了缓存也没有用,速度/内存什么的都和之前一摸一样。这个时候看到了(在 roadhog 中尝试支持happypack)[https://github.com/sorrycc/roadhog/issues/122]里面大神说了社区版本有问题。。。。。。虽然不知道具体的原因,但是实际效果是对 js 文件用 HappyPack 的配置,是没有起到想象中的多进程计算的优点的,原因或许出在 babel/HappyPack 身上了,最后还是落到了单线程计算上。具体就不分析了,有空可以在研究一下。
- uglifyPlugin
uglifyPlugin 是生产环境中必备的,毕竟压缩丑化代码,不仅可以降低客户端加载项目体积,降低打开时间,而且可以防止反向编译工程的可能性。在本文的开头就提到过,首次优化就是针对 uglifyPlugin 的,而且效果显著。
使用 webpack.optimize.UglifyJsPlugin
的时候,平均下来 webpack 的构建时间要达到 86s 左右。当不进行代码压缩丑化的话,构建时间下降了 68s 左右,并且构建时候,node.js 占用内存峰值下降了 380M 多,可以说不压缩丑化的话,效果是非常好的。但是项目体积却基本是原本的三倍之大,这是难以容忍的。webpack自带的uglifyPlugin,如此笨拙,要如何处理呢?
对 webpack.optimize.UglifyJsPlugin
在里面添加 cache: true
的配置也是没有什么效果,看了下官网介绍的另外一个 UglifyJsPlugin 插件,上面写着 webpack =< v3.0.0
已经包含 UglifyjsWebpackPlugin 的 0.4.6 版本了,如果想要安装最新版本才按照下面介绍的来。发现本地安装的 webpack 版本是 3.11.0,自然是内置 0.4.6 版本。1.0.0 版本是会在 webpack 4.0.0 里面安排的。那如果直接用 uglifyjs-webpack-plugin
最新版本呢?
安装 uglifyjs-webpack-plugin 1.2.2
,设置配置如下:
1 new UglifyJsPlugin({ 2 cache: true, 3 uglifyOptions: { 4 compress: { 5 warnings: false 6 }, 7 output: { 8 comments: false, 9 ascii_only: true 10 }, 11 ie8: true, 12 } 13 })
初次构建的时候,构建时间较之前多40s,也就是多了46.5%,有点夸张的多,内存还好,峰值基本和用 0.4.6 版本的一样。但是 再次构建呢?构建时间居然下降了68s,而且内存峰值和未用代码压缩丑化的时候相似,也就是减少了 380M,实在厉害,牛逼哄哄。
还可以开启并行,也就是多进程工作,设置 parallel: true
,设置之后测试,初次构建时间居然比普通的再次构建时间要少10s,但是问题也很明显 CPU 在平时的时候峰值基本在 45% 左右,而多进程后,CPU 的峰值居然很长一段时间都在 100%,内存也是达到了 1300+M,实在恐怖,如果正式服这么用不晓得会不会爆炸呢?hahaha。parallel 除了可以设置为 true 以外,还能设置成进程数,于是试了等于 2 的时候,CPU 运行峰值接近 95%,而内存峰值在 1100+M,也算是相对较好的数据,只是 CPU 还是接近于爆表。
对于再次构建 parallel 自然是起不到作用的,这里有不得不提另外一个插件 webpack-parallel-uglify-plugin
(下载量比另外一款 webpack-uglify-parallel
多上一倍,肯定使用这个嘛)。试了一下,初次构建基本和 uglifyjs-webpack-plugin 1.2.2
一致,只有构建时间快 7s。
综上所诉,对于服务器 CPU 豪华的可以考虑平行压缩丑化,一般时候用 uglifyjs-webpack-plugin 1.2.2
多进程就不用设置,使能 cache 就好了,初次构建会慢点,再次构建的话,速度就上天了。
- UglifyJsPlugin 与 CommonsChunkPlugin
最后自然也是要让两者合并试一试,效果如何呢?和为优化之前相比,初次构建,内存减少 120+M,构建时间基本一样,构建项目大小自然还是少了 3M。咋一看好像不怎么样,但是要知道这是用上了UglifyJsPlugin,有缓存的!结果再次构建数据如所想的一样,速度和内存数据,和没有用代码压缩丑化基本一致!
这样 uglifyjs-webpack-plugin 与 CommonsChunkPlugin 在生产环境自然是很好的选择。
本文主要是按照(Webpack 构建性能优化探索)[https://github.com/pigcan/blog/issues/1]介绍到的方法实践