背景
最近在参与一个DeFi收益聚合器项目的前端开发,我的任务是构建一个“流动性池分析”面板。这个面板需要展示Uniswap V3上某个特定交易对(比如ETH/USDC)池子的关键数据:当前价格、流动性总量、24小时交易量、手续费收入,以及最近的一批交易记录。
一开始,我的思路很“朴素”:直接用 ethers.js 或 viem 去读取池子合约的 slot0 获取价格,循环读取 Mint、Burn、Swap 事件来计算其他数据。我很快写了个原型,但问题立刻出现了。性能是第一个拦路虎。为了计算24小时交易量,我需要遍历过去24小时的所有 Swap 事件,这在主网上是一个海量操作,RPC调用次数爆炸,页面加载时间长达几十秒,用户体验极差。数据聚合是第二个难题。手续费收入需要累加所有交易的手续费,这又涉及到大量的事件筛选和计算。我意识到,这种“实时计算”的路子在前端根本走不通。
团队讨论后,方向转向了使用预索引数据的解决方案。我们提到了Chainlink、Covalent,但最终选择了 The Graph。原因很直接:Uniswap V3官方已经部署了完善的、社区维护的子图,它已经把池子、交易、流动性位置等数据都索引好了,我只需要用GraphQL去查询即可。这听起来很美,但作为一个之前只用过The Graph Explorer网站的前端,如何在自己的React应用里集成它,我其实心里没底。这就是本次实战要解决的问题:在我的React前端中,稳定、高效地查询The Graph上的Uniswap V3数据。
问题分析
我的最初想法是:“不就是个GraphQL API吗?我用 axios 或者 fetch 发个POST请求不就行了?” 于是我找到了Uniswap V3在以太坊主网的子图端点:https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3。
我写了一个简单的 fetch 尝试查询一个池子:
const query = `
query {
pools(where: { id: "0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8" }) {
id
token0 { symbol }
token1 { symbol }
}
}
`;
const response = await fetch('https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query }),
});
结果返回了数据!这让我兴奋了一下,以为问题解决了。但当我开始构建复杂的查询,比如关联查询池子的交易记录、需要分页、需要按时间筛选时,代码迅速变得混乱。手动拼接GraphQL查询字符串很容易出错,类型安全更是无从谈起。更重要的是,错误处理和加载状态管理变得很繁琐。每个查询我都要自己写 try...catch,管理 loading 和 error state。
我意识到,我需要一个更专业的GraphQL客户端来管理这些请求。在Web2项目中,我常用Apollo Client。那么,在Web3的React项目里,能不能用Apollo Client来连接The Graph呢?理论上完全可行。接下来的核心实现,就是围绕 “在React + TypeScript项目中,使用Apollo Client查询The Graph子图” 来展开。
核心实现
第一步:项目初始化与依赖安装
首先,我创建了一个新的React + TypeScript项目(如果是在已有项目中集成,跳过这一步)。
npx create-react-app my-graph-demo --template typescript
cd my-graph-demo
然后,安装必需的依赖:@apollo/client 和 graphql。Apollo Client是一个全面的GraphQL状态管理库,graphql包用于解析GraphQL查询。
npm install @apollo/client graphql
第二步:配置Apollo Client实例
这是最关键的一步。我需要创建一个Apollo Client实例,并配置其连接到The Graph的HTTPS端点。
我在 src 目录下创建了一个 apollo/client.ts 文件:
import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
import { onError } from '@apollo/client/link/error';
// 1. 定义子图端点
const UNISWAP_V3_SUBGRAPH_URL = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';
// 2. 创建HTTP链接
const httpLink = new HttpLink({
uri: UNISWAP_V3_SUBGRAPH_URL,
});
// 3. (可选)错误处理中间件
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors)
graphQLErrors.forEach(({ message, locations, path }) =>
console.error(`[GraphQL error]: Message: ${message}, Location: ${locations}, Path: ${path}`)
);
if (networkError) console.error(`[Network error]: ${networkError}`);
});
// 4. 创建Apollo Client实例
// 使用 `from` 组合多个中间件链接
export const apolloClient = new ApolloClient({
link: from([errorLink, httpLink]),
cache: new InMemoryCache(),
defaultOptions: {
watchQuery: {
fetchPolicy: 'cache-first', // 默认先查缓存,对不常变的数据很友好
},
},
});
这里有个坑:最初我直接 new HttpLink({ uri: ... }) 就把链接传给 ApolloClient,忽略了错误处理。当网络波动或查询语法错误时,错误信息很难追踪。加上 onError 链接后,调试体验好了很多。
第三步:将Apollo Provider集成到React应用中
Apollo Client使用React的Context机制来向下传递客户端实例。我需要用 ApolloProvider 包裹我的应用根组件。
修改 src/index.tsx:
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import { ApolloProvider } from '@apollo/client';
import { apolloClient } from './apollo/client'; // 导入上一步创建的客户端
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
root.render(
<React.StrictMode>
{/* 用 ApolloProvider 包裹 App,并传入 client */}
<ApolloProvider client={apolloClient}>
<App />
</ApolloProvider>
</React.StrictMode>
);
第四步:编写GraphQL查询并使用useQuery Hook
现在可以开始写查询了。我计划先查询一个特定池子的基本信息。为了更好的类型安全和代码提示,我倾向于将查询语句用 gql 模板标签定义。
首先,在 src 下创建 graphql/queries.ts:
import { gql } from '@apollo/client';
// 查询池子基本信息
export const POOL_DETAIL_QUERY = gql`
query GetPoolDetail($poolId: String!) {
pools(where: { id: $poolId }) {
id
token0 {
id
symbol
decimals
}
token1 {
id
symbol
decimals
}
feeTier
liquidity
sqrtPrice
tick
volumeUSD
feesUSD
# 注意:子图中的字段名可能是 feeGrowthGlobal0X128,需要查文档确认
}
}
`;
// 查询该池子的最新交易(分页)
export const POOL_SWAPS_QUERY = gql`
query GetPoolSwaps($poolId: String!, $first: Int!, $skip: Int!) {
swaps(
where: { pool: $poolId }
orderBy: timestamp
orderDirection: desc
first: $first
skip: $skip
) {
id
timestamp
amount0
amount1
amountUSD
sender
recipient
}
}
`;
注意这个细节:查询中的字段名必须和子图模式(schema)里定义的完全一致。比如手续费字段,我一开始想当然写了 feesUSD,但实际查询返回空,后来去 The Graph Explorer 上查看该子图的“Playground”,用文档浏览器才发现正确的字段名是 totalValueLockedUSD 或其他。一定要查官方文档或子图自带的schema!
然后,在React组件中使用 useQuery hook来执行查询。创建 src/components/PoolDashboard.tsx:
import React from 'react';
import { useQuery } from '@apollo/client';
import { POOL_DETAIL_QUERY, POOL_SWAPS_QUERY } from '../graphql/queries';
// 定义我们关心的池子ID,这里用的是ETH/USDC 0.3%池
const TARGET_POOL_ID = '0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8';
const PoolDashboard: React.FC = () => {
// 查询池子详情
const {
loading: poolLoading,
error: poolError,
data: poolData,
} = useQuery(POOL_DETAIL_QUERY, {
variables: { poolId: TARGET_POOL_ID },
});
// 查询交易记录,每次取10条
const {
loading: swapsLoading,
error: swapsError,
data: swapsData,
} = useQuery(POOL_SWAPS_QUERY, {
variables: {
poolId: TARGET_POOL_ID,
first: 10,
skip: 0, // 用于分页,skip = page * first
},
});
if (poolLoading || swapsLoading) return <div>Loading data from The Graph...</div>;
if (poolError) return <div>Error loading pool: {poolError.message}</div>;
if (swapsError) return <div>Error loading swaps: {swapsError.message}</div>;
const pool = poolData?.pools[0];
const swaps = swapsData?.swaps || [];
if (!pool) return <div>Pool not found.</div>;
// 简单计算当前价格:sqrtPrice转换需要公式,这里仅示意
// 真实公式: price = (sqrtPrice^2) / 2^192 * 10^(token1.decimals - token0.decimals)
// 此处省略具体实现,仅展示数据获取成功
return (
<div>
<h2>Pool: {pool.token0.symbol} / {pool.token1.symbol} ({pool.feeTier / 10000}%)</h2>
<p><strong>Pool Address:</strong> {pool.id}</p>
<p><strong>Liquidity:</strong> {parseFloat(pool.liquidity).toLocaleString()}</p>
<p><strong>Volume USD (24h):</strong> ${parseFloat(pool.volumeUSD).toFixed(2)}</p>
<hr />
<h3>Recent Swaps</h3>
<ul>
{swaps.map((swap: any) => (
<li key={swap.id}>
{new Date(swap.timestamp * 1000).toLocaleString()} -
Amount USD: ${parseFloat(swap.amountUSD).toFixed(2)}
</li>
))}
</ul>
</div>
);
};
export default PoolDashboard;
第五步:实现分页查询
上面的交易查询只取了前10条。要实现“加载更多”,我需要用到 fetchMore 函数,这是 useQuery 返回的另一个实用工具。
我对 PoolDashboard 组件进行改造,添加分页状态和“加载更多”按钮:
import React, { useState } from 'react';
import { useQuery } from '@apollo/client';
import { POOL_SWAPS_QUERY } from '../graphql/queries';
// 在组件内部...
const [swapsPage, setSwapsPage] = useState(0);
const SWAPS_PER_PAGE = 5;
const {
loading: swapsLoading,
error: swapsError,
data: swapsData,
fetchMore, // 获取 fetchMore 函数
} = useQuery(POOL_SWAPS_QUERY, {
variables: {
poolId: TARGET_POOL_ID,
first: SWAPS_PER_PAGE,
skip: 0, // 初始跳过0条
},
});
const handleLoadMore = () => {
const nextPage = swapsPage + 1;
fetchMore({
variables: {
skip: nextPage * SWAPS_PER_PAGE,
// first 保持不变
},
// 更新查询结果的方式:将新数据合并到旧数据中
updateQuery: (prevResult, { fetchMoreResult }) => {
if (!fetchMoreResult) return prevResult;
return {
swaps: [...prevResult.swaps, ...fetchMoreResult.swaps],
};
},
}).then(() => {
setSwapsPage(nextPage);
});
};
// 在渲染部分...
<button onClick={handleLoadMore} disabled={swapsLoading}>
{swapsLoading ? 'Loading...' : 'Load More Swaps'}
</button>
这里有个大坑:updateQuery 函数必须返回与原始查询结果结构完全一致的对象。我一开始只返回了 { swaps: fetchMoreResult.swaps },导致点击“加载更多”后,列表里只剩下新加载的5条,之前的被覆盖了。正确的做法是合并数组。
完整代码示例
以下是一个简化但可运行的 App.tsx 和关联文件,展示了集成The Graph查询的核心流程。
1. src/apollo/client.ts
import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
import { onError } from '@apollo/client/link/error';
const UNISWAP_V3_SUBGRAPH_URL = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';
const httpLink = new HttpLink({ uri: UNISWAP_V3_SUBGRAPH_URL });
const errorLink = onError(({ graphQLErrors, networkError }) => {
if (graphQLErrors) graphQLErrors.forEach((err) => console.error(`[GraphQL error]: ${err.message}`));
if (networkError) console.error(`[Network error]: ${networkError}`);
});
export const apolloClient = new ApolloClient({
link: from([errorLink, httpLink]),
cache: new InMemoryCache(),
});
2. src/graphql/queries.ts
import { gql } from '@apollo/client';
export const SIMPLE_POOL_QUERY = gql`
query GetSimplePoolInfo($poolId: String!) {
pools(where: { id: $poolId }) {
id
token0 { symbol }
token1 { symbol }
feeTier
liquidity
}
}
`;
3. src/components/SimplePoolView.tsx
import React from 'react';
import { useQuery } from '@apollo/client';
import { SIMPLE_POOL_QUERY } from '../graphql/queries';
const TARGET_POOL_ID = '0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8';
const SimplePoolView: React.FC = () => {
const { loading, error, data } = useQuery(SIMPLE_POOL_QUERY, {
variables: { poolId: TARGET_POOL_ID },
});
if (loading) return <p>Loading from The Graph...</p>;
if (error) return <p>Error: {error.message}</p>;
const pool = data?.pools[0];
if (!pool) return <p>Pool not found.</p>;
return (
<div style={{ border: '1px solid #ccc', padding: '1rem', margin: '1rem' }}>
<h3>Uniswap V3 Pool Info (via The Graph)</h3>
<p><strong>Pair:</strong> {pool.token0.symbol} / {pool.token1.symbol}</p>
<p><strong>Fee Tier:</strong> {pool.feeTier / 10000}%</p>
<p><strong>Liquidity:</strong> {Number(pool.liquidity).toLocaleString()}</p>
<p><small>Pool ID: {pool.id}</small></p>
</div>
);
};
export default SimplePoolView;
4. src/App.tsx
import React from 'react';
import './App.css';
import SimplePoolView from './components/SimplePoolView';
function App() {
return (
<div className="App">
<header className="App-header">
<h1>The Graph + React 实战</h1>
<SimplePoolView />
</header>
</div>
);
}
export default App;
5. src/index.tsx (如前所述,用 ApolloProvider 包裹)
运行 npm start,如果一切正常,你将看到一个显示Uniswap V3 ETH/USDC池基本信息的卡片。
踩坑记录
-
“字段不存在”错误:这是我遇到的第一个坑。我按照自己对数据的理解写了字段名,比如
txCount,结果查询返回Cannot query field \"txCount\" on type \"Pool\"。解决方法:老老实实打开The Graph Explorer,找到对应的子图,在“Playground”的文档浏览器里查看该实体(如Pool)下所有可用的字段,或者直接运行一个简单查询看返回的结构。 -
分页合并数据覆盖:如上文所述,在实现
fetchMore的updateQuery时,我错误地只返回了新数据,导致旧数据丢失。解决方法:仔细阅读Apollo文档关于updateQuery的示例,确保是合并(merge)而不是替换(replace)数据。 -
查询变量类型不匹配:子图中
ID类型通常是String!,但我最初在查询中定义变量类型为ID!,导致某些查询不返回数据且没有明显错误。解决方法:在子图的GraphQL Playground的“Schema”页签下,查看查询(如pools)的参数确切类型,并在前端查询定义中保持一致。 -
网络错误与速率限制:在开发过程中频繁刷新,偶尔会收到
429 Too Many Requests或网络错误。The Graph的公开端点有速率限制。解决方法:对于生产环境,可以考虑使用The Graph的托管服务或自建索引节点以获得更稳定的速率。在开发时,添加良好的错误处理UI(如重试按钮),并避免在useEffect中无脑地频繁轮询。
小结
通过这次实战,我彻底打通了React前端与The Graph子图之间的数据通道。核心收获是:将Apollo Client作为GraphQL状态管理工具引入Web3前端,能极大地简化对索引化链上数据的查询、状态管理和错误处理。这让前端开发者可以更专注于数据展示和交互逻辑,而不是底层的数据获取和聚合。接下来,我可以继续探索The Graph的更高级特性,比如订阅(Subscription)实时数据更新,或者为自定义的智能合约部署专属子图。