Vite插件---实现代码部署后通知用户刷新页面

110 阅读3分钟

常见版本更新检测方式

  • 轮询版本文件
  • 使用Server-Sent Events实现实时推送
  • WebSocket实现双向通知

如何检测版本号(如何检测、对比、通知)

如何生成版本号(即把构建产物抽象成一个版本号)

生成版本号有几个方案:

  • 方案一:使用时间戳

利用构建时的时间戳作为版本号,然后将这个版本号写入一个version.json文件。使用一个行内的vite插件

import { defineConfig } from 'vite'
import { writeFileSync } from 'fs';
export default defineConfig({
  plugins: [
    {
      name: 'generate-version',
      closeBundle() {
        writeFileSync('dist/version.json', JSON.stringify({ version: Date.now() }));
      },
    },
  ],
})

构建后,dist目录就多一个文件

{
 "version":"1745132149486"
}

检测版本号,使用轮询,fetch请求这个version.json即可拿到版本号,与旧版本号对比,不一致弹窗提示用户刷新

总结:存在一个问题:【即使没有改动任何代码,两次构建也会生成不同的版本号,导致不必要的通知刷新】

  • 方案二:文件内容哈希

不管webpack还是vite构建出的产物,一般js和css文件后面都会带上一串hash,vite默认使用contenthash,即基于静态资源内容的哈希,只要文件内容不变则多次构建出的产物hash也不变。

基于vite的SPA应用,入口文件是index.html,所有其他文件的文件名的变动都会通过路径依赖导致最终的index.html的文件内容发生变化。只需要对比两次轮询的index.html的内容是否一致即可。

但是运行时可能要多次轮询,可以在构建时计算index.html的内容摘要哈希,生成一个version.json文件,这样运行时的多次请求成本就被转移到构建时的一次成本上。

针对public文件夹内的文件不会经过构建步骤,而是原样复制到dist目录,所以这部分文件名不会携带contenthash,替换这部分文件也就不会导致index.html文件的变化。所以需要遍历public文件夹内的所有文件,计算hash值,这样public文件夹内的文件变动也会反应到version.json文件的变化。

完整代码如下

编写一个vite插件来更方便的完成这个功能。在项目根目录下新建build/vite-plugin-check-update.ts,整个插件的基本结构如下,其实就是一个函数,这个函数返回一个对象类型是PluginOption,函数我们可以根据需要自定义参数。

import { PluginOption } from 'vite'
import { readFileSync, readdirSync, writeFileSync, statSync, mkdirSync } from 'fs'
import { join, relative, dirname } from 'path'
import { type BinaryToTextEncoding, createHash } from 'crypto'

export interface VitePluginCheckUpdateOptions {
  /** 指定版本文件的路径,相对于build.outDir
   * @example
   * ```
   * {
   *   // 下面三种配置是等价的,都会输出到${build.outDir}/version.json
   *   versionFile: 'version.json',
   *   versionFile: './version.json',
   *   versionFile: '/version.json',
   *   // 还可以配置更深的路径、其它文件名
   *   versionFile: '/path/to/path/whatever.json',
   * }
   * ```
   */
  versionFile?: string
  /** 指定版本文件的键名
   */
  versionKey?: string
  /** 版本文件的哈希算法,即传递给`crypto.createHash(algorithm: string)`的algorithm参数 */
  hashAlgorithm?: string
  /** 哈希算法的编码方式,即传递给`crypto.createHash().digest(encoding: BinaryToTextEncoding)`的encoding参数 */
  encoding?: BinaryToTextEncoding
  /** 是否静默模式(静默则不自动生成和插入版本检测脚本,由开发者决策如何编写及何时执行脚本) */
  silent?: boolean
  /** 检测脚本的文件名 */
  checkScriptFile?: string
  /** 轮询间隔(毫秒) */
  pollInterval?: number
  /** 检测到新版本的提示文本 */
  checkTip?: string
}

export default function vitePluginCheckUpdate(
  options: VitePluginCheckUpdateOptions = {},
): PluginOption {
  const {
    versionFile = 'version.json',
    versionKey = 'version',
    hashAlgorithm = 'md5',
    encoding = 'hex',
    silent = false,
    checkScriptFile = 'version-check.js',
    pollInterval = 10 * 1000,
    checkTip = '发现新版本,是否立即刷新获取最新内容?',
  } = options

  // vite config.build.outDir
  let outDir: string
  // vite config.base
  let basePath = ''
  // vite config.publicDir
  let publicDir = ''
  let versionValue = ''

  return {
    name: 'vite-plugin-check-update',

    configResolved(config) {
      outDir = config.build.outDir
      basePath = config.base
      publicDir = config.publicDir
    },

    transformIndexHtml(html) {
      if (silent) return html

      // 自动注入检测脚本
      const scriptTag = `\n<script src="${join(basePath, checkScriptFile).replace(/\\/g, '/')}"></script>`
      if (/<\/head>/i.test(html)) {
        return html.replace(/<\/head>/i, `${scriptTag}\n</head>`)
      }
      return html + scriptTag
    },

    // 确保在构建完成后执行
    closeBundle: {
      order: 'post', // 确保在其他插件之后运行
      async handler() {
        // 计算index.html的hash
        const html = readFileSync(join(outDir, 'index.html'), 'utf-8')
        const htmlHash = createHash(hashAlgorithm).update(html).digest(encoding)

        // 计算public文件夹的hash
        let publicHash = ''
        if (statSync(publicDir, { throwIfNoEntry: false })?.isDirectory()) {
          publicHash = calcDirHash(publicDir, encoding)
        }

        // 合并生成最终版本号
        versionValue = createHash(hashAlgorithm)
          .update(htmlHash)
          .update(publicHash)
          .digest(encoding)

        if (silent) return
        // 确保writeFile目录存在
        const versionFilePath = join(outDir, versionFile)
        const versionDir = dirname(versionFilePath)
        if (!statSync(versionDir, { throwIfNoEntry: false })?.isDirectory()) {
          mkdirSync(versionDir, { recursive: true })
        }
        // 将版本号写入版本文件
        writeFileSync(versionFilePath, JSON.stringify({ [versionKey]: versionValue }, null, 2))

        // 生成检测脚本
        const checkScriptPath = join(outDir, checkScriptFile)
        const versionFileUrl = join(basePath, versionFile).replace(/\\/g, '/')

        const checkScriptContent = `// Auto-generated by vite-plugin-check-update
(function() {
  var currentVersion = '${versionValue}';
  var pollInterval = ${pollInterval};
  var versionKey = '${versionKey}';
  var versionFile = '${versionFileUrl}';
  var checkUpdate = function() {
    fetch(versionFile + '?t=' + Date.now())
      .then(function(res) { return res.json(); })
      .then(function(data) {
        if (data[versionKey] && data[versionKey] !== currentVersion) {
          currentVersion = data[versionKey];
          showUpdateNotification();
        }
      });
  };

  var showUpdateNotification = function() {
    if (confirm('${checkTip}')) {
      location.reload();
    }
  };

  setInterval(checkUpdate, pollInterval);
  checkUpdate();
})();`

        mkdirSync(dirname(checkScriptPath), { recursive: true })
        writeFileSync(checkScriptPath, checkScriptContent)
      },
    },
  }
}

// 递归遍历目录
function walkDir(dir: string, callback: (path: string) => void) {
  readdirSync(dir).forEach((file) => {
    const path = join(dir, file)
    if (statSync(path).isDirectory()) {
      walkDir(path, callback)
    } else {
      callback(path)
    }
  })
}

// 计算某个文件夹内所有文件的哈希值
function calcDirHash(dir: string, encoding: BinaryToTextEncoding): string {
  const hash = createHash('md5')
  const files: { path: string; content: Buffer }[] = []
  walkDir(dir, (filePath) => {
    const relativePath = relative(dir, filePath)
    const content = readFileSync(filePath)
    files.push({ path: relativePath, content })
  })

  // 对文件内容生成摘要
  files.sort((a, b) => a.path.localeCompare(b.path)) // 确保顺序一致
  files.forEach(({ content }) => {
    hash.update(content)
  })
  return hash.digest(encoding)
}

vite.config.ts中我们引入这个插件即可:

import vitePluginCheckUpdate from './build/vite-plugin-check-update'
export default defineConfig({
  plugins: [
    vitePluginCheckUpdate(),
  ]
})