背景
- 老板说, 项目中要进行前端触发任务同步,完成后进行实时通知
- 实时查看同步过程中的日志
技术选型
- 轮询(淘汰,性能要求)
- ws(websocket)(淘汰,过于强大,业务中只需要服务器主动推送,不需要互相推送)
- sse(符合)
技术栈
- 后端: java(SpringBoot + SseEmitter)
- 前端: vue3 + sse
问题
- 刷新页面
sse失效
- 方案: 在刷新页面后,重新请求sse 建立新的连接
- 打开一个新标签页,上一个标签页面
sse失效
- 方案一(项目中使用): 使用浏览器API
BroadcastChannel
// sse.js
import { reactive } from 'vue';
import { defineStore } from 'pinia';
// import { usePermissStore } from '@/store/permiss';
import { BASE_URL } from '@/utils/constant';
import { notify } from '@/utils/notification';
let sourceSync = null,
isMainTab = false,
mainTabClientId = null,
mainTabTimerId = null,
clientId = null;
const totalLogs = reactive({});
const syncStatus = reactive({});
const channel = new BroadcastChannel('sse-channel');
// const testRandom = ' === ' + randomRange(100, 1);
function sseClosedDelayTime() {
const sseDelay = 'SSE_Closed_Delay_Time';
const random = localStorage.getItem(sseDelay);
let r = 110;
if (random) {
r = Number(random) + 110;
if (r > 1500) {
r = 110;
}
}
localStorage.setItem(sseDelay, String(r));
return r;
}
function randomRange(max, min) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function createId() {
// const userStore = usePermissStore();
const data = localStorage.getItem('user');
if (data) {
const user = JSON.parse(data);
clientId = user.clientId;
}
}
const createSse = () => {
if ('EventSource' in window && !sourceSync) {
sourceSync = new EventSource(`${BASE_URL}AssessmentHospital/metricsInfos/examination-sse/${clientId}`, {
withCredentials: true,
});
sourceSync.addEventListener(
'sync',
function (event) {
const data = JSON.parse(event.data);
// console.log(data, 'sse sync');
channel.postMessage(formatSendMsg('sync', data));
syncHandler(data);
},
false,
);
sourceSync.addEventListener(
'log',
function (event) {
const data = JSON.parse(event.data);
// console.log(data, 'sse log');
// let message = data.message + '===channel' + testRandom;
// channel.postMessage(formatSendMsg('log', { ...data, message }));
// message = data.message + '===sse' + testRandom;
// logsHandler({ ...data, message });
channel.postMessage(formatSendMsg('log', data));
logsHandler(data);
},
false,
);
sourceSync.addEventListener(
'heartBeat',
function (event) {
const data = JSON.parse(event.data);
// console.log(data, 'sse headerbeat');
channel.postMessage(formatSendMsg('heartBeat', data));
},
false,
);
}
};
function syncHandler(data) {
const flag = data.status;
const msg = data.message;
const id = data.metricsId;
if (flag === 'ok' || flag === 'error') {
notify(msg);
syncStatus[id] = 'end';
}
}
function logsHandler(data) {
if (data.status === 'ok') {
if (!totalLogs[data.metricsId]) {
totalLogs[data.metricsId] = [];
}
const temp = {
id: 'log==' + totalLogs[data.metricsId].length,
text: data.message,
};
totalLogs[data.metricsId].push(temp);
}
}
function checkForMainTab() {
channel.postMessage(formatSendMsg('main-tab-check'));
mainTabTimerId = setTimeout(() => {
if (!isMainTab) {
becomeMainTab();
}
}, 100);
}
function becomeMainTab() {
console.log('become MainTab');
createSse();
isMainTab = true;
}
// 监听来自其他标签页的消息
channel.addEventListener('message', function (event) {
// 处理SSE消息
const type = event.data.type;
const data = event.data.data;
if (type === 'main-tab-check') {
if (isMainTab) {
console.log('isMainTab 999');
channel.postMessage(formatSendMsg('main-tab-response'));
}
return;
}
if (type === 'main-tab-response') {
console.log('has mainTab 888');
mainTabClientId = event.data.clientId;
if (mainTabClientId === clientId) {
clearTimeout(mainTabTimerId);
}
return;
}
if (type === 'main-tab-closed') {
const rt = randomRange(50, 10) * randomRange(25, 11);
setTimeout(() => {
const delay = sseClosedDelayTime();
console.log(rt, delay, 'rt');
setTimeout(checkForMainTab, delay);
}, rt);
}
if (type === 'sync') {
syncHandler(data);
} else if (type === 'log') {
// console.log(data, 'channel log');
logsHandler(data);
} else if (type === 'heartBeat') {
console.log(data, 'channel heartBeat');
}
});
function formatSendMsg(type, data = {}) {
const temp = {
type,
clientId,
data,
};
return temp;
}
// 在页面卸载时关闭EventSource
window.addEventListener('beforeunload', () => {
if (sourceSync) {
sourceSync.close();
}
if (isMainTab) {
channel.postMessage(formatSendMsg('main-tab-closed'));
channel.close();
}
});
export const useSseStore = defineStore('sse', () => {
return { totalLogs, syncStatus, sourceSync };
});
const init = () => {
// 检查是否已经存在活跃的SSE连接
createId();
checkForMainTab();
};
init();
-
方案二: 使用
SharedWorker(未验证) -
这个实现包含两个主要部分:
A. shared-worker.js:这是SharedWorker的脚本,它负责:
- 创建和维护SSE连接
- 管理连接到它的所有页面(ports)
- 将接收到的SSE消息广播给所有连接的页面
B. main.js:这是你在每个网页中使用的脚本,它负责:
- 创建SharedWorker的实例
- 处理从SharedWorker接收到的消息
使用这种方法,所有打开的标签页将共享同一个SSE连接,避免了连接数量限制问题。
要使用这个解决方案,你需要:
a. 将shared-worker.js放在你的服务器上,确保它可以被浏览器访问到。
b. 在你的网页中引入并使用main.js中的代码
// shared-worker.js 文件
let sseConnection = null;
let ports = [];
function createSSEConnection() {
sseConnection = new EventSource('/events');
sseConnection.onmessage = function(event) {
// Broadcast the message to all connected ports
ports.forEach(port => port.postMessage(event.data));
};
sseConnection.onerror = function(error) {
console.error('SSE connection error:', error);
// Implement reconnection logic here if needed
};
}
self.onconnect = function(e) {
const port = e.ports[0];
ports.push(port);
port.onmessage = function(e) {
if (e.data === 'connect' && !sseConnection) {
createSSEConnection();
}
};
port.start();
// If this is the first connection, create the SSE connection
if (ports.length === 1) {
createSSEConnection();
}
};
// main.js 文件
if (typeof SharedWorker !== 'undefined') {
const worker = new SharedWorker('shared-worker.js');
worker.port.onmessage = function(e) {
console.log('Received message:', e.data);
// Handle the SSE message here
};
worker.port.start();
worker.port.postMessage('connect');
} else {
console.error('SharedWorker is not supported in this browser');
// Fallback to regular SSE connection
}
- 主标签关闭后,选一个后续标签担任主标签的角色
- 方案: 广播随机延时,延时时间大于 110 ms
-
同一个用户,不同浏览器登录,同步同一个任务,
sse会被覆盖 -
使用sse日志,刷新浏览器后,之前叠加的日志,丢失
- 方案: 再单独开一个get接口,返回全部日志,每次打开日志窗口调用get接口,再叠加sse日志,进行展示