从零到一:在React前端中集成The Graph查询Uniswap V3池数据的实战记录

3 阅读9分钟

从零到一:在React前端中集成The Graph查询Uniswap V3池数据的实战记录

背景

上个月,我接了一个DeFi收益聚合器前端的活儿,其中有一个核心功能模块是“流动性挖矿机会发现”。简单说,就是需要展示各个主流DEX(比如Uniswap V3)上,不同交易对池子的实时数据,包括总流动性(TVL)、交易量、手续费收益率等。用户可以根据这些数据,决定把资金放到哪个池子里去挖矿。

最开始,我的思路很直接:用 ethers.js 或者 viem 在前端直接调用 Uniswap V3 工厂合约和各个池子合约,读取需要的数据。我吭哧吭哧写了一会儿,就发现这路子根本走不通。首先,前端直接轮询几十上百个池子合约的 slot0liquidity 等方法,RPC 调用次数爆炸,速度慢到令人发指,而且公共 RPC 节点有速率限制,动不动就给你报错。其次,像“24小时交易量”这种历史数据,你根本没法从一个只反映当前状态的合约里直接读出来。

项目眼看要卡壳,团队里一位老哥提了一嘴:“你为啥不用 The Graph 呢?Uniswap 官方肯定有现成的子图(Subgraph)。” 我之前听说过 The Graph,知道它是个链上数据索引协议,但总觉得配置起来麻烦,一直没深入研究。这次被需求逼到墙角,不得不硬着头皮上了。没想到,这一趟下来,从完全陌生到顺利跑通,踩的坑还真不少,但也确实打开了新世界的大门。

问题分析

我的需求很具体:在 React 前端页面里,展示一个列表,列出所有 ETH/USDC 这个交易对在 Uniswap V3 上不同费率等级(0.05%, 0.3%, 1%)的池子信息,包括池子地址、当前价格、总流动性和24小时手续费。

最初的错误思路就是“硬查合约”,上面已经说过了,此路不通。那么,正确的方向就是使用 The Graph。我的理解是,The Graph 网络里已经有人(可能是项目方自己,也可能是社区)把链上事件数据抓取下来,按照定义好的模式(Schema)整理好,存到了一个可以高效 GraphQL 查询的数据库中。我作为前端,只需要学会怎么找到这个数据库的“地址”(子图部署ID或端点),然后用 GraphQL 语言去查询我要的数据就行了。

听起来简单,但实操起来有几个关键问题要解决:

  1. 找对子图:Uniswap V3 在多个网络(Mainnet, Arbitrum, Polygon等)都有部署,对应的子图也不同。我需要在哪个网站找到官方、可信的子图?
  2. 写对查询:GraphQL 和我熟悉的 RESTful API 差别很大,怎么写查询语句才能精准拿到我需要的字段,并且能做过滤(比如只查 ETH/USDC 对)和排序?
  3. 前端集成:如何在 React 项目里优雅地发送 GraphQL 查询并管理状态(加载中、错误、数据)?用 fetch 硬写,还是用专门的客户端库?
  4. 处理分页:池子数量可能很多,我需要考虑分页查询,不能一次性拉取所有数据。

接下来,我就按照解决这些问题的步骤,分享一下我的实战过程。

核心实现

第一步:寻找并确定正确的子图端点

The Graph 有一个官方的托管服务(The Graph Hosted Service),但正在向去中心化的主网迁移。对于 Uniswap 这种顶级项目,两者都有部署。经过一番搜索和对比,我决定使用去中心化网络上的子图,感觉更符合 Web3 精神,也更持久稳定。

我来到了 The Graph 官方浏览器。在这里搜索 “uniswap v3”。果然,跳出来好几个结果。这里第一个坑就来了:有 uniswap-v3 结尾的,也有 uniswap-v3-optimism 的。我必须确认我需要的以太坊主网的子图。通过查看子图详情页的“部署详情”,确认其索引的网络是 Ethereum Mainnet

最终,我确定了要使用的子图部署ID是:5zvM1QpMpSqWkQYKgF8fQ5pt6kqFU7nWmQbFgPqg9j2C(这是一个示例ID,实际请查询最新地址)。对应的 GraphQL 端点 URL 格式为:https://gateway.thegraph.com/api/[api-key]/subgraphs/id/[部署ID]

注意:The Graph 去中心化网络需要 API 密钥来管理查询用量。你需要去他们的仪表板创建一个密钥。对于开发和中小规模应用,免费额度完全够用。

// src/config/subgraph.ts
// 配置子图端点
export const UNISWAP_V3_SUBGRAPH_ENDPOINT = `https://gateway.thegraph.com/api/YOUR_API_KEY/subgraphs/id/YOUR_DEPLOYMENT_ID`;

// 注意:将 YOUR_API_KEY 和 YOUR_DEPLOYMENT_ID 替换为你的实际值
// 生产环境中,这些敏感信息不应硬编码,而应通过环境变量注入。

第二步:探索数据模式与编写 GraphQL 查询

在子图浏览器页面,有一个“Playground”标签页,这是我们的神器。在这里,我可以探索这个子图定义了哪些实体(Entities),以及每个实体有哪些字段。右侧还有自动生成的文档。

我需要查询的是“池子”(Pool)。通过查看文档,我发现 Pool 实体有 id(即合约地址)、token0token1feeTierliquiditytotalValueLockedUSDvolumeUSD 等字段。token0token1 本身又是 Token 实体,有 symbolid(地址)字段。

我的查询目标是:找到 token0.symbol 为 “WETH” 且 token1.symbol 为 “USDC” 的所有池子,并按总锁仓价值从高到低排序。

在 Playground 里,我开始了“写查询-运行-看结果”的循环调试。最终,一个能够工作的查询语句诞生了:

query GetETHUSCDPools($first: Int!, $skip: Int!) {
  pools(
    first: $first
    skip: $skip
    orderBy: totalValueLockedUSD
    orderDirection: desc
    where: {
      token0_: { symbol: "WETH" }
      token1_: { symbol: "USDC" }
    }
  ) {
    id
    feeTier
    liquidity
    totalValueLockedUSD
    volumeUSD
    token0 {
      symbol
      id
    }
    token1 {
      symbol
      id
    }
  }
}

这个查询使用了变量 $first$skip 来实现分页。where 条件里的 token0_ 语法表示对 token0 这个关联实体的字段进行过滤。orderByorderDirection 实现了排序。

第三步:在 React 中集成 GraphQL 客户端

接下来就是在前端项目里执行这个查询了。最简单的方式是用 fetch 发一个 POST 请求。但我之前用过 React Query 管理服务器状态体验很好,而 GraphQL 请求本质上也是一种异步数据获取。我发现了 @tanstack/react-querygraphql-request 这个轻量组合,用起来非常顺手。

首先安装依赖:

npm install @tanstack/react-query graphql-request
# 或
yarn add @tanstack/react-query graphql-request

然后,我创建了一个 GraphQL 请求客户端和一个自定义 Hook:

// src/lib/graphqlClient.ts
import { GraphQLClient } from 'graphql-request';
import { UNISWAP_V3_SUBGRAPH_ENDPOINT } from '../config/subgraph';

// 创建全局的 GraphQL 客户端实例
export const graphQLClient = new GraphQLClient(UNISWAP_V3_SUBGRAPH_ENDPOINT);
// src/hooks/useUniswapV3Pools.ts
import { useInfiniteQuery } from '@tanstack/react-query';
import { graphQLClient } from '../lib/graphqlClient';
import { gql } from 'graphql-request';

// 定义 GraphQL 查询文档
const GET_POOLS_QUERY = gql`
  query GetETHUSCDPools($first: Int!, $skip: Int!) {
    pools(
      first: $first
      skip: $skip
      orderBy: totalValueLockedUSD
      orderDirection: desc
      where: { token0_: { symbol: "WETH" }, token1_: { symbol: "USDC" } }
    ) {
      id
      feeTier
      liquidity
      totalValueLockedUSD
      volumeUSD
      token0 { symbol id }
      token1 { symbol id }
    }
  }
`;

// 定义返回数据的 TypeScript 类型(根据实际返回结构定义)
export interface Pool {
  id: string;
  feeTier: string;
  liquidity: string;
  totalValueLockedUSD: string;
  volumeUSD: string;
  token0: { symbol: string; id: string };
  token1: { symbol: string; id: string };
}

interface PoolsResponse {
  pools: Pool[];
}

const PAGE_SIZE = 10; // 每页获取10个池子

export function useUniswapV3Pools() {
  return useInfiniteQuery<PoolsResponse>({
    queryKey: ['uniswapV3Pools', 'WETH', 'USDC'], // Query Key,用于缓存
    queryFn: async ({ pageParam = 0 }) => {
      // pageParam 在这里作为 skip 的值
      const variables = {
        first: PAGE_SIZE,
        skip: pageParam,
      };
      // 使用客户端发送请求
      return graphQLClient.request<PoolsResponse>(GET_POOLS_QUERY, variables);
    },
    getNextPageParam: (lastPage, allPages) => {
      // 如果上一页返回的数据不足 PAGE_SIZE,说明没有更多数据了
      if (lastPage.pools.length < PAGE_SIZE) {
        return undefined;
      }
      // 否则,下一页的 skip 值是已获取数据的总量
      return allPages.length * PAGE_SIZE;
    },
    initialPageParam: 0, // React Query v4 要求显式声明
    staleTime: 60 * 1000, // 数据保鲜期1分钟,避免过于频繁的请求
  });
}

这个 useUniswapV3Pools Hook 使用了 useInfiniteQuery,完美支持了无限滚动分页的逻辑。getNextPageParam 函数是关键,它决定了如何获取下一页。

第四步:在组件中消费数据并渲染

最后一步就是在 React 组件里使用这个 Hook 了。我打算做一个简单的列表,并添加一个“加载更多”的按钮。

// src/components/PoolList.tsx
import React from 'react';
import { useUniswapV3Pools } from '../hooks/useUniswapV3Pools';

export const PoolList: React.FC = () => {
  const {
    data,
    error,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    status,
  } = useUniswapV3Pools();

  if (status === 'pending') {
    return <div>正在加载池子数据...</div>;
  }

  if (status === 'error') {
    return <div>出错了: {error.message}</div>;
  }

  // 将无限查询的多页数据扁平化成一个数组
  const allPools = data?.pages.flatMap((page) => page.pools) || [];

  return (
    <div>
      <h2>Uniswap V3 WETH/USDC 流动性池</h2>
      <ul>
        {allPools.map((pool) => (
          <li key={pool.id} style={{ marginBottom: '1rem', padding: '1rem', border: '1px solid #ccc' }}>
            <div><strong>池子地址:</strong> {pool.id}</div>
            <div><strong>费率等级:</strong> {(Number(pool.feeTier) / 10000).toFixed(2)}%</div>
            <div><strong>流动性:</strong> {Number(pool.liquidity).toLocaleString()}</div>
            <div><strong>总锁仓价值 (USD):</strong> ${Number(pool.totalValueLockedUSD).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
            <div><strong>24小时交易量 (USD):</strong> ${Number(pool.volumeUSD).toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}</div>
            <div><strong>代币对:</strong> {pool.token0.symbol} / {pool.token1.symbol}</div>
          </li>
        ))}
      </ul>
      <div>
        <button
          onClick={() => fetchNextPage()}
          disabled={!hasNextPage || isFetchingNextPage}
        >
          {isFetchingNextPage
            ? '加载更多中...'
            : hasNextPage
            ? '加载更多池子'
            : '没有更多池子了'}
        </button>
      </div>
    </div>
  );
};

完整代码示例

以下是一个简化但可运行的 App 组件示例,整合了上述关键部分:

// src/App.tsx
import React from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { PoolList } from './components/PoolList';

// 创建 React Query 客户端
const queryClient = new QueryClient();

function App() {
  return (
    // 用 Provider 包裹应用,使 useQuery 等 Hook 可用
    <QueryClientProvider client={queryClient}>
      <div className="App">
        <header>
          <h1>DeFi 池子数据看板 (The Graph 实战)</h1>
        </header>
        <main>
          <PoolList />
        </main>
      </div>
    </QueryClientProvider>
  );
}

export default App;

确保你的 src/config/subgraph.tssrc/lib/graphqlClient.ts 已正确配置端点。

踩坑记录

  1. 子图端点返回 403 错误:一开始我用的是托管服务的旧端点,没注意它已经弃用。后来换到去中心化网络端点后,又忘了加 API Key,一直报 403。解决方法:仔细阅读 The Graph 文档,去他们的仪表板创建项目并获取 API 密钥,将其正确拼接到请求 URL 中。

  2. GraphQL 查询变量类型错误:在 Playground 里测试时,查询语句直接写了 first: 10。但把查询移到代码里用变量 $first 时,忘记在查询定义中声明变量类型,导致请求失败。解决方法:确保查询语句中所有使用的变量都在 query QueryName($变量名: 类型!) 中进行了明确定义。

  3. 分页逻辑混乱:最初我试图自己管理 skippage 的状态,结果和 React Query 的 useInfiniteQuery 机制冲突,状态更新混乱。解决方法:完全信任 useInfiniteQuery 的分页管理,只在其提供的 queryFngetNextPageParam 中实现业务逻辑,让库来处理页码状态。

  4. 数据字段为 null:查询某些新创建的或交易量极小的池子时,totalValueLockedUSDvolumeUSD 字段有时会返回 null。直接进行 Number(null) 转换会导致 NaN。解决方法:在渲染前进行空值检查,或者使用可选链和空值合并运算符:Number(pool.volumeUSD ?? '0')

小结

这次集成 The Graph 的经历,让我彻底摆脱了前端“硬查合约”的笨重模式。核心收获是:对于复杂的链上历史数据查询和聚合,使用 The Graph 这样的索引服务是唯一高效、可行的前端解决方案。下一步,我打算深入研究如何监听子图的新数据更新(订阅),以及尝试为自己部署的智能合约创建和维护一个专属子图,那将是另一个层次的挑战了。