背景
目前的web系统在版本迭代更新后,对于旧版本下的客户端来说(版本发布时,用户在线且未刷新页面),是无法感知到有新版本的,需要用户侧进行手动刷新页面才能加载最新的版本资源
实现目标
只针对旧版本下的客户端
- 版本发布时,主动进行一个版本更新提示,点击提示可自动刷新页面
- SPA路由跳转时,自动刷新页面
方案一(定时器轮询)
方案设计
前端项目构建时,生成一个当前版本json文件,放入dist目录,存入到腾讯云Cos中,同时在前端项目代码中,也注入全局变量当前版本号。客户端通过定时轮训的方式,拿服务端的json版本文件和本地的版本号进行对比版本号,不一致时则提示用户刷新页面,同时在路由跳转时,判断如果需要更新,则跳转后自动刷新一次当前页面
流程

gen-app-version-vite-plugin 插件实现
通过vite插件的transformIndexHtml钩子,在html中,注入版本信息,在closeBundle钩子中,写入版本文件
import fs from 'fs'
import path from 'path'
import dayjs from 'dayjs'
const currWorkingDir = process.cwd()
const distDir = path.join(currWorkingDir, 'dist')
function generateVersionPlugin() {
const getVersionData = () => {
const packageJsonPath = path.join(currWorkingDir, 'package.json')
const packageJsonData = JSON.parse(fs.readFileSync(packageJsonPath))
const currentTime = dayjs().format('YYYY/MM/DD HH:mm:ss')
const version = packageJsonData.version
const versionData = {
version: version,
buildTime: currentTime,
name: packageJsonData.name,
needUpgrade: false,
}
return versionData
}
const svaeFileToDistDir = () => {
const versionData = getVersionData()
if (!fs.existsSync(distDir)) {
fs.mkdirSync(distDir, { recursive: true })
}
try {
fs.writeFileSync(path.join(distDir, 'version.json'), JSON.stringify(versionData, null, 2))
console.log('文件写入成功')
} catch (err) {
console.error('文件写入失败:', err)
}
}
return {
name: 'generate-app-version-plugin',
configureServer(server) {
server.middlewares.use((req, res, next) => {
if (req.url.startsWith('/version.json?d=')) {
const filePath = './dist/version.json'
fs.readFile(filePath, 'utf8', (err, data) => {
if (err) {
res.statusCode = 500
res.end('Internal Server Error')
} else {
res.setHeader('Content-Type', 'application/json')
res.end(data)
}
})
} else {
next()
}
})
},
closeBundle() {
svaeFileToDistDir()
},
transformIndexHtml(htmlContent) {
const versionData = getVersionData()
const headEndIndex = htmlContent.indexOf('</head>')
// 在<head>标签结束位置插入全局变量
const newHtmlContent = htmlContent.slice(0, headEndIndex) + `<script>
Object.defineProperty(window, '__yt_build_info__', { value: ${JSON.stringify(versionData)}, writable: false })
</script>` + htmlContent.slice(headEndIndex)
return newHtmlContent
}
}
}
export default generateVersionPlugin
SPA 路由跳转时判断是否需要更新页面
编写一个高阶组件包裹所有路由组件,在挂载组件时判断是否有新版本,有则自动刷新一次页面,同时记录一个刷新标识,用于避免重复刷新
import useSessionStorageState from 'ahooks/es/useSessionStorageState'
const getLocalVersionInfo = (): YtBuildInfo => {
const localBuildInfo = window?.['__buildInfo__'] || {}
return localBuildInfo
}
export const CheckAppVersionHoc = (props: {
children: React.ReactNode,
path: string
}) => {
const localVersionInfo = getLocalVersionInfo()
const [refreshMark, setRefreshMark] = useSessionStorageState<boolean>(
localVersionInfo.name + 'refreshMark',
{
defaultValue: false
}
)
React.useEffect(() => {
if (props.path === '/' || !localVersionInfo?.name) {
return
}
if (window.__yt_build_info__.needUpgrade && !refreshMark) {
setRefreshMark(true)
location.reload()
}
}, [])
return <>{props.children}</>
}
实现效果

方案二(SSE服务端推送)
方案设计
前半部分与方案一相同,生成当前版本json文件存入到腾讯云cos中,前端项目代码中也注入全局变量当前版本号,区别是在不在采用轮询方案,而是借助服务端的Server-sent events推送能力来实现,使用nodejs搭建一个SSE的服务,以及对外提供一个sendMsg接口,该接口触发时可往所有连接的客户端推送消息,然后进行一个版本号对比的流程。
SSE介绍:juejin.cn/post/735566…
流程
服务端实现(nodejs)
设置最大连接数,降低对服务端资源的占用
import Koa from 'koa'
import Router from 'koa-router'
import cors from '@koa/cors'
import { PassThrough } from 'stream'
import dayjs from 'dayjs'
const port = process.env.PORT || 3000
const app = new Koa()
const router = new Router({ prefix: '/msg' })
app.use(cors())
app.use(router.routes())
.use(router.allowedMethods())
const sendMessage = async (stream, arr) => {
for (const value of arr) {
const data = value
const obj = {
type: typeof value === 'string' ? 'string' : 'json',
time: dayjs().format('YYYY-MM-DD hh:mm:ss'),
data: data
}
stream.write(`id: ${+new Date()}\n`) // 消息 ID
stream.write(`data: ${JSON.stringify(obj)}\n\n`)
stream.write('retry: 10000\n')
await new Promise((resolve) => setTimeout(resolve, 2000))
}
}
// 存储已连接的客户端
const clientsPool = new Map()
// 定义最大连接数
let MAX_CONNECTIONS = 2000
const setheader = (ctx) => {
const headers = {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Credentials': true,
'Access-Control-Allow-Origin': '*'
}
ctx.set(headers)
}
router.get('/sse', async ctx => {
const key = ctx.query.key
if (!key) {
const headers = {
'Access-Control-Allow-Credentials': true,
'Access-Control-Allow-Origin': '*'
}
ctx.set(headers)
ctx.body = 'Connection denial'
return
}
if (clientsPool.size >= MAX_CONNECTIONS) {
ctx.status = 503
const headers = {
'Access-Control-Allow-Credentials': true,
'Access-Control-Allow-Origin': '*'
}
ctx.set(headers)
ctx.body = 'Connection denial , Max connections reached'
return
}
setheader(ctx)
const stream = new PassThrough()
ctx.body = stream
clientsPool.set(key, ctx.body)
sendMessage(stream, ['ok'])
ctx.req.on('close', () => {
clientsPool.delete(key)
ctx.res.end()
})
})
router.get('/sendMsg', async ctx => {
ctx.status = 200
const query = ctx.query
const data = {
transferData: query,
}
ctx.body = data
clientsPool.forEach((client) => {
sendMessage(client, [data])
})
})
router.get('/api/getPool', async ctx => {
ctx.status = 200
const data = {}
clientsPool.forEach(((value, key) => data[key] = key))
ctx.body = {
data,
length: Object.keys(data).length
}
})
router.get('/api/delKey', async ctx => {
ctx.status = 200
console.warn('del')
const key = ctx.query.key
if (clientsPool.has(key)) {
clientsPool.delete(key)
ctx.body = {
status: true
}
return
}
ctx.body = {
status: false
}
return
})
app.listen(port, () => {
console.log(`Server is running on port ${port}`)
})
pm2部署
{
"apps": [
{
"name": "test-sse",
"script": "./index.mjs",
"env": {
"PORT": "3005"
}
},
{
"name": "prod-sse",
"script": "./index.mjs",
"env": {
"PORT": "4005"
}
}
]
}
SSE浏览器兼容性
整体来看,sse的兼容还是可以的,大多数浏览器都支持
最终结论
方案一通过轮询的方式来说有着一些问题,比如开启轮询通道后不能在关闭,而且通知实效性没那么高,高频的请求对服务端有压力,会污染浏览器的F12看板数据给开发者抓包调试带来了不便。方案二走服务端SSE主动推送,相对来说通知实效性会高一些,且也少了频繁请求的开销,只需要nodejs搭建一个简单的服务端即可,目前两种方案前端都有实现demo ,前额这边暂时最终落地会选用方案二。
如果要考虑低版本浏览器(远古级别),可以用方案一来做兜底,针对不支持SSE的浏览器,采用降级走轮询方案。