本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!
前言
看过小编上一篇文章 - 简单聊一聊 Vite 开发模式下的缓存策略 童鞋们,想必会记得小编在文章结束时,简单提到了使用类似 Webpack5
的持久缓存策略来优化 Vite
开发模式下的首屏性能。
经过几天的实际验证,小编发现这确实一个切实可行的优化策略。今天,借着本文,小编就和大家聊一聊自己是如何实现持久化缓存策略的,希望能给到大家一些不一样的启发,😄。
本文的目录结构:
效果演示
在讲解整个具体实现过程之前,小编先给大家演示一下使用持久缓存策略前后的效果对比。
使用持久缓存策略前的效果演示
正常启动 dev server
, 我们发现应用的首屏性能还是有点差的。
主要原因有两点:
-
大量的文件请求;
-
尽管有强缓存和协商缓存策略,首次请求的业务文件依然需要实时做
resolve
、load
、transform
操作;
其中,第二点对首屏性能影响最大,大量的时间都消耗在对源文件做 transform
的过程中,比如将 less
转化为 css
。
使用持久缓存策略后的效果演示
对比没有使用持久缓存策略的效果图,我们可以很明显的看到 dev server
启动以后,应用的首屏性能有很明显的提升。
打开 network
面板,我们可以看到 less
类型的请求文件,响应耗时变短。
通过上面两组效果图的对比,我们可以发现使用持久缓存策略是能明显提升应用首屏性能的。接下来,小编就给大家讲讲自己是如何通过一个 vite plugin
来实现持久缓存策略的。
持久化缓存策略
在前面 简单聊一聊 Vite 开发模式下的缓存策略 一文中,小编有提到为了优化性能,Vite
在开发模式下使用了浏览器缓存策略。其中,预构建内容采用强缓存策略,业务内容采用协商缓存策略。
不过在 dev server
启动以后首次访问业务内容时,协商缓存尽管有生效,但仍需要消耗时间去做 resolve
、load
、transform
操作,导致协商缓存并没有起到应有的作用。
其关键代码如下:
// 取自 transformMiddleware
...
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch && (await moduleGraph.getModuleByUrl(url, false))?.transformResult?.etag === ifNoneMatch) {
res.statusCode = 304;
return res.end();
}
const result = await transformRequest(url, server, {
html: req.headers.accept?.includes('text/html')
});
if (result) {
...
// send 方法执行时,会比较请求头里面的 etag 和 result 的 etag
// 如果相同,返回 304
// 不相同,返回 result.code, 状态码为 200
return send(req, res, result.code, type, {
etag: result.etag,
cacheControl: isDep ? 'max-age=31536000,immutable' : 'no-cache',
headers: server.config.server.headers,
map: result.map
});
}
...
在上面代码中,有一个关键对象 moduleGraph
, 它是 dev server
在工作过程中用来缓存已经请求过的文件对应的 module
对象的。
当浏览器请求一个文件时,dev server
会先根据请求 url
去 moduleGraph
中查找是否有已经缓存的 module
:
-
如果不存在,那么就对请求
url
做resolve
、load
、transform
操作,然后将解析以后的绝对路径、转换以后的文件内容、内容对应的etag
保存到对应的module
对象中,并收集到moduleGraph
中,最后将转换以后的内容、etag
信息返回给浏览器。返回响应内容时,会将转换内容的
etag
和请求头中的etag
信息做比较。如果相同,返回304
;如果不相同,返回响应内容和etag
,状态码为200
。 -
如果存在,根据对应的
module
,获取etag
信息,然后和请求头中的etag
信息做对比。相同,则返回304
,告诉浏览器使用缓存;不相同,则重新做resolve
、load
、transform
操作,返回新的内容并收集到moduleGraph
中。
dev server
启动时,moduleGraph
还没有缓存任何内容,所有的请求都需要走一遍上面的过程 1
, 导致响应时间较长,影响了首屏性能。
读到这里,可能有些童鞋会有这样的想法: 既然 dev server
启动时,moduleGraph
为空,那我在上次 dev server
关闭时,把 moduleGraph
的内容缓存到本地,等到下次启动时,读取本地缓存,然后初始化 moduleGraph
不就行了吗?
是的,确实如此,这个想法是正确的,小编的持久化缓存策略也是基于此实现的。如果大家能 get
到这一点,那小编就要给大家点赞了。
在这个方案中,最关键的有两点:
-
获取
moduleGraph
并缓存到本地; -
读取缓存,然后初始化
moduleGraph
;
其中,第一点实现起来非常简单。通过 Vite
提供的一些 api
,我们可以获取到 moduleGraph
对象,然后把 moduleGraph
里面的内容缓存到本地即可。
第二点实现起来比较麻烦,主要原因是 moduleGraph
并没有提供合适的 api
来完成我们预想的初始化动作。
那怎么办呢?
针对这个问题,我们可以换个思路来解决。比如,我们可以自定义一个 middleware
。通过自定义 middleware
,我们可以拦截请求,然后将请求 url
的 etag
和本地缓存中的 etag
做比较。如果相同,直接返回 304
,后续的 middleware
不用工作;如果不相同,继续走后面的 middleware
, 如上面的 transformMiddleware
。
因此,整个持久缓存策略分为三步:
-
dev server
关闭时,将moduleGraph
保存到本地; -
dev server
启动时,读取本地缓存; -
启用一个自定义
middleware
,拦截请求,然后使用本地缓存进行判断;
有了这三步作为指导,接下来我们就通过代码实现这个插件吧。
实现过程
定义一个自定义插件
代码如下:
const persistentCachePlugin = {
name: 'persistent-cache-plugin'
}
保存 moduleGraph
这一步,最关键的是获取 moduleGraph
。那怎样才能获取到 moduleGraph
呢?
其实,Vite 官网 在介绍ViteDevServer
这个 API 时,已经告诉了我们获取 moduleGraph
的方式。
而 ViteDevServer
,我们可以通过 Vite
提供的 configureServer hook 来获取。
知道了这些,那我们的插件就可以这样写:
const persistentCachePlugin = () => {
let _server;
return {
name: 'persistent-cache-plugin',
configureServer: async server => {
_server = server
}
}
}
那什么时候将 moduleGraph
保存到本地呢?我们期望的是在 dev server
关闭时触发保存操作,那 Vite
有没有这样的 hook
呢?
答案是有的。dev server
在关闭时,会触发 buildEnd hook
, 我们把保存 moduleGraph
的逻辑写到里面就可以了。
不过这里有个小点需要注意一下。
Vite
内部在实现关闭 dev server
时,是这样写的:
exitProcess = async () => {
try {
await server.close()
} finally {
process.exit()
}
}
process.once('SIGTERM', exitProcess);
通常,我们会采用 ctrl + c
的方式关闭 dev server
。而这种方式是不会触发 SIGTERM
事件的,只会触发 SIGINT
事件, 因此我们需要在 dev server
启动时给 process
注册 SIGINT
事件。
这一块儿逻辑,我们可以在 buildStart hook
中实现。
综上,我们的插件可以这样实现:
const persistentCachePlugin = () => {
let _server;
let cache = {};
let cachePath = path.resolve('./', 'node_modules/.vite/cache/');
return {
name: 'persistent-cache-plugin',
configureServer: async server => {
_server = server
},
buildStart: async () => {
process.once('SIGINT', async () => {
try {
await server.close();
} finally {
process.exit();
}
});
},
buildEnd: async () => {
if (!fs.existsSync(cachePath)) {
fs.mkdirSync(cachePath);
}
for(let [key, value] of _server.moduleGraph.urlToModuleMap) {
if (value.transformResult && value.transformResult.etag) {
cache[key] = value.transformResult.etag
}
}
fs.writeFileSync(cachePath + '/cache.json', JSON.stringify(cache) , err => {
console.log(err);
});
}
}
由于 moduleGraph
中最关键的是 module
对应的 etag
信息,所以我们只需要将请求 url
和对应的 etag
信息保存到本地就好了。
读取本地缓存
读取本地缓存的操作非常简单,我们直接在 buildStart hook
中读取本地缓存,然后初始化 cache
就可以了。
代码如下:
const persistentCachePlugin = () => {
let _server;
let cache = {};
let cachePath = path.resolve('./', 'node_modules/.vite/cache/');
return {
name: 'persistent-cache-plugin',
configureServer: async server => {
_server = server
},
buildStart: async () => {
if (fs.existsSync(cachePath + '/cache.json')) {
cache = require(cachePath + '/cache.json');
}
process.once('SIGINT', async () => {
try {
await server.close();
} finally {
process.exit();
}
});
},
buildEnd: async () => {
if (!fs.existsSync(cachePath)) {
fs.mkdirSync(cachePath);
}
for(let [key, value] of _server.moduleGraph.urlToModuleMap) {
if (value.transformResult && value.transformResult.etag) {
cache[key] = value.transformResult.etag
}
}
fs.writeFileSync(cachePath + '/cache.json', JSON.stringify(cache) , err => {
console.log(err);
});
}
}
}
自定义 middleware
接下来就是最关键的一步 - 定义一个自定义 middleware
,拦截请求。
通过 Vite
提供 configureServer hook
,我们可以给 server
添加自定义 middleware
。
过程如下:
const myPlugin = () => ({
name: 'configure-server',
configureServer(server) {
server.middlewares.use((req, res, next) => { // custom handle request... });
// return () => {
// server.middlewares.use((req, res, next) => { // custom handle request... })
// }
} })
这里也有一个关键点需要注意。在上面代码中,我们发现使用自定义 middleware
时有两种方式:
- 直接在
configureServer
中调用server.middlewares.use
; configureServer
返回一个callback
,在callback
中调用server.middlewares.use
;
这两种方式的唯一区别就是自定义 middleware
在什么时候触发。如果采用第一种,自定义 middleware
在所有的 Vite
内部 middleware
之前触发;采用第二种,自定义 middleware
则在所有的 Vite
内部 middleware
之后触发。
在这里,我们需要自定义 middleware
优先处理,所以采用第一种方式。
整个代码如下:
const persistentCachePlugin = () => {
let _server;
let cache = {};
let cachePath = path.resolve('./', 'node_modules/.vite/cache/');
return {
name: 'persistent-cache-plugin',
configureServer: async server => {
_server = server;
server.middlewares.use((req, res, next) => {
if (cache[req.url]) {
const ifNoneMatch = req.headers['if-none-match'];
if (ifNoneMatch && cache[req.url] === ifNoneMatch) {
const { moduleGraph, transformRequest } = server;
if (moduleGraph.urlToModuleMap.size && moduleGraph.urlToModuleMap.get(req.url) && moduleGraph.urlToModuleMap.get(req.url).transformResult) {
next();
return;
} else {
res.statusCode = 304;
setTimeout(() => {
transformRequest(req.url, server, {
html: req.headers.accept?.includes('text/html')
});
}, 3000);
return res.end();
}
}
}
next();
})
},
buildStart: async () => {
if (fs.existsSync(cachePath + '/cache.json')) {
cache = require(cachePath + '/cache.json');
}
process.once('SIGINT', async () => {
try {
await server.close();
} finally {
process.exit();
}
});
},
buildEnd: async () => {
if (!fs.existsSync(cachePath)) {
fs.mkdirSync(cachePath);
}
for(let [key, value] of _server.moduleGraph.urlToModuleMap) {
if (value.transformResult && value.transformResult.etag) {
cache[key] = value.transformResult.etag
}
}
fs.writeFileSync(cachePath + '/cache.json', JSON.stringify(cache) , err => {
console.log(err);
});
}
}
}
当 dev server
收到请求时,会做如下处理:
- 如果本地缓存
cache
中没有缓存请求url
,直接走Vite
原来的处理逻辑。 - 如果本地缓存
cache
中有相关缓存,再做如下处理:- 如果
moduleGraph
为空,判断请求携带的etag
和cache
中缓存的etag
是否一致。如果一致,返回304
,不一致则走Vite
原来的处理逻辑。 - 如果
moduleGraph
不为空,且存在请求相关module
,直接走Vite
原来的处理逻辑。
- 如果
在上面的代码中,有一个过程需要特别注意,即 transformRequest
调用。这一步的作用是本地缓存生效的时候,要主动触发 dev server
去初始化 moduleGraph
。如果没有这一步,那么热更新就会生效。另外,这一步需要设置成延迟异步触发,否则会对首屏性能造成影响。
结束语
到这里,关于如何利用持久化缓存策略来提升 Vite
开发模式下首屏性能的梳理就结束了。相信通过小编在第二、三章节的讲解,大家对整个实现思路应该有一个清晰的认识了吧。大家可以把这个插件应用到自己的 Vite
项目中,然后实际体验一下, 看看效果怎么样。
其实关于持久化缓存,尤大也说了要在之后的版本中实现。到时候等正式版出来的时候,我们可以再研究一下,看看尤大他们是怎么做的,😄。