从零到一:在React前端中集成The Graph查询Uniswap V3池数据实战
背景
上个月,我接手了一个DeFi收益聚合器项目的前端开发。产品经理提了一个需求:要在仪表盘首页展示用户可能感兴趣的几个热门Uniswap V3流动性池的实时数据,包括24小时交易量、总流动性和当前手续费率。
我的第一反应是:"简单,直接用 ethers.js 或 viem 去读合约的 public 变量和事件不就行了?" 于是,我吭哧吭哧写了段代码,通过 useEffect 轮询调用池子合约的 slot0 函数获取当前价格,再通过 provider.getLogs 拉取最近24小时的 Swap 事件来计算交易量。本地测试时,面对一个池子还好。一上线,用户钱包里要是多几个池子,页面直接卡死,RPC调用次数爆炸,速度慢得让人想砸键盘。我意识到,对于这种需要聚合和分析历史链上数据的场景,直接与节点交互是条死路。这时,我想起了那个听过很多次但一直没亲手用过的工具------The Graph。
问题分析
The Graph 的核心是一个去中心化的索引协议,它监听区块链事件,将数据按照定义好的模式(Subgraph)处理后存入可高效查询的数据库。对于前端来说,我们不用再关心如何从海量事件日志里筛选和计算,只需要像调用API一样,用GraphQL查询语句去获取已经处理好的结构化数据。
我的需求很明确:查询Uniswap V3在以太坊主网上特定池子的聚合数据。理论上,我不需要自己部署Subgraph,因为Uniswap官方已经维护了一个非常完善的 Uniswap V3 Subgraph。我的任务就是在前端React应用中,学会如何与这个已部署的Subgraph进行交互。
最初的尝试是直接用 fetch 或 axios 向Subgraph的GraphQL端点发送POST请求。这确实能跑通,但很快遇到了问题:1. 需要手动管理查询字符串和变量,容易出错;2. 缺乏类型安全,返回的数据结构全靠猜;3. 没有内置的请求状态(loading, error)管理,需要自己用useState和useEffect封装,很繁琐。我需要一个更"React"的、类型友好的解决方案。
核心实现
第一步:环境搭建与GraphQL客户端选择
首先,我创建了一个新的React + TypeScript项目(或者在你的现有项目中操作)。关键的依赖是 @apollo/client 和 graphql。Apollo Client 是一个强大的GraphQL状态管理库,它提供了React Hook(如 useQuery)、缓存、错误处理等开箱即用的功能,能极大简化前端与The Graph的交互。
bash
npm install @apollo/client graphql
接下来,我需要初始化Apollo Client,并配置其连接到Uniswap V3的Hosted Service端点。
这里有个坑 :The Graph的Hosted Service端点URL结构是 https://api.thegraph.com/subgraphs/name/<用户名>/<子图名称>。对于Uniswap V3以太坊主网,用户名是 uniswap,子图名称是 uniswap-v3。千万别去官方文档里找"API Key",Hosted Service在查询限额内是免费的,直接使用即可。
我创建了一个文件 lib/apolloClient.ts 来配置客户端:
typescript
// lib/apolloClient.ts
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
// Uniswap V3 以太坊主网 Subgraph 端点
const UNISWAP_V3_GRAPH_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';
const httpLink = new HttpLink({
uri: UNISWAP_V3_GRAPH_ENDPOINT,
});
// 创建 Apollo Client 实例
// 注意:默认缓存策略可能不适合实时性极高的数据,对于交易量等数据可以考虑调整fetchPolicy
export const apolloClient = new ApolloClient({
link: httpLink,
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network', // 优先返回缓存,同时在后台更新
},
query: {
fetchPolicy: 'network-only', // 对于主动查询,总是从网络获取
},
},
});
第二步:编写GraphQL查询并生成类型
这是核心步骤。我需要去 The Graph Explorer 找到 uniswap/uniswap-v3 子图,研究其数据模式(Schema)。我需要的池子(Pool)数据,在Schema中对应 Pool 实体,里面包含了 id(合约地址)、totalValueLockedUSD、volumeUSD、feesUSD、token0、token1 等字段。
为了获取24小时数据,子图通常会有类似 poolDayData 的时间序列实体。经过探索,我发现查询最近24小时数据的最佳方式是:先查询 Pool 实体本身获取当前快照数据(如TVL),再关联查询其最新的 poolDayData(按日期排序取第一条)来获取过去24小时的交易量和手续费。
我创建了一个GraphQL查询文件 queries/poolData.graphql:
graphql
# queries/poolData.graphql
query PoolData($poolId: String!) {
# 查询池子基础信息
pool(id: $poolId) {
id
totalValueLockedUSD
feeTier
token0 {
id
symbol
decimals
}
token1 {
id
symbol
decimals
}
# 关联查询最近的日数据(过去24小时)
poolDayData(first: 1, orderBy: date, orderDirection: desc) {
volumeUSD
feesUSD
date
}
}
}
注意这个细节 :$poolId 是池子的合约地址,但在The Graph中,id 字段通常是全小写的地址字符串。所以从链上获取的地址,在传入查询变量前最好先 .toLowerCase() 处理一下,避免查不到数据。
接下来,为了让TypeScript认识查询返回的数据结构,我使用GraphQL Code Generator来自动生成类型。这需要额外配置,但一劳永逸。简单起见,我也可以手动定义类型,但对于复杂查询,自动生成更可靠。这里我展示手动定义的方式,更贴近快速上手的场景:
typescript
// types/poolData.ts
export interface Token {
id: string;
symbol: string;
decimals: string;
}
export interface PoolDayData {
volumeUSD: string;
feesUSD: string;
date: number;
}
export interface PoolData {
id: string;
totalValueLockedUSD: string;
feeTier: string;
token0: Token;
token1: Token;
poolDayData: PoolDayData[];
}
export interface GraphQLPoolResponse {
pool: PoolData | null;
}
第三步:创建自定义React Hook
为了让数据获取逻辑可以在组件中优雅复用,我决定将其封装成一个自定义Hook:usePoolData。
typescript
// hooks/usePoolData.ts
import { useQuery, gql } from '@apollo/client';
import { GraphQLPoolResponse } from '../types/poolData';
// 直接在Hook中定义GraphQL查询,避免额外文件
// 注意:gql`...` 是Apollo Client的模板标签函数,用于解析GraphQL查询字符串
const POOL_DATA_QUERY = gql`
query PoolData($poolId: String!) {
pool(id: $poolId) {
id
totalValueLockedUSD
feeTier
token0 {
id
symbol
decimals
}
token1 {
id
symbol
decimals
}
poolDayData(first: 1, orderBy: date, orderDirection: desc) {
volumeUSD
feesUSD
date
}
}
}
`;
interface UsePoolDataProps {
poolId: string | undefined; // 池子合约地址
skip?: boolean; // 是否跳过查询
}
export const usePoolData = ({ poolId, skip = false }: UsePoolDataProps) => {
// 使用 useQuery Hook
// 它自动处理 loading, error 状态,并返回 data
const { loading, error, data, refetch } = useQuery<GraphQLPoolResponse>(
POOL_DATA_QUERY,
{
variables: {
poolId: poolId?.toLowerCase(), // 关键:地址转小写
},
skip: !poolId || skip, // 如果没有poolId或主动跳过,则不执行查询
// fetchPolicy: 'network-only' // 可以根据需要覆盖默认策略
}
);
// 对返回的数据进行简单处理和类型断言
const poolData = data?.pool;
return {
loading,
error,
poolData,
refetch, // 用于手动刷新数据
};
};
这个Hook的设计非常"React":它接收依赖项(poolId),管理内部状态,并返回一个清晰的状态对象。在组件中使用时,我可以轻松地根据 loading 显示加载框,根据 error 显示错误信息,用 poolData 渲染UI。
第四步:在组件中集成并使用
最后,我在一个React组件中使用这个Hook。假设我要显示USDC/ETH 0.05%费率的池子(一个非常常见的池)。
tsx
// components/PoolCard.tsx
import React from 'react';
import { usePoolData } from '../hooks/usePoolData';
// 已知的 Uniswap V3 USDC/ETH 0.05% 池地址(以太坊主网)
const USDC_ETH_POOL_ADDRESS = '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640';
const PoolCard: React.FC = () => {
const { loading, error, poolData } = usePoolData({
poolId: USDC_ETH_POOL_ADDRESS,
});
if (loading) {
return <div className="p-4 border rounded-lg">加载池数据中...</div>;
}
if (error) {
return (
<div className="p-4 border rounded-lg bg-red-50 text-red-700">
查询失败: {error.message}
</div>
);
}
if (!poolData) {
return <div className="p-4 border rounded-lg">未找到池子数据</div>;
}
const dailyVolume = poolData.poolDayData[0]?.volumeUSD || '0';
const tvl = poolData.totalValueLockedUSD;
return (
<div className="p-4 border rounded-lg shadow-sm bg-white">
<h3 className="font-bold text-lg">
{poolData.token0.symbol} / {poolData.token1.symbol} Pool
</h3>
<p className="text-sm text-gray-500">费率: {Number(poolData.feeTier) / 10000}%</p>
<div className="mt-3 space-y-2">
<div>
<span className="text-gray-600">总锁定价值 (TVL): </span>
<span className="font-semibold">
${Number(tvl).toLocaleString(undefined, { maximumFractionDigits: 0 })}
</span>
</div>
<div>
<span className="text-gray-600">24小时交易量: </span>
<span className="font-semibold">
${Number(dailyVolume).toLocaleString(undefined, { maximumFractionDigits: 0 })}
</span>
</div>
<div className="text-xs text-gray-400">
池地址: {poolData.id}
</div>
</div>
</div>
);
};
export default PoolCard;
至此,一个完整的、从The Graph获取Uniswap V3池数据并展示的前端功能就实现了。代码清晰、类型安全、且易于维护和扩展。
完整代码
以下是关键文件的完整代码汇总,你可以复制到一个新的React + TypeScript项目中运行测试:
1. 安装依赖:
bash
npx create-react-app my-graph-demo --template typescript
cd my-graph-demo
npm install @apollo/client graphql
2. 配置 Apollo Client (src/lib/apolloClient.ts):
typescript
import { ApolloClient, InMemoryCache, HttpLink } from '@apollo/client';
const UNISWAP_V3_GRAPH_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';
const httpLink = new HttpLink({
uri: UNISWAP_V3_GRAPH_ENDPOINT,
});
export const apolloClient = new ApolloClient({
link: httpLink,
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-and-network',
},
query: {
fetchPolicy: 'network-only',
},
},
});
3. 定义类型 (src/types/poolData.ts):
typescript
export interface Token {
id: string;
symbol: string;
decimals: string;
}
export interface PoolDayData {
volumeUSD: string;
feesUSD: string;
date: number;
}
export interface PoolData {
id: string;
totalValueLockedUSD: string;
feeTier: string;
token0: Token;
token1: Token;
poolDayData: PoolDayData[];
}
export interface GraphQLPoolResponse {
pool: PoolData | null;
}
4. 创建自定义Hook (src/hooks/usePoolData.ts):
typescript
import { useQuery, gql } from '@apollo/client';
import { GraphQLPoolResponse } from '../types/poolData';
const POOL_DATA_QUERY = gql`
query PoolData($poolId: String!) {
pool(id: $poolId) {
id
totalValueLockedUSD
feeTier
token0 {
id
symbol
decimals
}
token1 {
id
symbol
decimals
}
poolDayData(first: 1, orderBy: date, orderDirection: desc) {
volumeUSD
feesUSD
date
}
}
}
`;
interface UsePoolDataProps {
poolId: string | undefined;
skip?: boolean;
}
export const usePoolData = ({ poolId, skip = false }: UsePoolDataProps) => {
const { loading, error, data, refetch } = useQuery<GraphQLPoolResponse>(
POOL_DATA_QUERY,
{
variables: {
poolId: poolId?.toLowerCase(),
},
skip: !poolId || skip,
}
);
const poolData = data?.pool;
return {
loading,
error,
poolData,
refetch,
};
};
5. 创建展示组件 (src/components/PoolCard.tsx):
tsx
import React from 'react';
import { usePoolData } from '../hooks/usePoolData';
const USDC_ETH_POOL_ADDRESS = '0x88e6a0c2ddd26feeb64f039a2c41296fcb3f5640';
const PoolCard: React.FC = () => {
const { loading, error, poolData } = usePoolData({
poolId: USDC_ETH_POOL_ADDRESS,
});
if (loading) return <div>加载中...</div>;
if (error) return <div>错误: {error.message}</div>;
if (!poolData) return <div>无数据</div>;
const dailyVolume = poolData.poolDayData[0]?.volumeUSD || '0';
const tvl = poolData.totalValueLockedUSD;
return (
<div style={{ border: '1px solid #ccc', padding: '1rem', borderRadius: '8px' }}>
<h3>{poolData.token0.symbol} / {poolData.token1.symbol} Pool</h3>
<p>费率: {Number(poolData.feeTier) / 10000}%</p>
<div>
<div>TVL: ${Number(tvl).toLocaleString()}</div>
<div>24h Volume: ${Number(dailyVolume).toLocaleString()}</div>
</div>
<small>地址: {poolData.id}</small>
</div>
);
};
export default PoolCard;
6. 在应用入口集成 (src/App.tsx):
tsx
import React from 'react';
import { ApolloProvider } from '@apollo/client';
import { apolloClient } from './lib/apolloClient';
import PoolCard from './components/PoolCard';
import './App.css';
function App() {
return (
<ApolloProvider client={apolloClient}>
<div className="App">
<h1>Uniswap V3 池数据看板 (The Graph)</h1>
<PoolCard />
{/* 可以在这里添加更多 PoolCard,传入不同的 poolId */}
</div>
</ApolloProvider>
);
}
export default App;
运行 npm start,你应该能看到一个显示USDC/ETH池数据的卡片。
踩坑记录
-
"池子找不到 (Pool not found)" :这是我遇到的第一个也是最多人踩的坑。我确认地址没错,但查询返回
null。后来在The Graph的Discord社区提问才知道,Subgraph中存储的地址id字段全是小写 。而我从链上或Etherscan复制的地址可能是大小写混合的校验和格式。解决方法 :在将地址作为变量传入查询前,务必执行.toLowerCase()。 -
查询超时或响应慢 :第一次查询一个不常被查询的冷门池子时,可能会遇到响应时间较长的情况。这是因为The Graph的索引器需要为这次查询执行索引工作。解决方法:对于用户体验要求高的场景,前端要做好加载状态提示。另外,可以检查Subgraph的健康状态,有时是公共端点负载问题。
-
数据类型不匹配 :GraphQL查询返回的数字,即使是
BigInt在Subgraph中,通过API返回时也是字符串格式 。直接用于计算会出错。解决方法 :在前端使用前,用Number()、parseFloat()或更适合大数的库如BigNumber.js(ethers.js自带) 进行转换。我的示例中用了Number(),对于TVL和交易量这种可能很大的数,在生产环境中建议使用ethers.BigNumber或BigInt来处理。 -
"Cannot read property 'symbol' of null" :在测试时,我传了一个非Uniswap V3池的地址,查询返回的
pool不为null,但内部的token0或token1可能为null(如果子图索引不完整)。解决方法 :在组件渲染中使用可选链操作符?.或进行严格的空值检查,就像我在示例中处理poolDayData[0]一样。
小结
这次实战让我彻底把The Graph从"听说过"变成了"上手用过"。它的核心价值在于将复杂的链上数据索引、聚合工作从前端剥离,让开发者能像查询普通API一样高效获取结构化数据。对于构建数据驱动的DeFi、NFT应用前端,它几乎是必备工具。下一步,我可以探索更复杂的查询(如分页获取多个池子、历史时间序列分析),甚至尝试为自己项目的合约部署一个专属的Subgraph。