从零到一:在 React 前端中集成 The Graph 查询 Uniswap V3 池子数据

2 阅读1分钟

背景

上个月,我接了一个 DeFi 数据聚合看板的需求,核心功能之一是展示 Uniswap V3 上各个交易对的实时数据,比如流动性总量、24小时交易量、当前价格等。我的第一反应是直接用 ethers.jsviem 去读取对应的合约。简单试了一下,获取单个池子的基本信息(如 token0, token1, feeTier)还行,但一旦涉及到需要聚合或筛选历史事件的数据,比如“过去24小时的交易量总和”,问题就来了。

直接在链上遍历事件日志来计算这个总和,对于前端应用来说是完全不现实的,速度慢、消耗的 RPC 调用次数多,用户体验会极差。这时候,团队里有经验的同事提了一句:“这种复杂查询,得上 The Graph。” 我之前听说过这个去中心化的索引协议,知道它能把链上数据索引成可以轻松查询的 GraphQL API,但一直没在实际项目里用过。这次,正好是个实战的好机会。

问题分析

我的目标是:在前端 React 应用中,展示一个 Uniswap V3 池子的列表,并显示每个池子的关键指标。最初的思路是去 The Graph 的托管服务上,找一个现成的 Uniswap V3 子图。一搜,果然有官方发布的子图。我兴冲冲地打开它的 Playground,准备写查询。

第一个拦路虎是 GraphQL 查询语法。虽然结构清晰,但和写 REST API 或者直接调用合约函数的感觉完全不同。我需要先理解子图里定义好的 Schema(数据模型)。比如,池子数据对应的是 Pool 实体,里面有 token0token1totalValueLockedUSD 这些字段。我得学会如何构造查询语句来精确获取我想要的字段。

其次,数据量可能很大。Uniswap V3 上有成千上万个池子,我一次查询全拉回来肯定不行,前端会卡死,GraphQL 查询也可能超时。所以必须实现分页。

最后,是如何在前端项目中优雅地集成。是直接用 fetch 调用 The Graph 的 HTTP 端点,还是用专门的 GraphQL 客户端(如 Apollo Client)?考虑到后续可能还有更复杂的查询和状态管理,我决定采用更专业的方案。

核心实现

1. 理解子图并构造基础查询

首先,我访问了 Uniswap V3 在 Ethereum 主网的官方子图页面。在 Playground 里,我可以看到所有的实体和字段。我需要的是一个包含池子基础信息和一些汇总数据的列表。

我构思的第一个查询是:获取流动性最高的前 10 个池子。对应的 GraphQL 查询语句如下:

{
  pools(first: 10, orderBy: totalValueLockedUSD, orderDirection: desc) {
    id
    token0 {
      symbol
      id
    }
    token1 {
      symbol
      id
    }
    feeTier
    totalValueLockedUSD
    volumeUSD
  }
}

这里有几个关键点:

  • first: 10: 限制返回数量,实现“分页”的第一步。
  • orderBy: totalValueLockedUSD: 按总锁仓价值排序。
  • orderDirection: desc: 降序排列,流动性最高的排前面。
  • 注意 token0token1 本身也是实体,所以我需要嵌套查询它们的 symbolid

2. 在 React 项目中集成 GraphQL 客户端

我选择使用 @apollo/client 这个成熟的 GraphQL 客户端。它提供了缓存、状态管理、友好的 React Hooks 等特性。

首先安装依赖:

npm install @apollo/client graphql

然后,在应用入口(如 App.tsx)创建 Apollo Client 实例,并配置 The Graph 的 API 端点。

// App.tsx
import React from 'react';
import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';

// 创建 Apollo Client 实例
const client = new ApolloClient({
  uri: 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3', // Uniswap V3 主网子图端点
  cache: new InMemoryCache(),
});

function App() {
  return (
    <ApolloProvider client={client}>
      {/* 你的应用组件 */}
      <PoolDashboard />
    </ApolloProvider>
  );
}

export default App;

这里有个坑:The Graph 的托管服务端点 URL 容易搞错。一定要去对应子图的详情页,复制“Queries (HTTP)”下的完整 URL,而不是浏览器地址栏的。

3. 使用 useQuery Hook 获取并展示数据

在展示池子数据的组件 PoolDashboard.tsx 中,我使用 @apollo/client 提供的 useQuery Hook 来执行查询。

首先,用 gql 模板标签定义查询语句:

// queries.ts
import { gql } from '@apollo/client';

export const GET_TOP_POOLS = gql`
  query GetTopPools($first: Int!) {
    pools(first: $first, orderBy: totalValueLockedUSD, orderDirection: desc) {
      id
      token0 {
        symbol
        id
      }
      token1 {
        symbol
        id
      }
      feeTier
      totalValueLockedUSD
      volumeUSD
    }
  }
`;

然后,在组件中使用:

// PoolDashboard.tsx
import React from 'react';
import { useQuery } from '@apollo/client';
import { GET_TOP_POOLS } from './queries';

const PoolDashboard: React.FC = () => {
  const { loading, error, data } = useQuery(GET_TOP_POOLS, {
    variables: { first: 10 },
  });

  if (loading) return <p>Loading pools...</p>;
  if (error) return <p>Error : {error.message}</p>;

  return (
    <div>
      <h1>Top Uniswap V3 Pools by TVL</h1>
      <ul>
        {data.pools.map((pool: any) => (
          <li key={pool.id}>
            <strong>{pool.token0.symbol}/{pool.token1.symbol}</strong> (Fee: {pool.feeTier / 10000}%)
            <br />
            TVL: ${Number(pool.totalValueLockedUSD).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
            <br />
            24h Vol: ${Number(pool.volumeUSD).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
          </li>
        ))}
      </ul>
    </div>
  );
};

export default PoolDashboard;

这样,一个最简单的池子列表就展示出来了。useQuery Hook 自动管理了请求状态(loading, error, data),非常方便。

4. 实现分页与筛选

展示前10个没问题,但用户想看更多怎么办?这就需要用到分页。GraphQL 的常见分页模式是使用 first/skip 参数,或者使用游标(cursor)。我查看子图,发现 Pool 实体支持 firstskip

我修改查询,增加 $skip 变量,并添加一个“加载更多”按钮:

// queries.ts 更新
export const GET_POOLS_PAGINATED = gql`
  query GetPoolsPaginated($first: Int!, $skip: Int!) {
    pools(first: $first, skip: $skip, orderBy: totalValueLockedUSD, orderDirection: desc) {
      id
      token0 { symbol id }
      token1 { symbol id }
      totalValueLockedUSD
    }
  }
`;

在组件中,我需要用 useQueryfetchMore 方法来实现分页加载,并手动合并数据到缓存中。

// PoolDashboardWithPagination.tsx
import React, { useState } from 'react';
import { useQuery } from '@apollo/client';
import { GET_POOLS_PAGINATED } from './queries';

const POOLS_PER_PAGE = 10;

const PoolDashboardWithPagination: React.FC = () => {
  const [skip, setSkip] = useState(0);
  
  const { loading, error, data, fetchMore } = useQuery(GET_POOLS_PAGINATED, {
    variables: { first: POOLS_PER_PAGE, skip: 0 },
    notifyOnNetworkStatusChange: true, // 使 fetchMore 时 loading 状态更新
  });

  const loadMore = () => {
    const newSkip = skip + POOLS_PER_PAGE;
    setSkip(newSkip);
    fetchMore({
      variables: {
        first: POOLS_PER_PAGE,
        skip: newSkip,
      },
      // 手动更新缓存合并新旧数据
      updateQuery: (prev, { fetchMoreResult }) => {
        if (!fetchMoreResult) return prev;
        return {
          pools: [...prev.pools, ...fetchMoreResult.pools],
        };
      },
    });
  };

  if (error) return <p>Error: {error.message}</p>;
  
  const pools = data?.pools || [];

  return (
    <div>
      <h1>Uniswap V3 Pools</h1>
      <ul>
        {pools.map((pool: any) => (
          <li key={pool.id}>{pool.token0.symbol}/{pool.token1.symbol} - TVL: ${pool.totalValueLockedUSD}</li>
        ))}
      </ul>
      {loading && <p>Loading more...</p>}
      <button onClick={loadMore} disabled={loading}>
        Load More
      </button>
    </div>
  );
};

注意这个细节updateQuery 函数是合并分页数据的关键,它决定了新数据如何与现有缓存数据结合。这里我简单地将新旧数组合并。

完整代码

以下是一个简化但可运行的完整示例,整合了上述核心步骤:

// App.tsx
import React from 'react';
import { ApolloClient, InMemoryCache, ApolloProvider } from '@apollo/client';
import PoolDashboard from './PoolDashboard';

const client = new ApolloClient({
  uri: 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3',
  cache: new InMemoryCache(),
});

function App() {
  return (
    <ApolloProvider client={client}>
      <PoolDashboard />
    </ApolloProvider>
  );
}

export default App;
// queries.ts
import { gql } from '@apollo/client';

export const GET_TOP_POOLS = gql`
  query GetTopPools($first: Int!) {
    pools(first: $first, orderBy: totalValueLockedUSD, orderDirection: desc) {
      id
      token0 {
        symbol
        id
      }
      token1 {
        symbol
        id
      }
      feeTier
      totalValueLockedUSD
      volumeUSD
    }
  }
`;
// PoolDashboard.tsx
import React from 'react';
import { useQuery } from '@apollo/client';
import { GET_TOP_POOLS } from './queries';

const PoolDashboard: React.FC = () => {
  const { loading, error, data } = useQuery(GET_TOP_POOLS, {
    variables: { first: 10 },
  });

  if (loading) return <div className="loading">Loading pools...</div>;
  if (error) return <div className="error">Error: {error.message}</div>;

  return (
    <div className="dashboard">
      <h1>Top 10 Uniswap V3 Pools by TVL</h1>
      <div className="pool-list">
        {data.pools.map((pool: any) => (
          <div key={pool.id} className="pool-card">
            <h3>{pool.token0.symbol} / {pool.token1.symbol}</h3>
            <p>Fee Tier: {pool.feeTier / 10000}%</p>
            <p>Total Value Locked: ${Number(pool.totalValueLockedUSD).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</p>
            <p>24h Volume: ${Number(pool.volumeUSD).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</p>
            <small>Pool ID: {pool.id}</small>
          </div>
        ))}
      </div>
    </div>
  );
};

export default PoolDashboard;

踩坑记录

  1. 查询超时与 first 限制:一开始我尝试 first: 1000,想一次性多拉点数据,结果请求经常超时或返回不完全。后来明白,The Graph 的公共节点有查询复杂度限制。解决方法:保守设置 first 参数(比如 10-100),并做好分页。对于需要大量数据的场景,考虑使用索引游标分页或异步导出数据。

  2. 字段类型不匹配:在写 updateQuery 函数时,我曾直接返回 fetchMoreResult,导致 UI 只显示新加载的数据,旧数据被覆盖。这是对 Apollo Client 缓存更新机制不熟导致的。解决方法:仔细阅读文档,理解 updateQuery 必须返回合并后的完整数据对象,如 { pools: [...prev.pools, ...newPools] }

  3. 子图数据延迟:我发现通过 The Graph 查询到的“24小时交易量”和直接从区块链浏览器看到的有几分钟的延迟。这不是 Bug,而是特性。The Graph 需要时间索引新区块。解决方法:在 UI 上添加“数据最后更新于...”的提示,让用户知晓。对于实时性要求极高的数据(如当前价格),可能需要结合合约的直接调用。

  4. 跨链查询:我的项目后来需要支持 Polygon 上的 Uniswap V3。我一开始以为改个 RPC 端点就行,结果发现不同链有完全独立的子图部署。解决方法:找到对应链(如 Polygon)的 Uniswap V3 子图端点(https://api.thegraph.com/subgraphs/name/uniswap/v3-polygon),并可能需要根据链的特性调整查询。

小结

这次实战让我彻底打通了前端与 The Graph 的集成流程。核心收获是:对于复杂的链上数据查询,The Graph 是提升前端性能和开发效率的利器。下一步,我打算深入研究如何利用子图的聚合字段(如 snapshots)来生成历史图表数据,让我的数据看板更加丰富。