Appearance
多线程打包与HappyPack;
缩小打包作用域;
动态链接库思想与DllPlugin;
死代码检测与去除死代码。
1. HappyPack
是一个通过多线程来提升Webpack打包速度的工具
1.1 工作原理
打包过程中有一项非常耗时的工作,就是使用loader对各种资源进行转译处理。最常见的包括使用babel-loader转译ES6+语法和使用ts-loader转译TypeScript。我们可以简单地将代码转译的工作流程概括如下:
1)从配置中获取打包入口;
2)匹配loader规则,并对入口模块进行转译;
3)对转译后的模块进行依赖查找(如a.js中加载了b.js和c.js);
4)对新找到的模块重复进行步骤2和步骤3,直到没有新的依赖模块。
不难看出从步骤2~4是一个递归的过程,Webpack需要一步步获取更深层级的资源,然后逐个进行转译。这里的问题在于Webpack是单线程的,假设一个模块依赖于其他几个模块,则Webpack必须对这些模块逐个进行转译。虽然这些转译任务彼此之间没有任何依赖关系,却必须串行地执行。HappyPack恰恰以此为切入点,它的核心特性是可以开启多个线程,并行地对不同模块进行转译,从而充分利用本地的计算资源来提升打包速度
HappyPack适用于转译任务比较重的工程,当我们把类似babel-loader和ts-loader等loader迁移到HappyPack之上后,一般都可以收到不错的效果,而对于其他如sass-loader、less-loader本身消耗时间并不太多的工程则效果一般。
2. 缩小打包作用域
从宏观角度来看,提升性能的方法无非两种:增加资源或者缩小范围。增加资源是指使用更多CPU和内存,用更多的计算能力来缩短执行任务的时间;缩小范围则是针对任务本身,比如去掉冗余的流程,尽量不做重复性的工作等。前面我们说的HappyPack属于增加资源,那么接下来我们谈谈如何缩小范围。
2.1 exclude和include
在配置loader的时候一般都会加上它们。对于JS来说,一般要把node_modules目录排除掉。另外,当exclude和include规则有重叠的部分时,exclude的优先级更高
2.2 noParse
对于有些库,我们希望Webpack完全不要去进行解析,即不希望应用任何loader规则,库的内部也不会有对其他模块的依赖,那么这时可以使用noParse实现。请看下面的例子:
module.exports = {
//...
module: {
noParse: /lodash/,
}
};
上面的配置将会忽略所有文件名中包含lodash的模块,这些模块仍然会被打包进资源文件,只不过Webpack不会对其进行任何解析。
2.3 IgnorePlugin
exclude和include是确定loader的规则范围,noParse是不去解析但仍会打包到bundle中。最后我们再看一个插件IgnorePlugin,它可以完全排除一些模块,被排除的模块即便被引用了也不会被打包进资源文件中。
IgnorePlugin对于排除一些库相关文件非常有用。对于一些由库产生的额外资源,我们其实并不会用到但又无法去掉,因为引用的语句处于库文件的内部。比如,Moment.js是一个日期时间处理相关的库,为了做本地化它会加载很多语言包,占很大的体积,但我们一般用不到其他地区的语言包,这时就可以用IgnorePlugin来去掉。
plugins: [
new webpack.IgnorePlugin({
resourceRegExp: /^\.\/locale$/, // 匹配资源文件
contextRegExp: /moment$/, // 匹配检索目录
})
],
2.4 缓存
使用缓存也可以有效减少Webpack的重复工作,进而提升打包效率。我们可以令Webpack将已经进行过预编译的文件内容保存到一个特定的目录中。当下一次接收到打包指令时,可以去查看源文件是否有改动,如没有改动则直接使用缓存即可,中间的各种预编译步骤都可以跳过。Webpack 5引入了一个新的缓存配置项。在默认情况下,它会在开发模式中开启,在生产模式下禁用。我们也可以通过以下方式来强制开启或关闭:
module.exports = {
// ...
cache: true,
};
这里我们通过true或false控制的其实只是Webpack基于内存的缓存。Webpack还支持另外一种基于文件系统的缓存,这种缓存机制必须要强制开启才会生效,开启的配置如下:
module.exports = {
// ...
cache: {
type: 'filesystem',
},
};
这里的cache配置项是对象类型,并指明缓存类型为文件系统。同时我们也可以传入更多的配置项来进行更细致的缓存管理。
不难想到,相对于在内存中进行缓存管理,使用文件系统会使缓存持续得更久,但为什么默认情况下它会被禁用呢?其中的原因在于,相比构建的性能,Webpack更注重于构建的正确性——使用文件系统缓存可能会带来一定的风险。
举个最简单的例子。假设我们已经进行了一次资源打包,并将其结果缓存了下来。这时,我们升级了一个Webpack相关插件,并重新进行了打包。按理说,如果这个插件会影响最后的输出结果,则所有的缓存都不应该被使用,而应该从头进行处理。但是Webpack只会检查工程源代码文件是否有改动,并不会知道有个插件升级了。最后Webpack会直接采用缓存,进而可能引发各种问题。与此类似的情况还有:
- 更改Webpack配置;
- 通过命令行传入不同的构建参数;
- loader、plugin或第三方包更新;
- Node.js、npm或yarn更新。
上述情况都有可能引发缓存问题。相比较而言,基于内存的缓存持续时间短,且只在开发模式下启用,可一定程度上避免这种风险。因此,Webpack宁愿牺牲一部分性能,使用基于内存缓存的方式来保证构建的结果是正确的。
了解了使用文件系统缓存的风险,我们就可以采取一些办法来解决该风险。目前最简单的解决方案就是更新配置中的cache.version。如:
module.exports = {
// ...
cache: {
type: 'filesystem',
version: '<version_string>',
},
};
更新了任何第三方包或者Webpack配置后,我们可以手动修改cache.version来让缓存过期,或者可以动态设置cache.version并将其内容依赖于yarn.lock的hash等,让其版本随着第三方包的更新而更新,等等。尽管如此,在使用这个配置项时还是需要非常谨慎。
另外,有些loader会有一个缓存配置项,用来在编译代码后同时保存一份缓存。在执行下一次编译前,它会先检查源码文件是否有变化,如果没有再直接采用缓存,这样也可以使整体构建速度上有一定的提升。
3 动态链接库与DllPlugin
动态链接库是早期Windows系统由于受限于当时计算机内存空间较小的问题而出现的一种内存优化方法。当一段相同的子程序被多个程序调用时,为了减少内存消耗,可以将这段子程序存储为一个可执行文件,只在内存中生成和使用同一个实例。
DllPlugin借鉴了动态链接库的这种思路,对于第三方模块或者一些不常变化的模块,可以将它们预先编译和打包,然后在项目实际构建过程中直接取用即可。
当然,通过DllPlugin实际生成的还是JS文件而不是动态链接库,取这个名字只是由于方法类似罢了。在打包vendor的时候还会附加生成一份vendor的模块清单,这份清单将会在工程业务模块打包时起到链接和索引的作用。
4 去除死代码
ES6 Module依赖关系的构建是在代码编译时而非运行时。基于这项特性Webpack提供了去除死代码(tree shaking)功能,它可以在打包过程中帮助我们检测工程中是否有没被引用过的模块,这部分代码将永远无法被执行到,因此也被称为“死代码”。Webpack会对这部分代码进行标记,并在资源压缩时将它们从最终的bundle中去掉。下面的例子简单展示了去除死代码是如何工作的。
// index.js
import { foo } from './util';
foo();
// util.js
export function foo() {
console.log('foo');
}
export function bar() { // 没有被任何其他模块引用,属于“死代码”
console.log('bar');
}
Webpack打包时会在bar()添加一个标记,在正常开发模式下它仍然存在,只是在生产环境的压缩那一步会被移除掉。去除死代码有时可以使bundle体积显著减小,但实现它则需要一些前提条件,分析如下。
4.1 ES6 Module
去除死代码只能对ES6 Module生效。有时我们会发现虽然只引用了某个库中的一个接口,却把整个库加载进来了,而bundle的体积并没有因为去除死代码而减小。这可能是由于该库是使用CommonJS的形式导出的,为了获得更好的兼容性,目前大部分的npm包还在使用CommonJS的形式。
也有一些npm包同时提供了ES6 Module和CommonJS两种形式,我们应该尽可能使用ES6 Module形式的模块,这样去除死代码的效率更高。
4.2 使用Webpack进行依赖关系构建
如果我们在工程中使用了babel-loader,那么一定要通过配置来禁用它的模块依赖解析。因为如果由babel-loader来做依赖解析,Webpack接收到的就都是转化过的CommonJS形式的模块,无法对死代码进行去除。禁用babel-loader模块依赖解析的配置示例如下:
module.exports = {
// ...
module: {
rules: [{
test: /\.js$/,
exclude: /node_modules/,
use: [{
loader: 'babel-loader',
options: {
presets: [
// 这里一定要加上 modules: false
[@babel/preset-env, { modules: false }]
],
},
}],
}],
},
};
4.3 使用压缩工具去除死代码
Webpack提供的去除死代码的功能本身只是为死代码添加标记,真正去除死代码是通过压缩工具来进行的,使用我们前面介绍的terser-webpack-plugin即可。在Webpack 4之后的版本中,将mode设置为production也可以达到相同的效果。