前端版本更新提示技术方案调研与实现

需求场景

项目迭代频繁,如果用户打网页后,长时间不关闭对应标签页,也不刷新页面,而此期间有新的版本已经上线了,需要用户手动刷新,不然会出现一直使用旧版本以及会出现一些不可预知的错误

实现思路

  • 采取定时检查,定期向服务器发送请求,检查是否有新版本可用。
  • 拦截页面的网络请求,并且在发现有新版本时,提示用户更新或者自动更新。
  • 通过WebSocket连接实时接收到服务器的推送消息,如果有新版本可用,服务器可以通过WebSocket发送通知给客户端,提醒用户刷新页面。

主流实现方案

方案一 通过 ETag 获取应用版本

"ETag"(Entity Tag)是HTTP标头的一部分,用于标识网络资源的版本。它通常与HTTP缓存机制一起使用,以便客户端可以在后续请求中使用ETag来检查资源是否已经发生了变化。

1722917837011

ETag的生成

  • 对于静态文件(如css、js、图片等),ETag的生成策略是:文件大小的16进制+修改时间

  • 对于字符串或Buffer,ETag的生成策略是:字符串/Buffer长度的16进制+对应的hash值

代码实现

我们可以通过 fetch 请求获取当前应用的 etag 标签:

fetch(location.href, {method: 'HEAD',cache: 'no-cache'}).then((res)=>{
console.log(res.headers.get('etag'))})

当应用新版本发布后, etag 的值也会更新。所以,我们可以通过比较 etag 的值来判断是否有新版本发布。

通过Web Worker来轮询最新的ETag:

version-worker.js:

self.onmessage=(e)=>{
/!获取当前版本的ETag值currentETag
const currentETag = e.data.currentETag
// 设置定时器,每隔3秒向指定的checkurl发送HEAD请求//当服务器返回的ETag值与currentETag不相同时,向主线程发送消息'has new version'setInterval(()=>{
fetch(e.data.checkUrl,{method: 'HEAD'
cache:'no-cache
}).then((res)=>{if(res.headers.get('etag')!= currentETag){self.postMessage('has new version')
子)
},3000)

主页代码:

<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title></title>
</head>
<body>
<script>
// 创建worker
const myWorker = new Worker('version-worker.js')
// 接收版本⽐较结果
myWorker.onmessage = (e) => {
myWorker.terminate()
const result = confirm('有新版本,是否更新')
if (result) {
location.reload()
}
}
// 获取当前版本并发送给worker
fetch(location.href, {
method: 'HEAD',
cache: 'no-cache'
}).then((res) => {
myWorker.postMessage({
checkUrl: location.href,
currentETag: res.headers.get('etag')
})
})
</script>
</body>
</html>

主页面获取当前 etag 值后,发送消息给 worker , worker 将会异步的定时请求最新的etag值,并进行比较,当etag值不⼀致时会发送消息给主页。主页面接受到新版本发布消息后,可对用户进行提醒。

相关第三方组件version-polling

仓库地址:https://github.com/JoeshuTT/version-polling

安装: npm install version-polling –save

实现原理
  1. 使用 Web Worker APl在浏览器后台轮询请求页面,不会影响主线程运行。
  2. 命中协商缓存,对比本地和服务器请求响应头etag字段值。
  3. 如果 etag 值不一致,说明有更新,则弹出更新提示,并引导用户手动刷新页面(例如弹窗提示),完成应用更新。
  4. 当页面不可见时(例如切换标签页或最小化窗口),停止实时检测任务;再次可见时(例如切换回标签页或还原窗口),恢复实时检测任务。
使用方法
方法一 通过 npm 引入,并通过构建工具进行打包
//在应用入口文件中使用:如 main.js,app.jsx
import { createVersionPolling } from "version-polling";

createVersionPolling({
appETagKey:"_APP_ETAG__",
pollingInterval:5*1000,//单位为毫秒
silent:process.env.NODE_ENV ==="development",//开发环境下不检测
onUpdate:(self)=>{
//当检测到有新版本时,执行的回调函数,可以在这里提示用户刷新页面
const result = confirm("页面有更新,点击确定刷新页面!");
if(result){
self.onRefresh();
} else {
self.oncancel();
//强制更新可以用alert
// alert('有新版本,请刷新页面”);
},
});
方法二 通过 script 引⼊,直接插⼊到 HTML(无侵入用法,接入成本最低)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>前端页面自动检测更新</title>
</head>
<body>
<script src="//unpkg.com/version-polling/dist/version-polling.min.js"></scri
<script>
VersionPolling.createVersionPolling({
appETagKey: "__APP_ETAG__",
pollingInterval: 5 * 1000,
onUpdate: (self) => {
// 当检测到有新版本时,执⾏的回调函数,可以在这里页面
const result = confirm("页面有更新,点击确定刷新页面!");
if (result) {
self.onRefresh();
} else {
self.onCancel();
}
},
});
</script>
</body>
</html>
  • version-polling 需要在⽀持 web worker 和 fetchAPI 的浏览器中运⾏,不⽀持 IE 浏览器
  • version-polling 需要在 web 应⽤的⼊⼝⽂件(通常是 index.html)中引⼊,否则⽆法检测到更新
  • version-polling 需要在 web 应⽤的服务端配置协商缓存,否则⽆法命中缓存,会增加⽹络请求
  • version-polling 需要在 web 应⽤的服务端保证每次发版后,index.html ⽂件的 etag 字段值会改变,否则⽆法检测到更新

方案缺陷

  • 频繁的轮询请求,且每次请求Size大:即使应用程序没有更新也会消耗网络带宽和服务器资源,可能会对性能产生负面影响,尤其是在大量用户同时访问的情况下。

  • 无法实时推送更新:使用轮询方式无法实现实时推送更新的功能。轮询方式通常有一定的时间间隔,用户只能等待下一次轮询请求才能收到更新通知。这种延迟可能会导致用户错过重要的更新或者在使用过时版本时遇到问题。

方案二 提取版本号保存至json文件,客户端轮询服务器上的版本号

1722921319728

以 git commit hash(也支持svn revision number、package.json version.build timestamp 、custom)为版本号,打包时将版本号写入一个 json文件,同时注入客户端运行的代码。客户端轮询服务器上的版本号(浏览器窗口的visibilitychange、focus 事件辅助),和本地作比较,如果不相同则通知用户刷新页面。

相关第三方组件 plugin-web-update-notification

仓库:https://github,com/GreatAuk/plugin-web-update-notification

安装:(可以使用npm安装)

# vite
pnpm add @plugin-web-update-notification/vite -D
# umijs
pnpm add @plugin-web-update-notification/umijs -D
# webpack plugin
pnpm add @plugin-web-update-notification/webpack -D
优点
  • 接入简单,安装插件,修改配置文件即可,不用修改业务代码(如果不自定义行为)
  • 支持多种版本号类型(git commit hash、svn revision number、package.js中的 version 字段、 build timestamp 运行打包命令时的时间戳、custom 用户自定义版本可以自定义更新提示的文案、样式、位置,且支持多语言,也可以取消默认的 Notification,监听到更新事件后自定义行为。
  • 支持手动控制检测
  • 注入的 js 和css 文件压缩后不到2kb
  • 完善的 ts 类型提示
  • issue 响应即时
基于项目的实践项目采用vue-cli进行打包

安装第三方组件后,在 vue.config.js 文件中加入:

const { WebUpdateNotificationPlugin } = require(@plugin-web-update-notification/vite')
const { defineConfig } = require('@vue/cli-service')
module.exports = {
// ...other config
configureWebpack: {
resolve: {
// ...other config
},
plugins: [
new WebUpdateNotificationPlugin({
logVersion: true,
versionType: 'git_commit_hash',
checkInterval: 0.5 * 60 * 1000,
checkOnWindowFocus: true,
checkImmediately: true,
checkOnLoadFileError: true,
notificationConfig: {
placement: 'topRight'
},
notificationProps: {
title: '页面已经发生了更新',
description: '检测到当前页面内容已经发生了更新,请刷新页面后使用',
buttonText: '刷新',
dismissButtonText: '忽略'
},
}),
],
},
}
效果展示

运行打包命令 yarn run build

dist目录下多了⼀个⽂件夹,包含含有版本号的JSON文件

1722923207539

更新提示:

image-20240806140346695

检测更新的时机
  1. 首次加载页面。
  2. 轮询(default:10 * 60 * 1000 ms)
  3. js 脚本资源加载失败 (404 ?)
  4. 标签页 visibilitychange、focus 事件为 true 时。

自实现方法

思路
  1. 用 git 命令提取当前最新的 cmt id
  2. 把 cmt id 写入到一个文件里,这个文件会被轮询
  3. 把 cmt id 写入到 index.html里
  4. 在 main.js 里去轮询写入了 cmt id 的文件,拿来和 html 里进行对比
  5. 如果 html 存在 cmt id ,且轮询到了cmt id ,然后两个 cmt id 不相等,则提示更新

1722923475645

1722923497761

代码实现

通过git命令将最新的cmt id 注入latest_commit id.txt 文件中:

git rev-parse HEAD > latest_commit_id.txt

编写 html/updateIndex.js node 脚本文件,用于动态获取 commit id

const fs = require('fs');
const { execSync } = require('child_process');
// 使⽤ git 命令获取最新 commit id
const latestCommitId = execSync('git rev-parse HEAD').toString().trim();
// 将最新 commit id 写⼊ index.html ⽂件
let indexHtml = fs.readFileSync('./index.html', 'utf-8');
indexHtml = indexHtml.replace(
'window.latestCommitId = "";',
`window.latestCommitId = "${latestCommitId}";`
);
fs.writeFileSync('index.html', indexHtml);
// 将最新 commit id 写⼊ latest_commit_id.txt ⽂件
fs.writeFileSync('latest_commit_id.txt', latestCommitId);
console.log('最新 commit id 已插⼊到 index.html 和 latest_commit_id.txt ⽂件中。');

并加入在 package.json 打包命令中:

"scripts": {
"serve": "vue-cli-service serve && node ./html/updateIndex.js",
"build": "vue-cli-service build && node ./html/updateIndex.js",
},

编辑 html/index.html 文件,加入轮询代码:

<html>
<body>
<div id="app"></div>
</body>
<script>
window.latestCommitId = ""; // 替换为动态获取的 commit id
</script>
</html>

添加 src/common/updateCheck.js 文件,进行轮询操作:

import Vue from 'vue'
import UpdateModal from './UpdateModal.vue'

document.addEventListener('DOMContentLoaded', function () {
// ⽴即查询⼀次最新的 commit ID
fetchLatestCommitId()

// 设置定时器,每隔10分钟查询⼀次最新的 commit ID
setInterval(fetchLatestCommitId, 10 * 60 * 1000)
})

function fetchLatestCommitId() {
fetch('/latest_commit_id.txt')
.then((response) => response.text())
.then((newCommitId) => {
const currentCommitId = window.latestCommitId
if (currentCommitId && currentCommitId !== newCommitId) {
// 提⽰更新
showUpdateModal(newCommitId)
}
})
}
function showUpdateModal(newCommitId) {
const div = document.createElement('div')
document.body.appendChild(div)

const UpdateModalComponent = Vue.extend(UpdateModal)
const updateModalInstance = new UpdateModalComponent({
propsData: { newCommitId },
methods: {
close() {
this.$destroy()
div.parentNode.removeChild(div)
},
refresh() {
window.location.reload(true)
},
},
})

updateModalInstance.$mount(div)
}

这里使用了 DOMContentLoaded 来确保在操作页面元素之前等待页面加载完成

可见只轮询一个txt文件size要小很多

image-20240806135721956

添加 updateModal.vue 文件,编写弹框的样式

<template>
<div class="update-modal" v-if="!isClosed">
<div class="title">发现新版本</div>
<div class="content">⽹⻚更新啦!请刷新⻚⾯后使⽤</div>
<div class="actions">
<a @click="ignore" class="web-update-notice-refresh-btn">忽略</a>
<a @click="refresh" class="web-update-notice-dismiss-btn">刷新</a>
</div>
</div>
</template>

<script>
export default {
props: {
newCommitId: {
type: String,
required: true,
},
},
data() {
return {
isClosed: false,
}
},
methods: {
ignore() {
this.isClosed = true // 设置 isClosed 为 true 以关闭模态框
this.$emit('close')
},
refresh() {
this.$emit('refresh')
},
},
}
</script>

<style scoped>
.update-modal {
position: fixed;
bottom: 15%;
right: 3%;
background-color: #fff;
border-radius: 10px;
color: #000000d9;
border: 1px solid rgba(0, 0, 0, 0.1); /* 添加边框 */
padding: 8px 16px;
line-height: 1.5715;
width: 280px;
z-index: 9999;
}

.update-modal .title {
font-weight: 500;
margin-bottom: 4px;
font-size: 16px;
line-height: 24px;
text-align: left;
}

.update-modal .content {
font-size: 14px;
margin-bottom: 20px;
text-align: left;
}
.actions {
margin-top: 4px;
text-align: right;
}

.update-modal .actions a {
padding: 3px 8px;
line-height: 1;
transition: background-color 0.2s linear;
cursor: pointer;
font-size: 14px;
}

.update-modal .actions a:hover {
background-color: rgba(64, 87, 109, 0.1);
}

.update-modal .actions .web-update-notice-refresh-btn {
color: rgba(0, 0, 0, 0.25);
}

.update-modal .actions .web-update-notice-dismiss-btn {
margin-left: 8px;
color: #1677ff;
}
</style>

当轮询触发更新操作时,展示弹框。

当id⼀致时,继续进行轮询操作

1722924049477

继自实现方法的结构优化

index.html 替换掉原来的代码

<script>
window.__client__version__ = '<%= process.env.VUE_APP_SITE_VERSION %>';
const _cookieKey = 'accept_cookie_20211130';
</script>

修改 updateCheck.js 代码

import Vue from 'vue'
import UpdateModal from './updateModal.vue'
document.addEventListener('DOMContentLoaded', function () {
// 设置定时器,每隔10分钟查询⼀次最新的 commit ID
setInterval(fetchLatestCommitId, 10 * 60 * 1000)
})
function fetchLatestCommitId() {
fetch('/v.txt')
.then((response) => response.text())
.then((newCommitId) => {
const currentCommitId = window.__client__version__
if (currentCommitId && newCommitId && currentCommitId !== newCommitId) {
// 提⽰更新
showUpdateModal(newCommitId)
}
})
}

function showUpdateModal(newCommitId) {
const div = document.createElement('div')
document.body.appendChild(div)

const UpdateModalComponent = Vue.extend(UpdateModal)
const updateModalInstance = new UpdateModalComponent({
propsData: { newCommitId },
methods: {
close() {
this.$destroy()
div.parentNode.removeChild(div)
},
refresh() {
window.location.reload(true)
},
},
})
updateModalInstance.$mount(div)
}

还原 package.json 的修改,新增 vue.config.js 相关代码:

const fs = require('fs')
const { execSync } = require('child_process')
// 获取当前Git仓库的短提交哈希,将其存储在cmtId变量中
const cmtId = execSync('git rev-parse --short HEAD').toString().trim()
// 将cmtId写⼊到public⽬录下的v.txt⽂件中
const versionFile = path.join(process.cwd(), 'public', 'v.txt')
fs.writeFileSync(versionFile, cmtId)
process.env.VUE_APP_SITE_VERSION = cmtId

方案缺陷

  • **使用第三方组件缺乏定制化:**可能无法完全满足定制化需求,或者与应用现有的架构和代码库不兼容。
  • **第三方组件生态问题:**基于较小团队开发,质量不稳定,缺乏完整的文档
  • **无法实时推送更新:**轮询方式可能导致更新提示的延迟,用户可能需要等待一段时间才能收到更新提示,而且并不能保证实时性。
  • **网络请求频繁:**每次轮询都需要进行网络请求来获取最新的commit id,即使 commit id没有发生变化。这会增加网络流量和服务器负担。

方案三 使用 Service Worker拦截页面的网络请求

Service worker 是一项浏览器技术,它可以在浏览器后台运行,拦截和处理网络请求。通过Service Worker,网站可以实现离线缓存、网络请求代理、推送通知等功能,提高用户体验和网站性能。Service worker是一个浏览器中的进程而不是浏览器内核下的线程,因此它在被注册安装之后能够被在多个页面中使用,也不会因为页面的关闭而被销毁。因此,Service Worker 很适合被用与多个页面需要使用的复杂数据的计算——购买一次,全家“收益”。

基于 cli-plugin-pwa 插件的实现思路

引入cli-plugin-pwa

https://github.com/vite-pwa/vite-plugin-pwa

在 src/registerServiceWorker.js 添加事件触发
/* eslint-disable no-console */

import { register } from "register-service-worker";

if (process.env.NODE_ENV === "production" && navigator.serviceWorker) {
register(`${process.env.BASE_URL}service-worker.js`, {
ready() {
console.log(
"App is being served from cache by a service worker.\n" +
"For more details, visit https://goo.gl/AFskqB"
);
},

registered(registration) {
console.log("Service worker has been registered.");
// 通过测试新的服务⼯作线程来定期检查应⽤更新
setInterval(() => {
registration.update();
}, 1000); // 这⾥为了测试 每秒检查⼀次
},
cached() {
console.log("Content has been cached for offline use.");
},
updatefound() {
console.log("New content is downloading.");
},
// 有个更新 通知⻚⾯更新
updated(registration) {
console.log("New content is available; please refresh.");
// 创建⼀个⾃定义事件
const event = new CustomEvent("swupdatefound", { detail: registration });
// 触发这个事件
document.dispatchEvent(event);
},
offline() {
console.log(
"No internet connection found. App is running in offline mode."
);
},
error(error) {
console.error("Error during service worker registration:", error);
},
});

let refreshing;
// 监听需要更新事件 调⽤ window.location.reload()
navigator.serviceWorker.addEventListener("controllerchange", function () {
if (refreshing) return;

window.location.reload();

refreshing = true;
});
}
在页面中添加事件监听

在 main.js 或 store 中添加事件监听

// 添加对应⾃定义事件的监听
document.addEventListener("swupdatefound", (e) => {
// 提⽰⽤⼾刷新
let res = confirm("新内容可⽤,请刷新");
if (res) {
// e.detail == registration
// waiting.postMessage({ type: "SKIP_WAITING" }) 是固定写法
// ⽤于触发更新 navigator.serviceWorker.addEventListener("controllerchange"..
e.detail.waiting.postMessage({
type: "SKIP_WAITING",
});
}
});

优点

  • 离线支持: Service Worker 可以缓存页面资源,使得即使在离线状态下用户仍然可以访问应用,因此即使用户在没有网络连接的情况下打开页面,也能收到更新提示。
  • **即时更新提示:**当检测到新版本可用时,Service Worker可以立即向客户端发送更新提示,而无需等待客户端发起请求,从而能够及时通知用户有新版本可用。

方案缺陷

  • **兼容性问题:**出于对安全问题的考虑,Service Worker只能被使用在 https 或者本地的localhost 环境下。
  • **具有一定的理解和使用难度:**如果不正确地配置缓存策略,可能会导致 Service Worker无法正确地获取最新的资源,从而导致更新提示失败。
  • **资源占用问题:**接入 service worker 需要成本,本地运行一个 worker 也会占用内存和cpu资源。

方案四 基于WebSocket建立长期运行的连接

Websocket是一种协议,用于在 Web 应用程序中创建实时、双向的通信通道。WebSocket可以在浏览器和服务器之间建立一条双向通信的通道,实现服务器主动向浏览器推送消息,而无需浏览器向服务器不断发送请求。其原理是在浏览器和服务器之间建立一个“套接字”,通过“握手”的方式进行数据传输。由于该协议需要浏览器和服务器都支持,因此需要在应用程序中对其进行判断和处理。

1722924701316

实现思路

  1. 建立 WebSocket 连接:在客户端,使用 webSocket API 建立与服务器的 Websocket 连接。当连接建立成功后,客户端将可以接收服务器发送的实时消息。
  2. 服务器推送消息: 当服务器检测到新版本可用时,向与客户端建立的 webSocket 连接发送更新提示消息。消息内容可以包括版本号、更新说明等信息。
  3. 客户端接收消息: 客户端通过监听 webSocket 连接的消息事件,实时接收服务器发送的更新提示消息。
  4. 解析消息并提示用户: 客户端收到更新提示消息后,解析消息内容,并向用户显示更新提示。可以通过弹窗、通知栏等方式向用户提示有新版本可用,并提供刷新页面的选项。
  5. 用户操作:用户收到更新提示后,可以选择立即刷新页面以获取新版本,或者选择稍后刷新。
  6. 刷新页面: 如果用户选择立即刷新页面,客户端通过 JavaScript 脚本触发页面的刷新操作,使页面重新加载并获取最新版本的内容。
  7. 错误处理: 在实现过程中,需要考虑到网络连接中断、消息丢失等异常情况的处理,以确保系统的稳定性和可靠性。

优点

  • 实时性: WebSocket 提供了双向实时通信的能力,可以立即将更新提示推送到客户端,使用户能够及时得知新版本的可用性,提高用户体验。
  • 高效性: webSocket 是基于 TCP 连接的,相比传统的 HTTP 请求,WebSocket 的通信开销更小,消息传输更高效,可以快速地将更新提示发送到客户端。

方案缺陷

  • 需要后端配合
  • **服务器负担:**使用 webSocket进行实时通信会增加服务器的负担,特别是在用户量较大的情况下,可能会导致服务器压力过大。

方案比较&选择

方案一&方案二:方案二较之方案一,轮询加载latest_commit_id.txt比加载 index.html对服务器的压力更小,但都存在无法实时推送更新的问题

方案三:虽然能解决无法及时实时推送更新的问题,但是会造成更大的资源占用,且使用和配置具有一定难度和复杂性,可能会引起其他缓存相关的问题
方案四:需要一个websocket服务,且需要后端配合

考虑到平台的版本更新不会发生的很频繁且可以通过减少轮询周期来优化无法实时推送更新的问题,故综合考虑之下选择方案二