常见版本更新检测方式
- 轮询版本文件
- 使用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(),
]
})