refactor: ♻️ 存入localStorage移除前缀,websocket hook重构优化

This commit is contained in:
ray
2025-02-12 23:38:22 +08:00
parent 94c815eb58
commit 24057c3314
11 changed files with 247 additions and 190 deletions

View File

@@ -9,7 +9,7 @@ VITE_APP_API_URL=https://api.youlai.tech # 线上
# VITE_APP_API_URL=http://localhost:8989 # 本地
# WebSocket 端点(不配置则关闭),线上 ws://api.youlai.tech/ws ,本地 ws://localhost:8989/ws
VITE_APP_WS_ENDPOINT=
VITE_APP_WS_ENDPOINT=ws://localhost:8989/ws
# 启用 Mock 服务
VITE_MOCK_DEV_SERVER=false

151
src/hooks/useStomp.ts Normal file
View File

@@ -0,0 +1,151 @@
import { ref, watch, onMounted } from "vue";
import { Client, IMessage, StompSubscription } from "@stomp/stompjs";
import { getAccessToken } from "@/utils/auth";
export interface UseStompOptions {
/** WebSocket 地址,不传时使用 VITE_APP_WS_ENDPOINT 环境变量 */
brokerURL?: string;
/** 用于鉴权的 token不传时使用 getToken() 的返回值 */
token?: string;
/** 重连延迟,单位毫秒,默认为 5000 */
reconnectDelay?: number;
/** 是否开启调试日志 */
debug?: boolean;
}
export function useStomp(options: UseStompOptions = {}) {
// 默认值brokerURL 从环境变量中获取token 从 getAccessToken() 获取
const defaultBrokerURL = import.meta.env.VITE_APP_WS_ENDPOINT || "";
const defaultToken = getAccessToken();
// 将 brokerURL 定义为响应式 ref便于动态修改
const brokerURL = ref(options.brokerURL ?? defaultBrokerURL);
const token = options.token ?? defaultToken;
// 连接状态标记
const isConnected = ref(false);
// 存储所有订阅
const subscriptions = new Map<string, StompSubscription>();
// 用于保存 STOMP 客户端的实例
let client: Client | null = null;
/**
* 初始化 STOMP 客户端
* 只有在 brokerURL 非空时才会初始化客户端
*/
const initializeClient = () => {
if (!brokerURL.value) {
console.warn(
"brokerURL is required. Please set the WebSocket URL in your .env file (VITE_APP_WS_ENDPOINT)."
);
return;
}
if (!client) {
client = new Client({
brokerURL: brokerURL.value,
reconnectDelay: options.reconnectDelay ?? 5000,
debug: options.debug ? (msg) => console.log("[STOMP]", msg) : () => {},
connectHeaders: {
Authorization: `Bearer ${token}`,
},
heartbeatIncoming: 4000,
heartbeatOutgoing: 4000,
});
client.onConnect = (frame) => {
isConnected.value = true;
console.log("STOMP connected", frame);
};
client.onStompError = (frame) => {
console.error("Broker reported error: " + frame.headers["message"]);
console.error("Additional details: " + frame.body);
};
client.onWebSocketClose = (evt) => {
isConnected.value = false;
console.warn("WebSocket closed", evt);
};
}
};
// 监听 brokerURL 的变化,若地址改变则重新初始化
watch(brokerURL, (newURL, oldURL) => {
if (newURL !== oldURL) {
console.log(`brokerURL changed from ${oldURL} to ${newURL}`);
// 断开当前连接,重新激活客户端
if (client && client.connected) {
client.deactivate();
}
brokerURL.value = newURL;
initializeClient(); // 重新初始化客户端
}
});
// 在组件挂载时检查并初始化客户端
onMounted(() => {
initializeClient();
});
/**
* 激活连接(如果已经连接或正在激活则直接返回)
*/
const connect = () => {
if (client && (client.connected || client.active)) {
console.log("Already connected or connecting, skipping connect() call.");
return;
}
client?.activate();
};
/**
* 订阅指定主题
* @param destination 目标主题地址
* @param callback 接收到消息时的回调函数
* @returns 返回订阅 id用于后续取消订阅
*/
const subscribe = (destination: string, callback: (message: IMessage) => void): string => {
if (client) {
const subscription = client.subscribe(destination, callback);
subscriptions.set(subscription.id, subscription);
return subscription.id;
}
return "";
};
/**
* 取消指定订阅
* @param subscriptionId 要取消的订阅 id
*/
const unsubscribe = (subscriptionId: string) => {
const subscription = subscriptions.get(subscriptionId);
if (subscription) {
subscription.unsubscribe();
subscriptions.delete(subscriptionId);
}
};
/**
* 主动断开连接(如果未连接则不执行)
*/
const disconnect = () => {
if (client && !(client.connected || client.active)) {
console.log("Already disconnected, skipping disconnect() call.");
return;
}
client?.deactivate();
isConnected.value = false;
};
return {
client,
isConnected,
connect,
subscribe,
unsubscribe,
disconnect,
brokerURL, // 暴露 brokerURL 以便组件中动态修改
};
}

View File

@@ -167,8 +167,8 @@
<script setup lang="ts">
import NoticeAPI, { NoticePageVO } from "@/api/system/notice";
import WebSocketManager from "@/utils/websocket";
import router from "@/router";
import { useStomp } from "@/hooks/useStomp";
const activeTab = ref("notice");
const notices = ref<NoticePageVO[]>([]);
@@ -176,15 +176,23 @@ const messages = ref<any[]>([]);
const tasks = ref<any[]>([]);
const noticeDetailRef = ref();
// 初始化 useStomp hook这里仅用于订阅通知消息同时调用 connect 建立连接
const { connect, subscribe, disconnect } = useStomp({
debug: true,
});
// 获取未读消息列表并连接 WebSocket
onMounted(() => {
NoticeAPI.getMyNoticePage({ pageNum: 1, pageSize: 5, isRead: 0 }).then((data) => {
notices.value = data.list;
});
WebSocketManager.subscribeToTopic("/user/queue/message", (message) => {
// 建立连接
connect();
subscribe("/user/queue/message", (message) => {
console.log("收到消息:", message);
const data = JSON.parse(message);
const data = JSON.parse(message.body);
const id = data.id;
if (!notices.value.some((notice) => notice.id == id)) {
notices.value.unshift({
@@ -224,6 +232,11 @@ function markAllAsRead() {
notices.value = [];
});
}
onBeforeUnmount(() => {
// 如果需要取消订阅,可以在这里调用 disconnect 或 unsubscribe本示例直接断开连接
disconnect();
});
</script>
<style lang="scss" scoped>

View File

@@ -1,7 +1,7 @@
<template>
<el-dropdown trigger="click">
<div class="flex-center h100% p13px">
<img :src="userStore.userInfo.avatar" class="rounded-full mr-10px w24px h24px" />
<img :src="userStore.userInfo.avatar" class="rounded-full mr10px w24px h24px" />
<span>{{ userStore.userInfo.username }}</span>
</div>
<template #dropdown>

View File

@@ -6,7 +6,6 @@ import { setupRouter } from "@/router";
import { setupStore } from "@/store";
import { setupElIcons } from "./icons";
import { setupPermission } from "./permission";
import webSocketManager from "@/utils/websocket";
import { InstallCodeMirror } from "codemirror-editor-vue3";
export default {
@@ -23,8 +22,6 @@ export default {
setupElIcons(app);
// 路由守卫
setupPermission();
// 初始化 WebSocket
webSocketManager.setupWebSocket();
// 注册 CodeMirror
app.use(InstallCodeMirror);
},

View File

@@ -1,6 +1,6 @@
import type { NavigationGuardNext, RouteLocationNormalized, RouteRecordRaw } from "vue-router";
import NProgress from "@/utils/nprogress";
import { getToken } from "@/utils/auth";
import { getAccessToken } from "@/utils/auth";
import router from "@/router";
import { usePermissionStore, useUserStore } from "@/store";
@@ -11,7 +11,7 @@ export function setupPermission() {
router.beforeEach(async (to, from, next) => {
NProgress.start();
const isLogin = !!getToken(); // 判断是否登录
const isLogin = !!getAccessToken(); // 判断是否登录
if (isLogin) {
if (to.path === "/login") {
// 已登录,访问登录页,跳转到首页

View File

@@ -5,7 +5,7 @@ import { useDictStoreHook } from "@/store/modules/dict";
import AuthAPI, { type LoginFormData } from "@/api/auth";
import UserAPI, { type UserInfo } from "@/api/system/user";
import { setToken, setRefreshToken, getRefreshToken, clearToken } from "@/utils/auth";
import { setAccessToken, setRefreshToken, getRefreshToken, clearToken } from "@/utils/auth";
export const useUserStore = defineStore("user", () => {
const userInfo = useStorage<UserInfo>("userInfo", {} as UserInfo);
@@ -20,8 +20,8 @@ export const useUserStore = defineStore("user", () => {
return new Promise<void>((resolve, reject) => {
AuthAPI.login(LoginFormData)
.then((data) => {
const { tokenType, accessToken, refreshToken } = data;
setToken(tokenType + " " + accessToken); // Bearer eyJhbGciOiJIUzI1NiJ9.xxx.xxx
const { accessToken, refreshToken } = data;
setAccessToken(accessToken); // eyJhbGciOiJIUzI1NiJ9.xxx.xxx
setRefreshToken(refreshToken);
resolve();
})
@@ -77,8 +77,8 @@ export const useUserStore = defineStore("user", () => {
return new Promise<void>((resolve, reject) => {
AuthAPI.refreshToken(refreshToken)
.then((data) => {
const { tokenType, accessToken, refreshToken } = data;
setToken(tokenType + " " + accessToken);
const { accessToken, refreshToken } = data;
setAccessToken(accessToken);
setRefreshToken(refreshToken);
resolve();
})

View File

@@ -3,11 +3,11 @@ const ACCESS_TOKEN_KEY = "access_token";
// 刷新 token 缓存的 key
const REFRESH_TOKEN_KEY = "refresh_token";
function getToken(): string {
function getAccessToken(): string {
return localStorage.getItem(ACCESS_TOKEN_KEY) || "";
}
function setToken(token: string) {
function setAccessToken(token: string) {
localStorage.setItem(ACCESS_TOKEN_KEY, token);
}
@@ -24,4 +24,4 @@ function clearToken() {
localStorage.removeItem(REFRESH_TOKEN_KEY);
}
export { getToken, setToken, clearToken, getRefreshToken, setRefreshToken };
export { getAccessToken, setAccessToken, clearToken, getRefreshToken, setRefreshToken };

View File

@@ -2,7 +2,7 @@ import axios, { type InternalAxiosRequestConfig, type AxiosResponse } from "axio
import qs from "qs";
import { useUserStoreHook } from "@/store/modules/user";
import { ResultEnum } from "@/enums/ResultEnum";
import { getToken } from "@/utils/auth";
import { getAccessToken } from "@/utils/auth";
import router from "@/router";
// 创建 axios 实例
@@ -16,10 +16,10 @@ const service = axios.create({
// 请求拦截器
service.interceptors.request.use(
(config: InternalAxiosRequestConfig) => {
const accessToken = getToken();
const accessToken = getAccessToken();
// 如果 Authorization 设置为 no-auth则不携带 Token用于登录、刷新 Token 等接口
if (config.headers.Authorization !== "no-auth" && accessToken) {
config.headers.Authorization = accessToken;
config.headers.Authorization = `Bearer ${accessToken}`;
} else {
delete config.headers.Authorization;
}
@@ -75,7 +75,7 @@ async function handleTokenRefresh(config: InternalAxiosRequestConfig) {
return new Promise((resolve) => {
// 封装需要重试的请求
const retryRequest = () => {
config.headers.Authorization = getToken();
config.headers.Authorization = getAccessToken();
resolve(service(config));
};

View File

@@ -1,93 +0,0 @@
import { Client } from "@stomp/stompjs";
import { getToken } from "@/utils/auth";
class WebSocketManager {
private client: Client | null = null;
private messageHandlers: Map<string, ((message: string) => void)[]> = new Map();
private reconnectAttempts = 0;
private maxReconnectAttempts = 3; // 自定义最大重试次数
private reconnectDelay = 5000; // 重试延迟(单位:毫秒)
// 初始化 WebSocket 客户端
setupWebSocket() {
const endpoint = import.meta.env.VITE_APP_WS_ENDPOINT;
// 如果没有配置 WebSocket 端点或显式关闭,直接返回
if (!endpoint) {
console.log("WebSocket 已被禁用,如需打开请在配置文件中配置 VITE_APP_WS_ENDPOINT");
return;
}
if (this.client && this.client.connected) {
console.log("客户端已存在并且连接正常");
return this.client;
}
this.client = new Client({
brokerURL: endpoint,
connectHeaders: {
Authorization: getToken(),
},
heartbeatIncoming: 30000,
heartbeatOutgoing: 30000,
reconnectDelay: 0, // 设置为 0 禁用重连
onConnect: () => {
console.log(`连接到 WebSocket 服务器: ${endpoint}`);
this.reconnectAttempts = 0; // 重置重连计数
this.messageHandlers.forEach((handlers, topic) => {
handlers.forEach((handler) => {
this.subscribeToTopic(topic, handler);
});
});
},
onStompError: (frame) => {
console.error(`连接错误: ${frame.headers["message"]}`);
console.error(`错误详情: ${frame.body}`);
},
onDisconnect: () => {
console.log(`WebSocket 连接已断开: ${endpoint}`);
this.reconnectAttempts++;
if (this.reconnectAttempts < this.maxReconnectAttempts) {
console.log(`正在尝试重连... 尝试次数: ${this.reconnectAttempts}`);
} else {
console.log("重连次数已达上限,停止重连");
this.client?.deactivate();
}
},
});
this.client.activate();
}
// 订阅主题
public subscribeToTopic(topic: string, onMessage: (message: string) => void) {
console.log(`正在订阅主题: ${topic}`);
if (!this.client || !this.client.connected) {
this.setupWebSocket();
}
if (this.messageHandlers.has(topic)) {
this.messageHandlers.get(topic)?.push(onMessage);
} else {
this.messageHandlers.set(topic, [onMessage]);
}
if (this.client?.connected) {
this.client.subscribe(topic, (message) => {
const handlers = this.messageHandlers.get(topic);
handlers?.forEach((handler) => handler(message.body));
});
}
}
// 断开 WebSocket 连接
public disconnect() {
if (this.client) {
console.log("断开 WebSocket 连接");
this.client.deactivate();
this.client = null;
}
}
}
export default new WebSocketManager();

View File

@@ -13,6 +13,7 @@
<el-card>
<el-row>
<el-col :span="16">
<!-- 输入框允许修改 websocket 地址注意修改后不会自动更新已创建的 hook 实例 -->
<el-input v-model="socketEndpoint" class="w-220px" />
<el-button
type="primary"
@@ -94,98 +95,88 @@
</template>
<script setup lang="ts">
import { Client } from "@stomp/stompjs";
import { useStomp } from "@/hooks/useStomp";
import { getAccessToken } from "@/utils/auth"; // 此处可与 hook 内 getToken 保持一致
import { useUserStoreHook } from "@/store/modules/user";
import { getToken } from "@/utils/auth";
const userStore = useUserStoreHook();
const isConnected = ref(false);
// 用于手动调整 WebSocket 地址
const socketEndpoint = ref(import.meta.env.VITE_APP_WS_ENDPOINT);
const receiver = ref("root");
// 同步连接状态
const isConnected = ref(false);
// 消息接收列表
interface MessageType {
type?: string;
sender?: string;
content: string;
}
const messages = ref<MessageType[]>([]);
// 广播消息内容
const topicMessage = ref("亲爱的朋友们,系统已恢复最新状态。");
// 点对点消息内容(默认示例)
const queneMessage = ref("Hi, " + userStore.userInfo.username + " 这里是点对点消息示例!");
const receiver = ref("root");
const topicMessage = ref("亲爱的大冤种们由于一只史诗级的BUG系统版本已经被迫回退到了0.0.1。"); // 广播消息
// 调用 useStomp hook默认使用 socketEndpoint 和 token此处用 getAccessToken()
const {
isConnected: stompConnected,
connect,
subscribe,
disconnect,
client,
} = useStomp({
brokerURL: socketEndpoint.value,
token: getAccessToken(),
reconnectDelay: 5000,
debug: true,
});
const queneMessage = ref(
"hi , " + receiver.value + " , 我是" + userStore.userInfo.username + " , 想和你交个朋友 ! "
);
let stompClient: Client;
function connectWebSocket() {
stompClient = new Client({
brokerURL: socketEndpoint.value,
connectHeaders: {
Authorization: getToken(),
},
debug: (str: any) => {
console.log(str);
},
onConnect: () => {
console.log("连接成功");
isConnected.value = true;
// 同步 hook 的连接状态到组件
watch(stompConnected, (newVal) => {
isConnected.value = newVal;
if (newVal) {
// 连接成功后,订阅广播和点对点消息主题
subscribe("/topic/notice", (res) => {
messages.value.push({
sender: "Server",
content: "Websocket 已连接",
type: "tip",
content: res.body,
});
stompClient.subscribe("/topic/notice", (res: any) => {
messages.value.push({
sender: "Server",
content: res.body,
});
});
stompClient.subscribe("/user/queue/greeting", (res: any) => {
const messageData = JSON.parse(res.body) as MessageType;
messages.value.push({
sender: messageData.sender,
content: messageData.content,
});
});
},
onStompError: (frame: any) => {
console.error("Broker reported error: " + frame.headers["message"]);
console.error("Additional details: " + frame.body);
},
onDisconnect: () => {
isConnected.value = false;
});
subscribe("/user/queue/greeting", (res) => {
const messageData = JSON.parse(res.body) as MessageType;
messages.value.push({
sender: "Server",
content: "Websocket 已断开",
type: "tip",
sender: messageData.sender,
content: messageData.content,
});
},
});
stompClient.activate();
}
function disconnectWebSocket() {
if (stompClient && stompClient.connected) {
stompClient.deactivate();
isConnected.value = false;
});
messages.value.push({
sender: "Server",
content: "Websocket 已连接",
type: "tip",
});
} else {
messages.value.push({
sender: "Server",
content: "Websocket 已断开",
type: "tip",
});
}
});
// 连接 WebSocket
function connectWebSocket() {
connect();
}
// 断开 WebSocket
function disconnectWebSocket() {
disconnect();
}
// 发送广播消息
function sendToAll() {
if (stompClient.connected) {
stompClient.publish({
if (client && client.connected) {
client.publish({
destination: "/topic/notice",
body: topicMessage.value,
});
@@ -196,9 +187,10 @@ function sendToAll() {
}
}
// 发送点对点消息
function sendToUser() {
if (stompClient.connected) {
stompClient.publish({
if (client && client.connected) {
client.publish({
destination: "/app/sendToUser/" + receiver.value,
body: queneMessage.value,
});
@@ -212,6 +204,10 @@ function sendToUser() {
onMounted(() => {
connectWebSocket();
});
onBeforeUnmount(() => {
disconnectWebSocket();
});
</script>
<style scoped>
@@ -219,40 +215,33 @@ onMounted(() => {
display: flex;
flex-direction: column;
}
.message {
padding: 10px;
margin: 10px;
border-radius: 5px;
}
.message--sent {
align-self: flex-end;
background-color: #dcf8c6;
}
.message--received {
align-self: flex-start;
background-color: #e8e8e8;
}
.message-content {
display: flex;
flex-direction: column;
}
.message-sender {
margin-bottom: 5px;
font-weight: bold;
text-align: right;
}
.message-receiver {
margin-bottom: 5px;
font-weight: bold;
text-align: left;
}
.tip-message {
align-self: center;
padding: 5px 10px;