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

4 阅读1分钟

背景

最近在参与一个DeFi收益聚合器项目的前端开发,我的任务是构建一个“流动性池分析”面板。这个面板需要展示Uniswap V3上某个特定交易对(比如ETH/USDC)池子的关键数据:当前价格、流动性总量、24小时交易量、手续费收入,以及最近的一批交易记录。

一开始,我的思路很“朴素”:直接用 ethers.jsviem 去读取池子合约的 slot0 获取价格,循环读取 MintBurnSwap 事件来计算其他数据。我很快写了个原型,但问题立刻出现了。性能是第一个拦路虎。为了计算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,管理 loadingerror 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/clientgraphql。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池基本信息的卡片。

踩坑记录

  1. “字段不存在”错误:这是我遇到的第一个坑。我按照自己对数据的理解写了字段名,比如 txCount,结果查询返回 Cannot query field \"txCount\" on type \"Pool\"解决方法:老老实实打开The Graph Explorer,找到对应的子图,在“Playground”的文档浏览器里查看该实体(如 Pool)下所有可用的字段,或者直接运行一个简单查询看返回的结构。

  2. 分页合并数据覆盖:如上文所述,在实现 fetchMoreupdateQuery 时,我错误地只返回了新数据,导致旧数据丢失。解决方法:仔细阅读Apollo文档关于 updateQuery 的示例,确保是合并(merge)而不是替换(replace)数据。

  3. 查询变量类型不匹配:子图中 ID 类型通常是 String!,但我最初在查询中定义变量类型为 ID!,导致某些查询不返回数据且没有明显错误。解决方法:在子图的GraphQL Playground的“Schema”页签下,查看查询(如 pools)的参数确切类型,并在前端查询定义中保持一致。

  4. 网络错误与速率限制:在开发过程中频繁刷新,偶尔会收到 429 Too Many Requests 或网络错误。The Graph的公开端点有速率限制。解决方法:对于生产环境,可以考虑使用The Graph的托管服务或自建索引节点以获得更稳定的速率。在开发时,添加良好的错误处理UI(如重试按钮),并避免在 useEffect 中无脑地频繁轮询。

小结

通过这次实战,我彻底打通了React前端与The Graph子图之间的数据通道。核心收获是:将Apollo Client作为GraphQL状态管理工具引入Web3前端,能极大地简化对索引化链上数据的查询、状态管理和错误处理。这让前端开发者可以更专注于数据展示和交互逻辑,而不是底层的数据获取和聚合。接下来,我可以继续探索The Graph的更高级特性,比如订阅(Subscription)实时数据更新,或者为自定义的智能合约部署专属子图。