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

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

背景

上个月,我接手了一个DeFi收益聚合器项目的前端开发。产品经理提了一个需求:要在仪表盘首页展示用户可能感兴趣的几个热门Uniswap V3流动性池的实时数据,包括24小时交易量、总流动性和当前手续费率。

我的第一反应是:"简单,直接用 ethers.jsviem 去读合约的 public 变量和事件不就行了?" 于是,我吭哧吭哧写了段代码,通过 useEffect 轮询调用池子合约的 slot0 函数获取当前价格,再通过 provider.getLogs 拉取最近24小时的 Swap 事件来计算交易量。本地测试时,面对一个池子还好。一上线,用户钱包里要是多几个池子,页面直接卡死,RPC调用次数爆炸,速度慢得让人想砸键盘。我意识到,对于这种需要聚合和分析历史链上数据的场景,直接与节点交互是条死路。这时,我想起了那个听过很多次但一直没亲手用过的工具------The Graph。

问题分析

The Graph 的核心是一个去中心化的索引协议,它监听区块链事件,将数据按照定义好的模式(Subgraph)处理后存入可高效查询的数据库。对于前端来说,我们不用再关心如何从海量事件日志里筛选和计算,只需要像调用API一样,用GraphQL查询语句去获取已经处理好的结构化数据。

我的需求很明确:查询Uniswap V3在以太坊主网上特定池子的聚合数据。理论上,我不需要自己部署Subgraph,因为Uniswap官方已经维护了一个非常完善的 Uniswap V3 Subgraph。我的任务就是在前端React应用中,学会如何与这个已部署的Subgraph进行交互。

最初的尝试是直接用 fetchaxios 向Subgraph的GraphQL端点发送POST请求。这确实能跑通,但很快遇到了问题:1. 需要手动管理查询字符串和变量,容易出错;2. 缺乏类型安全,返回的数据结构全靠猜;3. 没有内置的请求状态(loading, error)管理,需要自己用useState和useEffect封装,很繁琐。我需要一个更"React"的、类型友好的解决方案。

核心实现

第一步:环境搭建与GraphQL客户端选择

首先,我创建了一个新的React + TypeScript项目(或者在你的现有项目中操作)。关键的依赖是 @apollo/clientgraphql。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(合约地址)、totalValueLockedUSDvolumeUSDfeesUSDtoken0token1 等字段。

为了获取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池数据的卡片。

踩坑记录

  1. "池子找不到 (Pool not found)" :这是我遇到的第一个也是最多人踩的坑。我确认地址没错,但查询返回 null。后来在The Graph的Discord社区提问才知道,Subgraph中存储的地址 id 字段全是小写 。而我从链上或Etherscan复制的地址可能是大小写混合的校验和格式。解决方法 :在将地址作为变量传入查询前,务必执行 .toLowerCase()

  2. 查询超时或响应慢 :第一次查询一个不常被查询的冷门池子时,可能会遇到响应时间较长的情况。这是因为The Graph的索引器需要为这次查询执行索引工作。解决方法:对于用户体验要求高的场景,前端要做好加载状态提示。另外,可以检查Subgraph的健康状态,有时是公共端点负载问题。

  3. 数据类型不匹配 :GraphQL查询返回的数字,即使是 BigInt 在Subgraph中,通过API返回时也是字符串格式 。直接用于计算会出错。解决方法 :在前端使用前,用 Number()parseFloat() 或更适合大数的库如 BigNumber.js (ethers.js自带) 进行转换。我的示例中用了 Number(),对于TVL和交易量这种可能很大的数,在生产环境中建议使用 ethers.BigNumberBigInt 来处理。

  4. "Cannot read property 'symbol' of null" :在测试时,我传了一个非Uniswap V3池的地址,查询返回的 pool 不为 null,但内部的 token0token1 可能为 null(如果子图索引不完整)。解决方法 :在组件渲染中使用可选链操作符 ?. 或进行严格的空值检查,就像我在示例中处理 poolDayData[0] 一样。

小结

这次实战让我彻底把The Graph从"听说过"变成了"上手用过"。它的核心价值在于将复杂的链上数据索引、聚合工作从前端剥离,让开发者能像查询普通API一样高效获取结构化数据。对于构建数据驱动的DeFi、NFT应用前端,它几乎是必备工具。下一步,我可以探索更复杂的查询(如分页获取多个池子、历史时间序列分析),甚至尝试为自己项目的合约部署一个专属的Subgraph。

相关推荐
Mintopia2 小时前
别再迷信"优化":大多数性能问题根本不在代码里
前端
倾颜2 小时前
接入 MCP,不一定要先平台化:一次 AI Runtime 的实战取舍
前端·后端·mcp
军军君012 小时前
Three.js基础功能学习十八:智能黑板实现实例五
前端·javascript·vue.js·3d·typescript·前端框架·threejs
恋猫de小郭2 小时前
Android 上为什么主题字体对 Flutter 不生效,对 Compose 生效?Flutter 中文字体问题修复
android·前端·flutter
Moment2 小时前
AI全栈入门指南:一文搞清楚NestJs 中的 Controller 和路由
前端·javascript·后端
禅思院2 小时前
前端架构演进:基于AST的常量模块自动化迁移实践
前端·vue.js·前端框架
程序员马晓博2 小时前
前端并发治理:从 Token 刷新聊起,一个 Promise 就够了
前端·javascript
许杰小刀2 小时前
FastAPI + Vue 前后端分离实战:我的项目结构“避坑指南”
前端·vue.js·fastapi
IT_陈寒2 小时前
Python的asyncio把我整不会了,原来问题出在这儿
前端·人工智能·后端