1. 场景
前端构建完上线,用户还停留还在老页面,用户不知道网页重新部署了,跳转页面的时候有时候js连接hash变了导致报错跳不过去,并且用户体验不到新功能。
前端构建完上线,用户还停留还在老页面,用户不知道网页重新部署了,跳转页面的时候有时候js连接hash变了导致报错跳不过去,并且用户体验不到新功能。
2. 解决方案
- 每次打包写入一个json文件,或者对比生成的
script的src引入的hash地址或者etag
不同,轮询调用,判断是否更新 - 前端使用websocket长连接,具体是每次构建,打包后通知后端,更新后通过websocket通知前端
轮询调用可以改成在前置路由守卫中调用,无需控制时间,用户有操作才去调用判断。
- 每次打包写入一个json文件,或者对比生成的
script的src引入的hash地址或者etag
不同,轮询调用,判断是否更新 - 前端使用websocket长连接,具体是每次构建,打包后通知后端,更新后通过websocket通知前端
轮询调用可以改成在前置路由守卫中调用,无需控制时间,用户有操作才去调用判断。
3. 具体实现
3.1 轮询方式
参考小满的实现稍微修改下:
class Monitor {
private oldScript: string[] = []
private newScript: string[] = []
private oldEtag: string | null = null
private newEtag: string | null = null
dispatch: Record() => void)[]> = {}
private stop = false
constructor() {
this.init()
}
async init() {
console.log('初始化')
const html: string = await this.getHtml()
this.oldScript = this.parserScript(html)
this.oldEtag = await this.getEtag()
}
// 获取html
async getHtml() {
const html = await fetch('/').then((res) => res.text())
return html
}
// 获取etag是否变化
async getEtag() {
const res = await fetch('/')
return res.headers.get('etag')
}
// 解析script标签
parserScript(html: string) {
const reg = /]*)?>(.*?)<\/script\s*>/gi
return html.match(reg) as string[]
}
// 订阅
on(key: 'update', fn: () => void) {
;(this.dispatch[key] || (this.dispatch[key] = [])).push(fn)
return this
}
// 停止
pause() {
this.stop = !this.stop
}
get value() {
return {
oldEtag: this.oldEtag,
newEtag: this.newEtag,
oldScript: this.oldScript,
newScript: this.newScript,
}
}
// 两层对比有任一个变化即可
compare() {
if (this.stop) return
const oldLen = this.oldScript.length
const newLen = Array.from(
new Set(this.oldScript.concat(this.newScript))
).length
if (this.oldEtag !== this.newEtag || newLen !== oldLen) {
this.dispatch.update.forEach((fn) => {
fn()
})
}
}
// 检查更新
async check() {
const newHtml = await this.getHtml()
this.newScript = this.parserScript(newHtml)
this.newEtag = await this.getEtag()
this.compare()
}
}
export const monitor = new Monitor()
// 路由前置守卫中调用
import { monitor } from './monitor'
monitor.on('update', () => {
console.log('更新数据', monitor.value)
Modal.confirm({
title: '更新提示',
icon: createVNode(ExclamationCircleOutlined),
content: '版本有更新,是否刷新页面!',
okText: '刷新',
cancelText: '不刷新',
onOk() {
// 更新操作
location.reload()
},
onCancel() {
monitor.pause()
},
})
})
router.beforeEach((to, from, next) => {
monitor.check()
})
参考小满的实现稍微修改下:
class Monitor {
private oldScript: string[] = []
private newScript: string[] = []
private oldEtag: string | null = null
private newEtag: string | null = null
dispatch: Record() => void)[]> = {}
private stop = false
constructor() {
this.init()
}
async init() {
console.log('初始化')
const html: string = await this.getHtml()
this.oldScript = this.parserScript(html)
this.oldEtag = await this.getEtag()
}
// 获取html
async getHtml() {
const html = await fetch('/').then((res) => res.text())
return html
}
// 获取etag是否变化
async getEtag() {
const res = await fetch('/')
return res.headers.get('etag')
}
// 解析script标签
parserScript(html: string) {
const reg = /]*)?>(.*?)<\/script\s*>/gi
return html.match(reg) as string[]
}
// 订阅
on(key: 'update', fn: () => void) {
;(this.dispatch[key] || (this.dispatch[key] = [])).push(fn)
return this
}
// 停止
pause() {
this.stop = !this.stop
}
get value() {
return {
oldEtag: this.oldEtag,
newEtag: this.newEtag,
oldScript: this.oldScript,
newScript: this.newScript,
}
}
// 两层对比有任一个变化即可
compare() {
if (this.stop) return
const oldLen = this.oldScript.length
const newLen = Array.from(
new Set(this.oldScript.concat(this.newScript))
).length
if (this.oldEtag !== this.newEtag || newLen !== oldLen) {
this.dispatch.update.forEach((fn) => {
fn()
})
}
}
// 检查更新
async check() {
const newHtml = await this.getHtml()
this.newScript = this.parserScript(newHtml)
this.newEtag = await this.getEtag()
this.compare()
}
}
export const monitor = new Monitor()
// 路由前置守卫中调用
import { monitor } from './monitor'
monitor.on('update', () => {
console.log('更新数据', monitor.value)
Modal.confirm({
title: '更新提示',
icon: createVNode(ExclamationCircleOutlined),
content: '版本有更新,是否刷新页面!',
okText: '刷新',
cancelText: '不刷新',
onOk() {
// 更新操作
location.reload()
},
onCancel() {
monitor.pause()
},
})
})
router.beforeEach((to, from, next) => {
monitor.check()
})
3.2 websocket方式
既然后端不好沟通,那就自己实现一个完整版。
具体流程如下:
既然后端不好沟通,那就自己实现一个完整版。
具体流程如下:
3.2.1 代码实现
服务端使用koa实现:
// 引入依赖 koa koa-router koa-websocket short-uuid koa2-cors
const Koa = require('koa')
const Router = require('koa-router')
const websockify = require('koa-websocket')
const short = require('short-uuid')
const cors = require('koa2-cors')
const app = new Koa()
// 使用koa2-cors中间件解决跨域
app.use(cors())
const router = new Router()
// 使用 koa-websocket 将应用程序升级为 WebSocket 应用程序
const appWebSocket = websockify(app)
// 存储所有连接的客户端进行去重处理
const clients = new Set()
// 处理 WebSocket 连接
appWebSocket.ws.use((ctx, next) => {
// 存储新连接的客户端
clients.add(ctx.websocket)
// 处理连接关闭事件
ctx.websocket.on('close', () => {
clients.delete(ctx.websocket)
})
ctx.websocket.on('message', (data) => {
ctx.websocket.send(666)//JSON.stringify(data)
})
ctx.websocket.on('error', (err) => {
clients.delete(ctx.websocket)
})
return next(ctx)
})
// 处理外部通知页面更新的接口
router.get('/api/webhook1', (ctx) => {
// 向所有连接的客户端发送消息,使用uuid确保不重复
clients.forEach((client) => {
client.send(short.generate())
})
ctx.body = 'Message pushed successfully!'
})
// 将路由注册到应用程序
appWebSocket.use(router.routes()).use(router.allowedMethods())
// 启动服务器
appWebSocket.listen(3000, () => {
console.log('Server started on port 3000')
})
前端页面代码:
websocket使用vueuse封装的,保持个心跳。
import { useWebSocket } from '@vueuse/core'
const { open, data } = useWebSocket('ws://hzsunrise.top/ws', {
heartbeat: {
message: 'ping',
interval: 5000,
pongTimeout: 10000,
},
immediate: true, // 自动连接
autoReconnect: {
retries: 6,
delay: 3000,
},
})
watch(data, (val) => {
if (val.length !== '3HkcPQUEdTpV6z735wxTum'.length) return
Modal.confirm({
title: '更新提示',
icon: createVNode(ExclamationCircleOutlined),
content: '版本有更新,是否刷新页面!',
okText: '刷新',
cancelText: '不刷新',
onOk() {
// 更新操作
location.reload()
},
onCancel() {},
})
})
// 建立连接
onMounted(() => {
open()
})
// 断开链接
onUnmounted(() => {
close()
})
服务端使用koa实现:
// 引入依赖 koa koa-router koa-websocket short-uuid koa2-cors
const Koa = require('koa')
const Router = require('koa-router')
const websockify = require('koa-websocket')
const short = require('short-uuid')
const cors = require('koa2-cors')
const app = new Koa()
// 使用koa2-cors中间件解决跨域
app.use(cors())
const router = new Router()
// 使用 koa-websocket 将应用程序升级为 WebSocket 应用程序
const appWebSocket = websockify(app)
// 存储所有连接的客户端进行去重处理
const clients = new Set()
// 处理 WebSocket 连接
appWebSocket.ws.use((ctx, next) => {
// 存储新连接的客户端
clients.add(ctx.websocket)
// 处理连接关闭事件
ctx.websocket.on('close', () => {
clients.delete(ctx.websocket)
})
ctx.websocket.on('message', (data) => {
ctx.websocket.send(666)//JSON.stringify(data)
})
ctx.websocket.on('error', (err) => {
clients.delete(ctx.websocket)
})
return next(ctx)
})
// 处理外部通知页面更新的接口
router.get('/api/webhook1', (ctx) => {
// 向所有连接的客户端发送消息,使用uuid确保不重复
clients.forEach((client) => {
client.send(short.generate())
})
ctx.body = 'Message pushed successfully!'
})
// 将路由注册到应用程序
appWebSocket.use(router.routes()).use(router.allowedMethods())
// 启动服务器
appWebSocket.listen(3000, () => {
console.log('Server started on port 3000')
})
前端页面代码:
websocket使用vueuse封装的,保持个心跳。
import { useWebSocket } from '@vueuse/core'
const { open, data } = useWebSocket('ws://hzsunrise.top/ws', {
heartbeat: {
message: 'ping',
interval: 5000,
pongTimeout: 10000,
},
immediate: true, // 自动连接
autoReconnect: {
retries: 6,
delay: 3000,
},
})
watch(data, (val) => {
if (val.length !== '3HkcPQUEdTpV6z735wxTum'.length) return
Modal.confirm({
title: '更新提示',
icon: createVNode(ExclamationCircleOutlined),
content: '版本有更新,是否刷新页面!',
okText: '刷新',
cancelText: '不刷新',
onOk() {
// 更新操作
location.reload()
},
onCancel() {},
})
})
// 建立连接
onMounted(() => {
open()
})
// 断开链接
onUnmounted(() => {
close()
})
3.2.2 发布部署
后端部署:
考虑服务器上没有安装node环境,直接使用docker进行部署,使用pm2运行node程序。
- 写一个DockerFile,发布镜像
// Dockerfile:
# 使用 Node.js 作为基础镜像
FROM node:14-alpine
# 设置工作目录
WORKDIR /app
# 复制 package.json 和 package-lock.json 到容器中
COPY package.json ./
# 安装项目依赖
RUN npm install
RUN npm install -g pm2
# 复制所有源代码到容器中
COPY . .
# 暴露端口号
EXPOSE 3000
# 启动应用程序
CMD ["pm2-runtime","app.js"]
本地进行打包镜像发送到docker hub,使用docker build -t f5l5y5/websocket-server-image:v0.0.1 .
命令生成镜像文件,使用docker push f5l5y5/websocket-server-image:v0.0.1
推送到自己的远程仓库
- 服务器拉取镜像,运行
后端部署:
考虑服务器上没有安装node环境,直接使用docker进行部署,使用pm2运行node程序。
- 写一个DockerFile,发布镜像
// Dockerfile:
# 使用 Node.js 作为基础镜像
FROM node:14-alpine
# 设置工作目录
WORKDIR /app
# 复制 package.json 和 package-lock.json 到容器中
COPY package.json ./
# 安装项目依赖
RUN npm install
RUN npm install -g pm2
# 复制所有源代码到容器中
COPY . .
# 暴露端口号
EXPOSE 3000
# 启动应用程序
CMD ["pm2-runtime","app.js"]
本地进行打包镜像发送到docker hub,使用docker build -t f5l5y5/websocket-server-image:v0.0.1 .
命令生成镜像文件,使用docker push f5l5y5/websocket-server-image:v0.0.1
推送到自己的远程仓库
- 服务器拉取镜像,运行
拉取镜像:docker pull f5l5y5/websocket-server-image:v0.0.1
运行镜像: docker run -d -p 3000:3000 --name websocket-server f5l5y5/websocket-server-image:v0.0.1
可进入容器内部查看:docker exec -it
查看容器运行情况:
、
进入容器内部查看程序运行情况,pm2常用命令
此时访问/api/webhook1
会找到项目的对应路由下,需要配置下nginx代理转发
- 配置nginx接口转发
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
server_name hzsunrise.top;
client_max_body_size 50M;
location / {
root /usr/local/openresty/nginx/html/xxx-admin;
try_files $uri $uri/ /index.html;
}
// 将触发的更新代理到容器的3000
location /api/webhook1 {
proxy_pass http://localhost:3000/api/webhook1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
// websocket 配置
location /ws {
# 反向代理到容器中的WebSocket接口
proxy_pass http://localhost:3000;
# 支持WebSocket协议
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
}
}
3.2.3 测试
url请求api/webhook即可
4. 总结
主要实践下两种方案:
- 轮询调用方案:轮询获取网页引入的脚本文件的hash值或者etag来实现。这种方案的优点是实现简单,但存在性能消耗和延迟较高的问题。
- WebSocket版本方案:在前端部署的同时建立一个WebSocket连接,将后端构建部署完成的通知发送给前端。当后端完成部署后,通过WebSocket向前端发送消息,提示用户刷新页面以加载最新版本。这种方案的优点是实时性好,用户体验较好,但需要在前端和后端都进行相应的配置和代码开发。
选择合适的方案取决于具体的需求和实际情况,仅供参考O^O!
参考文章
来源:juejin.cn/post/7264396960558399549