漫谈构建工具(十二):我是如何利用持久化缓存策略来提升 Vite 开发模式下首屏性能的 ?

4,054 阅读9分钟

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

前言

看过小编上一篇文章 - 简单聊一聊 Vite 开发模式下的缓存策略 童鞋们,想必会记得小编在文章结束时,简单提到了使用类似 Webpack5 的持久缓存策略来优化 Vite 开发模式下的首屏性能。

经过几天的实际验证,小编发现这确实一个切实可行的优化策略。今天,借着本文,小编就和大家聊一聊自己是如何实现持久化缓存策略的,希望能给到大家一些不一样的启发,😄。

本文的目录结构:

效果演示

在讲解整个具体实现过程之前,小编先给大家演示一下使用持久缓存策略前后的效果对比。

使用持久缓存策略前的效果演示

Nov-16-2022 14-58-11.gif

正常启动 dev server, 我们发现应用的首屏性能还是有点差的。

主要原因有两点:

  • 大量的文件请求;

  • 尽管有强缓存和协商缓存策略,首次请求的业务文件依然需要实时做 resolveloadtransform 操作;

其中,第二点对首屏性能影响最大,大量的时间都消耗在对源文件做 transform 的过程中,比如将 less 转化为 css

image.png

使用持久缓存策略后的效果演示

Nov-16-2022 15-19-57.gif

对比没有使用持久缓存策略的效果图,我们可以很明显的看到 dev server 启动以后,应用的首屏性能有很明显的提升。

打开 network 面板,我们可以看到 less 类型的请求文件,响应耗时变短。

image.png

通过上面两组效果图的对比,我们可以发现使用持久缓存策略是能明显提升应用首屏性能的。接下来,小编就给大家讲讲自己是如何通过一个 vite plugin 来实现持久缓存策略的。

持久化缓存策略

在前面 简单聊一聊 Vite 开发模式下的缓存策略 一文中,小编有提到为了优化性能,Vite 在开发模式下使用了浏览器缓存策略。其中,预构建内容采用强缓存策略,业务内容采用协商缓存策略。

不过在 dev server 启动以后首次访问业务内容时,协商缓存尽管有生效,但仍需要消耗时间去做 resolveloadtransform 操作,导致协商缓存并没有起到应有的作用。

其关键代码如下:

// 取自 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 会先根据请求 urlmoduleGraph 中查找是否有已经缓存的 module:

  • 如果不存在,那么就对请求 urlresolveloadtransform 操作,然后将解析以后的绝对路径、转换以后的文件内容、内容对应的 etag 保存到对应的 module 对象中,并收集到 moduleGraph 中,最后将转换以后的内容、etag 信息返回给浏览器。

    返回响应内容时,会将转换内容的 etag 和请求头中的 etag 信息做比较。如果相同,返回 304;如果不相同,返回响应内容和 etag,状态码为 200

  • 如果存在,根据对应的 module,获取 etag 信息,然后和请求头中的 etag 信息做对比。相同,则返回 304,告诉浏览器使用缓存;不相同,则重新做 resolveloadtransform 操作,返回新的内容并收集到 moduleGraph 中。

dev server 启动时,moduleGraph 还没有缓存任何内容,所有的请求都需要走一遍上面的过程 1, 导致响应时间较长,影响了首屏性能。

读到这里,可能有些童鞋会有这样的想法: 既然 dev server 启动时,moduleGraph 为空,那我在上次 dev server 关闭时,把 moduleGraph 的内容缓存到本地,等到下次启动时,读取本地缓存,然后初始化 moduleGraph 不就行了吗?

是的,确实如此,这个想法是正确的,小编的持久化缓存策略也是基于此实现的。如果大家能 get 到这一点,那小编就要给大家点赞了。

在这个方案中,最关键的有两点:

  • 获取 moduleGraph 并缓存到本地;

  • 读取缓存,然后初始化 moduleGraph

其中,第一点实现起来非常简单。通过 Vite 提供的一些 api,我们可以获取到 moduleGraph 对象,然后把 moduleGraph 里面的内容缓存到本地即可。

第二点实现起来比较麻烦,主要原因是 moduleGraph 并没有提供合适的 api 来完成我们预想的初始化动作。

那怎么办呢?

针对这个问题,我们可以换个思路来解决。比如,我们可以自定义一个 middleware。通过自定义 middleware,我们可以拦截请求,然后将请求 urletag 和本地缓存中的 etag 做比较。如果相同,直接返回 304,后续的 middleware 不用工作;如果不相同,继续走后面的 middleware, 如上面的 transformMiddleware

因此,整个持久缓存策略分为三步:

  1. dev server 关闭时,将 moduleGraph 保存到本地;

  2. dev server 启动时,读取本地缓存;

  3. 启用一个自定义 middleware,拦截请求,然后使用本地缓存进行判断;

有了这三步作为指导,接下来我们就通过代码实现这个插件吧。

实现过程

定义一个自定义插件

代码如下:

const persistentCachePlugin = {
    name: 'persistent-cache-plugin'
}

保存 moduleGraph

这一步,最关键的是获取 moduleGraph。那怎样才能获取到 moduleGraph 呢?

其实,Vite 官网 在介绍ViteDevServer 这个 API 时,已经告诉了我们获取 moduleGraph 的方式。

image.png

ViteDevServer,我们可以通过 Vite 提供的 configureServer hook 来获取。

image.png

知道了这些,那我们的插件就可以这样写:

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 为空,判断请求携带的 etagcache 中缓存的 etag 是否一致。如果一致,返回 304,不一致则走 Vite 原来的处理逻辑。
    • 如果 moduleGraph 不为空,且存在请求相关 module,直接走 Vite 原来的处理逻辑。

在上面的代码中,有一个过程需要特别注意,即 transformRequest 调用。这一步的作用是本地缓存生效的时候,要主动触发 dev server 去初始化 moduleGraph。如果没有这一步,那么热更新就会生效。另外,这一步需要设置成延迟异步触发,否则会对首屏性能造成影响。

结束语

到这里,关于如何利用持久化缓存策略来提升 Vite 开发模式下首屏性能的梳理就结束了。相信通过小编在第二、三章节的讲解,大家对整个实现思路应该有一个清晰的认识了吧。大家可以把这个插件应用到自己的 Vite 项目中,然后实际体验一下, 看看效果怎么样。

其实关于持久化缓存,尤大也说了要在之后的版本中实现。到时候等正式版出来的时候,我们可以再研究一下,看看尤大他们是怎么做的,😄。