从零到一:在React前端中集成The Graph查询Uniswap V3池数据的实战记录
背景
上个月,我接了一个DeFi收益聚合器前端的活儿,其中有一个核心功能模块是“流动性挖矿机会发现”。简单说,就是需要展示各个主流DEX(比如Uniswap V3)上,不同交易对池子的实时数据,包括总流动性(TVL)、交易量、手续费收益率等。用户可以根据这些数据,决定把资金放到哪个池子里去挖矿。
最开始,我的思路很直接:用 ethers.js 或者 viem 在前端直接调用 Uniswap V3 工厂合约和各个池子合约,读取需要的数据。我吭哧吭哧写了一会儿,就发现这路子根本走不通。首先,前端直接轮询几十上百个池子合约的 slot0、liquidity 等方法,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 语言去查询我要的数据就行了。
听起来简单,但实操起来有几个关键问题要解决:
- 找对子图:Uniswap V3 在多个网络(Mainnet, Arbitrum, Polygon等)都有部署,对应的子图也不同。我需要在哪个网站找到官方、可信的子图?
- 写对查询:GraphQL 和我熟悉的 RESTful API 差别很大,怎么写查询语句才能精准拿到我需要的字段,并且能做过滤(比如只查 ETH/USDC 对)和排序?
- 前端集成:如何在 React 项目里优雅地发送 GraphQL 查询并管理状态(加载中、错误、数据)?用
fetch硬写,还是用专门的客户端库? - 处理分页:池子数量可能很多,我需要考虑分页查询,不能一次性拉取所有数据。
接下来,我就按照解决这些问题的步骤,分享一下我的实战过程。
核心实现
第一步:寻找并确定正确的子图端点
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(即合约地址)、token0、token1、feeTier、liquidity、totalValueLockedUSD、volumeUSD 等字段。token0 和 token1 本身又是 Token 实体,有 symbol 和 id(地址)字段。
我的查询目标是:找到 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 这个关联实体的字段进行过滤。orderBy 和 orderDirection 实现了排序。
第三步:在 React 中集成 GraphQL 客户端
接下来就是在前端项目里执行这个查询了。最简单的方式是用 fetch 发一个 POST 请求。但我之前用过 React Query 管理服务器状态体验很好,而 GraphQL 请求本质上也是一种异步数据获取。我发现了 @tanstack/react-query 和 graphql-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.ts 和 src/lib/graphqlClient.ts 已正确配置端点。
踩坑记录
-
子图端点返回 403 错误:一开始我用的是托管服务的旧端点,没注意它已经弃用。后来换到去中心化网络端点后,又忘了加 API Key,一直报 403。解决方法:仔细阅读 The Graph 文档,去他们的仪表板创建项目并获取 API 密钥,将其正确拼接到请求 URL 中。
-
GraphQL 查询变量类型错误:在 Playground 里测试时,查询语句直接写了
first: 10。但把查询移到代码里用变量$first时,忘记在查询定义中声明变量类型,导致请求失败。解决方法:确保查询语句中所有使用的变量都在query QueryName($变量名: 类型!)中进行了明确定义。 -
分页逻辑混乱:最初我试图自己管理
skip和page的状态,结果和 React Query 的useInfiniteQuery机制冲突,状态更新混乱。解决方法:完全信任useInfiniteQuery的分页管理,只在其提供的queryFn和getNextPageParam中实现业务逻辑,让库来处理页码状态。 -
数据字段为 null:查询某些新创建的或交易量极小的池子时,
totalValueLockedUSD或volumeUSD字段有时会返回null。直接进行Number(null)转换会导致 NaN。解决方法:在渲染前进行空值检查,或者使用可选链和空值合并运算符:Number(pool.volumeUSD ?? '0')。
小结
这次集成 The Graph 的经历,让我彻底摆脱了前端“硬查合约”的笨重模式。核心收获是:对于复杂的链上历史数据查询和聚合,使用 The Graph 这样的索引服务是唯一高效、可行的前端解决方案。下一步,我打算深入研究如何监听子图的新数据更新(订阅),以及尝试为自己部署的智能合约创建和维护一个专属子图,那将是另一个层次的挑战了。