Files
vf_react/src/views/StockOverview/components/FlexScreen/hooks/useRealtimeQuote.ts
2025-12-10 15:37:22 +08:00

823 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 实时行情 Hook
* 管理上交所和深交所 WebSocket 连接,获取实时行情数据
*
* 连接方式:
* - 生产环境 (HTTPS): 通过 Nginx 代理使用 wss://
* - 开发环境 (HTTP): 直连 ws://
*
* 上交所 (SSE): 需主动订阅,提供五档行情
* 深交所 (SZSE): v4.0 API - 需主动订阅,提供十档行情
*/
import { useState, useEffect, useRef, useCallback } from 'react';
import { logger } from '@utils/logger';
import { WS_CONFIG, HEARTBEAT_INTERVAL, RECONNECT_INTERVAL } from './constants';
import { getExchange, normalizeCode, extractOrderBook, calcChangePct } from './utils';
import type {
Exchange,
ConnectionStatus,
QuotesMap,
QuoteData,
SSEMessage,
SSEQuoteItem,
SZSEMessage,
SZSERealtimeMessage,
SZSESnapshotMessage,
SZSEStockData,
SZSEIndexData,
SZSEBondData,
SZSEHKStockData,
SZSEAfterhoursData,
UseRealtimeQuoteReturn,
} from '../types';
/**
* 处理上交所消息
*/
const handleSSEMessage = (
msg: SSEMessage,
subscribedCodes: Set<string>,
prevQuotes: QuotesMap
): QuotesMap | null => {
if (msg.type !== 'stock' && msg.type !== 'index') {
return null;
}
const data = msg.data || {};
const updated: QuotesMap = { ...prevQuotes };
let hasUpdate = false;
Object.entries(data).forEach(([code, quote]: [string, SSEQuoteItem]) => {
const fullCode = code.includes('.') ? code : `${code}.SH`;
if (subscribedCodes.has(code) || subscribedCodes.has(fullCode)) {
hasUpdate = true;
updated[fullCode] = {
code: fullCode,
name: quote.security_name,
price: quote.last_price,
prevClose: quote.prev_close,
open: quote.open_price,
high: quote.high_price,
low: quote.low_price,
volume: quote.volume,
amount: quote.amount,
change: quote.last_price - quote.prev_close,
changePct: calcChangePct(quote.last_price, quote.prev_close),
bidPrices: quote.bid_prices || [],
bidVolumes: quote.bid_volumes || [],
askPrices: quote.ask_prices || [],
askVolumes: quote.ask_volumes || [],
updateTime: quote.trade_time,
exchange: 'SSE',
} as QuoteData;
}
});
return hasUpdate ? updated : null;
};
/**
* 处理深交所实时消息 (realtime)
*/
const handleSZSERealtimeMessage = (
msg: SZSERealtimeMessage,
subscribedCodes: Set<string>,
prevQuotes: QuotesMap
): QuotesMap | null => {
const { category, data, timestamp } = msg;
const rawCode = data.security_id;
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
if (!subscribedCodes.has(rawCode) && !subscribedCodes.has(fullCode)) {
return null;
}
const updated: QuotesMap = { ...prevQuotes };
switch (category) {
case 'stock': {
const stockData = data as SZSEStockData;
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(stockData.bids);
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(stockData.asks);
updated[fullCode] = {
code: fullCode,
name: prevQuotes[fullCode]?.name || '',
price: stockData.last_px,
prevClose: stockData.prev_close,
open: stockData.open_px,
high: stockData.high_px,
low: stockData.low_px,
volume: stockData.volume,
amount: stockData.amount,
numTrades: stockData.num_trades,
upperLimit: stockData.upper_limit,
lowerLimit: stockData.lower_limit,
change: stockData.last_px - stockData.prev_close,
changePct: calcChangePct(stockData.last_px, stockData.prev_close),
bidPrices,
bidVolumes,
askPrices,
askVolumes,
tradingPhase: stockData.trading_phase,
updateTime: timestamp,
exchange: 'SZSE',
} as QuoteData;
break;
}
case 'index': {
const indexData = data as SZSEIndexData;
updated[fullCode] = {
code: fullCode,
name: prevQuotes[fullCode]?.name || '',
price: indexData.current_index,
prevClose: indexData.prev_close,
open: indexData.open_index,
high: indexData.high_index,
low: indexData.low_index,
close: indexData.close_index,
volume: indexData.volume,
amount: indexData.amount,
numTrades: indexData.num_trades,
change: indexData.current_index - indexData.prev_close,
changePct: calcChangePct(indexData.current_index, indexData.prev_close),
bidPrices: [],
bidVolumes: [],
askPrices: [],
askVolumes: [],
tradingPhase: indexData.trading_phase,
updateTime: timestamp,
exchange: 'SZSE',
} as QuoteData;
break;
}
case 'bond': {
const bondData = data as SZSEBondData;
updated[fullCode] = {
code: fullCode,
name: prevQuotes[fullCode]?.name || '',
price: bondData.last_px,
prevClose: bondData.prev_close,
open: bondData.open_px,
high: bondData.high_px,
low: bondData.low_px,
volume: bondData.volume,
amount: bondData.amount,
numTrades: bondData.num_trades,
weightedAvgPx: bondData.weighted_avg_px,
change: bondData.last_px - bondData.prev_close,
changePct: calcChangePct(bondData.last_px, bondData.prev_close),
bidPrices: [],
bidVolumes: [],
askPrices: [],
askVolumes: [],
tradingPhase: bondData.trading_phase,
updateTime: timestamp,
exchange: 'SZSE',
isBond: true,
} as QuoteData;
break;
}
case 'hk_stock': {
const hkData = data as SZSEHKStockData;
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(hkData.bids);
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(hkData.asks);
updated[fullCode] = {
code: fullCode,
name: prevQuotes[fullCode]?.name || '',
price: hkData.last_px,
prevClose: hkData.prev_close,
open: hkData.open_px,
high: hkData.high_px,
low: hkData.low_px,
volume: hkData.volume,
amount: hkData.amount,
numTrades: hkData.num_trades,
nominalPx: hkData.nominal_px,
referencePx: hkData.reference_px,
change: hkData.last_px - hkData.prev_close,
changePct: calcChangePct(hkData.last_px, hkData.prev_close),
bidPrices,
bidVolumes,
askPrices,
askVolumes,
tradingPhase: hkData.trading_phase,
updateTime: timestamp,
exchange: 'SZSE',
isHK: true,
} as QuoteData;
break;
}
case 'afterhours_block':
case 'afterhours_trading': {
const afterhoursData = data as SZSEAfterhoursData;
const existing = prevQuotes[fullCode];
const afterhoursInfo = {
bidPx: afterhoursData.bid_px,
bidSize: afterhoursData.bid_size,
offerPx: afterhoursData.offer_px,
offerSize: afterhoursData.offer_size,
volume: afterhoursData.volume,
amount: afterhoursData.amount,
numTrades: afterhoursData.num_trades || 0,
};
if (existing) {
// 合并到现有数据
updated[fullCode] = {
...existing,
afterhours: afterhoursInfo,
updateTime: timestamp,
} as QuoteData;
} else {
// 盘后首次收到数据(刷新页面后),创建基础行情结构
updated[fullCode] = {
code: fullCode,
name: '',
price: 0,
prevClose: afterhoursData.prev_close,
open: 0,
high: 0,
low: 0,
volume: 0,
amount: 0,
change: 0,
changePct: 0,
bidPrices: [],
bidVolumes: [],
askPrices: [],
askVolumes: [],
tradingPhase: afterhoursData.trading_phase,
afterhours: afterhoursInfo,
updateTime: timestamp,
exchange: 'SZSE',
} as QuoteData;
}
break;
}
default:
return null;
}
return updated;
};
/**
* 处理深交所快照消息 (snapshot)
* 订阅后首次返回的批量数据
*/
const handleSZSESnapshotMessage = (
msg: SZSESnapshotMessage,
subscribedCodes: Set<string>,
prevQuotes: QuotesMap
): QuotesMap | null => {
const { stocks = [], indexes = [], bonds = [] } = msg.data || {};
const updated: QuotesMap = { ...prevQuotes };
let hasUpdate = false;
stocks.forEach((s: SZSEStockData) => {
const rawCode = s.security_id;
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
hasUpdate = true;
const { prices: bidPrices, volumes: bidVolumes } = extractOrderBook(s.bids);
const { prices: askPrices, volumes: askVolumes } = extractOrderBook(s.asks);
updated[fullCode] = {
code: fullCode,
name: '',
price: s.last_px,
prevClose: s.prev_close,
open: s.open_px,
high: s.high_px,
low: s.low_px,
volume: s.volume,
amount: s.amount,
numTrades: s.num_trades,
upperLimit: s.upper_limit,
lowerLimit: s.lower_limit,
change: s.last_px - s.prev_close,
changePct: calcChangePct(s.last_px, s.prev_close),
bidPrices,
bidVolumes,
askPrices,
askVolumes,
exchange: 'SZSE',
} as QuoteData;
}
});
indexes.forEach((i: SZSEIndexData) => {
const rawCode = i.security_id;
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
hasUpdate = true;
updated[fullCode] = {
code: fullCode,
name: '',
price: i.current_index,
prevClose: i.prev_close,
open: i.open_index,
high: i.high_index,
low: i.low_index,
volume: i.volume,
amount: i.amount,
numTrades: i.num_trades,
change: i.current_index - i.prev_close,
changePct: calcChangePct(i.current_index, i.prev_close),
bidPrices: [],
bidVolumes: [],
askPrices: [],
askVolumes: [],
exchange: 'SZSE',
} as QuoteData;
}
});
bonds.forEach((b: SZSEBondData) => {
const rawCode = b.security_id;
const fullCode = rawCode.includes('.') ? rawCode : `${rawCode}.SZ`;
if (subscribedCodes.has(rawCode) || subscribedCodes.has(fullCode)) {
hasUpdate = true;
updated[fullCode] = {
code: fullCode,
name: '',
price: b.last_px,
prevClose: b.prev_close,
open: b.open_px,
high: b.high_px,
low: b.low_px,
volume: b.volume,
amount: b.amount,
change: b.last_px - b.prev_close,
changePct: calcChangePct(b.last_px, b.prev_close),
bidPrices: [],
bidVolumes: [],
askPrices: [],
askVolumes: [],
exchange: 'SZSE',
isBond: true,
} as QuoteData;
}
});
return hasUpdate ? updated : null;
};
/**
* 实时行情 Hook
* @param codes - 订阅的证券代码列表(带后缀,如 000001.SZ
*/
export const useRealtimeQuote = (codes: string[] = []): UseRealtimeQuoteReturn => {
const [quotes, setQuotes] = useState<QuotesMap>({});
const [connected, setConnected] = useState<ConnectionStatus>({ SSE: false, SZSE: false });
const wsRefs = useRef<Record<Exchange, WebSocket | null>>({ SSE: null, SZSE: null });
const heartbeatRefs = useRef<Record<Exchange, NodeJS.Timeout | null>>({ SSE: null, SZSE: null });
const reconnectRefs = useRef<Record<Exchange, NodeJS.Timeout | null>>({ SSE: null, SZSE: null });
// 重连计数器(避免无限重连刷屏)
const reconnectCountRef = useRef<Record<Exchange, number>>({ SSE: 0, SZSE: 0 });
const MAX_RECONNECT_ATTEMPTS = 5;
// 深交所 WebSocket 就绪状态(收到 welcome 消息后才能订阅)
const szseReadyRef = useRef<boolean>(false);
// 待发送的深交所订阅队列(在 welcome 之前收到的订阅请求)
const szsePendingSubscribeRef = useRef<string[]>([]);
const subscribedCodes = useRef<Record<Exchange, Set<string>>>({
SSE: new Set(),
SZSE: new Set(),
});
const stopHeartbeat = useCallback((exchange: Exchange) => {
if (heartbeatRefs.current[exchange]) {
clearInterval(heartbeatRefs.current[exchange]!);
heartbeatRefs.current[exchange] = null;
}
}, []);
const startHeartbeat = useCallback((exchange: Exchange) => {
stopHeartbeat(exchange);
heartbeatRefs.current[exchange] = setInterval(() => {
const ws = wsRefs.current[exchange];
if (ws && ws.readyState === WebSocket.OPEN) {
// 上交所使用 action: 'ping',深交所使用 type: 'ping'
const pingMsg = exchange === 'SSE'
? { action: 'ping' }
: { type: 'ping' };
ws.send(JSON.stringify(pingMsg));
}
}, HEARTBEAT_INTERVAL);
}, [stopHeartbeat]);
/**
* 发送深交所订阅请求
* 格式:{ type: 'subscribe', securities: ['000001', '000002'] }
*/
const sendSZSESubscribe = useCallback((baseCodes: string[]) => {
const ws = wsRefs.current.SZSE;
if (ws && ws.readyState === WebSocket.OPEN && baseCodes.length > 0) {
ws.send(JSON.stringify({
type: 'subscribe',
securities: baseCodes,
}));
logger.info('FlexScreen', `SZSE 发送订阅请求`, { securities: baseCodes });
}
}, []);
/**
* 处理消息
*/
const handleMessage = useCallback((exchange: Exchange, msg: SSEMessage | SZSEMessage) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const anyMsg = msg as any;
// 心跳响应(上交所和深交所格式可能不同)
if (msg.type === 'pong' || anyMsg.action === 'pong') return;
if (exchange === 'SSE') {
// 上交所消息处理
if (msg.type === 'subscribed') {
logger.info('FlexScreen', 'SSE 订阅成功', { channels: anyMsg.channels });
return;
}
if (msg.type === 'error') {
logger.error('FlexScreen', 'SSE WebSocket 错误', { message: anyMsg.message });
return;
}
// 处理行情数据
const result = handleSSEMessage(
msg as SSEMessage,
subscribedCodes.current.SSE,
{}
);
if (result) {
setQuotes(prev => ({ ...prev, ...result }));
}
} else {
// 深交所消息处理
switch (msg.type) {
case 'welcome':
// 收到欢迎消息,深交所 WebSocket 就绪
logger.info('FlexScreen', 'SZSE WebSocket 就绪,可以订阅');
szseReadyRef.current = true;
// 发送之前待处理的订阅请求
if (szsePendingSubscribeRef.current.length > 0) {
sendSZSESubscribe(szsePendingSubscribeRef.current);
szsePendingSubscribeRef.current = [];
} else {
// 如果已有订阅的代码,立即发送订阅
const currentCodes = Array.from(subscribedCodes.current.SZSE).map(c => normalizeCode(c));
if (currentCodes.length > 0) {
sendSZSESubscribe(currentCodes);
}
}
break;
case 'subscribed':
// 订阅成功确认
logger.info('FlexScreen', 'SZSE 订阅成功', {
securities: anyMsg.securities,
categories: anyMsg.categories,
});
break;
case 'unsubscribed':
// 取消订阅确认
logger.info('FlexScreen', 'SZSE 取消订阅成功');
break;
case 'snapshot':
// 快照消息(订阅后首次返回的批量数据)
setQuotes(prev => {
const result = handleSZSESnapshotMessage(
msg as SZSESnapshotMessage,
subscribedCodes.current.SZSE,
prev
);
return result || prev;
});
break;
case 'realtime':
// 实时行情推送
setQuotes(prev => {
const result = handleSZSERealtimeMessage(
msg as SZSERealtimeMessage,
subscribedCodes.current.SZSE,
prev
);
return result || prev;
});
break;
case 'query_result':
case 'query_batch_result':
// 查询结果(目前不处理)
break;
case 'error':
logger.error('FlexScreen', 'SZSE WebSocket 错误', { message: anyMsg.message });
break;
default:
// 未知消息类型
break;
}
}
}, [sendSZSESubscribe]);
/**
* 创建 WebSocket 连接
*/
const createConnection = useCallback((exchange: Exchange) => {
const isHttps = typeof window !== 'undefined' && window.location.protocol === 'https:';
const wsUrl = WS_CONFIG[exchange];
const isInsecureWs = wsUrl.startsWith('ws://');
if (isHttps && isInsecureWs) {
logger.warn(
'FlexScreen',
`${exchange} WebSocket 配置错误HTTPS 页面尝试连接 ws:// 端点,请检查 Nginx 代理配置`
);
return;
}
if (wsRefs.current[exchange]) {
wsRefs.current[exchange]!.close();
}
// 重置深交所就绪状态
if (exchange === 'SZSE') {
szseReadyRef.current = false;
}
try {
const ws = new WebSocket(wsUrl);
wsRefs.current[exchange] = ws;
ws.onopen = () => {
logger.info('FlexScreen', `${exchange} WebSocket 已连接`, { url: wsUrl });
setConnected(prev => ({ ...prev, [exchange]: true }));
// 连接成功,重置重连计数
reconnectCountRef.current[exchange] = 0;
if (exchange === 'SSE') {
// 上交所:连接后立即发送订阅
const fullCodes = Array.from(subscribedCodes.current.SSE);
const baseCodes = fullCodes.map(c => normalizeCode(c));
if (baseCodes.length > 0) {
ws.send(JSON.stringify({
action: 'subscribe',
channels: ['stock', 'index'],
codes: baseCodes,
}));
}
}
// 深交所:等待 welcome 消息后再订阅
startHeartbeat(exchange);
};
ws.onmessage = (event: MessageEvent) => {
try {
const msg = JSON.parse(event.data);
handleMessage(exchange, msg);
} catch (e) {
logger.warn('FlexScreen', `${exchange} 消息解析失败`, e);
}
};
ws.onerror = (error: Event) => {
logger.error('FlexScreen', `${exchange} WebSocket 连接失败`, {
url: wsUrl,
readyState: ws.readyState,
hint: '请检查1) 后端服务是否启动 2) Nginx 代理是否配置正确',
});
};
ws.onclose = () => {
logger.info('FlexScreen', `${exchange} WebSocket 断开`);
setConnected(prev => ({ ...prev, [exchange]: false }));
stopHeartbeat(exchange);
// 重置深交所就绪状态
if (exchange === 'SZSE') {
szseReadyRef.current = false;
}
// 自动重连(有次数限制,避免刷屏)
const currentAttempts = reconnectCountRef.current[exchange];
if (
!reconnectRefs.current[exchange] &&
subscribedCodes.current[exchange].size > 0 &&
currentAttempts < MAX_RECONNECT_ATTEMPTS
) {
reconnectCountRef.current[exchange] = currentAttempts + 1;
// 指数退避3秒、6秒、12秒、24秒、48秒
const delay = RECONNECT_INTERVAL * Math.pow(2, currentAttempts);
logger.info('FlexScreen', `${exchange} 将在 ${delay / 1000} 秒后重连 (${currentAttempts + 1}/${MAX_RECONNECT_ATTEMPTS})`);
reconnectRefs.current[exchange] = setTimeout(() => {
reconnectRefs.current[exchange] = null;
if (subscribedCodes.current[exchange].size > 0) {
createConnection(exchange);
}
}, delay);
} else if (currentAttempts >= MAX_RECONNECT_ATTEMPTS) {
logger.warn('FlexScreen', `${exchange} 达到最大重连次数,停止重连。请检查 WebSocket 服务是否正常。`, {
url: wsUrl,
});
}
};
} catch (e) {
logger.error('FlexScreen', `${exchange} WebSocket 连接失败`, e);
setConnected(prev => ({ ...prev, [exchange]: false }));
}
}, [startHeartbeat, stopHeartbeat, handleMessage]);
/**
* 订阅单个证券
*/
const subscribe = useCallback((code: string) => {
const exchange = getExchange(code);
const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`;
const baseCode = normalizeCode(code);
subscribedCodes.current[exchange].add(fullCode);
const ws = wsRefs.current[exchange];
if (exchange === 'SSE') {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
action: 'subscribe',
channels: ['stock', 'index'],
codes: [baseCode],
}));
}
} else {
// 深交所
if (ws && ws.readyState === WebSocket.OPEN && szseReadyRef.current) {
sendSZSESubscribe([baseCode]);
} else if (ws && ws.readyState === WebSocket.OPEN) {
// WebSocket 已连接但未收到 welcome加入待处理队列
szsePendingSubscribeRef.current.push(baseCode);
}
}
if (!ws || ws.readyState !== WebSocket.OPEN) {
createConnection(exchange);
}
}, [createConnection, sendSZSESubscribe]);
/**
* 取消订阅
*/
const unsubscribe = useCallback((code: string) => {
const exchange = getExchange(code);
const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`;
const baseCode = normalizeCode(code);
subscribedCodes.current[exchange].delete(fullCode);
// 发送取消订阅请求(深交所)
const ws = wsRefs.current[exchange];
if (exchange === 'SZSE' && ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({
type: 'unsubscribe',
securities: [baseCode],
}));
}
setQuotes(prev => {
const updated = { ...prev };
delete updated[fullCode];
return updated;
});
if (subscribedCodes.current[exchange].size === 0) {
if (ws) {
ws.close();
wsRefs.current[exchange] = null;
}
}
}, []);
// 初始化和 codes 变化处理
useEffect(() => {
if (!codes || codes.length === 0) return;
const newSseCodes = new Set<string>();
const newSzseCodes = new Set<string>();
codes.forEach(code => {
const exchange = getExchange(code);
const fullCode = code.includes('.') ? code : `${code}.${exchange === 'SSE' ? 'SH' : 'SZ'}`;
if (exchange === 'SSE') {
newSseCodes.add(fullCode);
} else {
newSzseCodes.add(fullCode);
}
});
// 更新上交所订阅
const oldSseCodes = subscribedCodes.current.SSE;
const sseToAdd = [...newSseCodes].filter(c => !oldSseCodes.has(c));
const sseToAddBase = sseToAdd.map(c => normalizeCode(c));
if (sseToAdd.length > 0 || newSseCodes.size !== oldSseCodes.size) {
subscribedCodes.current.SSE = newSseCodes;
const ws = wsRefs.current.SSE;
if (ws && ws.readyState === WebSocket.OPEN && sseToAddBase.length > 0) {
ws.send(JSON.stringify({
action: 'subscribe',
channels: ['stock', 'index'],
codes: sseToAddBase,
}));
}
if (sseToAdd.length > 0 && (!ws || ws.readyState !== WebSocket.OPEN)) {
createConnection('SSE');
}
if (newSseCodes.size === 0 && ws) {
ws.close();
wsRefs.current.SSE = null;
}
}
// 更新深交所订阅
const oldSzseCodes = subscribedCodes.current.SZSE;
const szseToAdd = [...newSzseCodes].filter(c => !oldSzseCodes.has(c));
const szseToAddBase = szseToAdd.map(c => normalizeCode(c));
if (szseToAdd.length > 0 || newSzseCodes.size !== oldSzseCodes.size) {
subscribedCodes.current.SZSE = newSzseCodes;
const ws = wsRefs.current.SZSE;
if (szseToAdd.length > 0) {
if (ws && ws.readyState === WebSocket.OPEN && szseReadyRef.current) {
// WebSocket 已就绪,直接发送订阅
sendSZSESubscribe(szseToAddBase);
} else if (ws && ws.readyState === WebSocket.OPEN) {
// WebSocket 已连接但未就绪,加入待处理队列
szsePendingSubscribeRef.current.push(...szseToAddBase);
} else {
// WebSocket 未连接,创建连接
createConnection('SZSE');
}
}
if (newSzseCodes.size === 0 && ws) {
ws.close();
wsRefs.current.SZSE = null;
}
}
// 清理已取消订阅的 quotes
const allNewCodes = new Set([...newSseCodes, ...newSzseCodes]);
setQuotes(prev => {
const updated: QuotesMap = {};
Object.keys(prev).forEach(code => {
if (allNewCodes.has(code)) {
updated[code] = prev[code];
}
});
return updated;
});
}, [codes, createConnection, sendSZSESubscribe]);
// 清理
useEffect(() => {
return () => {
(['SSE', 'SZSE'] as Exchange[]).forEach(exchange => {
stopHeartbeat(exchange);
if (reconnectRefs.current[exchange]) {
clearTimeout(reconnectRefs.current[exchange]!);
}
const ws = wsRefs.current[exchange];
if (ws) {
ws.close();
}
});
};
}, [stopHeartbeat]);
return { quotes, connected, subscribe, unsubscribe };
};
export default useRealtimeQuote;