一、架构设计与技术选型
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)
现象:
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数据共享
// 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 数据错乱问题
问题: 在收藏列表点击取消收藏,该列会立即消失,影响用户体验
需求:
- 用户点击取消收藏后,不要立即从列表中移除
- 等下次切换 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);
};
附录1:
Favorites 组件及其数据源:
📊 Favorites 数据架构
API 层 (api/favorites.ts)
1. // 数据结构
2. interface FavoritesResponse {
3. spot?: string[]; // 现货
4. contract?: string[]; // 合约
5. }
6.
7. // API 接口
8. getCollectInfo(['bookSymbolSequence', 'spotBookSymbolSequence'])
9. // → /preferences
10.
11. updateCollectInfo({ upsert_keys: {...} })
12. // → /pref-setting
- 数据管理 Hooks
use-collect.ts (主要使用的 Hook)
状态管理:
spotCollectState: 现货收藏列表contractCollectState: 合约收藏列表collectInfo: 原始收藏信息 核心方法:addToFavorites(): 添加收藏refreshCollectInfo(): 刷新数据updateCollectInfoState(): 更新本地状态 数据流程: 初始化时调用getCollectInfo()获取数据 解析响应:res?.result.preferences分别设置spotCollectState和contractCollectStateuseFavorites.ts (备用 Hook) 提供了更完整的操作方法:addFavorite()/removeFavorite()/toggleFavorite()支持乐观更新(optimistic update) - UI 组件
Markets 组件 (containers/newMePage/Markets/index.tsx)
核心逻辑在
clickCollect函数 (第 101-204 行):
const clickCollect = async ({ data, doCollect }) => {
// 1. 获取当前收藏列表
const bookSymbolSequence = collectHooks.contractCollectState
const spotBookSymbolSequence = collectHooks.spotCollectState
// 2. 添加或删除交易对
// 3. 调用 API: updateCollectInfo()
// 4. 更新本地状态
// 5. 处理 UI 反馈
}
- 数据存储格式
// 后端存储 (逗号分隔的字符串)
2. {
3. bookSymbolSequence: "BTCUSDT,ETHUSDT,SOLUSDT", // 合约
4. spotBookSymbolSequence: "BTC/USDT,ETH/USDT,SOL/USDT" // 现货
5. }
6.
7. // 前端状态 (数组)
8. contractCollectState: ["BTCUSDT", "ETHUSDT", "SOLUSDT"]
9. spotCollectState: ["BTC/USDT", "ETH/USDT", "SOL/USDT"]
- 关键特性
✅ 双向同步: 本地状态 ↔ 远程 API
✅ 去重逻辑: 添加前检查是否已存在
✅ 格式标准化: 自动处理斜杠格式差异
✅ 乐观更新: 先更新 UI,再调用 API
✅ 延迟移除: Favorites tab 中取消收藏时使用
pendingUnfavorites进行视觉缓冲 需要查看具体实现细节吗?
你可能会问的问题1:
延迟移除: Favorites tab 中取消收藏时使用 pendingUnfavorites 进行视觉缓冲,为什么需要,Favorites tab 的列表数据不是从接口拿到然后再根据favorite接口返回的数据再过滤的么
原因是数据流的时序问题。
🔄 实际的数据流
Favorites Tab 的数据获取方式:
1. // 1. 获取完整的市场数据(所有交易对)
2. const symbolList = marketType === 'spot'
3. ? data.spotSymbolList // 所有现货交易对
4. : data.contractSymbolList; // 所有合约交易对
5.
6. // 2. 用 favoritesList 过滤出收藏的交易对
7. const filtered = symbolList?.filter((item) => {
8. return favoritesList.some(fav =>
9. normalizeTradingPair(fav) === normalizeTradingPair(item.symbol)
10. );
11. });
关键点:favoritesList 的来源(第 60-66 行):
1. let favoritesList = marketType === 'spot'
2. ? (spotCollectState.length > 0 // ⚠️ 优先使用本地状态!
3. ? spotCollectState
4. : collectInfo?.spotBookSymbolSequence?.split(','))
5. : (contractCollectState.length > 0
6. ? contractCollectState
7. : collectInfo?.bookSymbolSequence?.split(','));
❌ 如果没有 pendingUnfavorites 会发生什么?
用户点击取消收藏 → 触发 clickCollect({ doCollect: false })
立即更新本地状态 → collectHooks.setSpotCollectState(newList)
favoritesList 立即改变 → 因为它依赖 spotCollectState
列表立即重新过滤 → 该交易对从 filtered 中消失
UI 中直接消失 → 用户体验差!
- 用户点击 ⭐ → 项目立即从列表中消失(没有视觉反馈)
✅ 有了 pendingUnfavorites 的效果
第 71-84 行的逻辑:
1. // 在 favorites tab 中,将 pendingUnfavorites 中的项目临时加回列表
2. if (tabKey === 'favorites' && pendingUnfavorites.size > 0) {
3. const pendingItems = Array.from(pendingUnfavorites);
4. // 合并收藏列表和待移除列表
5. favoritesList = [...favoritesList, ...pendingItems];
6. }
第 103-115 行:用 currentCollectState 判断 selected 状态
1. // 检查是否真正在收藏列表中
2. const isInCollectState = currentCollectState.some(collect =>
3. collect?.replace('/', '') === item.symbolName?.replace('/', '')
4. );
5.
6. return {
7. ...
8. selected: isInCollectState // ⭐ 决定星星是实心还是空心
9. };
效果:
- 用户点击 ⭐ → 星星变空心(☆),但项目保留在列表中
- 切换 tab → 刷新数据 → 项目才真正消失
这是一个典型的 乐观更新 + 延迟视觉移除 的 UX 优化模式!
你可能会问的问题2:
tab的数据是从接口拿到然后再根据favoritesList再过滤的,那么favoritesList为什么要优先使用本地的数据,是为了做状态管理么
完全正确!优先使用本地状态确实是为了做状态管理,但更准确地说是为了实现 乐观更新(Optimistic Update) 和 即时 UI 响应。
❌ 如果直接依赖 collectInfo(接口数据)
1. // 假设的错误实现
2. let favoritesList = collectInfo?.spotBookSymbolSequence?.split(',') || [];
数据流程:
1. 1. 用户点击收藏
2. ↓
3. 2. 调用 API: await updateCollectInfo() ⏱️ 200-500ms
4. ↓
5. 3. 等待 API 响应...
6. ↓
7. 4. 重新调用 getCollectInfo() 获取最新数据 ⏱️ 200-500ms
8. ↓
9. 5. 更新 collectInfo
10. ↓
11. 6. 组件重新渲染,UI 更新
问题: ⏱️ 响应慢:用户要等 400-1000ms 才能看到变化
🐛 UI 卡顿:点击后没有立即反馈
🔄 需要重新请求:还要再调用一次 getCollectInfo()
✅ 优先使用本地状态 spotCollectState
数据流程:
1. 1. 用户点击收藏
2. ↓
3. 2. 立即更新本地状态 ⚡ <5ms
4. collectHooks.setSpotCollectState(newList)
5. ↓
6. 3. React 检测到状态变化 → 组件重新渲染 ⚡ <16ms
7. ↓
8. 4. UI 立即更新 ✅
9. ↓
10. 5. 同时在后台调用 API 🔄
11. await updateCollectInfo()
12.
13. 总耗时(用户感知): <20ms ✅
🎯 为什么需要这样的状态管理 看 Markets/index.tsx:179-187 的关键代码:
1. // 先调用 API(异步操作)
2. await updateCollectInfo(payload);
3.
4. // ⚡ 立即更新本地状态(同步操作)
5. collectHooks.setSpotCollectState(tmpSpotBookSequence); // 更新 spot 本地状态
6. collectHooks.setContractCollectState(tmpContractBookSequence); // 更新 contract 本地状态
7.
8. // 同时更新 collectInfo(保持一致性)
9. collectHooks.updateCollectInfoState(newCollectInfo);
使用spotCollectState (本地状态)
作为UI 渲染的主数据源
可以让用户操作后立即更新
时长 <5ms
使用collectInfo (接口数据)
作为初始化 & 备用数据源
用在页面加载时 / tab 切换时
时长⏱️ 200-500ms
🔄 完整的状态管理策略:
1️⃣ 初始化阶段 (use-collect.ts:75-102)
1. useEffect(() => {
2. getCollectInfo(['bookSymbolSequence', 'spotBookSymbolSequence']).then((res) => {
3. const info = res?.result.preferences;
4.
5. // 从接口初始化本地状态
6. setSpotCollectState(info.spotBookSymbolSequence.split(','));
7. setContractCollectState(info.bookSymbolSequence.split(','));
8. });
9. }, []);
2️⃣ 用户操作阶段 (Markets/index.tsx:179-187)
1. await updateCollectInfo(payload); // 后台同步到服务器
2.
3. // ⚡ 乐观更新:立即更新本地状态,不等待服务器响应
4. collectHooks.setSpotCollectState(tmpSpotBookSequence);
3️⃣ Tab 切换刷新 (Markets/index.tsx:86-98)
1. const changeTableHandler = async (activeKey: string) => {
2. // 从 favorites tab 切换出去时,刷新收藏数据
3. if (tabValue === 'favorites' && activeKey !== 'favorites') {
4. await collectHooks.refreshCollectInfo?.(); // 从服务器同步最新数据
5. }
6. };
4️⃣ MarketDataTable 渲染 (MarketDataTable/index.tsx:60-66)
// 优先使用本地状态(已是最新)
2. // 如果本地状态为空(初始化未完成),才使用 collectInfo 备用
3. let favoritesList = marketType === 'spot'
4. ? (spotCollectState.length > 0 // ✅ 优先
5. ? spotCollectState
6. : collectInfo?.spotBookSymbolSequence?.split(',')) // 备用
7. : ...
💡 总结
优先使用本地状态的原因: ⚡ 即时响应:用户操作后 UI 立即更新(<20ms vs 400-1000ms)
🎯 乐观更新:假设 API 会成功,先更新 UI,API 在后台同步
🔄 单一数据源:本地状态成为运行时的 "Single Source of Truth"
🛡️ 降级备用:collectInfo 作为初始化和备用数据源
🎨 更好的 UX:用户感觉界面响应迅速、流畅
这是现代 Web 应用的标准做法,React Query、SWR 等库都采用类似的策略!👍
三、项目成果
✅ Markets 收藏功能(已全量上线)
- Favorites 标签页(空状态 + 收藏列表)
- 星标交互覆盖 5 个排行榜(Hot/NewListing/Gainers/Losers/Turnover)
- PC + H5 双端完整适配
- 现货和衍生品独立管理
- 分页功能(每页 6 个,超过显示分页器)
- 延迟刷新机制(切换 Tab 时同步)