Vite 构建优化:ImportMap 固定入口文件实现增量更新

310 阅读3分钟

问题描述:

vite项目中,尽管每次只是部分文件更新,但构建之后,由于组件依赖关系,只要其中一个文件内容发生变化并且文件使用了[hash] 配置(相当于内容hash),其他文件的导入路径都会更新,导致内容变化,文件指纹也会变化。

解决思路:

vite 仓库相关 issue 和讨论 github.com/vitejs/vite…

主要是入口文件(entryFileNames)的路径名变化,影响绝大部分模块。 所以想要实现增量更新,这个入口文件名最好固定。

  1. 固定入口文件会导致浏览器缓存,整个应用都不再更新。
  2. 虽然可以在加载入口文件时加一个 query 时间戳。但是动态的组件中,对这个入口文件引用是不带query的,控制台会报错,页面会白屏。
  3. 要解决上面问题,就需要所有引用入口文件的地方,全部带上时间戳。可以 借助 importmap 进行映射。实现一个 plugin,自动在入口文件后面拼接时间戳,同时在 head 头中注入 importmap。
  4. 构建完成时,终端输出变化的文件列表。(相对于本地上次构建缓存中的记录文件),适用于开发者在本地 build 上传 dist 包的场景。如果想在 jenkins 等线上构建时,可以将记录上次构建结果的 json 从 .cache 文件中拿出来。
entryFileNames: "assets/[name].isEntry.js"
chunkFileNames: "assets/[name].[hash].js"
import type { Plugin } from "vite";
import fs from "fs";
import path from "path";

export default function viteImportMapPlugin(): Plugin {
  // 缓存文件路径(放在 node_modules/.cache 下)
  const cacheDir = path.resolve("node_modules/.cache");
  const recordFile = path.join(cacheDir, "vite-build-files.json");

  return {
    name: "html-entry-version-with-importmap",
    transformIndexHtml(html) {
      const version = Date.now();
      // 1. 给入口文件加 query,入口文件在 entryFileNames 处配置,使用 isEntry.js 作为标识
      const updatedHtml = html.replace(
        /(<script[^>]+src=")([^"]*isEntry)(.js)"/,
        `$1$2.js?v=${version}"`
      );
      // 2. 在 HTML 中插入 importmap
      // 提取 isEntry.js 路径(不带 query)
      const match = html.match(/<script[^>]+src="([^"]*isEntry).js"/);
      if (!match) return updatedHtml;
      const entryPath = match[1] + ".js";
      const entryWithVersion = `${entryPath}?v=${version}`;
      const importmap = `\n  <script type="importmap">\n    {\n      "imports": {\n        "${entryPath}": "${entryWithVersion}"\n      }\n    }\n  </script>\n`;
      // 3. 把 importmap 插入到 </head> 前
      return updatedHtml.replace("</head>", `${importmap}\n</head>`);
    },
    // 构建完成后,输出新增文件列表
    closeBundle() {
      const distPath = path.resolve("dist");
      const currentFiles: string[] = [];

      // 递归获取 dist 中的所有文件名(相对路径)
      function walkDir(dir: string) {
        for (const file of fs.readdirSync(dir)) {
          const fullPath = path.join(dir, file);
          if (fs.statSync(fullPath).isDirectory()) {
            walkDir(fullPath);
          } else {
            currentFiles.push(path.relative(distPath, fullPath));
          }
        }
      }
      if (fs.existsSync(distPath)) {
        walkDir(distPath);
      }
      // 确保缓存目录存在
      if (!fs.existsSync(cacheDir)) {
        fs.mkdirSync(cacheDir, { recursive: true });
      }
      // 读取上一次的文件列表
      let previousFiles: string[] = [];
      if (fs.existsSync(recordFile)) {
        try {
          previousFiles = JSON.parse(fs.readFileSync(recordFile, "utf-8"));
        } catch {
          previousFiles = [];
        }
      }
      // 对比文件名,找出新增的
      const newFiles = currentFiles.filter((f) => !previousFiles.includes(f));
      // 颜色工具
      const green = (text: string) => `\x1b[32m${text}\x1b[0m`; // 绿色
      const yellow = (text: string) => `\x1b[33m${text}\x1b[0m`; // 黄色
      const cyan = (text: string) => `\x1b[36m${text}\x1b[0m`; // 青色
      // 输出数量
      console.log(
        `${green("-----此次构建新增了")} ${yellow(
          String(newFiles.length)
        )} ${green("个文件-----")}`
      );
      // 输出文件列表(如果有新增)
      if (newFiles.length > 0) {
        console.log(green("新增文件列表:"));
        newFiles.forEach((f) => {
          console.log(`  ${cyan(f)}`);
        });
      }
      // 保存当前文件列表
      fs.writeFileSync(recordFile, JSON.stringify(currentFiles, null, 2));
    },
  };
}
plugins: [
  command === 'build' ? viteImportMapPlugin() : null, // 仅在构建时启用 
]

需关注的问题:

  1. importmap 兼容性目前还可以,但如果浏览器不支持,需要使用 es-module-shims 进行 polyfill。并且这个文件需要写在头部,使用 async属性,因为 importmap 是静态映射表,加载完成后,无法热更新。
<script async src="/es-module-shims.min.js"></script>
  1. 不适合多入口文件(应该可搞),不支持 SSR等。
  2. 如果修改公共文件,如 common.css 中的全局变量,仍然会导致大量文件变化。
  3. 观察一段时间看看是否有副作用。

其他优化

  • 利用 manualChunks 进行三方包分割