Webpack编译速度优化实战
james·von 人气:0当你的应用的规模还很小时,你可能不会在乎Webpack的编译速度,无论使用3.X还是4.X版本,它都足够快,或者说至少没让你等得不耐烦。但随着业务的增多,嗖嗖嗖一下项目就有上百个组件了,也是件很简单的事情。这时候当你再独立编前端模块的生产包时,或者CI工具中编整个项目的包时,如果Webpackp配置没经过优化,那编译速度都会慢得一塌糊涂。编译耗时10多秒钟的和编译耗时一两分钟的体验是迥然不同的。出于开发时的心情的考虑,加上不能让我们前端的代码编译拖累整个CI的速度这两个出发点,迫使我们必须去加快编译速度。本文主要是探讨下可做编译速度优化的地方,对一些API使用上不会做太多讲解,需要的同学可以直接翻看文档中的介绍。笔者的Webpack版本为4.29.6,后文中内容都基于这个版本。
一、已存在的针对编译速度的优化
笔者这套Webpack架子源自CRA的eject,基于Webpack4.x,在Loader和Plugin的选择和设计上已是最佳实践方案,基本上无需改动什么。其原有的对编译的优化配置在于这三处:
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设置中,我们可以得到这个启发:启用多进程来模拟多线程,并行处理资源的编译。于是笔者引入了HappyPack,笔者之前的那套老架子也用了它,但之前没写东西来介绍那套架子,这里就一并说了。关于HappyPack,经常玩Webpack的同学应该不会陌生,网上也有一些关于其原理的介绍文章,也写得很不错。HappyPack的工作原理大致就是在Webpack和Loader之间多加了一层,改成了Webpack并不是直接去和某个Loader进行工作,而是Webpack test到了需要编译的某个类型的资源模块后,将该资源的处理任务交给了HappyPack,再由HappyPack再起内部进行线程调度,分配一个线程调用处理该类型资源的Loader来处理这个资源,完成后上报处理结果,最后HappyPack把处理结果返回给Webpack,最后由Webpack输出到目的路径。将都在一个线程内的工作,分配到了不同的线程中并行处理。
使用方法如下:
首先引入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的架子就是因为这个问题导致编译速度慢,所以,只靠引入多线程就想解决大项目编译速度慢的问题是不现实的。
那我们还有什么办法吗?当然有,我们还是可以从TerserPlugin得到灵感,那就是依靠缓存:在下一次的编译中能够复用上一次的结果而不执行编译永远是最快的。
至少存在有这三种方式,可以让我们在执行构建时不进行某些文件的编译,从最本质上提升前端项目整体的构建速度:
1. 类似于terser-webpack-plugin的cache那种方式,这个插件的cache默认生成在node_modules/.cache/terser-plugin文件下,通过SHA或者base64编码之前的文件处理结果,并保存文件映射关系,方便下一次处理文件时可以查看之前同文件(同内容)是否有可用缓存。其他Webpack平台的工具也有类似功能,但缓存方式不一定相同。
2. 通过externals配置在编译的时候直接忽略掉外部库的依赖,不对它们进行编译,而是在运行的时候,通过<script>标签直接从CDN服务器下载这些库的生产环境文件。
3. 将某些可以库文件编译以后保存起来,每次编译的时候直接跳过它们,但在最终编译后的代码中能够引用到它们,这就是Webpack DLLPlugin所做的工作,DLL借鉴至Windows动态链接库的概念。
后面的段落将针对这几种方式做讲解。
三、Loader的Cache
除了段落一中提到的terser-webpack-plugin和babel-loader支持cache外,Webpack还直接另外提供了一种可以用来缓存前序Loader处理结果的Loader,它就是cache-loader。通常我们可以将耗时的Loader都通过cache-laoder来缓存编译结果。比如我们打生产环境的包,对于Less文件的缓存你可以这样使用它:
{ test: /\.less$/, use: [ { loader: MiniCssExtractPlugin.loader, options: { ...省略 }, }, { loader: 'cache-loader', options: { cacheDirectory: paths.appPackCacheCSS, } }, { loader: require.resolve('css-loader'), options: { ...省略 }, }, { loader: require.resolve('postcss-loader'), options: { ...省略 } } ] }
Loader的执行顺序是从下至上,因此通过上述配置,我们可以通过cache-laoder缓存postcss-loader和css-loader的编译结果。
但我们不能用cache-loader去缓存mini-css-extract-plugin的结果,因为它的作用是要从前序Loader编译成的含有样式字符串的JS文件中把样式字符串单独抽出来打成独立的CSS文件,而缓存这些独立CSS文件并不是cache-loader的工作。
但如果是要缓存开发环境的Less编译结果,cache-loader可以缓存style-loader的结果,因为style-loader并没有从JS文件中单独抽出样式代码,只是在编译后的代码中添加了一些额外代码,让编译后的代码在运行时,能够创建包含样式的<style>标签并放入<head>标签内,这样的性能不是太好,所以基本上只有开发环境采用这种方式。
在对样式文件配置cache-loader的时候,一定要记住上述这两点,要不然会出现样式无法正确编译的问题。
除了对样式文件的编译结果进行缓存外,对其他类型的文件(除了会打包成独立的文件外)的编译结果进行缓存也是可以的。比如url-laoder,只要大小没有达到limitation的图片都会被打成base64,大于limitation的文件会打成单独的图片类文件,就不能被cache-loader缓存了,如果遇到了这种情况,资源请求会404,这是在使用cache-loader时需要注意的。
当然,通过使用缓存能得到显著编译速度提升的,依旧是那些耗时的Loader,如果对某些类型的文件编译并不耗时,或者说文件本身数量太少,都可以先不必做缓存,因为即便做了缓存,编译速度的提升也不明显。
最后笔者将所有Loader和Plugin的cache默认目录从node_modules/.cache/移到了项目根目录的build_pack_cache/目录(生产环境)和dev_pack_cache目录(开发环境),通过NODE_ENV自动区分。这么做是因为笔者的CI工程每次会删除之前的node_modules文件夹,并从node_modules.tar.gz解压一个新的node_modules文件夹,所以将缓存放在node_modules/.cache/目录里面会无效,笔者也不想去动CI的代码。通过这个改动,对cache文件的管理更直观一些,也能避免node_modules的体积一直增大。如果想清除缓存,直接删掉对应目录即可。当然了,这两个个目录是不需要被Git跟踪的,所以需要在.gitignore中添加上。CI环境中如果没有对应的缓存目录,相关Loader会自动创建。而且,因为开发环境和生产环境编译出的资源是不同的,在开发环境下对资源的编译往往都没有做压缩和混淆处理等,为了有效地缓存不同环境下的编译结果,需要区分开缓存目录。
四、外部扩展externals
按照Webpack官方的说法:我们的项目如果想用一个库,但我们又不想Webpack对它进行编译(因为它的源码很可能已是经过编译和优化的生产包,可以直接使用)。并且我们可能通过window全局方式来访问它,或者通过各种模块化的方式来访问它,那么我们就可以把它配置进extenals里。
比如我要使用jquery可以这样配置:
externals: { jquery: 'jQuery' }
我就可以这样使用了,就像我们直接引入一个在node_modules中的包一样:
import $ from 'jquery'; $('.div').hide();
这样做能有效的前提就是我们在HTML文件中在上述代码执行以前就已经通过了<script>标签从CDN下载了我们需要的依赖库了,externals配置会自动在承载我们应用的html文件中加入:
<script src="https://code.jquery.com/jquery-1.1.14.js">
externals还支持其他灵活的配置语法,比如我只想访问库中的某些方法,我们甚至可以把这些方法附加到window对象上:
externals : { subtract : { root: ["math", "subtract"] } }
我就可以通过 window.math.subtract 来访问subtract方法了。
对于其他配置方式如果有兴趣的话可以自行查看文档。
但是,笔者的项目并没有这么做,因为在它最终交付给客户后,应该是处于一个内网环境(或者一个被防火墙严重限制的环境)中,极大可能无法访问任何互联网资源,因此通过<script>脚本请求CDN资源的方式将失效,前置依赖无法正常下载就会导致整个应用奔溃。
五、DllPlugin
在上个段落中的结尾处,提到了笔者的项目在交付用户后会面临的网络困境,所以笔者必须选择另外一个方式来实现类似于externals配置能够提供的功能。那就是Webpack DLLPlugin以及它的好搭档DLLReferencePlugin。笔者有关DLLPlugin的使用都是在构建生产包的时候使用。
要使用DLLPlugiin,我们需要单独开一个webpack配置,暂且将其命名为webpack.dll.config.js,以便和主Webpack的配置文件webpack.config.js进行区分。内容如下:
'use strict';
process.env.NODE_ENV = 'production'; const webpack = require('webpack'); const path = require('path'); const {dll} = require('.https://img.qb5200.com/download-x/dll'); const DllPlugin = require('webpack/lib/DllPlugin'); const TerserPlugin = require('terser-webpack-plugin'); const getClientEnvironment = require('./env'); const paths = require('./paths'); const shouldUseSourceMap = process.env.GENERATE_SOURCEMAP !== 'false'; module.exports = function (webpackEnv = 'production') { const isEnvDevelopment = webpackEnv === 'development'; const isEnvProduction = webpackEnv === 'production'; const publicPath = isEnvProduction ? paths.servedPath : isEnvDevelopment && '/'; const publicUrl = isEnvProduction ? publicPath.slice(0, -1) : isEnvDevelopment && ''; const env = getClientEnvironment(publicUrl); return { mode: isEnvProduction ? 'production' : isEnvDevelopment && 'development', devtool: isEnvProduction ? 'source-map' : isEnvDevelopment && 'cheap-module-source-map', entry: dll, output: { path: isEnvProduction ? paths.appBuildDll : undefined, filename: '[name].dll.js', library: '[name]_dll_[hash]' }, optimization: { minimize: isEnvProduction, minimizer: [ ...省略 ] }, plugins: [ new webpack.DefinePlugin(env.stringified), new DllPlugin({ context: path.resolve(__dirname), path: path.resolve(paths.appBuildDll, '[name].manifest.json'), name: '[name]_dll_[hash]', }), ], }; };
为了方便DLL的管理,我们还单独开了个dll.js文件来管理webpack.dll.config.js的入口entry,我们把所有需要DLLPlugin处理的库都记录在这个文件中:
const dll = { core: [ 'react', '@hot-loader/react-dom', 'react-router-dom', 'prop-types', 'antd/lib/badge', 'antd/lib/button', 'antd/lib/checkbox', 'antd/lib/col', ...省略 ], tool: [ 'js-cookie', 'crypto-js/md5', 'ramda/src/curry', 'ramda/src/equals', ], shim: [ 'whatwg-fetch', 'ric-shim' ], widget: [ 'cecharts', ], }; module.exports = { dll, dllNames: Object.keys(dll), };
对于要把哪些库放入DLL中,请根据自己项目的情况来定,对于一些特别大的库,又没法做模块分割和不支持Tree Shaking的,比如Echarts,建议先去官网按项目所需功能定制一套,不要直接使用整个Echarts库,否则会白白消耗许多的下载时间,JS预处理的时间也会增长,减弱首屏性能。
然后我们在webpack.config.js的plugins配置中加入DLLReferencePlguin来对DLLPlugin处理的库进行映射,好让编译后的代码能够从window对象中找到它们所依赖的库:
{ ...省略 plugins: [ ...省略 // 这里的...用于延展开数组,因为我们的DLL有多个,每个单独的DLL输出都需要有一个DLLReferencePlguin与之对应,去获取DLLPlugin输出的manifest.json库映射文件。
// dev环境下暂不采用DLLPlugin优化。 ...(isEnvProduction ? dllNames.map(dllName => new DllReferencePlugin({ context: path.resolve(__dirname), manifest: path.resolve(__dirname, '..', `build/statichttps://img.qb5200.com/download-x/dll/${dllName}.manifest.json`) })) : [] ), ...省略 ] ... }
我们还需要在承载我们应用的index.html模板中加入<script>,从webpack.dll.config.js里配置的output输出文件夹中前置引用这些DLL库。对于这个工作DLLPlguin和它的搭档不会帮我们做这件事情,而已有的html-webpack-plugin也不能帮助我们去做这件事情,因为我们没法通过它往index.html模板加入特定内容,但它有个增强版的兄弟script-ext-html-webpack-plugin可以帮我们做这件事情,笔者之前也用过这个插件内联JS到index.html中。但笔者懒得再往node_modules中加依赖包了,另辟了一个蹊径:
CRA这套架子已经使用了DefinePlugin来在编译时创建全局变量,最常用的就是创建process环境变量,让我们的代码可以分辨是开发还是生产环境,既然已有这样的设计,何不继续使用,让DLLPlugn编译的独立JS文件名暴露在某个全局变量下,并在index.html模板中循环这个变量数组,循环创建<script>标签不就行了,在上面提到的dll.js文件中最后导出的 dllNames 就是这个数组。
然后我们改造一下index.html:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title></title> <% if (process.env.NODE_ENV === "production") { %> <% process.env.DLL_NAMES.forEach(function (dllName){ %> <script src="/statichttps://img.qb5200.com/download-x/dll/<%= dllName %>.dll.js"></script> <% }) %> <% } %> </head> <body> <noscript>Please allow your browser to run JavaScript scripts.</noscript> <div id="root"></div> </body> </html>
最后我们改造一下build.js脚本,加入打包DLL的步骤:
function buildDll (previousFileSizes){ let allDllExisted = dllNames.every(dllName => fs.existsSync(path.resolve(paths.appBuildDll, `${dllName}.dll.js`)) && fs.existsSync(path.resolve(paths.appBuildDll, `${dllName}.manifest.json`)) ); if (allDllExisted){ console.log(chalk.cyan('Dll is already existed, will run production build directly...\n')); return Promise.resolve(); } else { console.log(chalk.cyan('Dll missing or incomplete, first starts compiling dll...\n')); const dllCompiler = webpack(dllConfig); return new Promise((resolve, reject) => { dllCompiler.run((err, stats) => { ...省略 }) }); } }
checkBrowsers(paths.appPath, isInteractive) .then(() => { // Start dll webpack build. return buildDll(); }) .then(() => { // First, read the current file sizes in build directory. // This lets us display how much they changed later. return measureFileSizesBeforeBuild(paths.appBuild); }) .then(previousFileSizes => { // Remove folders contains hash files, but leave statichttps://img.qb5200.com/download-x/dll folder. fs.emptyDirSync(paths.appBuildCSS); fs.emptyDirSync(paths.appBuildJS); fs.emptyDirSync(paths.appBuildMedia); // Merge with the public folder copyPublicFolder(); // Start the primary webpack build return build(previousFileSizes); }) .then(({stats, previousFileSizes, warnings}) => { ... 省略 }) ... 省略
大致逻辑就是如果xxx.dll.js文件存在且对应的xxx.manifest.json也存在,那么就不重新编译DLL,如果缺失任意一个,就重新编译。DLL的编译过程走完后再进行主包的编译。由于我们将DLLPlugin编译出的文件也放入build文件夹中,所以之前每次开始编译主包都需要清空整个build文件夹的操作也修改为了仅仅清空除了放置有dll.js和manifest.json的目录。
如果我们的底层依赖库确实发生了变化,需要我们更新DLL,按照之前的检测逻辑,我们只需要删除整个某个dll.js文件即可,或者直接删除掉整个build文件夹。
哈哈,到此所有的有关DLL的配置就完成了,大功告成。
本段落开始时有提到过DLLPlugin的使用都是在生产环境下。因为开发环境下的打包情况很特殊而且复杂:
在开发环境下整个应用是通过webpack-dev-server来打包并起一个Express服务来serve的。Express服务在内部挂载了webpack-dev-middleware作为中间件,webpack-dev-middleware可以实现serve由Webpack compiler编译出的所有资源、将所有的资源打入内存文件系统中的功能,并能结合dev-sever实现监听源文件改动并提供HRM。webpack-dev-server接收了一个Webpack compiler和一个有关HTTP配置的config作为实例化时的参数。这个compiler会在webpack-dev-middleware中执行,用监听方式启动compiler后,compiler的outputFileSystem会被webpack-dev-middleware替换成内存文件系统,其执行后打包出来的东西都没有实际落盘,而是存放在了这个内存文件系统中,而ouputFileSystem本身是在Node.js的fs模块基础上封装的。将编译结果存放进内存中是webpack-dev-middleware内部最奇妙的地方。
当我们的在开发环境的前端界面中发起一个静态资源请求,请求到达dev-server后,经过路由的判断,这个静态资源都会被重定向到内存文件系统中去获取资源,资源在内存中是二进制格式,以返回流的形式来响应请求,并且在response的时候会为content-type加上对应的MIME类型,浏览器拿到数据流后再根据response header中content-type的值就能正确解析服务器返回的资源了。事实上就算将文件资源落盘,也必须先把文件从磁盘读到内存中,再以流的形式返回给客户端,这样一来会多一个从磁盘中将文件读进内存的步骤,反而还没有直接操作内存快。内存文件系统在npm start起得进程被干掉后,就被回收了,下一次再起进程的时候,会创建一个全新的内存文件系统。
所有,由于开发环境打包的特殊性,怎么在开发环境使用DLLPlugin还需要再研究下。因此笔者只是在开发环境使用了多线程和各种缓存。由于开发环境下,编译的工作量少于生产环境,并且对所有资源的读写都是走内存的,因此速度很快,每次检测到源文件变动并进行重编译的过程也会很快。所以开发环境的编译速度在目前来看还可以接受,暂时不需要优化。
这里顺带说一句,笔者之前在看有关DLLPlugin的文档和文章时,也注意到了一个现象,就是很多人都说DLLPlugin过时了,externals使用起来更方便,配置更简单,甚至在CRA、vue-cli这些最佳实践的脚手架中都已经没再继续使用DLLPlugin了,因为Webpack4.x的编译速度已经足够快了。笔者的体会就是:我这个项目就是基于Webpack4.X的,项目规模变大以后,没有觉得4.X有多么地快。笔者的项目在交付客户后也极大可能不能访问互联网,所以externals配置对笔者的项目来说没有用,只能通过使用DLLPlugin提高生产包的编译速度。我想这也是为什么Webpack到了4.X版本依然没有去掉DLLPlugin的原因,因为不是所有的前端项目都一定是互联网项目。还是那句话:实践出真知,不要人云亦云。
六、编译速度提升了多少?
笔者开发机4和8线程,单核基础频率2.2GHz。所有测试都基于已对Echarts进行功能定制。
1. 最原始的CRA脚手架编译笔者这个项目的速度。
初次打包,无任何CRA原始配置的缓存,这和最初在CI上进行构建的情况完全一样,因为每次node_moduels都要删除重来,无法缓存任何结果:
大概1分10多秒。
有缓存以后:
如果不定制Echarts的话,直接引入整个Echarts,在没缓存的时候大概会多5秒时间。
2. 引入dll后。
初次打包,并且无任何DLL文件和CRA原始配置的缓存:
先编译DLL,再编译主包,整个过程耗时直接变成了57秒。
有DLL文件和缓存后:
降到27秒多了。
3.最后我们把多线程和cache-loader上了:
无任何DLL文件、CRA原始配置的缓存以及cache-loader的缓存时:
大概在接近60秒左右。
有DLL文件和所有缓存后:
最终,耗时已经下降至17秒左右了。在CI的服务器上执行,速度还会更快些。
在打包速度上,比最原始的1分10多秒耗时已经有本质上的提升了。这才是我们想要的。
七、总结
至此,我们已经将所有底层依赖DLL化了,几乎所有能做缓存的东西都缓存了,并支持多线程编译。当项目稳定后,无论项目规模多大,小幅度的修改将始终保持在这个编译速度,耗时不会有太大的变化。
据说在Webpack5.X带来了全新的编译性能体验,很期待使用它的时候。谈及到此,笔者只觉得有淡淡的忧伤,那就是前端技术、工具、框架、开发理念这些的更新速度实在是太快了。就拿Webpack这套构建平台来说,当Webpack5.X普及后,Webpack4.X这套优化可能也就过时了,又需要重新学习了。
加载全部内容