背景
上个月我接了一个 NFT 市场的前端需求,需要展示某个 ERC-721 合约的"所有已铸造的 NFT 列表",包括 tokenId、当前持有者、铸造时间、最近一次交易价格。听起来很常规对吧?我一开始想都没想,直接用 ethers.js 的 Contract 对象去监听 Transfer 事件,然后循环遍历从 0 到 currentTokenId 的所有 token。
结果一上线测试,页面直接白屏 8 秒,用户反馈"点进来以为网站挂了"。更离谱的是,如果合约里有 5000 个 token,前端就要发起 5000 次 RPC 调用,每个调用网络延迟 200ms,加起来就是 1000 秒------这还是在理想网络下。我当时就意识到,这条路走不通。
后来我在社区看到有人提到 The Graph,说它可以把链上事件索引成数据库,然后用 GraphQL 查询,速度比 RPC 快两个数量级。我决定试试。
问题分析
最初的思路很简单:把合约事件全部拉到前端,自己用 JavaScript 做缓存。但问题在于:
- RPC 请求数量爆炸:每个 token 的 ownerOf、tokenURI 都需要单独调用,5000 个 token 就是 5000 个请求。
- 没有分页能力:前端一次性加载所有数据,浏览器内存撑不住。
- 实时性差:用户铸造了新 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,它已经定义了 Token、Transfer、Owner 等实体。我只需要把它部署到测试网就行。
部署步骤很简单(如果你是新手,建议先看官方文档的 quick start):
- 安装
@graphprotocol/graph-cli - 运行
graph init初始化项目 - 修改
subgraph.yaml中的合约地址、起始区块 - 运行
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。
注意细节 :如果用户切换了链,之前缓存的查询结果应该清空,否则会显示旧链的数据。我通过给 useQuery 的 context 添加 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>
);
}
踩坑记录
-
查询结果为空 :部署 Subgraph 后,查询
tokens返回空数组。排查发现是subgraph.yaml中的startBlock填了 0,导致索引器从创世区块开始,但我的合约部署在区块 100000,所以事件全部被跳过了。解决方案:改为合约部署的区块号。 -
GraphQL 查询超时 :第一次查询时,传了
first: 5000试图一次加载所有数据,结果请求超时。The Graph 对单次查询返回的数据量有限制,默认最多 1000 条。解决方案:使用分页,每次只查 20-50 条。 -
订阅不触发 :配置了 WebSocket 订阅,但页面刷新后订阅不工作。原因是我没有在组件卸载时关闭 WebSocket 连接,导致连接数过多被服务端限流。解决方案:在
useEffect的清理函数中调用wsClient.dispose()。 -
时间戳显示错误 :
mintedAt是秒级 Unix 时间戳,直接new Date(token.mintedAt)返回 1970 年。解决方案:乘以 1000 再传给Date构造函数。
小结
The Graph 的核心价值在于把链上事件索引成可查询的数据库,让前端开发者不用再手搓 RPC 循环。这次实践让我从"8 秒白屏"到"0.3 秒加载",用户体验提升巨大。如果你想继续深挖,可以研究一下如何自定义 Subgraph 的实体关系(比如把 Transfer 事件聚合成价格曲线),或者用 The Graph 的 @entity 装饰器做更复杂的数据聚合。