缩小文件的搜索范围
Webpack在启动后会从配置的Entry出发,解析出文件中的导入语句,再递归解析。遇到导入语句时,Webpack会做以下两件事
- 根据导入语句去寻找对应的要导入的文件
- 根据找到的要导入的文件的后缀,使用配置中的Loader去处理文件
以上两件事处理一个文件非常快,但是项目大,文件多,构建速度就会变慢,所以尽量减少以上两件事的发生,提高构建速度
优化Loader配置
Loader对文件的转换操作很耗时,所以让尽量少的文件被loader处理,可以通过test,indlude,exclude三个配置项来命中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.modules配置
resolve.modules用于配置webpack去哪些目录下寻找第三方模块。默认是
['node_modules'],是当前目录下的node_modules目录没有就../node_modules,再没有就../../node_modules,层层递进
当安装的第三方模块都放在项目根目录的node_modules目录下时,就没有必要按照默认的方式去一层层地寻找,可以指明存放第三方模块的绝对路径,以减少寻找,配置如下:
module.exports = {
resolve: {
// 使用绝对路径指明第三方模块存放位置,以减少搜索步骤
// 其中,__dirname表示当前工作目录,也就是项目根目录
modules: [path.resolve(__dirname, 'node_modules')]
},
};
优化resolve.mainFields配置
它用于配置第三方模块使用哪个入口文件。
在安装的第三方模块中都会有一个package.json文件,用于描述这个模块的属性其中的某些字段用于描述入口文件在哪里,resolve.mainFields用于配置采用哪个字段作为入口文件的描述。 可以存在多个字段描述入口文件的原因是,某些模块可以同时用于多个环境中,针对不同的运行环境需要使用不同的代码
resolve.mainFields的默认值和当前的target配置有关系,对应的关系如下
- 当target为web或者webworker时,值是
["browser", "module", "main"] - 当target为其他情况时,值是
["module", "main"]
为了减少搜索步骤,明确第三方模块的入口文件描述字段时,我们可以将它设置的尽量少,因为大多数第三方模块都采用main字段描述入口文件的位置,所以配置wenpack:
module.exports = {
resolve: {
// 只采用main字段作为入口文件的描述字段,以减少搜索步骤
mainFields: ['main'],
}
}
考虑到所有运行时依赖的第三方模块的入口文件的描述字段,就算只有一个模块出错,也可能回造成构建出的代码无法正常运行
优化resolve.alias配置
通过别名来将原导入路径映射成一个新的导入路径。 在实战项目中经常会依赖一些庞大的第三方模块,以React库为例
- 一套是采用CommonJS规范的模块化代码,在lib目录下,以package.json中指定的入口文件react.js为模块入口
- 一套将React的所有相关代码打包好的完整代码放到一个单独的文件中,这些代码没有采用模块化,可以直接执行,其中
dist/react.js用于开发环境,里面包含检查和警告的代码,dist/react.min.js用于线上环境,被最小化了
默认情况下,webpack会从入口文件
./node_modules/react/react.js开始递归解析和处理依赖的几十个文件,这会是一个很耗时的操作,通过配置resolv e.alias, 可以让Webpack在处理React库时,直接使用单独、完整的react.min.js文件,从而跳过耗时的递归解析操作
module.exports = {
resolve: {
alias: {
// 使用alias将导入的react语句转换成直接使用单独、完整的react.min.js文件,从而跳过耗时的递归解析操作
'react': path.resolve(__dirname, './node_modules/react/dist/react.min.js'),
}
}
}
除了React库,大多数库被发布到Npm仓库中时都会包含打包好的完整文件,对于这些库,也可以对它们配置alias 对某些库使用本优化方法后,会影响到使用Tree-Sharking去除无效代码的优化,因为打包好的完整文件中有部分代码在我们的项目中可能永远用不上。一般对整体性比较强的库采用本方法优化,因为完整文件中的代码是个整体,每行都是不可或 缺的,但是对于一些工具类的库如lodash,项目中可能只用到了其中几个工具函数,就不能使用本方法优化,因为这会导致在我们的输出代码中包含很多永远不会被执行的代码。
优化resolve.extensions配置
关于extensions配置请参照配置-03 所以,当extensions字段描述的列表越长,或者正确的后缀越往后,就会造成尝试的次数越多,构建性能就越靠后,所以:
- 后缀尝试列表要尽可能小,不要将项目中不可能存在的情况写到后缀尝试列表中
- 频率出现最高的文件后缀要有限放在最前面,以做到尽快退出寻找过程
- 远吗中写导入语句时,尽可能带上后缀,从而可以避免寻找过程
module.exports = {
resolve: {
extensions: ['js'],
},
};
优化module.noParse配置
noParse配置可以让Webpack忽略对部分没采用模块化的文件的递归解析处理,这样做的好处是能提高构建性能,请参照配置-03 在前面讲解优化resolve.alias配置时讲到,单独、完整的react.min.js文件没有采用模块化,让我们通过配置module.noParse忽略对react.min.js文件的递归解析处理,相关的Webpack配置如下:
module.exports = {
module: {
module: {
// 单独、完整的react.min.js文件没有采用模块化,忽略对react.min.js文件的递归解析处理
noParse: [/react\.min\.js$/]
}
}
}
被忽略掉的文件里不应该包含import,require,define等模块化语句,不然会导致在构建出的代码中包含无法在浏览器环境下执行的模块化语句
使用DllPlugin
认识DLL
用过Windows系统的人应该会经常看到以.dll为后缀的文件,这些文件叫作动态链接库,在一个动态链接库中可以包含为其他模块调用的函数和数据 要给Web项目构建接入动态链接库的思想,需要完成以下事情
- 将网页依赖的基础模块抽离出来,打包到一个个单独的动态链接库中。在一个动态链接库中可以包含多个模块。
- 当需要导入的模块存在于某个动态链接库中时,这个模块不能被再次打包,而是去动态链接库中获取。
- 页面依赖的所有动态链接库都需要被加载。
为什么为Web项目构建接入动态链接库的思想后会大大提升构建速度呢?原因在于,包含大量复用模块的动态链接库只需被编译一次,在之后的构建过程中被动态链接库包含的模块将不会重新编译,而是直接使用动态链接库中的代码,由于动态链接库中大多数包含的是常用的第三方模块,例如react,react-dom,所以只要不升级这些模块的版本,动态链接库就不用重新编译。
接入webpack
Webpack己经内置了对动态链接库的支持,通过以下两个内置的插件接入
- 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模块
一个动态链接库文件中包含了大量模块的代码,这些模块被存放在一个数组里,用数组的索引号作为ID。并且通过_dll_react变量将自己暴露在全局中,即可以通过window._dll_react访问到其中包含的模块。
.manifest.json文件也是由DllPlugin生成的,清楚的描述了与其对应的在动态链接库文件中包含哪些模块,
main.js文件是被编译出来的执行入口文件,在遇到其依赖的模块在dll.js文件中时,会直接通过dll.js文件暴露的全局变量获取打包在dll.js文件中的模块,所以在index.html 文件中需要将依赖的两个dll.js文件加载进去,index.html的内容如下
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
<div id="app"></div>
<!--导入依赖的动态链接库文件-->
<script src="./dist/polyfill.dll.js"></script>
<script src="./dist/react.dll.js"></script>
<!--导入执行入口文件-->
<script src="./dist/main.js"></script>
</body>
</html>
So?如何实现呢?
构建出动态链接库
构建出的以下四个文件
|-- polyfill.dll.js
|-- polyfill.manifest.json
|-- react.dll.js
|-- react.manifest.json
和这个文件
|-- main.js
是由两份不同的构建分别输出的 动态链接库文件相关的文件需要由一份独立的构建输出,用于为主构建使用,新建一个webpack配置文件,webpack.dll.config.js专门用于构建他们,内容如下:
const path = require('path');
const DllPlugin = require('webpack/lib/DllPlugin');
module.exports = {
// js执行入口文件
entry: {
// 将React相关的模块放到一个单独的动态链接库中
react: ['react', 'react-dom'],
// 将项目需要所有的polyfill放到一个单独的动态链接库中
polifill: ['core-js/fn/object/assign', 'core-js/fn/promise', 'weatwg-fetch'],
},
output: {
// 输出的动态链接库的文件名称,[name]代表当前动态链接库的名称,也就是entry中配置的react和polyfill
filename: '[name].dll.js',
// 将输出的文件都放到 dist 目录下
path: path(__dirname, 'dist'),
// 存放动态链接库的全局变量名称,之所以在前面加上dll,是为了防止全局变量冲突
library: '_dll_[name]',
},
plugins: [
// 接入DllPlugin
new DllPlugin({
// 动态链接库的全局变量名称,需要和output.library中保持一致,该字段的值也就是输出的manifest.json文件中name字段的值
name: '_dll_[name]',
// 描述动态链接库的manifest.json文件输出时的文件名称
path: path.join(__dirname, 'dist', '[name].manifest.json'),
}),
],
}
使用动态链接库文件
构建出的动态链接库文件用于在其他地方使用,在这里用于在执行入口使用 用于输出main.js的主Webpack配置文件的内容如下:
const path = require('path');
const DllPlugin = require('webpack/lib/DllReferencePlugin');
module.exporets = {
entry: {
main: './main.js',
},
output: {
filrname: '[name].js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.js$/,
use['babel-loader'],
exclude: path.resolve(__dirname, 'node_modules'),
},
]
},
plugins: [
new DllReferencePlugin({
manifest: require('./dist/react.manifest.json'),
}),
new DllReferencePlugin({
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 --config wenpack_dll.config.js - 在确保动态链接库存在时,才能正常编译入口文件,方法是执行webpack命令。
使用HappyPack
文件读写操作无法避免,但是能不能让webpcak在同一时刻同时处理多个任务,发挥多核CPU电脑的功能来提升构建速度?HappPack is OK!它将任务分解给多个子进程去并发执行,子进程处理完后再将结果发送给主进程
js是单线程模型,所以要发挥多核CPU功能,就只能通过多进程实现,而无法通过多线程实现。
使用HappyPack
对于分解任务和管理线程的事情,HappyPack都会帮我们做好,我们所需要做的只是接入HappyPack。接HappyPack的相关代码如下:
module.exports = {
rules: [
{
test: /\.js$/,
use: ['happypack/loader?id=babel'],
exclude: path.resolve(__dirname, 'node_modules'),
},
{
test: /\.css$/,
use: ExtractTextPlugin.extract({
use: ['happypack/loader?id=css'],
})
},
],
plugins: [
new HappyPack({
id: 'babel',
loaders: ['babel-loader?cacheDirectory'],
...
}),
new HappyPack({
id: 'css',
loaders: ['css-loader'],
}),
new ExtractTextPlugin({
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时除了可以传入id和loader两个值,还可以传以下参数
- threads:代表开启几个子进程去处理这一类型的文件,默认三个,必须的整数
- verbose:是否允许HappyPack输出日志,默认是true
- threadPool:代表共享进程池,即多个HappyPack实例都使用同一个共享进程池中的子进程取处理任务,以防止资源占用过多,相关代码如下:
const HappyPack = require('happypack');
const happyThreadPool = HappyPack.ThreadPool({ size: 5 });
module.exports = {
plugins: [
new HappyPack({
id: 'babel',
loader: ['babel-loader?cacheDirectory'],
threadPool: happThreadPool,
}),
new HappyPack({
id: 'css',
loaders: ['css-loader'],
threadPool: happyThreadPool,
}),
new ExtractTextPlugin({
filename: `[name].css`
})
]
}
接入HappyPack后,需要为项目安装新的依赖:
npm i -D happypack
HappyPack原理
整个webpack构建流程中,最耗时的Loader对文件的转换操作,因为要转换的文件数据量巨大,而且这些转换操作只能一个个处理,HappyPack的核心原理就是将这部分任务分解到多个进程中取并行处理,从而减少构建时间
1、所有需要通过Loader处理的文件都先交给了happypack/loader处理,在收集到了这些文件的处理权后,HappyPack就可以统一分配了。 2、每通过new HappyPack()实例化一个HappyPack其实就是告诉HappyPack核心调器如何通过一系列Loader去转换一类文件,并且可以指定如何为这类转换操作分配子进程。 3、核心调度器的逻辑代码在主进程中,也就是运行着Webpack的进程中,核心调度器会将一个个任务分配给当前空闲的子进程,子进程处理完毕后将结果发送给核心调度器,它们之间的数据交换是通过进程间的通信API实现的 4、核心调度器收到来自子进程处理完毕的结果后,会通知Webpack该文件己处理完毕。
使用ParallelUglifyPlugin
在使用Webpack构建出用于发布到线上的代码时,会有压缩代码这一流程,最常见的是UglifyJS,并且Webpack也内置了它。 压缩代码时,需要先将代码解析成用Object抽象表示的AST语法树,再应用各种规则分析和处理AST,所以导致这个过程的计算量较大,耗时很多
当webpack有多个js文件需要输出和压缩时,原本会使用UglifyJS去一个一个压缩再输出,但是ParallelUglifyPlugin会开启多个子进程,将对多个文件的压缩工作分配给多个子进程去完成,每个子进程其实还是通过UglifyJS去压缩代码,但是变成了并行执行。所以ParallelUglifyPlugin能更快的完成对多个文件的压缩工作 ParallelUglifyPlugin的使用如下:(将原来webpack配置文件中内置的UglifyJsPlugin去掉后替换成ParallelUglifyPlugin)
const path require('path');
const DefinePlugin = require('webpack/lib/DefinePlugin');
const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');
module.exports = {
plugins: [
// 使用ParallelUglifyPlugin并行压缩输出的js代码
new ParallelUglifyPlugin({
// 传递给UglifyJS的参数
uglifyJS: {
output: {
// 最紧凑的输出
beatuify: false,
// 删除所有注释
comments: false,
},
compress: {
// 在UglifyJS删除没有用到的代码时不输出警告
warnings: false,
// 删除所有的console语句,可以兼容ie浏览器
drop_console: true,
// 内嵌已定义但是只用到一次的变量
collapse_vars: true,
// 提取出出现多次但是没有定义成变量去饮用的静态值
reduce_vars: true,
}
}
})
]
}
new ParallelUglifyPlugin实例化时,支持以下参数:
- test:匹配哪些文件被压缩
- include:去命中需要被压缩的文件
- exclude:命中不需要被压缩的文件
- cacheDir:缓存压缩后的结果,下次遇到一样的输入时直接从缓存中获取压缩后的结果并返回,用于设置配置缓存存放的目录路径,默认不会缓存,若想开启缓存,则设置一个目录路径
- workerCount:开启几个子进程去并发执行压缩,默认当前运算的计算机的CPU核数减一。
- sourceMap:是否输出SourceMap,会导致压缩过程变慢
- uglifyJS:用于压缩ES5代码时的配置,为object,被原封不动地传递给UglifyJS作为参数。
- uglifyES:用于压缩ES6代码时的配置,为object,被原封不动地传递给UglifyES作为参数。
test,include,exclude配置与loader的思想和用法一样 UglifyES是UglifyJS的变种,专门压缩es6代码,他们出自同一个项目,并且不能同时使用。 UglifyES用于比较新的js运行环境中。 ParallelUglifyPlugin同时内置了UglifyJS和UglifyES。
不要忘记安装ParallelUglifyPlugin依赖npm i -D webpack-parallel-uglify-plugin
速度会快很多,如果设置cacheDir开启缓存,则在之后的构建中速度会更快。