根本介绍
长久化缓存是 webpack5 所带来的十分弱小的个性之一。一句话概括就是构建后果长久化缓存到本地的磁盘,二次构建 (非 watch 模块) 间接利用磁盘缓存的后果从而跳过构建过程当中的 resolve、build 等耗时的流程,从而大大晋升编译构建的效率。
长久化缓存次要解决的就是优化编译流程,缩小编译耗时的问题,通过全新的缓存零碎的设计使得整个构建流程更加的高效和平安。在此之前官网或者社区也有不少解决编译耗时,进步编译效率的计划。
例如官网提供的 cache-loader
,可将上一个 loader 解决的后果缓存到磁盘上,下一次在走这个流程的时候 (pitch) 根据肯定的规定来应用缓存内容从而跳过前面 loader 的解决。不过 cache-loader
也仅仅能笼罩到经由 loader
解决后的文件内容,缓存内容的范畴比拟受限,此外就是 cache-loader
缓存是在构建流程当中进行的,缓存数据的过程也是有一些性能开销的,会影响整个的编译构建速度,所以倡议是搭配译耗时较长的 loader 一起应用。另外就是 cache-loader
是通过比照文件 metadata 的 timestamps,这种缓存生效策略不是十分的平安,具体见我之前遇到的 case。
此外还有 babel-loader
、eslint-loader
内置的缓存性能,DLL
等。外围的目标就是曾经解决过的内容不须要再从新走一遍原有的流程。这些计划都能解决肯定场景下的编译效率问题。
根本应用
长久化缓存是开箱即用的一个性能,然而默认不开启。为什么 webpack 没有将这个性能默认开启呢?这个其实也在长久化文档中有做阐明:webpack 将 (反) 序列化数据的流程内置并做到的开箱即用,然而对于我的项目的使用者而言是须要充沛理解长久化缓存的一些根本配置和策略,来确保在理论开发环节编译构建的安全性(缓存生效策略)。
先看下根本的配置规定 cache
应用:
// webpack.config.js
module.exports = {
cache: {
// 开启长久化缓存
type: 'fileSystem',
buildDependencies: {config: [__filename]
}
}
}
在 cache
字段下实现无关长久化缓存的根本配置,当 type 为 fileSystem 时开启长久化缓存的能力(watch 模式下是分级缓存配合应用),另外须要特地留神的是 buildDependencies
的配置,这个配置和整个构建流程的安全性无关。常见于和我的项目相干的一些配置信息,例如你是应用 @vue/cli
进行开发的我的项目,那么 vue.config.js
就须要作为我的项目的 buildDependencies
,此外 webpack 在外部解决流程当中将所有的 loader 也作为了 buildDependenceis
,一旦 buildDependencies
产生了变更,那么在编译流程的启动阶段便会导致整个缓存生效,进而走一遍新的构建流程。
此外,和长久化缓存另外一个相干的配置是:snapshot
。snapshot
相干的配置决定了缓存内存生成 snapshot
时所采纳的策略(timestamps
| content hash
| timestamps + content hash
),而这个策略最终会影响到缓存是否生效,即 webpack 是否决定来应用缓存。
const path = require('path')
module.exports = {
// ...
snapshot: {
// 针对包管理器保护寄存的门路,如果相干依赖命中了这些门路,那么他们在创立 snapshot 的过程当中不会将 timestamps、content hash 作为 snapshot 的创立办法,而是 package 的 name + version
// 个别为了性能方面的思考,managedPaths: [path.resolve(__dirname, '../node_modules')],
immutablePaths: [],
// 对于 buildDependencies snapshot 的创立形式
buildDependencies: {
// hash: true
timestamp: true
},
// 针对 module build 创立 snapshot 的形式
module: {
// hash: true
timestamp: true
},
// 在 resolve request 的时候创立 snapshot 的形式
resolve: {
// hash: true
timestamp: true
},
// 在 resolve buildDependencies 的时候创立 snapshot 的形式
resolveBuildDependencies: {
// hash: true
timestamp: true
}
}
}
不同的创立 snapshot 形式 有不同的性能体现、安全性思考和实用场景,具体能够参阅相干的文档。
其中须要留神一点 cache.buildDependencies
和 snapshot.buildDependencies
的含意并不统一。cache.buildDependencies
是将哪些文件 or 目录作为 buildDependencies
(webpack 外部会默认将所有的 loader
作为 buildDependencies
) 而 snapshot.buildDependencies
是定义这些 buildDependencies
创立 snapshot
的形式(hash/timestamp
)。
构建产出缓存在 webpack 外部曾经实现了,然而对于一个利用我的项目而言,高频的业务开发迭代节奏,根底库的降级、第三方库的接入等等,对于这部分的更新而言 webpack 显然须要做的一件事件就是感知其变更,同时使缓存生效而从新构建新的模块,构建完结后从新写入缓存,这也是 webpack 在长久化缓存设计当中十分重要的一个个性:安全性。
工作流 & 原理介绍
首先咱们来看下一个 module
的解决流程,个别会通过:
resolve
(门路查找,须要被解决的文件门路,loader 门路等)build
(构建)generate
(代码生成)
等阶段。
module
被创立之前,须要通过一系列的 resolve 过程,例如须要被解决的文件门路,loader 等等。
module
被创立之后,须要通过 build 的过程:基本上就是交由 loader 解决后进行 parse,这个过程完结后开始解决依赖。
特地是针对 resolve
、build
阶段都有一些优化的倡议或者是相干的插件来晋升其效率。例如在 resolve
阶段能够通过 resolve.extensions
缩小 resolve 阶段匹配门路的后缀类型。其外围的目标还是为了缩小 resolve
的流程来提高效率。在 build
阶段,针对一些耗时的操作应用 cache-loader
缓存对应的处理结果等等。
那么在长久化缓存的计划当中,针对这些场景又是如何来进行设计和解决的呢?
首先来看下和 resolve
相干的长久化缓存插件:ResolverCachePlugin.js
class CacheEntry {constructor(result, snapshot) {
this.result = result
this.snapshot = snapshot
}
// 部署 (反) 序列化接口
serialize({write}) {write(this.result)
write(this.snapshot)
}
deserialize({read}) {this.result = read()
this.snapshot = read()}
}
// 注册 (反) 序列化数据结构
makeSerializable(CacheEntry, 'webpack/lib/cache/ResolverCachePlugin')
class ResolverCachePlugin {apply(compiler) {const cache = compiler.getCache('ResolverCachePlugin')
let fileSystemInfo
let snapshotOptions
...
compiler.hooks.thisCompilation.tap('ResolverCachePlugin', compilation => {
// 创立 resolve snapshot 相干的配置
snapshotOptions = compilation.options.snapshot.resolve
fileSystemInfo = compilation.fileSystemInfo
...
})
const doRealResolve = (
itemCache,
resolver,
resolveContext,
request,
callback
) => {
...
resolver.doResolve(
resolver.hooks.resolve,
newRequest,
'Cache miss',
newResolveContext,
(err, result) => {
const fileDependencies = newResolveContext.fileDependencies
const contextDependencies = newResolveContext.contextDependencies
const missingDependencies = newResolveContext.missingDependencies
// 创立快照
fileSystemInfo.createSnapshot(
resolveTime,
fileDependencies,
contextDependencies,
missingDependencies,
snapshotOptions,
(err, snapshot) => {
...
// 长久化缓存
itemCache.store(new CacheEntry(result, snapshot), storeErr => {
...
callback()})
}
)
}
)
}
compiler.resolverFactory.hooks.resolver.intercept({factory(type, hook) {hook.tap('ResolverCachePlugin', (resolver, options, userOptions) => {
...
resolver.hooks.resolve.tapAsync({
name: 'ResolverCachePlugin',
stage: -100
}, (request, resolveContext, callback) => {
...
const itemCache = cache.getItemCache(identifier, null)
...
const processCacheResult = (err, cacheEntry) => {if (cacheEntry) {const { snapshot, result} = cacheEntry
// 判断快照是否生效
fileSystemInfo.checkSnapshotValid(
snapshot,
(err, valid) => {if (err || !valid) {
// 进入后续的 resolve 环节
return doRealResolve(
itemCache,
resolver,
resolveContext,
request,
done
)
}
...
// 应用缓存数据
done(null, result)
}
)
}
}
// 获取缓存
itemCache.get(processCacheResult)
})
})
}
})
}
}
webpack 在做 resolve 缓存的流程是十分清晰的:通过在 resolverFactory.hooks.resolver
上做了劫持,并增加 resolver.hooks.resolve
的钩子,须要留神的是这个 resolve hook 的执行机会 stage: -100
,这也意味着这个 hook 执行机会是十分靠前的。
通过 identifier 惟一标识去获取长久化缓存的内容:resolveData 和 snapshot,接下来判断 snapshot 是否生效,如果生效的话就从新走 resolve 的逻辑,如果没有生效间接返回 resolveData,跳过 resolve 流程。而在理论走 resolve 的过程当中,流程完结后,首先须要做的一个工作就是根据 fileDependencies
、contextDependencies
、missingDependencies
以及在 webpack.config 外面 snapshot 的 resolve 配置来生成快照的内容,到这里 resolve 的后果以及 snapshot 都曾经生成好了,接下来调用长久化缓存的接口 itemCache.store
将这个缓存动作放到缓存队列当中。
接下来看下 module build 当中和长久化缓存相干的内容。
在一个 module 在创立完后,须要将这个 module 退出到整个 moduleGraph 当中来,首先通过 _modulesCache.get(identifier)
来获取这个 module 的缓存数据,如果有缓存数据那么应用缓存数据,没有的话就应用本次创立 module 当中应用的数据。
class Compilation {
...
handleModuleCreation(
{
factory,
dependencies,
originModule,
contextInfo,
context,
recusive = true,
connectOrigin = recursive
},
callback
) {
// 创立 module
this.factorizeModule({}, (err, factoryResult) => {
...
const newModule = factoryResult.module
this.addModule(newModule, (err, module) => {...})
})
}
_addModule(module, callback) {const identifer = module.identifier()
// 获取缓存模块
this._modulesCache.get(identifier, null, (err, cacheModule) => {
...
this._modules.set(identifier, module)
this.modules.add(module)
...
callback(null, module)
})
}
}
接下来进入到 buildModule 阶段,在理论进入后续 build 流程之前,有个比拟重要的工作就是通过 module.build
办法判断以后 module 是否须要从新走 build 流程,这外面也有几层不同的判断逻辑,例如 loader 处理过程中指定 buildInfo.cachable
,又或者说以后模块没有 snapshot 去查看也是须要从新走 build 流程的,最初就是在 snapshot 存在的状况下,须要查看 snapshot 是否生效。一旦判断这个 module 的 snapshot 没有生效,即走缓存的逻辑,那么最终会跳过这个 module 被 loader 解决以及被 parse 的环节。因为这个 module 的所有进行都是从缓存当中获取,包含这个 module 的所有依赖,接下来就进入递归解决依赖的阶段。如果 snapshot 生效了,那么就走失常的 build 流程(loader 解决,parse,收集依赖等),build 流程完结后,会利用在构建过程中收集到的 fileDependencies
、contextDependencies
、missingDependencies
以及在 webpack.config 外面 snapshot 的 module 配置来生成快照的内容,此时以后 module 编译流程完结,同时快照也曾经生成好了,接下来才会调用长久化缓存接口 this._modulesCache.store(module.identifier(), null, module)
将这个缓存动作放到缓存队列当中。
// compilation.js
class Compilation {
...
_buildModule(module, callback) {
...
// 判断 module 是否须要被 build
module.needBuild(..., (err, needBuild) => {
...
if (!needBuild) {this.hooks.stillValidModule.call(module)
return callback()}
...
// 理论 build 环节
module.build(..., err => {
...
// 将以后 module 内容退出到缓存队列当中
this._modulesCache.store(module.identifier(), null, module, err => {
...
this.hooks.succeedModule.call(module)
return callback()})
})
}
)
}
}
// NormalModule.js
class NormalModule extends Module {
...
needBuild(context, callback) {if (this._forceBuild) return callback(null, true)
// always build when module is not cacheable
if (!this.buildInfo.cachable) return callback(null, true)
// build when there is no snapshot to check
if (!this.buildInfo.snapshot) return callback(nuull, true)
...
// check snapshot for validity
fileSystemInfo.checkSnapshotValid(this.buildInfo.snapshot, (err, valid) => {if (err) return callback(err);
if (!valid) return callback(null, true);
const hooks = NormalModule.getCompilationHooks(compilation);
hooks.needBuild.callAsync(this, context, (err, needBuild) => {if (err) {
return callback(
HookWebpackError.makeWebpackError(
err,
"NormalModule.getCompilationHooks().needBuild")
);
}
callback(null, !!needBuild);
});
});
}
}
一张图来梳理下上述的流程:
通过剖析 module 被创立之前的 resolve 流程以及创立之后的 build 流程,根本理解了在整个缓存零碎当中下层应用的流程,一个比拟重要的点就是缓存的安全性设计,即在做长久化缓存的过程中,须要被缓存的内容是一方面,无关这个缓存的 snapshot 也是须要被缓存下来的,这个是缓存是否生效的判断根据。
watch 阶段比照 snapshot:文件的变动触发新的一次 compilation,在 module.needBuild
中依据 snapshot 来判断是否须要从新走编译的流程,这个时候内存当中的 _snapshotCache
尽管存在,然而以 Object 作为 key 的 Map 获取 module.buildInfo.snapshot
阶段的时候为 undefined
,因而还是会进行 _checkSnapshotValidNoCache
,实际上 snapshot
信息一方面被长久化缓存到磁盘当中,此外在生成 snapshot 的阶段时,内存当中也缓存了不同 module 的 timestamp、content hash 这些信息,所以在 _checkSnapshotValidNoCache
执行的阶段也是优先从缓存当中获取这些信息并进行比照。
第二次热启动比照 snapshot:内存当中的 _snapshotCache
曾经不存在,首先从缓存当中读取 module.buildInfo.snapshot
快照的内容,而后进行 _checkSnapshotValidNoCache
那么对于 snapshot 来说,有哪些内容是须要被关注的呢?
首先第一点就是和以后须要被缓存内容强相干的门路依赖,蕴含了:fileDependencies
、contextDependencies
、missingDependencies
,在生成 snapshot 的过程当中,这些门路依赖是须要被蕴含在内的,同时也是判断缓存是否生效的根据;
第二点就是在 snapshot 相干的配置策略,这也决定了 snapshot 的生成形式(timestampes、content hash)、速度以及可靠性。
timestamps 成本低,然而容易生效,content hash 老本更高,然而更加平安。
本地开发的状况下,波及到频繁的变动,所以应用老本更低的 timestamps 校验的形式。然而对于 buildDependencies,相对来说一遍是供编译环节生产应用的配置文件等,对于单次的构建流程来说其变动的频率比拟小,同时个别这些配置文件会影响到大量的模块的编译,所以应用 content hash。
在 CI 场景下,例如如果是 git clone 的操作的话(缓存文件的寄存),个别是 timestamps 会变,然而为了减速编译速度,应用 content hash 来作为 snapshot 的校验规定。另外一种场景是 git pull 操作,局部内容的 timestamps 不会常常变,那么应用 timestamps + content hash 的策略规定。
这部分无关 snapshot 具体的生成策略能够参照 FileSystemInfo.js
外面无关 createSnapshot
办法的实现。
另外一个须要关注的点就是 buildDependencies
对于缓存安全性的影响,在构建启动之后 webpack 首先会读取缓存,然而在决定是否应用缓存之前有个十分重要的判断根据就是对于 buildDependencies
的 resolveDependencies
、buildDependencies
snapshot 进行查看,只有当两者的 snapshot 和缓存比照没有生效的状况下才可启用缓存数据,否则缓存数据会全量生效。其成果等同于我的项目进行一次全新的编译构建流程。
此外还想说下就是整个长久化缓存的底层设计:长久化缓存的流程设计是十分独立且和我的项目利用的 compile 流程齐全解耦的。
在这其中有一个十分重要的类 Cache
,连接了整个我的项目利用的 compile 流程以及长久化缓存的流程。
// lib/Cache.js
class Cache {constructor() {
this.hooks = {get: new AsyncSeriasBailHook(['identifer', 'etag', 'gotHandlers']),
store: new AsyncParallelHook(['identifer', 'etag', 'data']),
storeBuildDependenceies: new AsyncParallelHook(['dependencies']),
beginIdle: new SyncHook([]),
endIdle: new AsyncParallelHook([]),
shutdown: new AsyncParallelHook([])
}
},
get(identifier, etag, callback) {this.hooks.get.callAsync(identifer, etag, gotHandler, (err, result) => {...})
},
store(identifer, etag, data, callbackk) {this.hooks.store.callAsync(identifer, etag, data, makeWebpackErrorCallback(callback, 'Cache.hooks.store'))
}
}
compile 流程当中须要进行缓存的读取或者写入操作的时候调用 Cache
实例上裸露的 get
、store
办法,而后 Cache
通过本身裸露进去的 hooks.get
、hooks.store
来和缓存零碎进行交互。之前有提到过在应用长久化缓存的过程中 webpack 外部其实是启动了分级缓存,即:内存缓存 (MemoryCachePlugin.js
、MemoryWithGcCachePlugin.js
) 和文件缓存(IdleFileCachePlugin.js
)。
内存缓存和文件缓存别离注册 Cache
上裸露进去的 hooks.get
、hooks.store
,这样当在 compile 过程当中抛出 get
/store
事件时也就和缓存的流程连接上了。
在 get
的阶段,watch 模式下的继续构建环节,优先应用内存缓存(一个 Map 数据)。在二次构建,没有内存缓存的状况下,应用文件缓存。
在 store
的阶段,并非是立刻将缓存内容写入磁盘,而是将所有的写操作缓存到一个队列外面,当 compile 阶段完结后,才进行写的操作。
对于须要被长久化缓存的数据结构来说:
- 按约定独自部署 (反) 序列化数据的接口(
serialize
、deserialize
) - 注册序列化数据结构(
makeSerializable
)
在 compile 完结后进入到 (反) 序列化缓存数据的阶段,实际上也是对应调用数据结构上部署的 (de)serialize
接口降职 (反) 序列化数据。
整体看下来 webpack5 提供的长久化缓存的技术计划绝对于开篇提到的一些构建编译提效的计划来说更加齐备,牢靠,性能更优,次要体现在:
- 开箱即用,简略的配置即可开启长久化个性;
- 齐备性:v5 设计的一套缓存体系更加细粒度笼罩到了 compile 流程当中十分耗时的流程,例如不仅仅是上文提到的 module resolve、build 阶段,还在 代码生成、sourceMap 阶段都应用到了长久化缓存。此外对于开发者而言,遵循整个缓存体系的约定也能够开发出基于长久化缓存个性的性能个性,从而进步编译效率;
- 可靠性:相较于 v4 版本,内置了更加平安基于 content hash 的缓存比照策略,即 timestamp + content hash,不同的开发环境、场景下在开发效率和安全性之间获得均衡;
- 性能:compile 流程和长久化缓存解耦,在 compile 阶段长久化缓存数据的动作不会妨碍整个流程,而是缓存至一个队列当中,只有当 compile 完结后才会进行,与之相干的配置可参见
cache
对开发者而言
- 基于长久化缓存个性开发的 custom module、dependency 需依照约定部署相干接口;
- 依靠框架提供的缓存策略,构建安全可靠的依赖关系、缓存;
对使用者而言
- 须要理解长久化缓存所解决的问题;
- 不同开发环境 (dev、build)、场景(CI/CD) 下的缓存、snapshot 等相干的生成策略、配置,适用性;
- 缓存生效的策略规定;
相干文档:
- 官网文档
- webpack5 release
- changelog-v5
文章首发于集体 Blog: 如果您感觉不错请给个 star 吧~