利用SSE实现web项目版本发布,用户侧自动刷新

787 阅读2分钟

背景

目前的web系统在版本迭代更新后,对于旧版本下的客户端来说(版本发布时,用户在线且未刷新页面),是无法感知到有新版本的,需要用户侧进行手动刷新页面才能加载最新的版本资源

实现目标

只针对旧版本下的客户端

  • 版本发布时,主动进行一个版本更新提示,点击提示可自动刷新页面
  • SPA路由跳转时,自动刷新页面

方案一(定时器轮询)

方案设计

前端项目构建时,生成一个当前版本json文件,放入dist目录,存入到腾讯云Cos中,同时在前端项目代码中,也注入全局变量当前版本号。客户端通过定时轮训的方式,拿服务端的json版本文件和本地的版本号进行对比版本号,不一致时则提示用户刷新页面,同时在路由跳转时,判断如果需要更新,则跳转后自动刷新一次当前页面

流程

image.png

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

}


实现效果

image.png

方案二(SSE服务端推送)

方案设计

前半部分与方案一相同,生成当前版本json文件存入到腾讯云cos中,前端项目代码中也注入全局变量当前版本号,区别是在不在采用轮询方案,而是借助服务端的Server-sent events推送能力来实现,使用nodejs搭建一个SSE的服务,以及对外提供一个sendMsg接口,该接口触发时可往所有连接的客户端推送消息,然后进行一个版本号对比的流程。

SSE介绍:juejin.cn/post/735566…

流程

image.png

服务端实现(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的兼容还是可以的,大多数浏览器都支持

image.png

最终结论

方案一通过轮询的方式来说有着一些问题,比如开启轮询通道后不能在关闭,而且通知实效性没那么高,高频的请求对服务端有压力,会污染浏览器的F12看板数据给开发者抓包调试带来了不便。方案二走服务端SSE主动推送,相对来说通知实效性会高一些,且也少了频繁请求的开销,只需要nodejs搭建一个简单的服务端即可,目前两种方案前端都有实现demo ,前额这边暂时最终落地会选用方案二。

如果要考虑低版本浏览器(远古级别),可以用方案一来做兜底,针对不支持SSE的浏览器,采用降级走轮询方案。