写一个vite插件,实现部署后通知用户刷新页面

570 阅读7分钟
  • “切图仔,你改的Bug部署了吗?我这边看问题还在啊!”
  • “你…刷新页面了吗?”

作为前端开发者,这样的对话你是否似曾相识?每当项目部署后,总需要反复提醒用户手动刷新页面才能看到最新版本。

有没有一种技术手段,能自动检测版本更新并优雅地提示用户?

今天,我们将通过开发一个 Vite插件 彻底解决这个问题。这个插件会实现以下能力:

  1. 构建时生成版本指纹
  2. 运行时定时检测版本变化
  3. 智能提醒用户刷新(支持自定义UI和交互)

从原理剖析到代码实现,让我们一步步揭开其中的技术细节!

分析

首先这个问题可以拆解成2个子问题:

  1. 如何生成版本号(即把构建产物抽象成一个版本号)?
  2. 如何检测版本号(如何检测、对比、通知)?

如何检测版本号

先看第2个问题,这个问题比较简单,比较容易想到的有几个方案:

方案优点缺点
轮询实现简单、无需后端支持延迟较高(不过这个场景一般对实时性没多少要求)
WebSocket或SSE实时性强实现复杂、需要后端支持、可能会有性能问题
Service Worker可离线检测,体验流畅兼容性问题

对比下来肯定是轮询方案最合适。

如何生成版本号

生成版本号也有从简入繁的几个方案,我们逐一看一下。

1. 使用时间戳

构建时生成一个版本号,比如可以使用package.jsonversion字段,但是每次构建时都要修改这个字段,比较麻烦,而且可能会遗忘。

或者以构建时的时间戳作为版本号,然后把这个版本号写入一个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即可拿到版本号,然后对比下新旧版本号不一致则弹窗提示用户刷新:

;(async () => {
  // 1分钟检测一次,根据业务场景灵活调整
  const CHECK_INTERVAL = 60 * 1000
  let currentVersion = ''
  const res = await fetch(`/version.json?t=${Date.now()}`)
  currentVersion = (await res.json()).version

  async function checkVersion() {
    try {
      const res = await fetch(`/version.json?t=${Date.now()}`)
      const { version } = await res.json()
      if (version !== currentVersion) {
        // 提示用户刷新
        showUpdateNotification()
      }
    } catch (err) {
      console.error('版本检查失败', err)
    }
  }

  function showUpdateNotification() {
    if (confirm('发现新版本,是否立即刷新?')) {
      window.location.reload()
    }
  }

  // 启动轮询
  setInterval(checkVersion, CHECK_INTERVAL)
})()

注意上面请求的时候带上了时间戳,这样可以简单有效的保证请求的不是缓存数据。

目前为止,这个方案的实现很简单,看起来也很有效。但有个问题:即使没有改动任何代码,两次构建也会生成不同的版本号,这会导致不必要的通知刷新

2. 文件内容哈希

怎样在代码发生变化后才变更版本号呢?答案是文件内容哈希

不管是webpack还是vite构建出的产物,一般jscss文件后面都会带上一串hash,它细分为hashchunkhashcontenthash等概念,这里不展开。

vite默认使用的是contenthash,即基于基于静态资源内容的哈希,只要文件内容不变则多次构建出的产物hash也是不变的。rollup默认会生成一个 base-64 的哈希值,vite默认截取8位hash,可以通过下面的配置来自定义:

export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        assetFileNames: `assets/[name].[hash:10].[ext]`, // 自定义路径和哈希
      },
    },
  },
})

对于基于vite的SPA应用来说,入口文件是index.html,所有其它文件的文件名的变动都会通过路径依赖导致最终的index.html的文件内容发生变化(引入的jscss等文件的路径发生变化),所以我们只需要对比两次轮询的index.html的内容是否一致即可,之前的代码可以改成下面这样,省略部分代码。

// ...
try {
  const res = await fetch(`/index.html?t=${Date.now()}`)
  const text = await res.text()
  if (text !== currentText) {
    // 提示用户刷新
    showUpdateNotification()
  }
}
// ...

但是我们可以进一步优化,虽然index.html一般不会太大,但运行时轮询可是要轮询很多次的,我们可以在构建时计算index.html的内容摘要哈希,仍然生成一个跟之前一样的version.json,这样运行时的多次请求成本就被转移到到构建时的一次成本上了。

这可以使用node内置的crypto来完成,代码如下:

import { readFileSync, writeFileSync } from 'fs'
import { createHash } from 'crypto'

const html = readFileSync('dist/index.html', 'utf-8')
const htmlHash = createHash('md5').update(html).digest('hex')
writeFileSync('dist/version.json', JSON.stringify({ version: htmlHash }, null, 2))

到这一步已经很接近了,但我们遗漏了比较关键的一点:public文件夹内的文件不会经过构建步骤,而是原样复制到dist目录,所以这部分文件名不会携带contenthash,替换这部分文件也就不会导致index.html文件的变化

所以我们需要遍历public文件夹内的所有文件,计算其hash值,这样public文件夹内的文件变动也会反应到version.json文件的变化了。代码如下:

import { readFileSync, writeFileSync } from 'fs'
import { createHash } from 'crypto'

// 计算index.html的hash
const html = readFileSync('dist/index.html', 'utf-8')
const htmlHash = createHash('md5').update(html).digest('hex')

// 计算public文件夹的hash
const publicHash = calcDirHash('public')

// 合并两个hash生成最终的版本号
const version = createHash('md5').update(htmlHash).update(publicHash).digest('hex')

// 将版本号写入版本文件
writeFileSync('dist/version.json', JSON.stringify({ version }, null, 2))

// 递归遍历目录
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): 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('hex')
}

编写vite插件

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

import { PluginOption } from 'vite'

export interface VitePluginCheckUpdateOptions {
  // ...
}

export default function vitePluginCheckUpdate(
  options: VitePluginCheckUpdateOptions = {},
): PluginOption {
  return {
    name: 'vite-plugin-check-update',
    // ...
  }
}

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

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

接下来就是把上一节实现的代码的代码合适地组织进这个插件中,但是观察我们之前实现的代码我们会发现有很多硬编码的地方,需要考虑是提取成参数还是通过其它方式处理:

  • const html = readFileSync('dist/index.html', 'utf-8')这一行的dist就是硬编码的,开发者可以通过vite的配置文件中build.outDir来配置。
  • 同样,const publicHash = calcDirHash('public')这一行里的public也是硬编码的,开发者可以通过vite的配置文件中publicDir来配置。

对于这两个变量,我们不能提取成参数,而是要直接取vite配置中的值,这一点可以通过vite插件独有的钩子configResolved来完成。

  • const htmlHash = createHash('md5').update(html).digest('hex')这一行的md5hex是硬编码,我们可以提取成参数,让开发者自定义这部分行为。
  • writeFileSync('dist/version.json', JSON.stringify({ version }, null, 2))这一行的version.json文件和version字段也是硬编码,开发者可能希望把文件存储在其它地方,以及使用其它字段来存储版本号。

对于这4个变量我们可以提取成插件的参数。

整理后的代码如下:

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: string)`的encoding参数 */
  encoding?: BinaryToTextEncoding
}

export default function vitePluginCheckUpdate(
  options: VitePluginCheckUpdateOptions = {},
): PluginOption {
  const {
    versionFile = 'version.json',
    versionKey = 'version',
    hashAlgorithm = 'md5',
    encoding = 'hex',
  } = options

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

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

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

    // 确保在构建完成后执行
    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)
        }

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

        // 确保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))
      },
    },
  }
}

// 递归遍历目录
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插件时可以传递我们刚才定义的参数了:

import vitePluginCheckUpdate from './build/vite-plugin-check-update'
export default defineConfig({
  plugins: [
    vitePluginCheckUpdate({
      versionFile: '/hash/hash.json',
      versionKey: 'hash',
      hashAlgorithm: 'sha256',
      encoding: 'base64',
    }),
  ]
})

vite插件生成检测脚本

至此,我们编写的vite插件已经可以在构建时自动根据文件内容摘要计算生成版本号文件了。但检测版本是否发生变化及通知刷新的脚本还是得开发者自行编写,能不能让这个插件再方便一点呢,我们直接把这部分检测脚本生成出来插入到index.html

当然可以,但我们也得给开发者选择的权力,因为可能需要使用自定义组件来显示这个弹窗,以及决定在什么时机执行检查。要做到这一点需要把自动生成检测脚本并插入文档这个行为做成一个开关项。另外对检测脚本的文件名、检测的轮询间隔、检测弹窗的提示文本也都需要做成配置项。

export interface VitePluginCheckUpdateOptions {
  /** 是否静默模式(静默则不自动生成和插入版本检测脚本,由开发者决策如何编写及何时执行脚本) */
  silent?: boolean
  /** 检测脚本的文件名 */
  checkScriptFile?: string
  /** 轮询间隔(毫秒) */
  pollInterval?: number
  /** 检测到新版本的提示文本 */
  checkTip?: string
}

整理后的完整代码如下:

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)
}

最后

该插件已发布至npm:vite-plugin-check-update,欢迎下载。如有问题或建议可以提issue或在评论区留言。