Web客户端版本升级,提示用户更新

655 阅读6分钟

问题

在项目中,可能存在需要实时更新版本的需求;如某个表单升级了,需要立即更新到客户端,亦或者版本更新了接口,旧的接口已弃用等情况。

思考

对于这种场景,可以根据实际情况,思考具体方案。

  • 前后端同步更新版本时,可以采用socket连接,后端发布完成,推送新版本。优点是实时性强,缺点是socket长连接会耗费浏览器和服务器的连接资源
  • 如果前端单独发布和部署,则可以简化方案,采用前端轮询的方式;定时请求版本文件,对当前版本和最新版本进行比对,如果不相同,则表明版本已更新。这种方案的优化点是资源耗费少,但实时性较弱 (可以把轮询间隔设置在几秒内,这样实时性也会很好)
  • 如果是微服务部署,这时候需要考虑多个服务的协同的问题。可以采用在主服务上添加版本号,减少对子服务的请求。

下面我实现了第二种场景下的方案:

  • 首先,我们需要在项目中添加版本号。在package.jsonversion属性自定义一个版本号。版本号的编号规则通常遵循语义化版本规范(SemVer)。语义化版本规范定义了一个版本号格式为:MAJOR.MINOR.PATCH

    版本号格式

    • MAJOR(主版本号):当你做了不兼容的 API 修改时,增加主版本号。
    • MINOR(次版本号):当你做了向下兼容的功能性新增时,增加次版本号。
    • PATCH(修订号):当你做了向下兼容的问题修正时,增加修订号。
  • 第二步,我们需要把这个版本号添加到生产/测试环境的打包文件中去。这时候可以在public下单独新建一个version.json文件存放版本号,在每次打包完成后,将package.json中的版本号复制到该文件中。也可以将版本号直接存放到index.html页面下的meta标签中。我采用了第二种的方式,如图所示:

    image.png

    那如何实现呢,我们可以编写一个node脚本。在项目根目录下新建一个version-build.js文件,

        // npm build version
        const vueconfig = require('./vue.config.js')
        const packageConfig = require('./package.json')
        const NODE_ENV = process.env.NODE_ENV || ''
    
        let VERSION_NO = ''
        // development add version timestamp
        if (NODE_ENV === 'development') {
          // development add timestamp 
          VERSION_NO = `${packageConfig.version}-${new Date().getTime()}`
        } else {
          VERSION_NO = packageConfig.version || ''
        }
    
        // vueconfig.outputDir is the packaged directory
        const dist_path = `./${vueconfig.outputDir}/index.html`
    
        // Get the index.html page in dist and write the version number in it
        const fs = require('fs')
        const path = require('path')
        const distPath = path.join(__dirname, dist_path)
        let htmlContent = fs.readFileSync(distPath, 'utf8')
        htmlContent = htmlContent.replace('<head>', `<head><meta name="version_no" content="${VERSION_NO}" >`)
        fs.writeFileSync(distPath, htmlContent, 'utf8')
    
        // eslint-disable-next-line no-console
        console.log('[ build version success ] >', VERSION_NO)
    
        module.exports = VERSION_NO
    
    

    然后在package.json中添加脚本

    "scripts": {
        ...
        "build": "...  && npm run version"
        "version": "node version-build.js",
        ...
    }
    

    这样在打包之后,就可以把版本号写入html页面中。下一次我们则需要思考如何将版本号获取,并与最新的版本进行比对。

  • 第三步,我们在客户端加载完成后通过meta标签获取版本号,并将版本号存入localStorage

      //  addEventListener load
      Window.addEventListener('load', () => {
          const htmlMeta = document.querySelector(`meta[name='version_no']`)
          // Get the version number of the content
          const versionNo = htmlMeta?.getAttribute('content')
      })
      
    

    然后采用轮询请求最新的index.html的方式来获取最新版本号 (这里需要在请求时,强制不使用缓存)

    这时候会出现一个问题,如果我们的系统是多页面的系统,如果在每个页面都启用轮询请求,则会极大浪费浏览器性能。那可不可以依靠同源策略,我们保证只有一个窗口会请求呢。答案是肯定的,可以通过localStorage来实现这个功能。

    我们可以在每个页面启用定时器轮询,轮询时间间隔可以根据业务需要自定义调整,我这里设置了60秒,因为项目更新频率不高。当第一个窗口发送请求后,在localStorage添加一个等待的标识,并添加过期时间(失效时间);其他窗口在将要发送请求前获取这个标识,判断标识是否过期,已过期,则重复上一步的操作;没有过期,则不发送请求 (类似于一个时间锁)。下面是实现方式:

      /* eslint-disable no-undef */
      // 版本升级校验(v1.0.1版本,优化请求,同源窗口,只会其中一个窗口进行请求)
    
      import { MessageBox } from 'element-ui'
      import { store } from 'xijs'
    
      const VERSION_NO_KEY = 'version_no' // 版本号缓存key
    
      const VERSION_NO_FLAG = 'version_no_flag' // 版本号标识,用于是否发送请求的校验
    
      const VERSION_NO_FLAG_VALUE = 'true' // 版本号标识的值,用于是否发送请求的校验
    
      // 请求html地址
      const fetchUrl = process.env.VUE_APP_PUBLIC_PATH || '/'
    
      // 版本获取间隔时间,每WAIT_TIME请求一次
      const WAIT_TIME = 60 * 1000
    
      // message弹窗,校验是否已有弹窗
      let isMessage = false
    
      const setVersionNo = (versionNo) => {
        // 缓存版本号到localstorage
        localStorage.setItem(VERSION_NO_KEY, versionNo)
      }
    
      const getVersionNo = () => {
        return localStorage.getItem(VERSION_NO_KEY)
      }
    
      const setVersionFlag = (flag) => {
        if (flag) {
          // 设置版本号标识,50秒后过期
          store.set(VERSION_NO_FLAG, flag, Date.now() + WAIT_TIME - 1000)
        } else {
          store.setItem(VERSION_NO_FLAG, '')
        }
      }
      /**
       * 获取版本号标识,不存在,或者过期则返回false
       * @returns
       */
      const getVersionFlag = () => {
        const state = store.get(VERSION_NO_FLAG)
        if (state.value && state.status === store.status.SUCCESS) {
          return state.value === VERSION_NO_FLAG_VALUE
        } else {
          return false
        }
      }
    
      /**
       * 设置请求flag
       */
      const setFetchFlag = () => {
        if (getVersionFlag()) {
          return
        }
    
        // 如果没有flag,或者VERSION_NO_FLAG过期,则重新设置
        setVersionFlag(VERSION_NO_FLAG_VALUE)
    
        // 请求版本号
        fetchNewVersion()
      }
    
      /**
       * 清空缓存,重新请求
       * @param {string | undefined} newVersion
       */
      const refreshCache = (newVersion) => {
        // 先请求一次页面,设置不缓存,获取最新的html
        fetch(`${fetchUrl}?time=${new Date().getTime()}`, { cache: 'no-store' }).finally(() => {
          isMessage = false
          // 刷新页面
          // eslint-disable-next-line no-undef
          globalThis.location?.reload()
        })
      }
    
      /**
       * 请求页面,获取版本并比对
       */
      const fetchNewVersion = () => {
        // 在 js 中请求首页地址不会更新页面
        fetch(`${fetchUrl}?time=${new Date().getTime()}`, { cache: 'no-store' })
          .then((res) => res.text())
          .then((text) => {
            // const oldVersion = getVersionNo()
            const el = document.createElement('html')
            el.innerHTML = text
            const htmlMeta = el.querySelector(`meta[name='${VERSION_NO_KEY}']`)
            if (!htmlMeta) return
            const newVersion = htmlMeta.getAttribute('content')
            // 本页面判断,因为localStorage.setItem不会触发本页面的监听
            if (newVersion !== getVersionNo()) {
              showMessage()
            }
            setVersionNo(newVersion)
          })
          .catch((err) => {
            // eslint-disable-next-line no-console
            console.log('[ err ] >', err)
          })
      }
    
      /**
       * 设置当前版本号
       */
      const setCurrentVersionNo = () => {
        const htmlMeta = document.querySelector(`meta[name='${VERSION_NO_KEY}']`)
        // 获取content的版本号
        const versionNo = htmlMeta?.getAttribute('content')
        setVersionNo(versionNo)
      }
    
      setCurrentVersionNo()
    
      /**
       * 显示更新弹窗
       */
      const showMessage = () => {
        isMessage = true
        MessageBox.alert('发现新版本,请刷新页面后使用', {
          type: 'warning',
          showClose: false,
          confirmButtonText: '立即刷新',
          customClass: 'token-massage-alert',
          callback: () => {
            refreshCache()
          }
        })
      }
    
      // 添加storage变化监听,监听其他页面触发的版本号变化
      globalThis.addEventListener('storage', (ev) => {
        // oldValue不为空, 且version_no值变化
        if (ev && ev.key === VERSION_NO_KEY && ev.oldValue && ev.newValue !== ev.oldValue && !isMessage) {
          showMessage()
        }
      })
    
      setInterval(() => {
        setFetchFlag()
      }, WAIT_TIME)
    
    

    从请求得到的index.html中获取最新的版本号,与当前版本进行比较,如果不同,则弹窗提示用户刷新页面升级版本。

    这里我使用了第三方工具库xijs来帮助我实现localStorage属性的过期判断。

    Tips:如果面试时,面试官问你,localStorage支持设置失效/过期时间吗?请大胆的回答:不能。【滑稽】

  • 至此,我们完成了版本升级的判断流程,以及提示用户更新版本。

这是一个简单场景下前端版本升级校验的Web端代码,可以支持html5,把弹窗的方式修改即可。

仅供大家参考,如有错误,欢迎指正。