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

24 阅读6分钟

一、架构设计与技术选型

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 = 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数据共享

问题: 在收藏列表点击取消收藏,该列会立即消失,影响用户体验

需求:

  • 用户点击取消收藏后,不要立即从列表中移除
  • 等下次切换 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);
};

三、项目成果

Markets 收藏功能(已全量上线)

  • Favorites 标签页(空状态 + 收藏列表)
  • 星标交互覆盖 5 个排行榜(Hot/NewListing/Gainers/Losers/Turnover)
  • PC + H5 双端完整适配
  • 现货和衍生品独立管理
  • 分页功能(每页 6 个,超过显示分页器)
  • 延迟刷新机制(切换 Tab 时同步)