市场行情收藏列表联动的状态管理

71 阅读12分钟

一、架构设计与技术选型

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 = 0
  • hot.total = 0

原因分析

  1. NoFavorites 第一次挂载
  2. useMarket hook 创建新的 WebSocket 连接
  3. WebSocket 数据还未加载完成

2. 第二个问题

现象:从 favorites tab 切回其他 tab 后,其他 tab 也没有数据了

原因分析

  1. 子组件各自调用 useMarket,每次挂载都创建新的 WebSocket 连接
  2. 组件卸载时,WebSocket 连接被关闭
  3. 重新挂载时,新连接还没接收到数据

3. 尝试的方案 1:父组件共享 useMarket

实现

// Markets 父组件
const marketData = useMarket({ language: locale, userInfo });

// 传递给子组件
<NoFavorites marketData={marketData} />
<MarketDataTable marketData={marketData} />

结果:❌ 导致父组件重复渲染

  1. WebSocket 实时更新 → marketData 对象引用变化
  2. 父组件重新渲染 → 所有子组件也重新渲染
  3. 渲染频率过高

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. 父组件 1 个 useMarket
  2. NoFavorites 1 个 useMarket (虽然隐藏但仍在运行)
  3. MarketDataTable 1 个 useMarket
  4. 多个实例导致 WebSocket 连接混乱
  5. 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 时再同步

思路:

用户取消收藏:

  1. 立即更新本地的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
  1. 数据管理 Hooks use-collect.ts (主要使用的 Hook) 状态管理: spotCollectState: 现货收藏列表 contractCollectState: 合约收藏列表 collectInfo: 原始收藏信息 核心方法: addToFavorites(): 添加收藏 refreshCollectInfo(): 刷新数据 updateCollectInfoState(): 更新本地状态 数据流程: 初始化时调用 getCollectInfo() 获取数据 解析响应: res?.result.preferences 分别设置 spotCollectStatecontractCollectState useFavorites.ts (备用 Hook) 提供了更完整的操作方法: addFavorite() / removeFavorite() / toggleFavorite() 支持乐观更新(optimistic update)
  2. 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 反馈
  }
  1. 数据存储格式
 // 后端存储 (逗号分隔的字符串)
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"]
  1. 关键特性 ✅ 双向同步: 本地状态 ↔ 远程 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 中直接消失 → 用户体验差!

  1. 用户点击 ⭐ → 项目立即从列表中消失(没有视觉反馈)

✅ 有了 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. };

效果:

  1. 用户点击 ⭐ → 星星变空心(☆),但项目保留在列表中
  2. 切换 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 时同步)