问题
在项目中,可能存在需要实时更新版本的需求;如某个表单升级了,需要立即更新到客户端,亦或者版本更新了接口,旧的接口已弃用等情况。
思考
对于这种场景,可以根据实际情况,思考具体方案。
- 前后端同步更新版本时,可以采用socket连接,后端发布完成,推送新版本。优点是实时性强,缺点是socket长连接会耗费浏览器和服务器的连接资源
- 如果前端单独发布和部署,则可以简化方案,采用前端轮询的方式;定时请求版本文件,对当前版本和最新版本进行比对,如果不相同,则表明版本已更新。这种方案的优化点是资源耗费少,但实时性较弱 (可以把轮询间隔设置在几秒内,这样实时性也会很好)
- 如果是微服务部署,这时候需要考虑多个服务的协同的问题。可以采用在主服务上添加版本号,减少对子服务的请求。
下面我实现了第二种场景下的方案:
-
首先,我们需要在项目中添加版本号。在
package.json的version属性自定义一个版本号。版本号的编号规则通常遵循语义化版本规范(SemVer)。语义化版本规范定义了一个版本号格式为:MAJOR.MINOR.PATCH版本号格式
MAJOR(主版本号):当你做了不兼容的 API 修改时,增加主版本号。MINOR(次版本号):当你做了向下兼容的功能性新增时,增加次版本号。PATCH(修订号):当你做了向下兼容的问题修正时,增加修订号。
-
第二步,我们需要把这个版本号添加到生产/测试环境的打包文件中去。这时候可以在public下单独新建一个version.json文件存放版本号,在每次打包完成后,将
package.json中的版本号复制到该文件中。也可以将版本号直接存放到index.html页面下的meta标签中。我采用了第二种的方式,如图所示:那如何实现呢,我们可以编写一个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支持设置失效/过期时间吗?请大胆的回答:不能。【滑稽】 -
至此,我们完成了版本升级的判断流程,以及提示用户更新版本。