前言
之前我曾介绍过 2种纯前端检测版本更新提示,主要思路是通过轮询检测来实现,但这种方法存在一些缺陷。例如,当用户打开多个页面时,每个页面都会独立启动轮询检测,频繁请求相同的接口,导致资源浪费。此外,当资源更新时,每个页面都会显示更新提示,用户需要逐一点击确认,才能完成页面更新,过程较为繁琐。
为了优化这一流程,我采用了 SharedWorker 来改进版本更新检测机制。通过将轮询检测任务放在 SharedWorker 子线程中执行,多个页面在同一域名下可以共享同一个子线程,从而实现只需一次轮询检测即可覆盖所有页面。这样一来,不仅减少了服务器请求次数,降低了资源消耗,还能在某个页面接收到更新通知后,通过 SharedWorker 进行跨页面通信,通知其他标签页进行更新,提升了用户体验。
1. 基础知识
1. 作用
SharedWorker 是 Web Workers API 的一部分,它允许在多个浏览上下文(如不同的窗口、iframe 或其他 worker)之间共享一个工作线程。
2. 特点
-
长期运行:一旦创建,SharedWorker 会持续运行,直到所有客户端都断开连接或者浏览器关闭。
-
跨上下文共享:SharedWorker 可以由同一个源的多个页面或 iframe 访问,这意味着它们可以用于实现跨窗口通信。
-
消息传递:通过 postMessage 方法发送消息,并通过监听 message 事件接收消息,实现了客户端与 SharedWorker 之间的双向通信。
3. 限制
-
同源限制:只能在同一域名、端口和协议下的页面间共享
-
DOM限制:无法读取主线程所在网页的DOM对象,无法使用document、window这些对象。但是,可以使用navigator对象和location对象
2. 基本使用
1. 新建worker.js
self.onconnect = function(event) {
const port = event.ports[0];
port.onmessage = function(event) {
console.log('Received message:', event.data);
port.postMessage('Hello from the SharedWorker!');
};
};
self.onconnect:监听SharedWorker的连接。在监听事件中会获取到与SharedWorker连接的port,每个页面都会有一个port,通过这个port就可以和主线程进行通信了。
port.onmessage:监听消息
port.postMessage:发送消息
2. 创建SharedWorker实例
新建index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script>
//创建实例
const worker = new SharedWorker("./worker.js", { name: "shared_worker", type: "module" });
//发送消息
worker.port.postMessage("2");
//接收消息
worker.port.onmessage = function (val) {};
</script>
</body>
</html>
name:用于标识 SharedWorker 的名称,不同名称可创建不同实例。如果没有name,那么相同URL会共享一个SharedWorker
type:默认为"classic",可指定为“module”以使用ES6语法。对于不支持ES6的浏览器,可使用默认的classic并通过Babel进行兼容性处理。
使用VS Code的Live Server插件,启动index.html
在SharedWorker中打印的日志并没有出现在控制台,这是因为SharedWorker必须通过chrome://inspect
命令才能看到输出的内容,在Chrome浏览器地址栏输入chrome://inspect
在Shared workers栏点击inspect就能看到控制台内容
3. 多页面通信
SharedWorker线程就一个,但每个tab页都是独立的端口,当SharedWorker接收到一个消息时,可以给每个端口发送消息进行通知
1. 修改worker.js
var clients = [];
onconnect = function (e) {
var port = e.ports[0];
clients.push(port);
let index;
port.onmessage = function (e) {
const { type, data } = e.data;
switch (type) {
case "close":
index = clients.indexOf(port);
clients.splice(index, 1);
break;
case "message":
broadcast(data);
break;
}
};
};
// 给所有页面发送消息
function broadcast(data) {
clients.forEach((port) => {
port.postMessage(data);
});
}
使用clients数组记录连接端口信息,当worker接收到消息时,如果type为message,则给所有端口发送消息。
2. 修改index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<h3>共享线程 Shared Worker</h3>
<button id="likeBtn">点赞</button>
<p>一共收获了<span id="likedCount">0</span>个👍</p>
<script>
let likeBtn = document.querySelector("#likeBtn");
let likedCountEl = document.querySelector("#likedCount");
let worker = new SharedWorker("./worker.js", { name: "shared_worker", type: "module" });
//点击按钮,发送消息
likeBtn.addEventListener("click", function () {
worker.port.postMessage({
type: "message",
data: 1,
});
});
//监听消息
worker.port.onmessage = function (val) {
likedCountEl.innerHTML = +likedCountEl.innerHTML + val.data;
};
window.addEventListener("beforeunload", () => {
//当页面刷新或关闭时,通知worker
worker.port.postMessage({
type: "close",
});
});
</script>
</body>
</html>
Live Server默认端口为5500,同时在Chrome浏览器打开两个http://127.0.0.1:5500/index.html
页面,点击任一页面的按钮,两个页面的数值同时更新
4. 优化检测版本更新
检测版本更新策略:当页面可见时,通过轮询检测Etag值,如果Etag值不同,说明页面有更新;当页面不可见时,关闭轮询。
1. worker.js
在Vite创建的项目根目录中,新建worker.js
worker.js的port.onmessage监听函数中,根据消息类型type来做不同的事件处理。在这里分为四种类型:开始轮询的start;停止轮询的stop;当前页面刷新或关闭时的close;当前页面主动刷新通知其他页面刷新的refresh;
const portList = []; // 存储端口
const visiblePorts = []; //存储页面可见情况
let intervalId = null;
// eslint-disable-next-line no-undef
onconnect = function (e) {
const port = e.ports[0];
port.id = generateUUID();
// 存储端口
portList.push(port);
// 监听port推送
port.onmessage = async function (e) {
// 取数据
const data = e.data || {};
const type = data.type || '';
switch (type) {
case 'start': //开启轮询
//防止重复添加
if (!visiblePorts.find((o) => o === port.id)) {
visiblePorts.push(port.id);
}
if (intervalId !== null) {
clearInterval(intervalId);
}
intervalId = setInterval(() => {
getETag().then((res) => {
broadcast({
type: 'reflectGetEtag',
data: res,
});
});
}, 30000);
break;
case 'stop': //停止轮询
{
const visibleIndex = visiblePorts.indexOf(port.id);
if (visibleIndex > -1) visiblePorts.splice(visibleIndex, 1);
}
//当所有页面不可见时,才停止轮询
if (intervalId !== null && visiblePorts.length === 0) {
clearInterval(intervalId);
intervalId = null;
}
break;
case 'close': //关闭当前端口
{
const index = portList.indexOf(port);
if (index > -1) {
portList.splice(index, 1);
}
}
break;
case 'refresh': //主动刷新,通知其他页面刷新
sendMessage(port, {
type: 'reflectRefresh',
});
break;
default:
broadcast({ type: 'error', message: 'Unknown message type' });
break;
}
};
};
//给除自己外的窗口发送消息
function sendMessage(port, message) {
portList.forEach((o) => {
o.id !== port.id && o.postMessage(message);
});
}
// 给所有窗口发送消息
function broadcast(message) {
portList.forEach((port) => {
port.postMessage(message);
});
}
// 使用函数生成一个UUID
function generateUUID() {
function s4() {
return Math.floor((1 + Math.random()) * 0x10000)
.toString(16)
.substring(1);
}
return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
}
// 获取当前etag
const getETag = async () => {
try {
const response = await fetch(location.origin, {
method: 'HEAD',
cache: 'no-cache',
});
return response.headers.get('etag') || response.headers.get('last-modified');
} catch (error) {
throw new Error(`Fetch failed: ${error.message}`);
}
};
使用自定义函数generateUUID,为每个连接端口生成唯一uuid,用来区分页面连接SharedWorker的端口。
在start中(页面可见),将当前端口加入visiblePorts数组中,由此知道有多少个页面可见。并开启轮询,调用getETag函数,将获取到的Etag发送给主线程。
在stop中(页面不可见或关闭),在visiblePorts去掉当前端口id,并且visiblePorts为空,所有页面不可见时,才停止轮询。
在close中(页面刷新或关闭),在portList数组中,去掉当前端口
在refresh中(当前页面主动更新),通过调用sendMessage函数,通知其他页面更新(不包含自己,因为自身已经刷新了,防止重复刷新)
核心代码就是worker.js内容,接下来分别介绍在React和Vue中的使用
2. React版
为了方便理解,将代码拆成两个hooks,减小单个文件的代码量
1. 新建useCheckUpdateWorker.ts
MessageType 枚举定义了四种消息类型,分别用于控制 SharedWorker 的行为。
ReflectMessageType 枚举定义了 SharedWorker 处理消息后返回的事件类型:1.REFLECT_GET_ETAG:表示已经获取到 ETag。2. REFLECT_REFRESH:表示刷新操作已完成。
workerRef 是一个引用对象,用于存储 SharedWorker 实例,确保在整个组件生命周期内保持引用的一致性。
start、stop、close 和 refresh 是四个控制方法,分别用于发送 START、STOP、CLOSE 和 REFRESH 类型的消息。
在组件初始化时,创建一个新的 SharedWorker
实例,添加 beforeunload 事件监听器,当页面即将卸载时调用 close 方法。在组件卸载时移除 beforeunload 事件监听器。
import { useEffect, useRef, useCallback } from 'react';
//发送消息的类型
enum MessageType {
START = 'start', //开启轮询,检测Etag版本
STOP = 'stop', //停止轮询
CLOSE = 'close', //关闭或刷新页面时,关闭SharedWorker的端口
REFRESH = 'refresh', //主动刷新
}
//SharedWorker接收到MessageType类型事件后,处理后对应的事件返回,以reflect开头
export enum ReflectMessageType {
REFLECT_GET_ETAG = 'reflectGetEtag',
REFLECT_REFRESH = 'reflectRefresh',
}
// 用户消息推送Websocket连接
export default function useSharedWorker(url: string, options: WorkerOptions) {
const workerRef = useRef<SharedWorker>();
const sendMessage = useCallback((type: MessageType, data?: any) => {
workerRef.current?.port.postMessage({
type,
...data,
});
}, []);
const start = useCallback(() => {
sendMessage(MessageType.START);
}, [sendMessage]);
const stop = useCallback(() => {
sendMessage(MessageType.STOP);
}, [sendMessage]);
const close = useCallback(() => {
sendMessage(MessageType.CLOSE);
}, [sendMessage]);
const refresh = useCallback(() => {
sendMessage(MessageType.REFRESH);
}, [sendMessage]);
useEffect(() => {
if (!workerRef.current) {
workerRef.current = new SharedWorker(url, options);
}
window.addEventListener('beforeunload', close);
return () => {
window.removeEventListener('beforeunload', close);
};
}, [close, options, url]);
return { start, stop, refresh, workerRef };
}
2. 新建useVersion.tsx
forbidUpdate:一个引用对象,用于防止多次弹出更新提示。
versionRef:一个引用对象,用于存储当前版本的 ETag。
useCheckUpdateWorker:导入的自定义 Hook,用于管理 SharedWorker 的创建和消息传递。
openNotification:一个回调函数,用于显示更新通知弹窗。当用户点击“确认更新”按钮时,会调用 refresh 方法通知其他标签页刷新,并重新加载当前页面。
handlePageUpdateCheck:一个回调函数,用于根据 ETag 判断是否需要更新。如果当前版本与新版本不同且未禁止更新提示,则显示更新通知。
stopPollingPageUpdate:一个回调函数,用于停止版本更新检测。
startPollingPageUpdate:一个回调函数,用于启动版本更新检测。在开发环境中,默认不进行版本更新提示。
handleVisibilitychange:一个回调函数,用于处理页面可见性变化。当页面变为可见时,启动版本更新检测;当页面不可见时,停止检测。
初始化时启动版本更新检测,并添加 visibilitychange 事件监听器。组件卸载时移除 visibilitychange 事件监听器。
监听 SharedWorker 发送的消息,根据消息类型调用相应的处理函数:1. REFLECT_GET_ETAG:调用 handlePageUpdateCheck 检查版本更新。2. REFLECT_REFRESH:重新加载当前页面。
import { useCallback, useEffect, useRef } from 'react';
import { notification, Button } from 'antd';
import useCheckUpdateWorker, { ReflectMessageType } from './useCheckUpdateWorker';
const useVersion = () => {
const forbidUpdate = useRef(false);
const versionRef = useRef<string>();
const { start, stop, refresh, workerRef } = useCheckUpdateWorker(
new URL('./worker.js', import.meta.url).href,
{
name: 'updateModal',
type: 'module',
},
);
//通知更新弹窗
const openNotification = useCallback(() => {
forbidUpdate.current = true;
const btn = (
<Button
type="primary"
size="small"
onClick={() => {
//通知其他tab页刷新
refresh();
//刷新页面
window.location.reload();
}}
>
确认更新
</Button>
);
notification.open({
message: '版本更新提示',
description: '检测到系统当前版本已更新,请刷新后使用。',
btn,
duration: 0,
onClose: () => (forbidUpdate.current = false),
});
}, [refresh]);
//根据版本判断是否更新
const handlePageUpdateCheck = useCallback(
(etag: string) => {
if (etag) {
const version = versionRef.current;
if (!version) {
versionRef.current = etag;
} else if (version === etag) {
// eslint-disable-next-line no-console
console.log('最新版本');
} else {
// 版本更新,弹出提示,forbidUpdate防止重复弹出
!forbidUpdate.current && openNotification();
}
}
},
[openNotification],
);
//关闭检测
const stopPollingPageUpdate = useCallback(() => {
stop();
}, [stop]);
//开启检测
const startPollingPageUpdate = useCallback(() => {
//开发环境不进行版本更新提示
// if (process.env.NODE_ENV === 'development') return;
stopPollingPageUpdate();
//重新计时
start();
}, [start, stopPollingPageUpdate]);
const handleVisibilitychange = useCallback(() => {
if (document.visibilityState === 'visible') {
startPollingPageUpdate();
} else {
stopPollingPageUpdate();
}
}, [startPollingPageUpdate, stopPollingPageUpdate]);
useEffect(() => {
//初始化时,不会触发visibilitychange事件,先主动开启轮询检测
startPollingPageUpdate();
document.addEventListener('visibilitychange', handleVisibilitychange);
return () => {
document.removeEventListener('visibilitychange', handleVisibilitychange);
};
}, [handleVisibilitychange, startPollingPageUpdate]);
useEffect(() => {
if (workerRef.current) {
workerRef.current.port.onmessage = (e) => {
const data = e.data || {};
switch (data.type) {
case ReflectMessageType.REFLECT_GET_ETAG:
handlePageUpdateCheck(data.data);
break;
case ReflectMessageType.REFLECT_REFRESH:
//其他tab页面手动更新,同步更新
window.location.reload();
break;
default:
break;
}
};
}
}, [handlePageUpdateCheck, workerRef]);
};
export default useVersion;
之后在全局调用useVersion这个hook
3. Vue版
1. 新建useCheckUpdateWorker.ts
MessageType 枚举定义了四种消息类型,分别用于控制 SharedWorker 的行为。
ReflectMessageType 枚举定义了 SharedWorker 处理消息后返回的事件类型:1.REFLECT_GET_ETAG:表示已经获取到 ETag。2. REFLECT_REFRESH:表示刷新操作已完成。
onMounted 钩子用于管理 SharedWorker 的初始化:如果 workerRef.value 为空,则创建一个新的 SharedWorker 实例。添加 beforeunload 事件监听器,当页面即将卸载时调用 close 方法。 onBeforeUnmount 钩子用于在组件卸载前移除 beforeunload 事件监听器。
import { onBeforeUnmount, onMounted, ref } from 'vue';
//发送消息的类型
enum MessageType {
START = 'start', //开启轮询,检测Etag版本
STOP = 'stop', //停止轮询
CLOSE = 'close', //关闭或刷新页面时,关闭SharedWorker的端口
REFRESH = 'refresh', //主动刷新
}
//SharedWorker接收到MessageType类型事件后,处理后对应的事件返回,以reflect开头
export enum ReflectMessageType {
REFLECT_GET_ETAG = 'reflectGetEtag',
REFLECT_REFRESH = 'reflectRefresh',
}
// 用户消息推送Websocket连接
export default function useSharedWorker(url: string, options: WorkerOptions) {
const workerRef = ref<SharedWorker>();
const sendMessage = (type: MessageType, data?: any) => {
workerRef.value?.port.postMessage({
type,
...data,
});
};
const start = () => {
sendMessage(MessageType.START);
};
const stop = () => {
sendMessage(MessageType.STOP);
};
const close = () => {
sendMessage(MessageType.CLOSE);
};
const refresh = () => {
sendMessage(MessageType.REFRESH);
};
onMounted(() => {
if (!workerRef.value) {
workerRef.value = new SharedWorker(url, options);
}
window.addEventListener('beforeunload', close);
});
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', close);
});
return { start, stop, refresh, workerRef };
}
2. 新建useVersion.ts
forbidUpdate:一个响应式引用对象,用于防止多次弹出更新提示。
versionRef:一个响应式引用对象,用于存储当前版本的 ETag。
useCheckUpdateWorker:导入的自定义 Hook,用于管理 SharedWorker 的创建和消息传递。
openNotification:一个函数,用于显示更新通知弹窗。当用户关闭通知时,会调用 refresh 方法通知其他标签页刷新,并重新加载当前页面。
handlePageUpdateCheck:一个函数,用于根据 ETag 判断是否需要更新。如果当前版本与新版本不同且未禁止更新提示,则显示更新通知。
startPollingPageUpdate:一个函数,用于启动版本更新检测。在开发环境中,默认不进行版本更新提示。
stopPollingPageUpdate:一个函数,用于停止版本更新检测。
handleVisibilitychange:一个函数,用于处理页面可见性变化。当页面变为可见时,启动版本更新检测;当页面不可见时,停止检测。
onMounted:在组件挂载时,启动版本更新检测,并添加 visibilitychange 事件监听器。同时,监听 SharedWorker 发送的消息,根据消息类型调用相应的处理函数。
onBeforeUnmount:在组件卸载前,移除 visibilitychange 事件监听器。
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { ElNotification } from 'element-plus';
import useCheckUpdateWorker, { ReflectMessageType } from './useCheckUpdateWorker';
const useCheckUpdate = () => {
const forbidUpdate = ref(false); //防止弹出多个框
const versionRef = ref<string>();
const { start, stop, refresh, workerRef } = useCheckUpdateWorker(
new URL('./worker.js', import.meta.url).href,
{
name: 'updateModal',
type: 'module',
},
);
const openNotification = () => {
forbidUpdate.value = true;
//强制更新
ElNotification({
title: '版本更新提示',
message: '检测到系统当前版本已更新,请刷新后使用。',
duration: 0,
onClose: () => {
//通知其他tab页刷新
refresh();
//刷新页面
window.location.reload();
},
});
};
const handlePageUpdateCheck = (etag: string) => {
if (etag) {
const version = versionRef.value;
if (!version) {
versionRef.value = etag;
} else if (version === etag) {
// eslint-disable-next-line no-console
console.log('最新版本');
} else {
// 版本更新,弹出提示,forbidUpdate防止重复弹出
!forbidUpdate.value && openNotification();
}
}
};
//开启检测
const startPollingPageUpdate = () => {
//开发环境不进行版本更新提示
// if (process.env.NODE_ENV === 'development') return;
stopPollingPageUpdate();
start();
};
const stopPollingPageUpdate = () => {
stop();
};
const handleVisibilitychange = () => {
if (document.visibilityState === 'visible') {
startPollingPageUpdate();
} else {
stopPollingPageUpdate();
}
};
onMounted(() => {
//初始化时,不会触发visibilitychange事件,先主动开启轮询检测
startPollingPageUpdate();
document.addEventListener('visibilitychange', handleVisibilitychange);
if (workerRef.value) {
//监听worker事件
workerRef.value.port.onmessage = (e) => {
const data = e.data || {};
switch (data.type) {
case ReflectMessageType.REFLECT_GET_ETAG:
//forbidUpdate防止重复弹出
handlePageUpdateCheck(data.data);
break;
case ReflectMessageType.REFLECT_REFRESH:
//其他tab页面手动更新,同步更新
window.location.reload();
break;
default:
break;
}
};
}
});
onBeforeUnmount(() => {
document.removeEventListener('visibilitychange', handleVisibilitychange);
});
};
export default useCheckUpdate;
结尾
文章中只是介绍了大概的实现方式与思路,还有些细节可根据自己的实际情况实现。例如在开发环境下,不要弹出版本更新提示弹窗等功能。
如果有其他更好的方式实现版本更新提示,可以在评论区留言,大家积极探讨。
最后,创造不易,欢迎大家点赞支持!!!