万字长文详解Webpack构建打包优化(下)优化篇
前言
上篇 万字长文详解Webpack构建打包优化(上)提效篇我们讲了编译提效和优化提效,这篇让我们来讲讲增量构建,并系统总结Webpack 5的优化方案。
三、增量构建
虽然前面我们介绍了很多 webpack 构建优化的办法,但是我们发现一个问题却迟迟没有得到解决,那就是尽管只改动了一行代码,但是在执行构建时,要完整执行所有模块的编译、优化和生成产物的处理过程,而不是只需要处理所改动的文件。那么如何实现只编译打包我们所改动的文件呢?
在开启 devServer的时候,当我们执行 webpack-dev-server 命令后,Webpack 会进行一次初始化的构建,构建完成后启动服务并进入到等待更新的状态。当本地文件有变更时,Webpack 几乎瞬间将变更的文件进行编译,并将编译后的代码内容推送到浏览器端。你会发现,这个文件变更后的处理过程就符合上面所说的只编译打包改动的文件的操作,这也被称为“增量构建”
module.exports = {
...
mode: 'development',
devServer: {
contentBase: './dist',
},
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
},
],
},
}
那么为什么在开发服务模式下可以实现增量构建的效果,而在生产环境下不行呢?
1、增量构建的影响因素
1)watch配置
在上面的增量构建过程中,第一个想到的就是需要监控文件的变化。显然,只有得知变更的是哪个文件后,才能进行后续的针对性处理。要实现这一点也很简单,在 Webpack 中启用 watch 配置即可,此外在使用 devServer 的情况下,该选项会默认开启。那么,如果在生产模式下开启 watch 配置,是不是再次构建时,就会按增量的方式执行呢?
module.exports = {
mode: 'production',
watch: true,
entry: {
'example_dll': './src/index.js'
},
module: {
rules: [
{
test: /\.js$/,
use: ['babel-loader'],
},
],
},
output: {
filename: '[name]-[contenthash:8].js',
},
}
但是我们发现在生产模式下开启 watch 配置后,相比初次构建,再次构建所编译的模块数量并未减少,即使只改动了一个文件,也仍然会对所有模块进行编译。因此可以得出结论,在生产环境下只开启 watch 配置后的再次构建并不能实现增量构建。
2)cache配置
仔细查阅 Webpack 的配置项文档,会在菜单最下方的“其他选项”一栏中找到 cache 选项(需要注意是 Webpack 4 版本的文档,Webpack 5 中这一选项会有大的改变)。这一选项的值有两种类型:布尔值和对象类型。一般情况下默认为false,即不使用缓存,但在开发模式开启 watch 配置的情况下,cache 的默认值变更为true。此外,如果 cache 传值为对象类型,则表示使用该对象来作为缓存对象,这往往用于多个编译器 compiler 的调用情况。
下面我们就来看一下,在生产模式下,如果watch 和 cache 都为 true,结果会如何?
正如我们所期望的,再次构建时,在编译模块阶段只对有变化的文件进行了重新编译,实现了增量编译的效果。
但是美中不足的是,在优化阶段压缩代码时仍然耗费了较多的时间。这一点很容易理解:我们将提及较大的依赖模块和入口模块打入了同一个 Chunk 中,即使修改的模块是单独分离的 bar.js,但它的产物名称的变化仍然需要反映在入口 Chunk 的 runtime 模块中。因此入口 Chunk 也需要跟着重新压缩而无法复用压缩缓存数据。因此我们还可以通过 split chunks 进一步优化构建速度。
2、增量构建的实现原理
1)watch 配置的作用
watch 配置的具体逻辑在 Webpack 的 Watching.js 中。查看源码可以看到,在它构建相关的 _go 方法中,执行的依然是 compiler实例的 compile 方法,这一点与普通构建流程并无区别。真正的区别在于,在 watch 模式下,构建完成后并不自动退出,因此构建上下文的对象(包括前一次构建后的缓存数据对象)都可以保留在内存中,并在 rebuild 时重复使用,如下面的代码所示:
// lib/Watching.js
...
_go() {
...
this.compiler.hooks.watchRun.callAsync(this.compiler, err => {
const onCompiled = (err, compilation) => {
...
}
this.compiler.compile(onCompiled);
}
}
2)cache 配置的作用
cache 配置的源码逻辑主要涉及两个文件:CachePlugin.js 和 Compilation.js。其中 CachePlugin.js 的核心作用是将该插件实例的 cache 属性传入 compilation 实例中,如下面的代码所示:
// lib/CachePlugin.js
...
compiler.hooks.thisCompilation.tap("CachePlugin", compilation => {
compilation.cache = cache;
...
}
而在 Compilation.js 中,运用 cache 的地方有两处:
- 在编译阶段添加模块时,若命中缓存module,则直接跳过该模块的编译过程(与 cache-loader 等作用于加载器的缓存不同,此处的缓存可直接跳过 Webpack 内置的编译阶段)。
- 在创建 Chunk 产物代码阶段,若命中缓存Chunk,则直接跳过该 Chunk 的产物代码生成过程。
// lib/Compilation.js
...
addModule(module, cacheGroup) {
...
if (this.cache && this.cache[cacheName]) {
const cacheModule = this.cache[cacheName];
...
//缓存模块存在情况下判断是否需要rebuild
rebuild = ...
if (!rebuild) {
...
//无须rebuild情况下返回cacheModule,并标记build:false
return {
module: cacheModule,
issuer: true,
build: false,
dependencies: true
}
}
...
}
if (this.cache) {
this.cache[cacheName] = module;
}
...
//无缓存或需要rebuild情况下返回module,并标记build:true
return {
module: module,
issuer: true,
build: true,
dependencies: true
};
}
...
createChunkAssets() {
...
if ( this.cache && this.cache[cacheName] && this.cache[cacheName].hash === usedHash ) {
source = this.cache[cacheName].source;
} else {
source = fileManifest.render();
...
}
}
以上就是 Webpack 4 中 watch 和 cache 配置的作用原理。通过 Webpack 内置的 cache 插件,将整个构建中相对耗时的两个内部处理环节——编译模块和生成产物,进行缓存的读写处理,从而实现增量构建处理。那么我们是不是就可以在生产环境下直接使用这个方案呢?
3、生产环境下使用增量构建的阻碍
增量构建之所以快是因为将构建所需的数据(项目文件、node_modules 中的文件数据、历史构建后的缓存数据等)都保留在内存中。在 watch 模式下保留着构建使用的 Node 进程,使得下一次构建时可以直接读取内存中的数据。
而生产环境下的构建通常在集成部署系统中进行。对于管理多项目的构建系统而言,构建过程是任务式的:任务结束后即结束进程并回收系统资源。对于这样的系统而言,增量构建所需的保留进程与长时间占用内存,通常都是不可接受的。
因此,基于内存的缓存数据注定无法运用到生产环境中。要想在生产环境下提升构建速度,首要条件是将缓存写入到文件系统中。只有将文件系统中的缓存数据持久化,才能脱离对保持进程的依赖,你只需要在每次构建时将缓存数据读取到内存中进行处理即可。事实上,这也是上一课时中讲到的那些 Loader 与插件中的缓存数据的存储方式。
遗憾的是,Webpack 4 中的 cache 配置只支持基于内存的缓存,并不支持文件系统的缓存。因此,我们只能通过一些支持缓存的第三方处理插件将局部的构建环节应用“增量处理”。
不过好消息是 Webpack 5 中正式支持基于文件系统的持久化缓存(Persistent Cache)。
四、Webpack 5的优化
Webpack 5 中的变化有很多,完整的功能变更清单参见官方文档,这里我们介绍其中与构建效率相关的几个主要功能点:
- Persistent Caching
- Tree Shaking
- Logs
1、Persistent Caching
module.exports = {
...
cache: {
type: 'filesystem',
cacheLocation: path.resolve(__dirname, '.appcache'),
buildDependencies: {
config: [__filename],
},
},
...
}
1)Cache基本配置
在 Webpack 4 中,cache 只是单个属性的配置,所对应的赋值为 true 或 false,用来代表是否启用缓存,或者赋值为对象来表示在构建中使用的缓存对象。而在 Webpack 5 中,cache 配置除了原本的 true 和 false 外,还增加了许多子配置项,例如:
- cache.type:缓存类型。值为 'memory'或‘filesystem’,分别代表基于内存的临时缓存,以及基于文件系统的持久化缓存。在选择 filesystem 的情况下,下面介绍的其他属性生效。
- cache.cacheDirectory:缓存目录。默认目录为 node_modules/.cache/webpack。
- cache.name:缓存名称。同时也是 cacheDirectory 中的子目录命名,默认值为 Webpack 的 config.name−{config.name}-config.name−{config.mode}。
- cache.cacheLocation:缓存真正的存放地址。默认使用的是上述两个属性的组合:path.resolve(cache.cacheDirectory, cache.name)。该属性在赋值情况下将忽略上面的 cacheDirectory 和 name 属性。
2)单个模块的缓存失效
Webpack 5 会跟踪每个模块的依赖项:fileDependencies、contextDependencies、missingDependencies。当模块本身或其依赖项发生变更时,Webpack 能找到所有受影响的模块,并重新进行构建处理。
这里需要注意的是,对于 node_modules 中的第三方依赖包中的模块,出于性能考虑,Webpack 不会跟踪具体模块文件的内容和修改时间,而是依据依赖包里package.json 的 name 和 version 字段来判断模块是否发生变更。因此,单纯修改 node_modules 中的模块内容,在构建时不会触发缓存的失效。
3)全局的缓存失效
当模块代码没有发生变化,但是构建处理过程本身发生变化时(例如升级了 Webpack 版本、修改了配置文件、改变了环境变量等),也可能对构建后的产物代码产生影响。因此在这种情况下不能复用之前缓存的数据,而需要让全局缓存失效,重新构建并生成新的缓存。在 Webpack 5 中共提供了 3 种不同维度的全局缓存失效配置。
buildDependencies
第一种配置是cache.buildDependencies,用于指定可能对构建过程产生影响的依赖项。
它的默认选项是{defaultWebpack: ["webpack/lib"]}
。这一选项的含义是,当 node_modules 中的 Webpack 或 Webpack 的依赖项(例如 watchpack 等)发生变化时,当前的构建缓存即失效。
上述选项是默认内置的,无须写在项目配置文件中。配置文件中的 buildDenpendencies 还支持增加另一种选项 {config: [__filename]}
,它的作用是当配置文件内容或配置文件依赖的模块文件发生变化时,当前的构建缓存即失效。
version
第二种配置是 cache.version。当配置文件和代码都没有发生变化,但是构建的外部依赖(如环境变量)发生变化时,预期的构建产物代码也可能不同。这时就可以使用 version 配置来防止在外部依赖不同的情况下混用了相同的缓存。例如,可以传入 cache: {version: process.env.NODE_ENV}
,达到当不同环境切换时彼此不共用缓存的效果。
name
缓存的名称除了作为默认的缓存目录下的子目录名称外,也起到区分缓存数据的作用。例如,可以传入 cache: {name: process.env.NODE_ENV}
。这里有两点需要补充说明:
name 的特殊性:与 version 或 buildDependencies 等配置不同,name 在默认情况下是作为缓存的子目录名称存在的,因此可以利用 name 保留多套缓存。在 name 切换时,若已存在同名称的缓存,则可以复用之前的缓存。与之相比,当其他全局配置发生变化时,会直接将之前的缓存失效,即使切换回之前已缓存过的设置,也会当作无缓存处理。
当 cacheLocation 配置存在时,将忽略 name 的缓存目录功能,上述多套缓存复用的功能也将失效。
其他
除了上述介绍的配置项外,cache 还支持其他属性:managedPath、hashAlgorithm、store、idleTimeout 等,具体功能可以通过官方文档进行查询。
此外,在 Webpack 4 中,部分插件是默认启用缓存功能的(例如压缩代码的 Terser 插件等),项目在生产环境下构建时,可能无意识地享受缓存带来的效率提升,但是在 Webpack 5 中则不行。无论是否设置 cache 配置,Webpack 5 都将忽略各插件的缓存设置(例如 TerserWebpackPlugin),而由引擎自身提供构建各环节的缓存读写逻辑。因此,项目在迁移到 Webpack 5 时都需要通过上面介绍的 cache 属性来单独配置缓存。
除此之外通过对 compiler.cache.hook.get 的追踪不难发现:持久化缓存一共影响下面这些环节与内置的插件:
- 编译模块:ResolverCachePlugin、Compilation/modules。
- 优化模块:FlagDependencyExportsPlugin、ModuleConcatenationPlugin。
- 生成代码:Compilation/codeGeneration、Compilation/assets。
- 优化产物:TerserWebpackPlugin、RealContentHashPlugin。
正是通过这样多环节的缓存读写控制,才打造出 Webpack 5 高效的持久化缓存功能。
2、Tree Shaking
Webpack 5 中的另一项优化体现在 Tree Shaking 功能方面。Webpack 4 中的 Tree Shaking 功能在使用上存在限制:只支持 ES6 类型的模块代码分析,且需要相应的依赖包或需要函数声明为无副作用等。这使得在实际项目构建过程中 Tree Shaking 的优化效果往往不尽如人意。而这一问题在 Webpack 5 中得到了不少改善。
1)Nested Tree Shaking
Webpack 5 增加了对嵌套模块的导出跟踪功能,能够找到那些嵌套在最内层而未被使用的模块属性。例如下面的示例代码,在构建后的结果代码中只包含了引用的内部模块的一个属性,而忽略了不被引用的内部模块和中间模块的其他属性:
// ./src/inner-module.js
export const a = 'inner_a'
export const b = 'inner_b'
// .src/nested-module.js
import * as inner from './inner-module'
const nested = 'nested'
export { inner, nested }
// ./src/example-tree-nested.js
import * as nested from './nested-module'
console.log(nested.inner.a)
//./dist/tree-nest.js
(()=>{"use strict";console.log("inner_a")})();
2)Inner Module Tree Shaking
除了上面对嵌套引用模块的依赖分析优化外,Webpack 5 中还增加了分析模块中导出项与导入项的依赖关系的功能。通过 optimization.innerGraph(生产环境下默认开启)选项,Webpack 5 可以分析特定类型导出项中对导入项的依赖关系,从而找到更多未被使用的导入模块并加以移除。例如下面的示例代码:
//./src/inner-module.js
export const a = 'inner_a'
export const b = 'inner_b'
export const c = 'inner_c'
//./src/example-tree-nested.js 同上面示例
//.src/nested-module.js
...
const useB = function () {
return inner.b
}
export const usingB = function () {
return useB()
}
//./dist/tree-nest.js (默认optimization.innerGraph = true)
... const t="inner_a",n="inner_b"} ...
//./dist/tree-nest.js (optimization.innerGraph = false)
... const t="inner_a"} ...
在 nested-module.js 中新增了导出项 usingB,该导出项间接依赖导入项 inner.b,而这一导出项在入口模块中并未使用。在默认情况下,构建完成后只保留真正被使用的 inner.a。但是如果将优化项 innerGraph 关闭(且需要同时设置 concatenateModules:false),构建后会发现间接引用的导出项没有被移除,该导出项间接引用的 inner.b 也被保留到了产物代码中。
3)CommonJS Tree Shaking
Webpack 5 中增加了对一些 CommonJS 风格模块代码的静态分析功功能:
- 支持 exports.xxx、this.exports.xxx、module.exports.xxx 语法的导出分析。
- 支持 object.defineProperty(exports, "xxxx", ...) 语法的导出分析。
- 支持 require('xxxx').xxx 语法的导入分析。
//./src/commonjs-module.js
exports.a = 11
this.exports.b = 22
module.exports.c = 33
console.log('module')
//./src/example-tree-commonjs.js
const a = require('./commonjs-module').a
console.log(a)
//./dist/tree-commonjs.js
()=>{var o={263:function(o,r){r.a=11,console.log("module")}}...
可以看到产物代码中只有被引入的属性 a 和 console 语句,而其他两个导出属性 b 和 c 已经在产物中被排除了。
3、Logs
第三个要提到的 Webpack 5 的效率优化点是,它增加了许多内部处理过程的日志,可以通过 stats.logging 来访问。下面两张图是使用相同配置 stats: {logging: "verbose"}
的情况下,Webpack 4 和 Webpack 5 构建输出的日志:
可以看到,Webpack 5 构建输出的日志要丰富完整得多。通过这些日志能够很好地反映构建各阶段的处理过程、耗费时间,以及缓存使用的情况。在大多数情况下,它已经能够代替之前人工编写的统计插件功能。
参考资料
《前端工程化精讲》
《玩转Webpack》
转载自:https://juejin.cn/post/7156060517776228382