加速Webpack构建技巧总结
Web应用日益复杂,相关开发技术也百花齐放,这对前端构建工具提出了更高的要求。Webpack从众多构建工具中脱颖而出成为目前最流行的构建工具,几乎成为目前前端开发里的必备工具之一。大多数人在使用Webpack的过程中都会遇到构建速度慢的问题,在项目大时显得尤为突出,这极大的影响了我们的开发体验,降低了我们的开发效率。
本文将传授你一些加速Webpack构建的技巧,下面来一一介绍。
通过多进程并行处理
由于有大量文件需要解析和处理,构建是文件读写和计算密集型的操作,特别是当文件数量变多后,Webpack构建慢的问题会显得严重。运行在Node.js之上的Webpack是单线程模型的,也就是说Webpack需要处理的任务需要一件件挨着做,不能多个事情一起做。
文件读写和计算操作是无法避免的,那能不能让Webpack同一时刻处理多个任务,发挥多核CPU电脑的威力,以提升构建速度呢?
使用HappyPack
HappyPack就能让Webpack做到上面抛出的问题,它把任务分解给多个子进程去并发的执行,子进程处理完后再把结果发送给主进程。
接入HappyPack的相关代码如下:
constpath=require('path'); constExtractTextPlugin=require('extract-text-webpack-plugin'); constHappyPack=require('happypack'); module.exports={ module:{ rules:[ {test:/\.js$/, //把对.js文件的处理转交给id为babel的HappyPack实例 use:['happypack/loader?id=babel'], //排除node_modules目录下的文件,node_modules目录下的文件都是采用的ES5语法,没必要再通过Babel去转换 exclude:path.resolve(__dirname,'node_modules'), }, { //把对.css文件的处理转交给id为css的HappyPack实例 test:/\.css$/, use:ExtractTextPlugin.extract({ use:['happypack/loader?id=css'], }), }, ]}, plugins:[ newHappyPack({ //用唯一的标识符id来代表当前的HappyPack是用来处理一类特定的文件 id:'babel', //如何处理.js文件,用法和Loader配置中一样 loaders:['babel-loader?cacheDirectory'], }), newHappyPack({ id:'css', //如何处理.css文件,用法和Loader配置中一样 loaders:['css-loader'],}), newExtractTextPlugin({ filename:`[name].css`, }), ], };
以上代码有两点重要的修改:
在Loader配置中,所有文件的处理都交给了happypack/loader去处理,使用紧跟其后的querystring?id=babel去告诉happypack/loader去选择哪个HappyPack实例去处理文件。
在Plugin配置中,新增了两个HappyPack实例分别用于告诉happypack/loader去如何处理.js和.css文件。选项中的id属性的值和上面querystring中的?id=babel相对应,选项中的loaders属性和Loader配置中一样。
接入HappyPack后,你需要给项目安装新的依赖:
npmi-Dhappypack
安装成功后重新执行构建,你就会看到以下由HappyPack输出的日志:
Happy[babel]:Version:4.0.0-beta.5.Threads:3 Happy[babel]:Allset;signalingwebpacktoproceed.Happy[css]:Version:4.0.0-beta.5.Threads:3Happy[css]:Allset;signalingwebpacktoproceed.
说明你的HappyPack配置生效了,并且可以得知HappyPack分别启动了3个子进程去并行的处理任务。
在整个Webpack构建流程中,最耗时的流程可能就是Loader对文件的转换操作了,因为要转换的文件数据巨多,而且这些转换操作都只能一个个挨着处理。HappyPack的核心原理就是把这部分任务分解到多个进程去并行处理,从而减少了总的构建时间。
从前面的使用中可以看出所有需要通过Loader处理的文件都先交给了happypack/loader去处理,收集到了这些文件的处理权后HappyPack就好统一分配了。
每通过newHappyPack()实例化一个HappyPack其实就是告诉HappyPack核心调度器如何通过一系列Loader去转换一类文件,并且可以指定如何给这类转换操作分配子进程。
核心调度器的逻辑代码在主进程中,也就是运行着Webpack的进程中,核心调度器会把一个个任务分配给当前空闲的子进程,子进程处理完毕后把结果发送给核心调度器,它们之间的数据交换是通过进程间通信API实现的。
核心调度器收到来自子进程处理完毕的结果后会通知Webpack该文件处理完毕。
使用ParallelUglifyPlugin
在使用Webpack构建出用于发布到线上的代码时,都会有压缩代码这一流程。最常见的JavaScript代码压缩工具是UglifyJS,并且Webpack也内置了它。
用过UglifyJS的你一定会发现在构建用于开发环境的代码时很快就能完成,但在构建用于线上的代码时构建一直卡在一个时间点迟迟没有反应,其实卡住的这个时候就是在进行代码压缩。
由于压缩JavaScript代码需要先把代码解析成用Object抽象表示的AST语法树,再去应用各种规则分析和处理AST,导致这个过程计算量巨大,耗时非常多。
为什么不把多进程并行处理的思想也引入到代码压缩中呢?
ParallelUglifyPlugin就做了这个事情。当Webpack有多个JavaScript文件需要输出和压缩时,原本会使用UglifyJS去一个个挨着压缩再输出,但是ParallelUglifyPlugin则会开启多个子进程,把对多个文件的压缩工作分配给多个子进程去完成,每个子进程其实还是通过UglifyJS去压缩代码,但是变成了并行执行。所以ParallelUglifyPlugin能更快的完成对多个文件的压缩工作。
使用ParallelUglifyPlugin也非常简单,把原来Webpack配置文件中内置的UglifyJsPlugin去掉后,再替换成ParallelUglifyPlugin,相关代码如下:
constpath=require('path'); constParallelUglifyPlugin=require('webpack-parallel-uglify-plugin'); module.exports={ plugins:[ //使用ParallelUglifyPlugin并行压缩输出的JS代码 newParallelUglifyPlugin({ //传递给UglifyJS的参数 uglifyJS:{ }, }), ], };
接入ParallelUglifyPlugin后,项目需要安装新的依赖:
npmi-Dwebpack-parallel-uglify-plugin
安装成功后,重新执行构建你会发现速度变快了许多。如果设置cacheDir开启了缓存,在之后的构建中会变的更快。
缩小文件搜索范围
Webpack启动后会从配置的Entry出发,解析出文件中的导入语句,再递归的解析。在遇到导入语句时Webpack会做两件事情:
根据导入语句去寻找对应的要导入的文件。例如require(‘react')导入语句对应的文件是./node_modules/react/react.js,而require(‘./util')导入语句对应的文件是./util.js。
根据找到的要导入文件的后缀,使用配置中的Loader去处理文件。例如使用ES6开发的JavaScript文件需要使用babel-loader去处理。
以上两件事情虽然对于处理一个文件非常快,但是当项目大了以后文件量会变的非常多,这时候构建速度慢的问题就会暴露出来。虽然以上两件事情无法避免,但需要尽量减少以上两件事情的发生,以提高速度。
接下来一一介绍可以优化它们的途径。
缩小resolve.modules的范围
Webpack的resolve.modules用于配置Webpack去哪些目录下寻找第三方模块。
resolve.modules的默认值是[‘node_modules'],含义是先去当前目录下的./node_modules目录下去找想找的模块,如果没找到就去上一级目录../node_modules中找,再没有就去../../node_modules中找,以此类推,这和Node.js的模块寻找机制很相似。
当安装的第三方模块都放在项目根目录下的./node_modules目录下时,没有必要按照默认的方式去一层层的寻找,可以指明存放第三方模块的绝对路径,以减少寻找,配置如下:
module.exports={ resolve:{ //使用绝对路径指明第三方模块存放的位置,以减少搜索步骤 //其中__dirname表示当前工作目录,也就是项目根目录 modules:[path.resolve(__dirname,'node_modules')] }, };
缩小Loader的命中范围
除此之外在使用Loader时可以通过test、include、exclude三个配置项来命中Loader要应用规则的文件。为了尽可能少的让文件被Loader处理,可以通过include去命中只有哪些文件需要被处理。
以采用ES6的项目为例,在配置babel-loader时,可以这样:
module.exports={ module:{ rules:[ { //如果项目源码中只有js文件就不要写成/\.jsx?$/,提升正则表达式性能 test:/\.js$/, //babel-loader支持缓存转换出的结果,通过cacheDirectory选项开启 use:['babel-loader?cacheDirectory'], //只对项目根目录下的src目录中的文件采用babel-loader include:path.resolve(__dirname,'src'), }, ] }, };
你可以适当的调整项目的目录结构,以方便在配置Loader时通过include去缩小命中范围。
缩小resolve.extensions的数量
在导入语句没带文件后缀时,Webpack会自动带上后缀后去尝试询问文件是否存在。Webpack配置中的resolve.extensions用于配置在尝试过程中用到的后缀列表,默认是:
extensions:['.js','.json']
也就是说当遇到require(‘./data')这样的导入语句时,Webpack会先去寻找./data.js文件,如果该文件不存在就去寻找./data.json文件,如果还是找不到就报错。
如果这个列表越长,或者正确的后缀在越后面,就会造成尝试的次数越多,所以resolve.extensions的配置也会影响到构建的性能。在配置resolve.extensions时你需要遵守以下几点,以做到尽可能的优化构建性能:
后缀尝试列表要尽可能的小,不要把项目中不可能存在的情况写到后缀尝试列表中。
频率出现最高的文件后缀要优先放在最前面,以做到尽快的退出寻找过程。
在源码中写导入语句时,要尽可能的带上后缀,从而可以避免寻找过程。例如在你确定的情况下把require(‘./data')写成require(‘./data.json')。
相关Webpack配置如下:
module.exports={ resolve:{ //尽可能的减少后缀尝试的可能性 extensions:['js'], }, };
缩小resolve.mainFields的数量
Webpack配置中的resolve.mainFields用于配置第三方模块使用哪个入口文件。
安装的第三方模块中都会有一个package.json文件用于描述这个模块的属性,其中有些字段用于描述入口文件在哪里,resolve.mainFields用于配置采用哪个字段作为入口文件的描述。
可以存在多个字段描述入口文件的原因是因为有些模块可以同时用在多个环境中,针对不同的运行环境需要使用不同的代码。以isomorphic-fetchfetchAPI为例,它是的一个实现,但可同时用于浏览器和Node.js环境。
为了减少搜索步骤,在你明确第三方模块的入口文件描述字段时,你可以把它设置的尽量少。由于大多数第三方模块都采用main字段去描述入口文件的位置,可以这样配置Webpack:
module.exports={ resolve:{ //只采用main字段作为入口文件描述字段,以减少搜索步骤 mainFields:['main'], }, };
使用本方法优化时,你需要考虑到所有运行时依赖的第三方模块的入口文件描述字段,就算有一个模块搞错了都可能会造成构建出的代码无法正常运行。
善用现存的文件
通过module.noParse忽略文件
Webpack配置中的module.noParse配置项可以让Webpack忽略对部分没采用模块化的文件的递归解析处理,这样做的好处是能提高构建性能。原因是一些库,例如jQuery、ChartJS,它们庞大又没有采用模块化标准,让Webpack去解析这些文件耗时又没有意义。
在上面的优化resolve.alias配置中讲到单独完整的react.min.js文件就没有采用模块化,让我们来通过配置module.noParse忽略对react.min.js文件的递归解析处理,相关Webpack配置如下:
module.exports={ module:{ //独完整的`react.min.js`文件就没有采用模块化,忽略对`react.min.js`文件的递归解析处理 noParse:[/react\.min\.js$/], }, };
注意被忽略掉的文件里不应该包含import、require、define等模块化语句,不然会导致构建出的代码中包含无法在浏览器环境下执行的模块化语句。
通过resolve.alias映射文件
Webpack配置中的resolve.alias配置项通过别名来把原导入路径映射成一个新的导入路径。
在实战项目中经常会依赖一些庞大的第三方模块,以React库为例,库中包含两套代码:
一套是采用CommonJS规范的模块化代码,这些文件都放在lib目录下,以package.json中指定的入口文件react.js为模块的入口。
一套是把React所有相关的代码打包好的完整代码放到一个单独的文件中,这些代码没有采用模块化可以直接执行。其中dist/react.js是用于开发环境,里面包含检查和警告的代码。dist/react.min.js是用于线上环境,被最小化了。
默认情况下Webpack会从入口文件./node_modules/react/react.js开始递归的解析和处理依赖的几十个文件,这会时一个耗时的操作。通过配置resolve.alias可以让Webpack在处理React库时,直接使用单独完整的react.min.js文件,从而跳过耗时的递归解析操作。
相关Webpack配置如下:
module.exports={ resolve:{ //使用alias把导入react的语句换成直接使用单独完整的react.min.js文件, //减少耗时的递归解析操作 alias:{ 'react':path.resolve(__dirname,'./node_modules/react/dist/react.min.js'), } }, };
除了React库外,大多数库发布到Npm仓库中时都会包含打包好的完整文件,对于这些库你也可以对它们配置alias。
但是对于有些库使用本优化方法后会影响到后面要讲的使用Tree-Shaking去除无效代码的优化,因为打包好的完整文件中有部分代码你的项目可能永远用不上。一般对整体性比较强的库采用本方法优化,因为完整文件中的代码是一个整体,每一行都是不可或缺的。但是对于一些工具类的库,例如lodash,你的项目可能只用到了其中几个工具函数,你就不能使用本方法去优化,因为这会导致你的输出代码中包含很多永远不会执行的代码。
使用DllPlugin
在介绍DllPlugin前先给大家介绍下DLL。用过Windows系统的人应该会经常看到以.dll为后缀的文件,这些文件称为动态链接库,在一个动态链接库中可以包含给其他模块调用的函数和数据。
要给Web项目构建接入动态链接库的思想,需要完成以下事情:
把网页依赖的基础模块抽离出来,打包到一个个单独的动态链接库中去。一个动态链接库中可以包含多个模块。
当需要导入的模块存在于某个动态链接库中时,这个模块不能再次被打包,而是去动态链接库中获取。
页面依赖的所有动态链接库需要被加载。
为什么给Web项目构建接入动态链接库的思想后,会大大提升构建速度呢?原因在于包含大量复用模块的动态链接库只需要编译一次,在之后的构建过程中被动态链接库包含的模块将不会在重新编译,而是直接使用动态链接库中的代码。由于动态链接库中大多数包含的是常用的第三方模块,例如react、react-dom,只要不升级这些模块的版本,动态链接库就不用重新编译。
接入Webpack
Webpack已经内置了对动态链接库的支持,需要通过2个内置的插件接入,它们分别是:
DllPlugin插件:用于打包出一个个单独的动态链接库文件。
DllReferencePlugin插件:用于在主要配置文件中去引入DllPlugin插件打包好的动态链接库文件。
下面以基本的React项目为例,为其接入DllPlugin,在开始前先来看下最终构建出的目录结构:
├──main.js ├──polyfill.dll.js ├──polyfill.manifest.json ├──react.dll.js └──react.manifest.json
其中包含两个动态链接库文件,分别是:
polyfill.dll.js里面包含项目所有依赖的polyfill,例如Promise、fetch等API。
react.dll.js里面包含React的基础运行环境,也就是react和react-dom模块。
以react.dll.js文件为例,其文件内容大致如下:
var_dll_react=(function(modules){ //...此处省略webpackBootstrap函数代码 }([ function(module,exports,__webpack_require__){ //模块ID为0的模块对应的代码 } //...此处省略剩下的模块对应的代码 ]));
可见一个动态链接库文件中包含了大量模块的代码,这些模块存放在一个数组里,用数组的索引号作为ID。并且还通过_dll_react变量把自己暴露在了全局中,也就是可以通过window._dll_react可以访问到它里面包含的模块。
其中polyfill.manifest.json和react.manifest.json文件也是由DllPlugin生成,用于描述动态链接库文件中包含哪些模块,以react.manifest.json文件为例,其文件内容大致如下:
{ //描述该动态链接库文件暴露在全局的变量名称 "name":"_dll_react", "content":{ "./node_modules/process/browser.js":{ "id":0, "meta":{} }, //...此处省略部分模块 } }
可见manifest.json文件清楚地描述了与其对应的dll.js文件中包含了哪些模块,以及每个模块的路径和ID。
main.js文件是编译出来的执行入口文件,当遇到其依赖的模块在dll.js文件中时,会直接通过dll.js文件暴露出的全局变量去获取打包在dll.js文件的模块。所以在index.html文件中需要把依赖的两个dll.js文件给加载进去,index.html内容如下:
以上就是所有接入DllPlugin后最终编译出来的代码,接下来教你如何实现。
构建出动态链接库文件
构建输出的以下这四个文件
├──polyfill.dll.js ├──polyfill.manifest.json ├──react.dll.js └──react.manifest.json
和以下这一个文件
├──main.js
是由两份不同的构建分别输出的。
与动态链接库相关的文件需要由一个独立的构建输出,用于给主构建使用。新建一个Webpack配置文件webpack_dll.config.js专门用于构建它们,文件内容如下:
constpath=require('path'); constDllPlugin=require('webpack/lib/DllPlugin'); module.exports={ //JS执行入口文件 entry:{ //把React相关模块的放到一个单独的动态链接库 react:['react','react-dom'], //把项目需要所有的polyfill放到一个单独的动态链接库 polyfill:['core-js/fn/object/assign','core-js/fn/promise','whatwg-fetch'], }, output:{ //输出的动态链接库的文件名称,[name]代表当前动态链接库的名称, //也就是entry中配置的react和polyfill filename:'[name].dll.js', //输出的文件都放到dist目录下 path:path.resolve(__dirname,'dist'), //存放动态链接库的全局变量名称,例如对应react来说就是_dll_react //之所以在前面加上_dll_是为了防止全局变量冲突 library:'_dll_[name]', }, plugins:[ //接入DllPlugin newDllPlugin({ //动态链接库的全局变量名称,需要和output.library中保持一致 //该字段的值也就是输出的manifest.json文件中name字段的值 //例如react.manifest.json中就有"name":"_dll_react" name:'_dll_[name]', //描述动态链接库的manifest.json文件输出时的文件名称 path:path.join(__dirname,'dist','[name].manifest.json'), }), ], };
使用动态链接库文件
构建出的动态链接库文件用于在其它地方使用,在这里也就是给执行入口使用。
用于输出main.js的主Webpack配置文件内容如下:
constDllReferencePlugin=require('webpack/lib/DllReferencePlugin'); module.exports={ plugins:[ //告诉Webpack使用了哪些动态链接库 newDllReferencePlugin({ //描述react动态链接库的文件内容 manifest:require('./dist/react.manifest.json'), }), newDllReferencePlugin({ //描述polyfill动态链接库的文件内容 manifest:require('./dist/polyfill.manifest.json'), }), ], devtool:'source-map' };
注意:在webpack_dll.config.js文件中,DllPlugin中的name参数必须和output.library中保持一致。原因在于DllPlugin中的name参数会影响输出的manifest.json文件中name字段的值,而在webpack.config.js文件中DllReferencePlugin会去manifest.json文件读取name字段的值,把值的内容作为在从全局变量中获取动态链接库中内容时的全局变量名。
执行构建
在修改好以上两个Webpack配置文件后,需要重新执行构建。重新执行构建时要注意的是需要先把动态链接库相关的文件编译出来,因为主Webpack配置文件中定义的DllReferencePlugin依赖这些文件。
执行构建时流程如下:
如果动态链接库相关的文件还没有编译出来,就需要先把它们编译出来。方法是执行webpack–configwebpack_dll.config.js命令。
在确保动态链接库存在的前提下,才能正常的编译出入口执行文件。方法是执行webpack命令。这时你会发现构建速度有了非常大的提升。
相信给你的项目加上以上优化方法后,构建速度会大大提高,赶快去试试把!