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

6 阅读1分钟

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

背景

上个月,我接了一个DeFi策略分析面板的前端开发需求。其中一个核心功能是展示Uniswap V3上特定交易对(比如ETH/USDC)的流动性池详情,包括当前价格、流动性总量、手续费率等。我的第一反应是直接用 ethers.jsviem 去读取对应的智能合约。这确实能行,我写了几个 readContract 调用,数据也拿到了。

但问题很快来了。当我想展示这个池子最近24小时的交易量变化,或者想列出这个池子所有大的流动性提供者(LP)时,直接查合约就变得非常笨重和低效。我需要遍历大量历史事件,这在浏览器端几乎不可能实现,而且会消耗天量的RPC请求。项目需要一个既能查询实时状态又能高效检索历史事件的解决方案。这时,我想到了 The Graph——一个专门用于索引和查询区块链数据的去中心化协议。理论上,我可以通过它订阅一个已经索引好的Uniswap V3子图,用GraphQL轻松拿到所有结构化数据。

问题分析

一开始,我以为集成The Graph会很简单:找个现成的Uniswap V3子图,用 fetchaxios 发个GraphQL请求不就完了?但上手后发现,事情没那么直白。

首先,我找到了Uniswap官方在The Graph托管服务上部署的V3子图。但我直接用自己的前端项目去请求它的公开端点时,遇到了CORS(跨域资源共享)错误。浏览器的安全策略阻止了我的本地开发服务器向 https://api.thegraph.com 发起请求。这是第一个拦路虎。

其次,即使CORS问题解决了,GraphQL查询的编写也让我有点懵。子图暴露的数据模式(Schema)和我直接从合约里读到的原始数据格式不一样,它是被索引和加工过的实体(Entities)。我需要搞清楚有哪些实体可用,以及它们之间的关联关系。

最后,我希望能有一个类型安全的开发体验。GraphQL查询返回的 any 类型在TypeScript项目里用着心里发虚,后期维护也容易出错。我需要一种方法能为查询结果生成明确的TypeScript接口。

最初的“简单fetch方案”显然走不通,我需要一个更正式、更健壮的前端集成方案。

核心实现

1. 选择客户端与绕过CORS

直接调用The Graph的公共HTTPS端点遇到CORS限制,这是前端开发中常见的问题。The Graph的托管服务默认可能没有配置允许所有来源。解决这个问题有几种思路:配置自己的代理服务器,或者使用支持自定义端点的Graph客户端库。

我选择了 Apollo Client。它是一个功能强大的GraphQL客户端,不仅帮我管理请求状态、缓存,更重要的是,它通常用于服务端渲染(SSR)或静态生成(SSG)场景,在这些场景中,请求发自Node.js环境而非浏览器,从而天然避开了CORS问题。对于我的纯前端项目,我可以先通过配置一个简单的本地开发代理来解决CORS问题,未来部署时可以考虑使用无服务器函数作为代理。

首先,我安装了必要的依赖:

npm install @apollo/client graphql

然后,我创建了Apollo Client的实例,指向Uniswap V3在以太坊主网的子图端点。

// src/lib/apolloClient.ts
import { ApolloClient, InMemoryCache, HttpLink, from } from '@apollo/client';
import { onError } from '@apollo/client/link/error';

// 注意:在浏览器中直接使用此端点会因CORS失败
// 在开发环境中,我们需要配置代理或使用其他方法
const GRAPHQL_ENDPOINT = 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3';

const httpLink = new HttpLink({
  uri: GRAPHQL_ENDPOINT,
});

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}`);
});

export const apolloClient = new ApolloClient({
  link: from([errorLink, httpLink]),
  cache: new InMemoryCache(),
});

这里有个坑:在本地开发时,如果你在浏览器控制台看到CORS错误,一个快速的解决方案是在 vite.config.tswebpack.config.js 中配置开发服务器代理,将 /subgraph 路径的请求转发到The Graph API。

// vite.config.ts 示例
export default defineConfig({
  // ... 其他配置
  server: {
    proxy: {
      '/subgraph-api': {
        target: 'https://api.thegraph.com',
        changeOrigin: true,
        rewrite: (path) => path.replace(/^\/subgraph-api/, ''),
      },
    },
  },
});

然后,将 GRAPHQL_ENDPOINT 改为 ‘/subgraph-api/subgraphs/name/uniswap/uniswap-v3’。这样,浏览器请求的是同源地址,由开发服务器代为转发,就绕过了CORS。

2. 编写并执行GraphQL查询

接下来,我需要编写正确的GraphQL查询。我先去The Graph的Explorer查看了 uniswap-v3 子图的Schema。我找到了几个关键实体:Pool(流动性池)、Token(代币)、Swap(兑换事件)等。

我的目标是查询一个特定池子的基本信息。我知道Uniswap V3池子的合约地址是由两个代币地址和手续费层级(feeTier)共同决定的。但更方便的是,子图已经为每个池子生成了一个唯一的ID,通常是合约地址。所以,我可以直接用池子合约地址来查询。

我在项目中创建了一个GraphQL查询文件:

# src/graphql/queries/poolInfo.graphql
query GetPoolInfo($poolId: ID!) {
  pool(id: $poolId) {
    id
    token0 {
      id
      symbol
      name
      decimals
    }
    token1 {
      id
      symbol
      name
      decimals
    }
    feeTier
    liquidity
    sqrtPrice
    tick
    volumeUSD
    txCount
    # 当前价格,需要根据sqrtPrice和代币精度计算
    # 这里我们先取出来原始数据,在前端转换
  }
}

然后,在React组件中,我使用 @apollo/clientuseQuery hook来执行这个查询。我选择了一个知名的ETH/USDC 0.3%费率的池子地址作为示例。

// src/components/PoolInfo.tsx
import { useQuery, gql } from '@apollo/client';
import React from 'react';

// 使用gql标签定义查询
const GET_POOL_INFO = gql`
  query GetPoolInfo($poolId: ID!) {
    pool(id: $poolId) {
      id
      token0 {
        id
        symbol
        name
        decimals
      }
      token1 {
        id
        symbol
        name
        decimals
      }
      feeTier
      liquidity
      sqrtPrice
      tick
      volumeUSD
      txCount
    }
  }
`;

// 一个已知的ETH/USDC 0.3%池地址
const SAMPLE_POOL_ID = '0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8';

export const PoolInfo: React.FC = () => {
  const { loading, error, data } = useQuery(GET_POOL_INFO, {
    variables: { poolId: SAMPLE_POOL_ID },
  });

  if (loading) return <p>Loading pool data from The Graph...</p>;
  if (error) return <p>Error :( {error.message}</p>;

  const pool = data.pool;
  // 计算当前价格:价格 = (sqrtPrice^2) / 2^(192) * (10^decimals1 / 10^decimals0)
  // 简化处理:这里只展示一个概念
  const token0Decimals = pool.token0.decimals;
  const token1Decimals = pool.token1.decimals;

  return (
    <div>
      <h2>Pool: {pool.token0.symbol} / {pool.token1.symbol}</h2>
      <p>Fee Tier: {pool.feeTier / 10000}%</p>
      <p>Liquidity: {parseFloat(pool.liquidity).toLocaleString()}</p>
      <p>Volume (USD): ${parseFloat(pool.volumeUSD).toLocaleString(undefined, { maximumFractionDigits: 2 })}</p>
      <p>Transaction Count: {pool.txCount}</p>
      <p>Pool Contract: <code>{pool.id}</code></p>
    </div>
  );
};

注意这个细节sqrtPricetick 是Uniswap V3用于表示价格的核心变量。前端需要根据公式将它们转换为人类可读的价格。上面的计算只是示意,实际项目中需要实现精确的转换函数。

3. 实现类型安全(Codegen)

手动为GraphQL查询结果定义TypeScript接口非常繁琐且容易出错。我决定使用 GraphQL Code Generator 来自动完成这项工作。

首先,安装必要的开发依赖:

npm install -D @graphql-codegen/cli @graphql-codegen/typescript @graphql-codegen/typescript-operations

然后,创建配置文件 codegen.yml

# codegen.yml
overwrite: true
schema: 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3'
documents: 'src/graphql/**/*.graphql'
generates:
  src/generated/graphql.ts:
    plugins:
      - 'typescript'
      - 'typescript-operations'
    config:
      skipTypename: false
      withHooks: true # 如果使用React,可以生成对应的hooks

package.json 中添加一个脚本:

"scripts": {
  "codegen": "graphql-codegen --config codegen.yml",
  "codegen:watch": "graphql-codegen --config codegen.yml --watch"
}

运行 npm run codegen 后,会在 src/generated/graphql.ts 中自动生成所有类型定义和可能的React Hooks。现在,我可以以完全类型安全的方式重写我的查询:

// src/components/PoolInfoTyped.tsx
import React from 'react';
import { useGetPoolInfoQuery } from '../generated/graphql'; // 自动生成的Hook
import { apolloClient } from '../lib/apolloClient';
import { ApolloProvider } from '@apollo/client';

const SAMPLE_POOL_ID = '0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8';

const PoolInfoInner: React.FC = () => {
  // 现在,`data`、`variables` 的类型都是自动推断的!
  const { loading, error, data } = useGetPoolInfoQuery({
    variables: { poolId: SAMPLE_POOL_ID },
  });

  if (loading) return <p>Loading (with types)...</p>;
  if (error) return <p>Error (with types): {error.message}</p>;
  // TypeScript知道`data.pool`可能为null,因为GraphQL查询可能返回空
  if (!data || !data.pool) return <p>No pool found.</p>;

  const pool = data.pool;
  return (
    <div>
      <h2>Pool: {pool.token0.symbol} / {pool.token1.symbol}</h2>
      <p>Pool ID: <code>{pool.id}</code></p>
      {/* 访问其他属性都有完整的类型提示 */}
    </div>
  );
};

// 需要在外层提供Apollo Client
export const PoolInfoTyped: React.FC = () => (
  <ApolloProvider client={apolloClient}>
    <PoolInfoInner />
  </ApolloProvider>
);

通过Codegen,我获得了完美的开发体验:自动补全、类型检查、以及查询字段变更时的编译时报错,大大提升了代码的可靠性和开发效率。

4. 处理分页与复杂查询

基础信息查询搞定后,我需要实现更复杂的功能,比如列出该池子最近的Swap事件。这类列表查询通常涉及分页。The Graph的子图查询支持经典的 firstskipwhere 过滤参数。

我编写了一个分页查询Swap事件的GraphQL:

# src/graphql/queries/poolSwaps.graphql
query GetPoolSwaps($poolId: ID!, $first: Int!, $skip: Int!) {
  swaps(
    where: { pool: $poolId }
    orderBy: timestamp
    orderDirection: desc
    first: $first
    skip: $skip
  ) {
    id
    timestamp
    transaction {
      id
    }
    sender
    recipient
    amount0
    amount1
    amountUSD
  }
}

在React组件中,我可以结合 useQuery 和分页状态(如当前页码)来动态获取数据。对于无限滚动或加载更多,Apollo Client的 fetchMore 函数非常好用。

// 使用 useQuery 的 fetchMore 示例片段
const { data, loading, fetchMore } = useGetPoolSwapsQuery({
  variables: {
    poolId: SAMPLE_POOL_ID,
    first: 10,
    skip: 0,
  },
});

const loadMore = () => {
  fetchMore({
    variables: {
      skip: data?.swaps.length || 0,
    },
    // 更新查询结果的方式
    updateQuery: (prev, { fetchMoreResult }) => {
      if (!fetchMoreResult) return prev;
      return {
        swaps: [...prev.swaps, ...fetchMoreResult.swaps],
      };
    },
  });
};

完整代码示例

以下是一个简化但可运行的React组件示例,集成了上述所有关键点(假设已配置代理解决CORS,并已运行Codegen生成类型)。

// src/App.tsx
import React from 'react';
import { ApolloProvider } from '@apollo/client';
import { apolloClient } from './lib/apolloClient';
import { PoolDashboard } from './components/PoolDashboard';

function App() {
  return (
    <ApolloProvider client={apolloClient}>
      <div className="App">
        <h1>Uniswap V3 Pool Dashboard (Powered by The Graph)</h1>
        <PoolDashboard />
      </div>
    </ApolloProvider>
  );
}

export default App;
// src/components/PoolDashboard.tsx
import React, { useState } from 'react';
import { useGetPoolInfoQuery, useGetPoolSwapsQuery } from '../generated/graphql';

const ETH_USDC_POOL = '0x8ad599c3a0ff1de082011efddc58f1908eb6e6d8';
const PAGE_SIZE = 5;

export const PoolDashboard: React.FC = () => {
  // 查询池子基本信息
  const { data: poolData, loading: poolLoading, error: poolError } = useGetPoolInfoQuery({
    variables: { poolId: ETH_USDC_POOL },
  });

  // 查询Swap事件,带分页
  const [swapsSkip, setSwapsSkip] = useState(0);
  const {
    data: swapsData,
    loading: swapsLoading,
    error: swapsError,
    fetchMore,
  } = useGetPoolSwapsQuery({
    variables: {
      poolId: ETH_USDC_POOL,
      first: PAGE_SIZE,
      skip: swapsSkip,
    },
  });

  const handleLoadMore = () => {
    const currentLength = swapsData?.swaps.length || 0;
    fetchMore({
      variables: { skip: currentLength },
      updateQuery: (prev, { fetchMoreResult }) => {
        if (!fetchMoreResult) return prev;
        return {
          swaps: [...prev.swaps, ...fetchMoreResult.swaps],
        };
      },
    }).then(() => {
      setSwapsSkip(currentLength);
    });
  };

  if (poolLoading) return <div>Loading pool info...</div>;
  if (poolError) return <div>Error loading pool: {poolError.message}</div>;
  if (!poolData?.pool) return <div>Pool not found.</div>;

  const pool = poolData.pool;

  return (
    <div style={{ padding: '20px' }}>
      <section>
        <h2>
          {pool.token0.symbol} / {pool.token1.symbol} Pool (Fee: {pool.feeTier / 10000}%)
        </h2>
        <p>
          <strong>Liquidity:</strong> {parseInt(pool.liquidity).toLocaleString()}
        </p>
        <p>
          <strong>24h Volume USD:</strong> $
          {parseFloat(pool.volumeUSD).toLocaleString(undefined, {
            maximumFractionDigits: 0,
          })}
        </p>
      </section>

      <section style={{ marginTop: '40px' }}>
        <h3>Recent Swaps</h3>
        {swapsError && <p>Error loading swaps: {swapsError.message}</p>}
        {swapsLoading && <p>Loading swaps...</p>}
        <ul>
          {swapsData?.swaps.map((swap) => (
            <li key={swap.id} style={{ marginBottom: '10px', borderBottom: '1px solid #eee', paddingBottom: '5px' }}>
              <div>Tx: {swap.transaction.id.slice(0, 10)}...</div>
              <div>
                Amounts: {parseFloat(swap.amount0).toFixed(4)} {pool.token0.symbol} /{' '}
                {parseFloat(swap.amount1).toFixed(4)} {pool.token1.symbol}
              </div>
              <div>Value: ${parseFloat(swap.amountUSD).toFixed(2)}</div>
              <div>Time: {new Date(parseInt(swap.timestamp) * 1000).toLocaleString()}</div>
            </li>
          ))}
        </ul>
        <button onClick={handleLoadMore} disabled={swapsLoading}>
          {swapsLoading ? 'Loading...' : 'Load More Swaps'}
        </button>
      </section>
    </div>
  );
};

踩坑记录

  1. CORS错误:如前所述,在浏览器中直接调用The Graph托管服务API会遇到CORS。解决方法:在开发环境配置本地代理(如Vite的 server.proxy),在生产环境可以考虑使用Cloudflare Worker、AWS Lambda等无服务器函数作为代理,或者寻找支持CORS的公共网关(有些社区提供)。
  2. 查询返回 null:我传入一个正确的合约地址,但 pool 查询返回 null原因:子图索引的ID可能不是合约地址本身,而是小写格式。另外,有些池子可能因为索引延迟或尚未被索引而不存在。解决方法:确保ID格式正确(全小写),并检查子图是否已经同步到最新区块。可以在The Graph Explorer中先用相同ID测试查询。
  3. 类型生成失败:运行 graphql-codegen 时失败,报错“无法获取schema”。原因:网络问题或端点URL错误。解决方法:检查 codegen.yml 中的 schema URL是否正确且可访问。有时需要科学上网。也可以先将schema下载到本地文件,然后指向本地文件路径。
  4. 分页性能与 skip 限制:使用 skip 参数进行深度分页(例如 skip: 10000)在The Graph上可能非常慢甚至超时,因为底层数据库查询效率问题。解决方法:尽量避免大数值的 skip。推荐使用基于游标(cursor)的分页,即使用 where: { id_gt: $lastId }orderBy: id。但需要注意的是,这要求子图的Schema设计支持这种模式,并非所有查询都适用。

小结

这次实战让我彻底打通了从前端到链上索引数据的管道。The Graph + Apollo Client + GraphQL Codegen 的组合,为Web3前端提供了一套类型安全、高效且强大的数据查询方案。下一步,我计划深入研究子图的定义和部署,为自己项目的合约定制专属索引,从而解锁更复杂的数据展示和分析功能。