feat: Architecture initialization

This commit is contained in:
2026-07-14 10:31:17 +08:00
commit 6f37264047
59 changed files with 8752 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
export * from './useEffect';
export * from './useState';
export * from './usePagination';
export * from './useRequest';
export * from './useDebounce';
export * from './useAuth';
export * from './useWebSocket';
export * from './useBasicLayout';
export * from './useBreadcrumb';
export * from './useContext';
+34
View File
@@ -0,0 +1,34 @@
// src/hooks/useAuth.ts
import { useState } from './useState';
import { watch } from 'vue';
const TOKEN_KEY = 'MY_APP_AUTH_TOKEN';
function createAuth() {
const [token, setToken] = useState<string | null>(window.localStorage.getItem(TOKEN_KEY));
watch(token, (newToken) => {
if (newToken) {
window.localStorage.setItem(TOKEN_KEY, newToken);
} else {
window.localStorage.removeItem(TOKEN_KEY);
}
});
const login = (newToken: string) => {
setToken(newToken);
};
const logout = () => {
setToken(null);
};
return {
token,
isLoggedIn: () => !!token.value,
login,
logout,
};
}
export const auth = createAuth();
+46
View File
@@ -0,0 +1,46 @@
import { h } from 'vue';
import { useRouter, useRoute } from 'vue-router';
import { TrophyOutlined, WalletOutlined } from '@ant-design/icons-vue';
import { useBreadcrumb } from './useBreadcrumb';
export function useBasicLayout() {
const router = useRouter();
const route = useRoute();
const { breadcrumbItems } = useBreadcrumb();
const menuItems = [
{
key: '/events',
icon: h(TrophyOutlined),
label: '我的赛事',
},
// { TODO 本期先做隐藏
// key: '/wallet',
// icon: h(WalletOutlined),
// label: '钱包管理',
// },
];
const getActiveMenuKey = () => {
const matchedRoutes = route.matched;
for (const r of matchedRoutes) {
if (r.meta?.activeMenu) {
return r.meta.activeMenu as string;
}
}
if (route.path.startsWith('/events')) return '/events';
if (route.path.startsWith('/wallet')) return '/wallet';
return route.path;
};
const handleMenuClick = (info: any) => {
router.push(String(info.key));
};
return {
breadcrumbItems,
menuItems,
getActiveMenuKey,
handleMenuClick,
};
}
+67
View File
@@ -0,0 +1,67 @@
import { computed, h } from 'vue';
import { useRoute, useRouter } from 'vue-router';
export function useBreadcrumb() {
const route = useRoute();
const router = useRouter();
/**
* 根据当前路径和记录路径,计算拼接后的路径
*/
const buildPath = (currentPath: string, recordPath: string): string => {
if (recordPath.startsWith('/')) {
return recordPath;
}
return currentPath ? `${currentPath}/${recordPath}`.replace(/\/+/g, '/') : `/${recordPath}`;
};
const breadcrumbs = computed(() => {
const matched = route.matched;
const items: Array<{ title: string; path?: string }> = [];
let currentPath = '';
for (const record of matched) {
currentPath = buildPath(currentPath, record.path);
if (record.meta && record.meta.title) {
items.push({
title: record.meta.title as string,
path: currentPath,
});
}
}
return items;
});
const breadcrumbItems = computed(() => {
return breadcrumbs.value.map((item, index) => ({
key: index,
title:
item.path && index !== breadcrumbs.value.length - 1
? h(
'a',
{
onClick: (e: Event) => {
e.preventDefault();
// 最保守方案:只有当前路径包含 /bracket/ 且目标路径是 /events/bracket 时才保留 query
// 也就是只有:/events/bracket/xxx → /events/bracket 时才保留
const targetPath = item.path || '/';
const isBracketChildPage =
route.path.includes('/bracket/') && targetPath === '/events/bracket';
router.push({
path: targetPath,
query: isBracketChildPage ? route.query : {},
});
},
},
item.title,
)
: item.title,
}));
});
return {
breadcrumbs,
breadcrumbItems,
};
}
+137
View File
@@ -0,0 +1,137 @@
import { reactive, provide, inject, type InjectionKey } from 'vue';
/**
* 深合并:将 source 的属性递归合并到 target 中
* - 对于原始值和数组,直接赋值(数组整体替换,不做元素级 diff)
* - 对于普通对象,递归合并,保留 target 中已有的响应式 Proxy
* - 跳过值为 undefined 的属性
*/
function deepSyncState<T extends Record<string, any>>(target: T, source: Partial<T>): void {
const keys = Object.keys(source) as (keyof T)[];
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (!(key in target) || source[key] === undefined) continue;
const sourceVal = source[key];
const targetVal = target[key];
// 两边都是普通对象 → 递归合并,保留响应式
if (
isPlainObject(sourceVal) &&
isPlainObject(targetVal) &&
!Array.isArray(sourceVal) &&
!Array.isArray(targetVal)
) {
deepSyncState(targetVal as Record<string, any>, sourceVal as Record<string, any>);
} else {
// 原始值、数组、或类型不匹配 → 直接赋值
(target as any)[key] = sourceVal;
}
}
}
/**
* 判断是否为普通对象(Plain Object
* 排除 Array、Date、RegExp、Map、Set 等内置类型
*/
function isPlainObject(val: unknown): val is Record<string, any> {
if (val === null || typeof val !== 'object') return false;
const proto = Object.getPrototypeOf(val);
// Object.create(null) 的 proto 为 null,也视为普通对象
return proto === null || proto === Object.prototype;
}
export interface Context<T extends Record<string, any>> {
/**
* 在当前组件及所有子组件中提供 Context 数据
*
* @param value - 可选的部分更新,会深合并到初始值中
* @returns dispose 清理函数,调用后恢复初始值
*/
provide: (value?: Partial<T>) => () => void;
/** 在子组件中消费 Context 数据,返回响应式对象 */
useContext: () => T;
/** 底层 InjectionKey,用于自定义 provide/inject 场景 */
readonly key: InjectionKey<T>;
}
/**
* 创建一个 Context 实例
*
* @param initialValue - 初始状态对象,所有属性将具备响应式能力
* @param options.displayName - 可选,用于 DevTools 调试时的标识
*/
export function createContext<T extends Record<string, any>>(
initialValue: T,
options?: { displayName?: string },
): Context<T> {
const name = options?.displayName || 'Context';
const key: InjectionKey<T> = Symbol(name) as InjectionKey<T>;
// 深拷贝初始值,确保源数据不被污染,同时作为后续恢复的快照
const snapshot = deepClone(initialValue);
// 创建全局响应式容器
const initialReactive = reactive(snapshot) as T;
// 记录当前是否已被 dispose,防止重复调用
let disposed = false;
return {
key,
/**
* 在当前组件树中提供 Context
* 调用后,所有子组件可通过 useContext() 获取到响应式状态
*
* @param value - 可选的部分更新,会深合并到初始值中
* @returns 清理函数,调用后将状态恢复为 initialValue
*/
provide(value?: Partial<T>): () => void {
disposed = false;
// 如果传入了 value,深合并到全局 reactive 对象
if (value) {
deepSyncState(initialReactive, value);
}
// 通过 Vue 原生 provide 向下注入
provide(key, initialReactive);
// 返回清理函数
return () => {
if (disposed) return;
disposed = true;
// 恢复初始值
deepSyncState(initialReactive, initialValue);
};
},
/**
* 在子组件中消费 Context
* 必须在 Provider 所在组件的子级 setup 中调用
*/
useContext(): T {
const injected = inject(key);
if (!injected) {
// 降级返回初始值,保证组件不会崩溃
return initialReactive;
}
return injected;
},
};
}
/**
* 深拷贝普通对象,保留嵌套结构
* 仅用于创建初始快照,不处理特殊对象(Date、RegExp 等保持引用)
*/
function deepClone<T>(obj: T): T {
if (obj === null || typeof obj !== 'object') return obj;
if (Array.isArray(obj)) return obj.map((item) => deepClone(item)) as T;
const result = {} as T;
const keys = Object.keys(obj) as (keyof T)[];
for (let i = 0; i < keys.length; i++) {
const k = keys[i];
result[k] = deepClone(obj[k]);
}
return result;
}
+80
View File
@@ -0,0 +1,80 @@
// src/hooks/useDebounce.ts
import { watch, onUnmounted } from 'vue';
import { useState } from './useState';
export interface UseDebounceOptions {
delay?: number;
immediate?: boolean;
maxWait?: number;
}
export function useDebounce<T>(
source: any, // Ref<T>
options: number | UseDebounceOptions = {},
) {
const config = typeof options === 'number' ? { delay: options } : options;
const { delay = 300, immediate = false, maxWait } = config;
const [debouncedValue, setDebouncedValue] = useState<T>(source.value);
const [isPending, setIsPending] = useState(false);
let timer: any = null;
let maxWaitTimer: any = null;
let isFirstCall = true; // 标记是否为首次调用,用于 immediate 模式
// 清理
const clearTimers = () => {
if (timer) {
clearTimeout(timer);
timer = null;
}
if (maxWaitTimer) {
clearTimeout(maxWaitTimer);
maxWaitTimer = null;
}
};
// 更新
const updateValue = () => {
setDebouncedValue(source.value);
setIsPending(false);
clearTimers();
// 安静期结束后重置 isFirstCall,使下次触发能立即执行(immediate 模式)
isFirstCall = true;
};
watch(source, () => {
setIsPending(true);
// 处理 maxWait
if (maxWait && !maxWaitTimer) {
maxWaitTimer = setTimeout(() => {
updateValue();
}, maxWait);
}
// 处理 delay
if (timer) clearTimeout(timer);
if (immediate && isFirstCall) {
isFirstCall = false;
updateValue();
} else {
timer = setTimeout(() => {
updateValue();
}, delay);
}
});
// 卸载
onUnmounted(() => {
clearTimers();
});
return {
debouncedValue,
isPending,
refresh: updateValue,
cancel: clearTimers,
};
}
+32
View File
@@ -0,0 +1,32 @@
import { watchEffect, watch, WatchSource, isRef, isReactive } from 'vue';
export function useEffect(effect: () => void | (() => void), deps?: WatchSource<any>[]) {
if (!deps) {
return watchEffect((onCleanup) => {
const cleanup = effect();
if (cleanup) onCleanup(cleanup);
});
}
const validDeps = deps.map((dep) => {
if (dep === null || dep === undefined) {
return () => null;
}
if (isRef(dep) || isReactive(dep) || typeof dep === 'function') {
return dep;
}
return () => dep;
});
return watch(
validDeps,
() => {
const cleanup = effect();
return cleanup;
},
{
immediate: true,
flush: 'post',
},
);
}
+185
View File
@@ -0,0 +1,185 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
// src/hooks/usePagination.ts
import { computed, ref, watch, Ref, triggerRef } from 'vue';
/**
* 分页配置项
*/
export interface UsePaginationOptions {
/** 当前页码 (支持 v-model 双向绑定) */
current?: number;
/** 每页条数 (支持 v-model 双向绑定) */
pageSize?: number;
/** 数据总条数 (支持 v-model 双向绑定) */
total?: number;
/** 默认页码 (非受控模式下使用) */
defaultCurrent?: number;
/** 默认每页条数 (非受控模式下使用) */
defaultPageSize?: number;
/** 每页条数切换选项 (用于生成下拉菜单) */
pageSizeOptions?: number[];
/** 是否显示快速跳转 (可选功能) */
showQuickJumper?: boolean;
}
/**
* 分页操作 API
*/
export interface UsePaginationActions {
/** 设置当前页 */
setCurrent: (current: number) => void;
/** 设置每页条数 (会自动重置页码为 1) */
setPageSize: (size: number) => void;
/** 设置总条数 */
setTotal: (total: number) => void;
/** 下一页 */
next: () => void;
/** 上一页 */
prev: () => void;
/** 跳转到指定页 */
jump: (page: number) => void;
/** 重置到初始状态 */
reset: () => void;
/** 强制刷新 (用于某些极端响应式场景) */
refresh: () => void;
}
/**
* 分页计算属性
*/
export interface UsePaginationComputed {
/** 响应式请求参数对象 (给 API 用的) */
params: Ref<{ page: number; pageSize: number }>;
/** 总页数 */
totalPages: Ref<number>;
/** 是否有上一页 */
hasPrev: Ref<boolean>;
/** 是否有下一页 */
hasNext: Ref<boolean>;
/** 当前页起始索引 (用于显示 "显示 1-10 条") */
startIndex: Ref<number>;
/** 当前页结束索引 */
endIndex: Ref<number>;
/** 是否为第一页 */
isFirstPage: Ref<boolean>;
/** 是否为最后一页 */
isLastPage: Ref<boolean>;
}
export type UsePaginationReturn = UsePaginationOptions &
UsePaginationActions &
UsePaginationComputed;
export function usePagination(options: UsePaginationOptions = {}): UsePaginationReturn {
const { defaultCurrent = 1, defaultPageSize = 10, pageSizeOptions = [10, 20, 50, 100] } = options;
const currentRef = ref(options.current ?? defaultCurrent);
const pageSizeRef = ref(options.pageSize ?? defaultPageSize);
const totalRef = ref(options.total ?? 0);
watch(
() => options.current,
(val) => {
if (val !== undefined) currentRef.value = val;
},
);
watch(
() => options.pageSize,
(val) => {
if (val !== undefined) pageSizeRef.value = val;
},
);
watch(
() => options.total,
(val) => {
if (val !== undefined) totalRef.value = val;
},
);
const totalPages = computed(() => {
const total = totalRef.value;
const size = pageSizeRef.value;
return size === 0 ? 0 : Math.ceil(total / size);
});
const hasPrev = computed(() => currentRef.value > 1);
const hasNext = computed(() => currentRef.value < totalPages.value);
const isFirstPage = computed(() => currentRef.value === 1);
const isLastPage = computed(() => currentRef.value === totalPages.value);
const startIndex = computed(() =>
totalPages.value === 0 ? 0 : (currentRef.value - 1) * pageSizeRef.value + 1,
);
const endIndex = computed(() => Math.min(currentRef.value * pageSizeRef.value, totalRef.value));
const params = computed(() => ({
page: currentRef.value,
pageSize: pageSizeRef.value,
}));
const setCurrent = (page: number) => {
const safePage = Math.max(1, Math.min(page, totalPages.value || 1));
if (currentRef.value !== safePage) {
currentRef.value = safePage;
}
};
const setPageSize = (size: number) => {
if (pageSizeRef.value !== size) {
pageSizeRef.value = size;
currentRef.value = 1;
}
};
const setTotal = (total: number) => {
totalRef.value = total;
const maxPage = Math.max(1, Math.ceil(total / pageSizeRef.value));
if (currentRef.value > maxPage) {
currentRef.value = maxPage;
}
};
const next = () => setCurrent(currentRef.value + 1);
const prev = () => setCurrent(currentRef.value - 1);
const jump = (page: number) => {
if (!Number.isInteger(page) || page < 1) return;
setCurrent(page);
};
const reset = () => {
currentRef.value = defaultCurrent;
pageSizeRef.value = defaultPageSize;
};
const refresh = () => {
triggerRef(currentRef);
};
return {
// @ts-ignore
current: currentRef,
// @ts-ignore
pageSize: pageSizeRef,
// @ts-ignore
total: totalRef,
pageSizeOptions,
params,
totalPages,
hasPrev,
hasNext,
isFirstPage,
isLastPage,
startIndex,
endIndex,
setCurrent,
setPageSize,
setTotal,
next,
prev,
jump,
reset,
refresh,
};
}
+170
View File
@@ -0,0 +1,170 @@
/* eslint-disable @typescript-eslint/ban-ts-comment */
import { ref, Ref, watch, WatchSource, onUnmounted, computed } from 'vue';
export interface UseRequestReturn<T> {
/** 纯净的数据,不包含后端外层壳 */
data: Ref<T | undefined>;
/** 加载状态 */
loading: Ref<boolean>;
/** 错误对象 */
error: Ref<Error | null>;
/** 手动触发请求 */
run: (...args: any[]) => Promise<T | undefined>;
/** 取消请求 */
cancel: () => void;
/** 重置数据 */
reset: () => void;
}
export function useRequest<T>(
fetcher: (...args: any[]) => Promise<any>,
options: {
/** 是否手动触发,默认 false (即自动触发) */
manual?: boolean;
/** 初始数据 */
initialData?: T;
/** 依赖数组,变化时重新请求 */
refreshDeps?: WatchSource[];
/**
* 数据格式化函数
* 默认逻辑:如果返回是对象且有 data 属性,则返回 res.data,否则返回原数据
*/
formatResult?: (res: any) => T;
/** 是否准备好可以发起请求,默认 true */
ready?: Ref<boolean>;
} = {},
): UseRequestReturn<T> & {
/** 获取当前 AbortController 的 signal,可传递给 fetcher */
getSignal: () => AbortSignal | undefined;
} {
const {
manual = false,
initialData,
refreshDeps,
formatResult = (res) => (res && typeof res === 'object' && 'data' in res ? res.data : res),
ready = ref(true),
} = options;
const data = ref<T | undefined>(initialData);
const loading = ref(false);
const error = ref<Error | null>(null);
let abortController: AbortController | null = null;
let isUnmounted = false;
onUnmounted(() => {
isUnmounted = true;
if (abortController) {
abortController.abort();
}
});
const run = async (...args: any[]): Promise<T | undefined> => {
if (isUnmounted) return undefined;
// 取消之前的请求
if (abortController) {
abortController.abort();
}
// 创建新的 AbortController
abortController = new AbortController();
const currentSignal = abortController.signal;
loading.value = true;
error.value = null;
try {
// 将 signal 作为最后一个参数传递给 fetcher
// 调用方可以通过最后一个参数获取 signal: async (...args, { signal }) => {...}
const res = await fetcher(...args, { signal: currentSignal });
if (currentSignal.aborted || isUnmounted) {
return undefined;
}
const formattedData = formatResult(res);
if (!isUnmounted) {
data.value = formattedData;
}
return formattedData;
} catch (e: any) {
if (e.name === 'AbortError' || e.message?.includes('aborted')) {
return undefined;
}
if (!isUnmounted) {
error.value = e;
}
return undefined;
} finally {
// 只有当前请求未被取消时才重置 loading
if (!currentSignal.aborted && !isUnmounted) {
loading.value = false;
}
// 如果这是当前活动的 controller,清理引用
if (abortController?.signal === currentSignal) {
abortController = null;
}
}
};
const cancel = () => {
if (abortController) {
abortController.abort();
loading.value = false;
abortController = null;
}
};
const reset = () => {
cancel();
data.value = initialData;
error.value = null;
};
// 使用 shouldAutoRun 统一管理自动请求,避免 refreshDeps 和 immediate watch 重复触发 run
const shouldAutoRun = computed(() => !manual && ready.value);
let hasAutoRun = false; // 标记是否已自动执行过,防止重复触发
watch(
shouldAutoRun,
(val) => {
if (val && !hasAutoRun) {
hasAutoRun = true;
run();
} else if (!val) {
// 当条件不满足时重置 hasAutoRun,使下次满足条件时能再次自动执行
hasAutoRun = false;
}
},
{ immediate: true },
);
// refreshDeps 变化时重新请求(初始化时不再重复触发)
if (refreshDeps && refreshDeps.length > 0) {
watch(
refreshDeps,
() => {
if (shouldAutoRun.value) {
run();
}
},
{ deep: true },
);
}
const getSignal = () => abortController?.signal;
return {
// @ts-ignore
data,
loading,
error,
run,
cancel,
reset,
getSignal,
};
}
+17
View File
@@ -0,0 +1,17 @@
import { ref, Ref } from 'vue';
/**
* 返回一个 [state, setState] 元组
*/
export function useState<T>(initialValue: T): [Ref<T>, (newVal: T) => void] {
const state: any = ref<T>(initialValue);
const setState = (newVal: T) => {
if (typeof newVal === 'function') {
state.value = (newVal as (prev: T) => T)(state.value);
} else {
state.value = newVal;
}
};
return [state, setState];
}
+245
View File
@@ -0,0 +1,245 @@
import { ref, watch, onBeforeUnmount, toValue } from 'vue';
import type { Ref, MaybeRefOrGetter } from 'vue';
// --- 类型定义 ---
export type WebSocketStatus = 'connecting' | 'open' | 'closed' | 'error';
export interface UseWebSocketOptions {
// 是否自动连接
autoConnect?: boolean;
// 子协议
protocols?: string[];
// --- 重连配置 ---
reconnectLimit?: number; // 最大重连次数,默认 -1 (无限)
reconnectInterval?: number; // 初始重连间隔 ms
reconnectIncrement?: number; // 每次重连增加的间隔 ms (用于指数退避)
// --- 心跳配置 ---
heartbeat?: boolean; // 是否开启心跳
heartbeatInterval?: number; // 心跳间隔 ms
heartbeatMessage?: any; // 心跳发送的数据
// --- 回调钩子 ---
onOpen?: (event: Event) => void;
onClose?: (event: CloseEvent) => void;
onMessage?: (data: any, event: MessageEvent) => void;
onError?: (event: Event) => void;
}
// --- 返回值接口 ---
export interface UseWebSocketReturn {
status: Ref<WebSocketStatus>;
latestData: Ref<any>; // 最新接收到的数据
send: (data: any) => void; // 发送方法
connect: () => void; // 手动连接
disconnect: () => void; // 手动断开
}
/**
* useWebSocket Hook
* 仿照 ahooks 设计,支持动态配置、指数退避重连、心跳检测
*/
// 模块级变量,替代 window 全局挂载,避免全局污染和内存泄漏
const webSocketList: WebSocket[] = [];
export function useWebSocket(
url: MaybeRefOrGetter<string>,
options: UseWebSocketOptions = {},
): UseWebSocketReturn {
const {
autoConnect = true,
protocols = [],
reconnectLimit = -1,
reconnectInterval = 1000,
reconnectIncrement = 1000,
heartbeat = true,
heartbeatInterval = 30000,
heartbeatMessage = 'ping',
onOpen,
onClose,
onMessage,
onError,
} = options;
// --- 响应式状态 ---
const status = ref<WebSocketStatus>('closed');
const latestData = ref<any>(null);
const wsRef = ref<WebSocket | null>(null);
// --- 内部变量 ---
let reconnectCount = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
// --- 辅助函数 ---
// 清除所有定时器
const clearTimers = () => {
if (reconnectTimer) clearTimeout(reconnectTimer);
if (heartbeatTimer) clearInterval(heartbeatTimer);
};
// 启动心跳
const startHeartbeat = () => {
if (!heartbeat) return;
stopHeartbeat(); // 防止重复启动
heartbeatTimer = setInterval(() => {
if (wsRef.value && wsRef.value.readyState === WebSocket.OPEN) {
wsRef.value.send(
typeof heartbeatMessage === 'string'
? heartbeatMessage
: JSON.stringify(heartbeatMessage),
);
}
}, heartbeatInterval);
};
// 停止心跳
const stopHeartbeat = () => {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
};
// 建立连接的核心逻辑
const connect = () => {
// 如果已经有连接且是开启状态,不重复创建
if (wsRef.value?.readyState === WebSocket.OPEN) return;
// 关闭旧连接(如果有)
if (wsRef.value) {
wsRef.value.close();
wsRef.value = null;
}
status.value = 'connecting';
try {
// 使用 toValue 支持响应式 URL
const wsUrl = toValue(url);
wsRef.value = new WebSocket(wsUrl, protocols);
// 追踪所有 WebSocket 实例用于调试和清理(使用模块级变量,避免全局污染)
webSocketList.push(wsRef.value);
wsRef.value.onopen = (event) => {
status.value = 'open';
reconnectCount = 0; // 重置重连计数
startHeartbeat(); // 启动心跳
onOpen?.(event);
};
wsRef.value.onmessage = (event) => {
// 简单的心跳回显处理:如果收到的消息等于发送的心跳消息,则忽略(或者根据业务协议处理 pong)
// 这里假设服务端会原样返回 ping 或者返回特定的 pong
// 实际项目中建议检查 event.data
try {
const data = JSON.parse(event.data);
latestData.value = data;
onMessage?.(data, event);
} catch (e) {
latestData.value = event.data;
onMessage?.(event.data, event);
}
};
wsRef.value.onerror = (event) => {
status.value = 'error';
onError?.(event);
};
wsRef.value.onclose = (event) => {
status.value = 'closed';
stopHeartbeat();
onClose?.(event);
// 触发重连逻辑
handleReconnect();
};
} catch (err) {
console.error(err);
status.value = 'error';
onError?.(err as Event);
handleReconnect();
}
};
// 处理重连(指数退避策略)
const handleReconnect = () => {
// 如果是用户手动调用的 disconnect,不应该重连(可以通过标记位控制,这里简化处理:只要没达到限制就重连)
// 如果需要彻底断开,请调用 disconnect()
if (reconnectLimit !== -1 && reconnectCount >= reconnectLimit) {
return;
}
// 计算延迟时间:线性增长 1s, 2s, 3s, 4s... (最大 10s)
const delay = Math.min(10000, reconnectInterval + reconnectCount * reconnectIncrement);
reconnectCount++;
reconnectTimer = setTimeout(() => {
connect();
}, delay);
};
// 发送消息
const send = (data: any) => {
if (wsRef.value && wsRef.value.readyState === WebSocket.OPEN) {
const strData = typeof data === 'string' ? data : JSON.stringify(data);
wsRef.value.send(strData);
} else {
console.warn('WebSocket 未连接,无法发送消息');
}
};
// 手动断开(通常意味着不再自动重连,除非再次调用 connect)
const disconnect = () => {
reconnectCount = reconnectLimit; // 设置计数为最大值,阻止后续重连逻辑
clearTimers();
if (wsRef.value) {
// 从模块级列表中移除这个实例
const index = webSocketList.indexOf(wsRef.value);
if (index > -1) {
webSocketList.splice(index, 1);
}
wsRef.value.close();
wsRef.value = null;
}
status.value = 'closed';
};
// --- 监听与副作用 ---
// 监听 URL 变化,如果变了则重新连接
watch(
() => toValue(url),
() => {
if (status.value !== 'closed') {
connect();
}
},
);
// 组件卸载时清理资源
onBeforeUnmount(() => {
disconnect();
});
// 初始化:如果不是手动模式,则自动连接
if (autoConnect) {
connect();
}
return {
status,
latestData,
send,
connect,
disconnect,
};
}