Webpack v4编译速度优化实践
当我们的应用还处于小规模的时候,或者只是写个Demo做个技术预研,我们可能不会在乎Webpack的编译速度,无论使用3.X还是4.X版本,它都足够快,或者说至少没让你等得不耐烦。但随着业务的增多,嗖嗖嗖一下项目就有上百个组件甚至几万行代码,这也是件很容易发生的事情。这时候当你再独立编前端模块的生产包时,或者在CI工具中编整个项目的生产包时,如果Webpackp配置没经过优化,那编译速度都会慢得一塌糊涂。在日常开发中,如果编译慢,在修改代码以后也要过很久才能看到通过HMR带来的变化。编译耗时长短带来的体验是迥然不同的。从开发期间的初次启动的效率、代码修改到呈现的效率、做临时部署时的效率以及CI的整体效率等方面出发,我们都有必要去加快前端项目的编译速度,这是对前端开发工作效率上的提升。
本文基于笔者在月初对项目编译速度的优化实践,从工具链配置和使用的角度(轮子是现成的)介绍下基于Webpack的项目可做编译速度优化的地方。文中所有的可配置项和工具都由Webpack平台或者社区的工具提供,笔者对它们API的使用上不会做太多讲解,需要的同学可以直接翻看对应的文档。笔者的Webpack版本为4.29.6,后文中的内容都基于这个版本。
一、已存在的针对编译速度的优化
笔者这套Webpack架子是eject CRA种子脚手架并且改进而来的,基于Webpack4.x,选择CRA的原因在于其本身在对不同资源模块的处理上已是最佳实践的方案,在这方面基本上无需改动什么。对于小微项目来说CRA可以直接零配置使用,无需eject出工程化配置,但在编译速度优化上较弱,而且在项目规模扩大后,劣势非常明显,所以我们需要对其工程化流程进行优化。
除了code split分chunk、common chunk、include、exclude等等外,其原有的针对编译速度的优化配置主要还有这三处:
1. 通过terser-webpack-plugin的parallel和cache配置来并行处理并缓存之前的编译结果。terser-webpack-plugin是之前UglifyPlugin的一个替代品,因为UglifyPlugin已经没有继续维护了,从Webpack4.x起,已经推荐使用terser-webpack-plugin来进行代码压缩、混淆,以及Dead Code Elimination以实现Tree Shaking。对于parallel从整个设置的名称大家就会知道它有什么用,没错,就是并行,而cache也就是缓存该插件的处理结果,在下一次的编译中对于内容未改变的文件可以直接复用上一次编译的结果。
2. 通过babel-loader的cache配置来缓存babel的编译结果。
3. 通过IgnorePlugin设置对moment的整个locale本地化文件夹导入的正则匹配,来防止将所有的本地化文件进行打包。如果你确实需要某国语言,仅手动导入那国的语言包即可。
在项目逐渐变大的过程中,生产包的编译时间也从十几秒增长到了一分多钟,这是让人受不了的,这就迫使着笔者必须进行额外的优化以加快编译速度,为编包节省时间。
下面的段落就讲解下笔者做的几个额外优化。总体思路就是:开多线程,尽可能地做增量编译,通过加缓存来用空间换时间。
二、多线程(多进程模拟)支持
从上个段落的terser-webpack-plugin的parallel设置中,我们可以得到这个启发:启用多进程来模拟多线程(因为一个Node.js进程是单线程的),并行处理资源的编译。于是笔者引入了HappyPack,笔者之前的那套老架子也用了它,但之前没写东西来介绍那套架子,这里就一并说了。关于HappyPack,经常玩Webpack的同学应该不会陌生,网上也有一些关于其原理是使用的介绍文章,也写得很不错。HappyPack的工作原理大致就是在Webpack和Loader之间多加了一层,改成了Webpack并不是直接去和某个Loader进行工作,而是Webpack test到了需要编译的某个类型的资源模块后,将该资源的处理任务交给了HappyPack,由HappyPack在内部线程池中进行任务调度,分配一个线程调用处理该类型资源的Loader来处理这个资源,完成后上报处理结果,最后HappyPack把处理结果返回给Webpack,最后由Webpack输出到目的路径。通过这一系列操作,将原本都在一个Node.js线程内的工作,分配到了不同的线程(进程)中并行处理。
使用方法如下:
首先引入HappyPack并创建线程池:
const HappyPack = require('happypack');
const happyThreadPool = HappyPack.ThreadPool({size: require('os').cpus().length - 1});
替换之前的Loader为HappyPack的插件:
{ test: /\.(js|mjs|jsx|ts|tsx)$/, include: paths.appSrc, use: ['happypack/loader?id=babel-application-js'], },
将原Loader中的配置,移动到对应插件中:
new HappyPack({ id: 'babel-application-js', threadPool: happyThreadPool, verbose: true, loaders: [ { loader: require.resolve('babel-loader'), options: { ...省略 }, }, ], }),
大致使用方式如上所示,HappyPack的配置讲解文章有很多,不会配的同学可以自己搜索,本文这里只是顺带说说而已。
HappyPack老早也没有维护了,它对url-loader的处理是有问题的,会导致经过url-loader处理的图片都无效,笔者之前也去提过一个Issue,有别的开发者也发现过这个问题。总之,用的时候一定要测试一下。
对于多线程的优势,我们举个例子:
比如我们有四个任务,命名为A、B、C、D。
任务A:耗时5秒
任务B:耗时7秒
任务C:耗时4秒
任务D:耗时6秒
单线程串行处理的总耗时大约在22秒:
改成多线程并行处理后,总耗时大约在7秒,也就是那个最耗时的任务B的执行时长:
仅仅通过配置多线程处理我们就能得到大幅的编译速度提升。
写到这里,大家是不是觉得编译速度优化就可以到此结束了?哈哈,当然不是,上面这个例子在实际的项目中根本不具有广泛的代表性,笔者项目的实际情况是这样的:
依然是四个任务,命名为A、B、C、D。
任务A:耗时5秒
任务B:耗时60秒
任务C:耗时4秒
任务D:耗时6秒
单线程串行处理的总耗时大约在75秒
改成多线程并行处理后,总耗时大约在60秒:
从75秒优化到60秒,确实有一定速度上的提升,但是因为任务B的耗时太长了,导致整个项目的编译速度并没有发生本质上的变化。事实上笔者之前那套Webpack3.X的架子就是因为这个问题导致编译速度慢,在大多数项目中也都是这个情况,处理某种资源类型的Loader的执行时间非常长,远远超过了其他Loader的执行时间,它成为了一个速度瓶颈。所以,只靠引入多线程编译就想解决规模化项目在生产环境下编译速度慢的问题是不现实的。
那我们还有什么办法吗?当然有,我们还是可以从TerserPlugin得到灵感,那就是依靠缓存:在下一次的编译中能够复用上一次的结果而不执行编译永远是最快的。
至少存在有这三种方式,可以让我们在执行构建时不进行某些文件的编译,从最本质上提升前端项目整体的构建速度:
1. 类似于terser-webpack-plugin的cache那种方式,这个插件的cache默认生成在node_modules/.cache/terser-plugin文件下,通过SHA或者base64编码之前的文件处理结果,并保存文件映射关系,方便下一次处理文件时可以查看之前同文件(同内容)是否有可用缓存。其他Webpack平台的工具也有类似功能,但缓存方式不一定相同。
2. 通过externals配置在编译的时候直接忽略掉外部库的依赖,不对它们进行编译,而是在运行的时候,通过