用 The Graph 查询链上数据实战:从手搓 RPC 到 Subgraph,我的 NFT 项目数据加载快了 10 倍

背景

上个月我接了一个 NFT 市场的前端需求,需要展示某个 ERC-721 合约的"所有已铸造的 NFT 列表",包括 tokenId、当前持有者、铸造时间、最近一次交易价格。听起来很常规对吧?我一开始想都没想,直接用 ethers.js 的 Contract 对象去监听 Transfer 事件,然后循环遍历从 0 到 currentTokenId 的所有 token。

结果一上线测试,页面直接白屏 8 秒,用户反馈"点进来以为网站挂了"。更离谱的是,如果合约里有 5000 个 token,前端就要发起 5000 次 RPC 调用,每个调用网络延迟 200ms,加起来就是 1000 秒------这还是在理想网络下。我当时就意识到,这条路走不通。

后来我在社区看到有人提到 The Graph,说它可以把链上事件索引成数据库,然后用 GraphQL 查询,速度比 RPC 快两个数量级。我决定试试。

问题分析

最初的思路很简单:把合约事件全部拉到前端,自己用 JavaScript 做缓存。但问题在于:

  1. RPC 请求数量爆炸:每个 token 的 ownerOf、tokenURI 都需要单独调用,5000 个 token 就是 5000 个请求。
  2. 没有分页能力:前端一次性加载所有数据,浏览器内存撑不住。
  3. 实时性差:用户铸造了新 NFT,必须手动刷新页面才能看到。

我试过用 ethers.provider.getLogs 批量拉取 Transfer 事件,但返回的数据只有原始 log,需要自己解码 topic 和 data,而且一次最多拉 10000 条,超过会报错。最要命的是,跨链时 RPC URL 还得手动切换,维护成本极高。

后来我研究了一下 The Graph 的文档,发现它本质上是一个去中心化的事件索引器。你只需要写一个 subgraph.yaml 定义要监听哪些合约事件,然后部署到 Graph Network 上,它就会自动把事件数据整理成关系型表。前端通过 GraphQL 查询,一次请求就能拿到所有需要的数据,而且支持分页、排序、过滤。

核心实现

第一步:找到或部署一个 Subgraph

首先,你得有一个 Subgraph 可以查询。如果你的合约是热门项目(比如 CryptoPunks、Bored Ape),大概率已经有现成的 Subgraph 了。我用的合约是自己写的测试合约,所以需要自己部署。

但这里有个坑:部署 Subgraph 需要写 schema.graphql 和映射逻辑,对于前端开发者来说学习成本较高。我当时的做法是去 The Graph 的 Explorer(thegraph.com/explorer) 搜索类似合约的 Subgraph,直接 fork 过来改一下合约地址和起始区块。

对于我的场景,我找到了一个通用的 ERC-721 Subgraph,它已经定义了 TokenTransferOwner 等实体。我只需要把它部署到测试网就行。

部署步骤很简单(如果你是新手,建议先看官方文档的 quick start):

  1. 安装 @graphprotocol/graph-cli
  2. 运行 graph init 初始化项目
  3. 修改 subgraph.yaml 中的合约地址、起始区块
  4. 运行 graph deploy 上传到 The Graph 的托管服务

部署成功后,你会得到一个 GraphQL 端点,类似 https://api.thegraph.com/subgraphs/name/你的用户名/你的子图名

这里有个坑:起始区块一定要填合约部署的那个区块,否则会漏掉早期的事件。我当时填了 0,结果查询返回的数据全是空的,排查了半天才发现是起始区块太早,导致索引器跳过了所有事件。

第二步:在前端安装 GraphQL 客户端

有了端点后,前端怎么查询?最直接的方式是用 fetch 发送 POST 请求,但这样没有类型安全和缓存。我选择了 urql,它和 React 集成得非常好,支持缓存、分页和订阅。

安装命令:

bash 复制代码
npm install urql graphql

然后创建一个 GraphQL 客户端实例:

typescript 复制代码
// graphql-client.ts
import { createClient, cacheExchange, fetchExchange } from 'urql';

const client = createClient({
  url: 'https://api.thegraph.com/subgraphs/name/your-username/your-subgraph',
  exchanges: [cacheExchange, fetchExchange],
});

export default client;

注意:The Graph 的查询端点只支持 POST 方法,urql 默认就是 POST,所以没问题。如果你用 Apollo Client,需要手动配置 fetch 方法。

第三步:编写 GraphQL 查询获取 NFT 列表

现在开始写查询。我的需求是:获取所有 Token,按铸造时间降序排列,每次加载 20 条,并支持分页。

GraphQL 查询如下:

graphql 复制代码
query GetTokens($first: Int!, $skip: Int!) {
  tokens(
    first: $first
    skip: $skip
    orderBy: mintedAt
    orderDirection: desc
  ) {
    id
    tokenId
    owner {
      id
    }
    mintedAt
    lastTransferPrice
    uri
  }
}

这个查询里的 tokens 实体就是 Subgraph 里定义的,owner 是一个嵌套对象,它关联了 Owner 实体。一次查询就能拿到所有字段,不需要多次 RPC 调用。

在前端组件中使用:

typescript 复制代码
import { useQuery } from 'urql';
import { useState } from 'react';

const TOKENS_QUERY = `
  query GetTokens($first: Int!, $skip: Int!) {
    tokens(first: $first, skip: $skip, orderBy: mintedAt, orderDirection: desc) {
      id
      tokenId
      owner {
        id
      }
      mintedAt
      lastTransferPrice
      uri
    }
  }
`;

export function NFTList() {
  const [page, setPage] = useState(0);
  const pageSize = 20;

  const [result] = useQuery({
    query: TOKENS_QUERY,
    variables: { first: pageSize, skip: page * pageSize },
  });

  const { data, fetching, error } = result;

  if (fetching) return <p>加载中...</p>;
  if (error) return <p>错误: {error.message}</p>;

  return (
    <div>
      {data.tokens.map((token) => (
        <div key={token.id}>
          <span>Token #{token.tokenId}</span>
          <span>持有者: {token.owner.id}</span>
          <span>铸造时间: {new Date(token.mintedAt * 1000).toLocaleString()}</span>
        </div>
      ))}
      <button onClick={() => setPage(page - 1)} disabled={page === 0}>上一页</button>
      <button onClick={() => setPage(page + 1)}>下一页</button>
    </div>
  );
}

这里有个坑mintedAt 字段在 Subgraph 中存储的是 Unix 时间戳(秒级),前端需要乘以 1000 才能用 new Date() 解析。我当时直接用了 new Date(token.mintedAt),结果日期显示成了 1970 年,排查了半小时才发现这个问题。

第四步:处理实时更新------使用 Subscription

我的项目还需要在用户铸造新 NFT 后,列表自动刷新。传统做法是用 setInterval 轮询,但这样浪费请求且延迟高。The Graph 支持基于 WebSocket 的订阅(Subscription),当数据变化时主动推送。

首先,确保你的 urql 客户端配置了 subscription 支持:

bash 复制代码
npm install @urql/subscription graphql-ws

然后在客户端添加 subscription exchange:

typescript 复制代码
import { subscriptionExchange } from '@urql/subscription';
import { createClient, cacheExchange, fetchExchange } from 'urql';
import { createClient as createWSClient } from 'graphql-ws';

const wsClient = createWSClient({
  url: 'wss://api.thegraph.com/subgraphs/name/your-username/your-subgraph',
});

const client = createClient({
  url: 'https://api.thegraph.com/subgraphs/name/your-username/your-subgraph',
  exchanges: [
    cacheExchange,
    fetchExchange,
    subscriptionExchange({
      forwardSubscription: (operation) => ({
        subscribe: (sink) => ({
          unsubscribe: wsClient.subscribe(operation, sink),
        }),
      }),
    }),
  ],
});

然后写一个订阅查询,监听新的 Token 事件:

typescript 复制代码
const SUBSCRIPTION = `
  subscription OnNewToken {
    tokens(first: 1, orderBy: mintedAt, orderDirection: desc) {
      id
      tokenId
      owner {
        id
      }
      mintedAt
    }
  }
`;

export function useNewTokenListener() {
  const [result] = useSubscription({ query: SUBSCRIPTION });
  const { data } = result;

  useEffect(() => {
    if (data?.tokens?.length) {
      // 触发列表刷新
      console.log('新的 NFT 铸造了:', data.tokens[0].tokenId);
    }
  }, [data]);
}

这里有个坑:The Graph 的订阅是"拉模式"的,它不会在每次事件触发时都推送,而是每隔几秒检查一次数据变化。所以实时性不是毫秒级的,但对于 NFT 市场来说,1-2 秒的延迟完全可以接受。如果你需要真正的实时性,可能需要考虑用 Mempool 或者直接监听合约事件。

第五步:处理跨链------动态切换端点

我的项目支持以太坊主网和 Goerli 测试网,需要根据当前链动态切换 The Graph 端点。我用的 wagmi 管理链状态,所以可以监听 chainId 变化:

typescript 复制代码
import { useChainId } from 'wagmi';
import { useMemo } from 'react';

const SUBGRAPH_URLS = {
  1: 'https://api.thegraph.com/subgraphs/name/your-username/mainnet-subgraph',
  5: 'https://api.thegraph.com/subgraphs/name/your-username/goerli-subgraph',
};

export function useSubgraphClient() {
  const chainId = useChainId();

  const client = useMemo(() => {
    const url = SUBGRAPH_URLS[chainId];
    if (!url) {
      throw new Error(`不支持的链 ID: ${chainId}`);
    }
    return createClient({
      url,
      exchanges: [cacheExchange, fetchExchange],
    });
  }, [chainId]);

  return client;
}

然后在组件中用 Provider 包裹,或者直接用 useMemo 返回的 client 调用 useQuery

注意细节 :如果用户切换了链,之前缓存的查询结果应该清空,否则会显示旧链的数据。我通过给 useQuerycontext 添加 key 属性来解决:

typescript 复制代码
const [result] = useQuery({
  query: TOKENS_QUERY,
  variables: { first: pageSize, skip: page * pageSize },
  context: useMemo(() => ({ requestPolicy: 'network-only' }), [chainId]),
});

这样每次切换链都会重新请求,而不是使用缓存。

完整代码

下面是一个可直接运行的 React 组件,它集成了上述所有功能:分页查询、实时订阅、跨链支持。

tsx 复制代码
// NFTList.tsx
import React, { useState, useEffect } from 'react';
import { useQuery, useSubscription, Provider, createClient, cacheExchange, fetchExchange, subscriptionExchange } from 'urql';
import { createClient as createWSClient } from 'graphql-ws';
import { useChainId } from 'wagmi';

// 定义不同链的 Subgraph 端点
const SUBGRAPH_URLS = {
  1: 'https://api.thegraph.com/subgraphs/id/Qm...',
  5: 'https://api.thegraph.com/subgraphs/id/Qm...',
};

const TOKENS_QUERY = `
  query GetTokens($first: Int!, $skip: Int!) {
    tokens(first: $first, skip: $skip, orderBy: mintedAt, orderDirection: desc) {
      id
      tokenId
      owner { id }
      mintedAt
      lastTransferPrice
      uri
    }
  }
`;

const NEW_TOKEN_SUBSCRIPTION = `
  subscription OnNewToken {
    tokens(first: 1, orderBy: mintedAt, orderDirection: desc) {
      id
      tokenId
      owner { id }
      mintedAt
    }
  }
`;

function NFTListContent() {
  const [page, setPage] = useState(0);
  const pageSize = 20;

  // 分页查询
  const [result, reexecuteQuery] = useQuery({
    query: TOKENS_QUERY,
    variables: { first: pageSize, skip: page * pageSize },
    // 每次切换链时强制重新请求
    context: { requestPolicy: 'network-only' },
  });

  const { data, fetching, error } = result;

  // 订阅新 Token
  const [subResult] = useSubscription({ query: NEW_TOKEN_SUBSCRIPTION });
  useEffect(() => {
    if (subResult.data?.tokens?.length) {
      // 新 NFT 铸造,刷新列表
      reexecuteQuery({ requestPolicy: 'network-only' });
    }
  }, [subResult.data]);

  if (fetching) return <div>加载中...</div>;
  if (error) return <div>错误: {error.message}</div>;

  return (
    <div>
      <h2>NFT 列表 (第 {page + 1} 页)</h2>
      <ul>
        {data.tokens.map((token) => (
          <li key={token.id}>
            Token #{token.tokenId} - 持有者: {token.owner.id.slice(0, 6)}...
            - 铸造于: {new Date(token.mintedAt * 1000).toLocaleString()}
          </li>
        ))}
      </ul>
      <button onClick={() => setPage(p => Math.max(0, p - 1))} disabled={page === 0}>
        上一页
      </button>
      <button onClick={() => setPage(p => p + 1)}>下一页</button>
    </div>
  );
}

export function NFTList() {
  const chainId = useChainId();
  const url = SUBGRAPH_URLS[chainId];

  if (!url) {
    return <div>不支持的链,请切换到以太坊主网或 Goerli 测试网</div>;
  }

  const wsClient = createWSClient({ url: url.replace('https', 'wss') });

  const client = createClient({
    url,
    exchanges: [
      cacheExchange,
      fetchExchange,
      subscriptionExchange({
        forwardSubscription: (operation) => ({
          subscribe: (sink) => ({
            unsubscribe: wsClient.subscribe(operation, sink),
          }),
        }),
      }),
    ],
  });

  return (
    <Provider value={client}>
      <NFTListContent />
    </Provider>
  );
}

踩坑记录

  1. 查询结果为空 :部署 Subgraph 后,查询 tokens 返回空数组。排查发现是 subgraph.yaml 中的 startBlock 填了 0,导致索引器从创世区块开始,但我的合约部署在区块 100000,所以事件全部被跳过了。解决方案:改为合约部署的区块号。

  2. GraphQL 查询超时 :第一次查询时,传了 first: 5000 试图一次加载所有数据,结果请求超时。The Graph 对单次查询返回的数据量有限制,默认最多 1000 条。解决方案:使用分页,每次只查 20-50 条。

  3. 订阅不触发 :配置了 WebSocket 订阅,但页面刷新后订阅不工作。原因是我没有在组件卸载时关闭 WebSocket 连接,导致连接数过多被服务端限流。解决方案:在 useEffect 的清理函数中调用 wsClient.dispose()

  4. 时间戳显示错误mintedAt 是秒级 Unix 时间戳,直接 new Date(token.mintedAt) 返回 1970 年。解决方案:乘以 1000 再传给 Date 构造函数。

小结

The Graph 的核心价值在于把链上事件索引成可查询的数据库,让前端开发者不用再手搓 RPC 循环。这次实践让我从"8 秒白屏"到"0.3 秒加载",用户体验提升巨大。如果你想继续深挖,可以研究一下如何自定义 Subgraph 的实体关系(比如把 Transfer 事件聚合成价格曲线),或者用 The Graph 的 @entity 装饰器做更复杂的数据聚合。

相关推荐
妙码生花1 小时前
从 PHP 到 AI + Golang,程序员自救转型手记(十九):点选验证码代码逐行目检
前端·后端·go
Awu12272 小时前
⚡从零开发 Agent CLI(五)实现一个可治理、可扩展的工具系统
前端·人工智能·claude
咪库咪库咪2 小时前
Vue3-生命周期
前端
莪_幻尘3 小时前
你的 AI Skill 越多越蠢?Token 上下文爆炸的求生指南
前端·ai编程
lichenyang4533 小时前
从 has.echo 到异步 API 注册表:一次 ASCF API 回调不触发的排查复盘
前端
林瞅瞅3 小时前
Nuxt3 项目部署 Nginx 防盗链后特定 JS 文件 403 问题修复方案
前端
kyriewen4 小时前
别再每次都 Google 了:我整理了前端日常最常踩的 10 个 Git 坑,附速查表
前端·javascript·git
一颗奇趣蛋4 小时前
Web 视频开发完全指南:从入门到精通
前端
非洲农业不发达4 小时前
windows终端体验大升级,让你拥有macos级别的美化
前端·后端