一、架构设计与技术选型
1.1 Markets 模块核心架构
在noFavorite组件 初始化的时候直接从接口拿到数据显示 然后再维护一个状态数组保存收藏的种类
自定义 Hook: useCollect(核心状态管理)
- 初始化的时候调用API得到collectInfo,设置CollectState的初始状态
- 用户添加收藏的时候,调用接口,再更新本地状态和collectInfo
- 显示UI的时候,优先显示本地状态,备用collectInfo
- 延迟同步机制:通过 pendingUnfavorites 实现"不立即消失"需求
- 状态分离:Spot 和 Contract 独立管理,避免数据混淆
useCollect() → 本地状态立即更新 → 异步同步服务端 → 切换 Tab 时刷新
// apps/my-page/hooks/use-collect.ts
export const useCollect = () => {
const [collectInfo, setCollectInfo] = useState<CollectInfo>();
const [spotCollectState, setSpotCollectState] = useState<string[]>([]);
const [contractCollectState, setContractCollectState] = useState<string[]>([]);
const [pendingUnfavorites, setPendingUnfavorites] = useState<Set<string>>(new Set());
// 初始化收藏数据
const initCollectInfo = async () => {
const res = await getCollectInfo();
if (res.ret_code === 0) {
setCollectInfo(res.result.preferences);
setSpotCollectState(res.result.preferences.spotBookSymbolSequence?.split(',').filter(Boolean) || []);
setContractCollectState(res.result.preferences.bookSymbolSequence?.split(',').filter(Boolean) || []);
}
};
// 刷新收藏数据(切换 Tab 时调用)
const refreshCollectInfo = async () => {
await initCollectInfo();
setPendingUnfavorites(new Set()); // 清空待移除列表
};
// 点击收藏/取消收藏
const clickCollect = async (data: MarketData, marketType: 'spot' | 'contract') => {
if (!data?.tradingPair) return;
const normalizedPair = normalizeTradingPair(data.tradingPair); // 移除斜杠
const currentList = marketType === 'spot' ? spotCollectState : contractCollectState;
const isCurrentlyCollected = currentList.includes(normalizedPair);
// 计算新的收藏列表
const newList = isCurrentlyCollected
? currentList.filter(item => item !== normalizedPair)
: [...currentList, normalizedPair];
// 乐观更新本地状态(立即反馈用户)
if (marketType === 'spot') {
setSpotCollectState(newList);
} else {
setContractCollectState(newList);
}
// 异步同步到服务端
await updateCollectInfo({
upsert_keys: {
spotBookSymbolSequence: marketType === 'spot' ? newList.join(',') : spotCollectState.join(','),
bookSymbolSequence: marketType === 'contract' ? newList.join(',') : contractCollectState.join(',')
}
});
// 埋点记录
track?.('MyPageClick', {
section_type: 'Markets',
button_name: isCurrentlyCollected ? 'Remove_from_Favorites' : 'Add_to_Favorites',
page_type: 'favorites_candidate',
page_name: marketType
});
};
return {
collectInfo,
spotCollectState,
contractCollectState,
pendingUnfavorites,
initCollectInfo,
refreshCollectInfo,
clickCollect,
setPendingUnfavorites
};
};
1.2 市场数据共享(PC 端使用 Context)
// apps/my-page/contexts/MarketDataContext.tsx
export const MarketDataProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
const marketData = useMarket({
needSpot: true,
needContract: true,
defaultType: 'spot',
sortRule: [
{ label: 'hot', tab: 'hot' },
{ label: 'newListing', tab: 'newListing' },
// ... 其他排行榜
]
});
return (
<MarketDataContext.Provider value={marketData}>
{children}
</MarketDataContext.Provider>
);
};
// 子组件使用
const { spot, contract, tabKey } = useMarketData();
优势:
- 不在顶层使用useMarket hook是因为顶层的父组件会根据市场种类(spot或衍生品)和tab的state的变化而重新渲染,如果继续使用hook会导致重复发送请求.使用context就只是在Provider里请求一次
- 解决 Tab 切换时 Spot/Contract 数据错乱问题:
现象:
1. 最初问题
现象:切换到 favorites tab 时,NoFavorites 组件没有数据
contractSymbolList.length = 0hot.total = 0
原因分析:
NoFavorites第一次挂载useMarkethook 创建新的 WebSocket 连接- WebSocket 数据还未加载完成
2. 第二个问题
现象:从 favorites tab 切回其他 tab 后,其他 tab 也没有数据了
原因分析:
- 子组件各自调用
useMarket,每次挂载都创建新的 WebSocket 连接 - 组件卸载时,WebSocket 连接被关闭
- 重新挂载时,新连接还没接收到数据
3. 尝试的方案 1:父组件共享 useMarket
实现:
// Markets 父组件
const marketData = useMarket({ language: locale, userInfo });
// 传递给子组件
<NoFavorites marketData={marketData} />
<MarketDataTable marketData={marketData} />
结果:❌ 导致父组件重复渲染
- WebSocket 实时更新 →
marketData对象引用变化 - 父组件重新渲染 → 所有子组件也重新渲染
- 渲染频率过高
4. 尝试的方案 2:CSS display:none 保持挂载
实现:
<div style={{ display: tabValue === 'favorites' ? 'block' : 'none' }}>
<NoFavorites />
</div>
<div style={{ display: tabValue !== 'favorites' ? 'block' : 'none' }}>
<MarketTable />
</div>
结果:❌ 3 个 useMarket 实例互相干扰
- 父组件 1 个
useMarket NoFavorites1 个useMarket(虽然隐藏但仍在运行)MarketDataTable1 个useMarket- 多个实例导致 WebSocket 连接混乱
- Contract derivatives 数据全部为空:
contractSymbolList: []
当前状态
代码结构:
- 父组件调用
useMarket - 两个子组件都用
display: none控制显示 - 两个子组件内部也各自调用
useMarket - 共 3 个
useMarket实例同时运行
问题: 在 Derivatives 市场类型下
- 所有 tab (hot, gainers, losers, etc.) 都没有数据
contractSymbolList: []- 所有 ranking 数据都是
{total: 0, data: Array(0)}
根本矛盾
┌─────────────────────────────────────────────────────┐
│ 根本矛盾 │
├─────────────────────────────────────────────────────┤
│ 1. 数据实时性要求 ←──────→ 避免重复渲染 │
│ • WebSocket实时推送 • 状态变化触发重渲染 │
│ • 价格频繁变化 • 组件结构复杂 │
│ │
│ 2. 数据共享要求 ←──────→ 模块独立性 │
│ • 避免重复连接 • 组件解耦 │
│ • 减少内存占用 • 维护简单 │
│ │
│ 3. 连接复用要求 ←──────→ 生命周期管理 │
│ • 保持WebSocket连接 • 组件卸载清理资源 │
│ • 避免重复建立连接 • 避免内存泄漏 │
└─────────────────────────────────────────────────────┘
解决方案就是上述的useContext数据共享
问题: 在收藏列表点击取消收藏,该列会立即消失,影响用户体验
需求:
- 用户点击取消收藏后,不要立即从列表中移除
- 等下次切换 Tab 时再同步
思路:
用户取消收藏:
- 立即更新本地的collectState,因为星标显示收藏与否是collectState来控制的,这样用户取消收藏,星标UI就能显示为空心,
// 立即更新本地状态,保持数据一致性
collectHooks.setSpotCollectState(tmpSpotBookSequence);
collectHooks.setContractCollectState(tmpContractBookSequence);
if (collectHooks.updateCollectInfoState) {
collectHooks.updateCollectInfoState(newCollectInfo);//调用接口更新
}
// 如果在 favorites tab 中取消收藏,将被取消收藏的种类保存到pendingUnfavorites里
if (tabValue === 'favorites' && !doCollect) {
setPendingUnfavorites(prev => new Set(prev).add(normalizeTradingPair(data.tradingPair)));
} else if (tabValue === 'favorites' && doCollect) {
// 如果在 favorites tab 中重新收藏,从待移除列表中移除
const normalizedPair = normalizeTradingPair(data.tradingPair);
setPendingUnfavorites(prev => {
const newSet = new Set(prev);
newSet.delete(normalizedPair);
return newSet;
});
- 在favorites tab 中同时显示favoritesList(接口拿到的数据)和pendingUnfavorites,保留已经取消收藏的项目
// 在 favorites tab 中,将 pendingUnfavorites 中的项目也加入显示列表
// 这样即使已经从 collectState 中移除,也会暂时保留在列表中
if (tabKey === 'favorites' && pendingUnfavorites.size > 0) {
const pendingItems = Array.from(pendingUnfavorites);
// 合并收藏列表和待移除列表,去重
const combined = [...favoritesList, ...pendingItems];
favoritesList = Array.from(new Set(combined.map(normalizeTradingPair)))
.map(normalized => {
// 尝试从原列表中找到原始格式
return favoritesList.find(item => normalizeTradingPair(item) === normalized) ||
pendingItems.find(item => normalizeTradingPair(item) === normalized) ||
normalized;
});
- 在切换tab的时候清空pendingUnfavorites,然后调用接口刷新收藏数据,再次切换到favorites tab,之前取消收藏的项目就不会显示了
二、核心功能实现
2.1 Favorites 空状态默认推荐hot榜单前 6 条数据
组件结构:
// /Markets/NoFavorites/index.tsx
const NoFavorites = ({ marketType, onAddToFavorites }) => {
const { spot, contract } = useMarketData();
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
// 获取 Hot 榜单前 6 条数据
//..
// 默认全选
useEffect(() => {
const allIds = hotData.map(item => normalizeTradingPair(item.symbolName));
setSelectedItems(new Set(allIds));
}, [hotData]);
// 添加到收藏
const handleAddToFavorites = async () => {
const selectedArray = Array.from(selectedItems);
await onAddToFavorites(selectedArray);
// 埋点
//...
};
return (
<div className={styles.noFavorites}>
<div className={styles.grid}>
{hotData.map(item => (
<NoFavoritesItem
key={item.symbol}
data={item}
selected={selectedItems.has(normalizeTradingPair(item.symbolName))}
onToggle={(id) => {
const newSet = new Set(selectedItems);
newSet.has(id) ? newSet.delete(id) : newSet.add(id);
setSelectedItems(newSet);
}}
/>
))}
</div>
<Button onClick={handleAddToFavorites}>
Add to Favorites ({selectedItems.size})
</Button>
</div>
);
};
2.2 星标交互实现
MarketDataTable 组件:
// /Markets/MarketDataTable/index.tsx
const MarketDataTable = ({ marketType, tabValue }) => {
const { spot, contract } = useMarketData();
const { spotCollectState, contractCollectState, clickCollect } = useCollect();
// 获取当前市场的收藏列表
const collectState = marketType === 'spot' ? spotCollectState : contractCollectState;
// 过滤数据:favorites tab 只显示收藏项
const filteredList = useMemo(() => {
const data = marketType === 'spot' ? spot : contract;
const rawData = tabValue === 'favorites'
? (marketType === 'spot' ? data?.spotSymbolList : data?.contractSymbolList)
: data?.ranking?.[tabValue]?.data;
return rawData?.map(item => ({
id: item.symbol,
price: item.price,
//..
volume24h: item.turnover24hUSD,
isCollected: collectState.includes(normalizeTradingPair(item.symbolName)) // 收藏状态
}));
}, [spot, contract, tabValue, marketType, collectState]);
// 分页处理
const paginatedData = useMemo(() => {
return filteredList?.slice((currentPage - 1) * 6, currentPage * 6) || [];
}, [filteredList, currentPage]);
return (
<div className={styles.tableWrapper}>
<table>
<thead>
<tr>
<th>Trading Pair</th>
<th>Price</th>
//..
</tr>
</thead>
<tbody>
{paginatedData.map(row => (
<tr key={row.id}>
<td>
<div className={styles.pairCell}>
{/* 星标图标 */}
<Tooltip title={row.isCollected ? 'Remove from Favorites' : 'Add to Favorites'}>
<div
className={styles.starIcon}
onClick={() => clickCollect(row, marketType)}
>
{row.isCollected ? <StarFilled /> : <StarOutlined />}
</div>
</Tooltip>
<img src={row.icon} alt={row.tradingPair} />
<span>{row.tradingPair}</span>
</div>
</td>
<td>{row.price}</td>
<td className={row.change24h >= 0 ? styles.positive : styles.negative}>
{row.change24h}%
</td>
<td>{row.volume24h}</td>
<td>
<Button href={row.link}>Trade</Button>
</td>
</tr>
))}
</tbody>
</table>
{/* 分页器 */}
<Pagination
current={currentPage}
total={filteredList?.length || 0}
pageSize={6}
onChange={setCurrentPage}
/>
</div>
);
};
星标样式:
.starIcon {
cursor: pointer;
font-size: 16px;
margin-right: 8px;
&:hover {
transform: scale(1.2);
transition: transform 0.2s;
}
// 黄色星标(已收藏)
:global(.anticon-star) {
color: #FFD700;
}
// 灰色星标(未收藏)
:global(.anticon-star-outlined) {
color: #8C8C8C;
}
}
2.3 API 接口包含
// /api/favorites.ts
// 获取收藏列表
export const getCollectInfo = () => {
return api.get('/example/spot');
};
// 更新收藏列表
export const updateCollectInfo = (data: {
//传参
//..
}) => {
return api.post('/example/pref-setting', data);
};
三、项目成果
✅ Markets 收藏功能(已全量上线)
- Favorites 标签页(空状态 + 收藏列表)
- 星标交互覆盖 5 个排行榜(Hot/NewListing/Gainers/Losers/Turnover)
- PC + H5 双端完整适配
- 现货和衍生品独立管理
- 分页功能(每页 6 个,超过显示分页器)
- 延迟刷新机制(切换 Tab 时同步)