Web3 数据查询
链上数据查询的挑战
区块链是一个只追加的数据结构,所有状态变更都通过交易写入区块。前端要查询链上数据,面临三大核心挑战:
| 挑战 | 说明 | 举例 |
|---|---|---|
| RPC 限制 | 节点 RPC 只提供基础查询能力,无法做复杂聚合 | 无法一次查出"某地址持有的所有 NFT" |
| 历史数据索引 | 区块链不是数据库,没有通用索引 | 查某 Token 过去 30 天的转账记录需要逐区块扫描 |
| 复杂查询困难 | RPC 不支持 JOIN、GROUP BY 等操作 | 统计 DEX 过去 24 小时的交易量需要自建索引 |
为什么链上查询难?怎么解? RPC 节点接口只能做基础查询,不会有 JOIN/聚合:
- 「实时余额、当前价格」走 RPC + Multicall(一笔请求批量打多个 view 函数,省往返)。
- 「历史交易、Token 持有者列表、过去 24h 交易量」必须靠索引服务,否则得逐区块扫日志。
The Graph / Subgraph 是什么? 最常用的去中心化索引方案:
- 你写一个 Subgraph:声明监听哪些合约的哪些事件,怎么把事件映射成 GraphQL 实体。
- 部署到 The Graph 网络后,前端就能用 GraphQL 查询「过去一周成交量 Top10 池子」之类的复杂数据。
- 收费按查询次数走 GRT 代币;自托管也行(graph-node)。
还有哪些数据查询路径?
- Etherscan/BscScan API:现成 REST,免费档够小项目用,但不通用、不去中心化。
- Alchemy/Moralis/QuickNode 增强 API:提供「某地址持有的所有 NFT/Token」「交易历史」等开箱即用接口。
- Dune Analytics:SQL 查链上数据做仪表盘,分析师友好。
- Multicall3:一次 RPC 调 N 个 view 函数;Wagmi 的
useReadContracts默认就用它。
- Web3 数据查询:公共 RPC 随时可能限流或下线,生产环境务必使用付费 RPC 服务或自建节点。核心要点可以围绕 公共RPCvs付费服务、RPC方法分类、请求优化:批量请求与WebSocket、什么是Subgraph 展开,并补充边界情况、复杂度或适用场景。
RPC 节点服务
公共 RPC vs 付费服务
| 特性 | 公共 RPC | Alchemy / Infura / QuickNode |
|---|---|---|
| 价格 | 免费 | 免费额度 + 付费套餐 |
| 速率限制 | 严格(通常 <10 次/秒) | 宽松(数百次/秒) |
| 可靠性 | 不稳定,高峰期丢请求 | SLA 保障,99.9%+ |
| 归档节点 | 通常不支持 | 支持查询历史状态 |
| 增强 API | 无 | NFT API、Token API、Webhook 等 |
公共 RPC 随时可能限流或下线,生产环境务必使用付费 RPC 服务或自建节点。
RPC 方法分类
import { createPublicClient, http } from 'viem';
import { mainnet } from 'viem/chains';
const client = createPublicClient({
chain: mainnet,
transport: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
});
// 1. 读取链状态
const balance = await client.getBalance({
address: '0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045',
});
// 2. 调用合约只读方法(eth_call)
const totalSupply = await client.readContract({
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', // USDC
abi: erc20Abi,
functionName: 'totalSupply',
});
// 3. 查询事件日志(eth_getLogs)
const logs = await client.getLogs({
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
event: transferEvent,
fromBlock: 19000000n,
toBlock: 19000100n,
});
请求优化:批量请求与 WebSocket
const client = createPublicClient({
chain: mainnet,
transport: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY', {
batch: true, // viem 自动合并同一 tick 内的请求
}),
});
// 以下三个请求会合并为一次 HTTP 请求
const [balance, blockNumber, gasPrice] = await Promise.all([
client.getBalance({ address }),
client.getBlockNumber(),
client.getGasPrice(),
]);
import { createPublicClient, webSocket } from 'viem';
import { mainnet } from 'viem/chains';
const wsClient = createPublicClient({
chain: mainnet,
transport: webSocket('wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
});
// 监听新区块
wsClient.watchBlockNumber({
onBlockNumber: (blockNumber) => console.log('新区块:', blockNumber),
});
// 监听合约事件
wsClient.watchContractEvent({
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
abi: erc20Abi,
eventName: 'Transfer',
onLogs: (logs) => console.log('USDC 转账:', logs),
});
WebSocket 延迟 1-2 秒,轮询至少有 N 秒延迟。DEX 价格更新等实时场景优先用 WebSocket。
The Graph 协议
什么是 Subgraph
The Graph 是一个去中心化的链上数据索引协议,它将链上事件索引到 PostgreSQL 数据库中,并通过 GraphQL API 对外提供查询服务。
开发者编写 Subgraph(子图)来定义要监听的合约事件,以及如何将事件映射为结构化实体。
Subgraph 架构组成
- Schema(
schema.graphql):定义数据模型 - Manifest(
subgraph.yaml):声明监听的合约、事件、处理函数 - Mapping(
src/mapping.ts):事件处理逻辑(AssemblyScript)
type Transfer @entity {
id: Bytes!
from: Bytes!
to: Bytes!
value: BigInt!
blockNumber: BigInt!
blockTimestamp: BigInt!
transactionHash: Bytes!
}
前端查询 Subgraph
import { Client, cacheExchange, fetchExchange, gql } from 'urql';
const subgraphClient = new Client({
url: 'https://api.thegraph.com/subgraphs/name/uniswap/uniswap-v3',
exchanges: [cacheExchange, fetchExchange],
});
const POOLS_QUERY = gql`
query TopPools($first: Int!, $skip: Int!) {
pools(
first: $first, skip: $skip
orderBy: volumeUSD, orderDirection: desc
where: { volumeUSD_gt: "1000000" }
) {
id
token0 { symbol }
token1 { symbol }
volumeUSD
totalValueLockedUSD
}
}
`;
const { data } = await subgraphClient
.query(POOLS_QUERY, { first: 10, skip: 0 })
.toPromise();
Hosted Service vs 去中心化网络
The Graph 已从 Hosted Service(集中托管、免费)迁移到去中心化网络(Subgraph Studio),查询按量付费(GRT 代币),但获得了多节点冗余的高可用保障。
Multicall 批量查询
原理
Multicall 是一个部署在链上的聚合合约,它将多个合约调用打包到一次 RPC 请求中执行,大幅减少网络往返次数。
viem 内置 Multicall
viem 的 readContract 和 getBalance 等方法在 multicall: true(默认开启)时,会自动使用 Multicall 合约聚合请求:
const USDC = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
const addresses = ['0xAddr1...', '0xAddr2...', '0xAddr3...'];
// 以下多个 readContract 会自动聚合为一次 Multicall 请求
const balances = await Promise.all(
addresses.map((address) =>
client.readContract({
address: USDC,
abi: erc20Abi,
functionName: 'balanceOf',
args: [address],
})
)
);
const results = await client.multicall({
contracts: [
{ address: USDC, abi: erc20Abi, functionName: 'totalSupply' },
{ address: USDC, abi: erc20Abi, functionName: 'balanceOf', args: ['0xAddr1...'] },
{ address: WETH, abi: erc20Abi, functionName: 'decimals' },
],
});
// results: [{ result: 1000000n, status: 'success' }, ...]
- 批量查多个地址的 Token 余额
- 批量查 NFT 集合中多个 tokenId 的 metadata
- 仪表盘一次性加载多个合约的状态数据
事件日志查询
eth_getLogs 与 Filter
链上事件(Event / Log)是前端获取历史数据的重要途径。合约通过 emit 触发事件,这些事件记录在交易收据中,可通过 eth_getLogs 查询。
import { parseAbiItem } from 'viem';
const transferEvent = parseAbiItem(
'event Transfer(address indexed from, address indexed to, uint256 value)'
);
const logs = await client.getLogs({
address: '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48',
event: transferEvent,
args: {
// indexed 参数可用于过滤
from: '0xSenderAddress...',
},
fromBlock: 19000000n,
toBlock: 19001000n,
});
logs.forEach((log) => {
console.log(`${log.args.from} → ${log.args.to}: ${log.args.value}`);
});
区块范围限制与分页策略
大多数 RPC 服务对 eth_getLogs 有区块范围限制(通常 2000-10000 个区块)。查询大范围历史数据时需要分页扫描:
async function getAllLogs(
client: PublicClient,
params: { address: `0x${string}`; event: AbiEvent; fromBlock: bigint; toBlock: bigint },
chunkSize = 2000n
) {
const allLogs = [];
let current = params.fromBlock;
while (current <= params.toBlock) {
const chunkEnd = current + chunkSize - 1n > params.toBlock
? params.toBlock
: current + chunkSize - 1n;
const logs = await client.getLogs({ ...params, fromBlock: current, toBlock: chunkEnd });
allLogs.push(...logs);
current = chunkEnd + 1n;
}
return allLogs;
}
扫描数万区块耗时且消耗大量 RPC 配额,历史数据查询优先使用 The Graph 或 Etherscan API。
其他数据源
| 数据源 | 适用场景 | 特点 |
|---|---|---|
| Etherscan API | 交易历史、合约 ABI、Token 转账记录 | 免费额度 5 次/秒,数据全面 |
| Alchemy Enhanced API | NFT 元数据、Token 余额、交易历史 | 单一 API 返回丰富的结构化数据 |
| Moralis | NFT API、DeFi 数据、跨链查询 | SDK 友好,支持多链 |
| Dune Analytics | 链上数据分析、仪表盘 | 用 SQL 查链上数据,适合分析场景 |
| Covalent | 统一多链数据 API | 单一 API 覆盖 100+ 链 |
const response = await fetch(
`https://eth-mainnet.g.alchemy.com/nft/v3/${ALCHEMY_KEY}/getNFTsForOwner?owner=${address}`
);
const { ownedNfts } = await response.json();
// ownedNfts: [{ contract: { name }, tokenId, name, image }, ...]
前端数据缓存策略
链上数据有一个天然特性:同一区块号下,数据是确定性的。利用这一点可以构建高效的缓存策略。
import { useQuery } from '@tanstack/react-query';
import { useBlockNumber } from 'wagmi';
function useTokenBalance(tokenAddress: string, userAddress: string) {
const { data: blockNumber } = useBlockNumber({ watch: true });
return useQuery({
queryKey: ['tokenBalance', tokenAddress, userAddress, blockNumber],
queryFn: () =>
client.readContract({
address: tokenAddress, abi: erc20Abi,
functionName: 'balanceOf', args: [userAddress],
}),
enabled: !!blockNumber,
staleTime: 12_000, // 12 秒(一个区块时间)
});
}
- 区块号作为缓存键:区块号变化时自动使旧缓存失效
- staleTime 设为区块时间:以太坊约 12 秒出块,在此期间数据不会变
- WebSocket 监听新块:新块到来时触发相关查询刷新
- Multicall 聚合:减少 RPC 请求数,降低配额消耗
更多关于链上交互的内容,参考 ethers.js 与 viem 和 智能合约交互。
常见面试问题
Q1: 前端查询链上数据有哪些方式?
答案:
| 方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| RPC | 实时性最高 | 查询能力有限 | 余额、allowance |
| The Graph | 复杂查询、分页排序 | 有索引延迟 | 历史数据、统计 |
| Etherscan API | 开箱即用 | 中心化、速率限制 | 交易历史、合约 ABI |
| Alchemy / Moralis | API 丰富 | 付费、厂商锁定 | NFT、Token 数据 |
Q2: 什么是 Multicall?解决了什么问题?
答案:
Multicall 是一个部署在链上的聚合合约,它允许在一次 RPC 请求中批量执行多个合约的只读调用。核心解决的问题是 N 次 RPC 请求合并为 1 次,减少网络往返和 RPC 配额消耗。典型场景包括批量查询多个地址的 Token 余额、加载仪表盘数据等。viem 默认内置了 Multicall 支持,调用多个 readContract 时会自动聚合。
Q3: The Graph 的 Subgraph 是如何工作的?
答案:
开发者在 subgraph.yaml 中声明监听的合约和事件,在 schema.graphql 中定义实体模型,在 mapping.ts(AssemblyScript)中编写事件到实体的映射逻辑。部署后,Graph Node 持续监听链上新区块,执行 Mapping 处理器将数据写入 PostgreSQL,前端通过 GraphQL API 查询,支持分页、排序、过滤等复杂操作。
Q4: eth_getLogs 查询大范围区块时如何处理?
答案:
RPC 服务限制单次查询的区块范围(通常 2000-10000 块),需分块查询:将大范围拆成多个小范围请求后合并结果。高频场景建议用 The Graph 索引历史事件而非直接扫链。
Q5: WebSocket 订阅和轮询各自适用于什么场景?
答案:
WebSocket:实时性要求高的场景(DEX 价格更新、交易确认通知),延迟 1-2 秒,但需维护长连接。轮询:实时性要求不高的场景(余额刷新),实现简单但有轮询间隔的固有延迟。
Q6: 前端如何设计链上数据的缓存策略?
答案:
核心是利用区块确定性(同一区块号下数据不变):用 TanStack Query/SWR 将区块号纳入缓存键,staleTime 设为一个出块时间(以太坊约 12 秒),通过 WebSocket 监听新区块触发刷新。对不常变化的数据(Token 符号、小数位数)可设更长缓存。
Q7: Etherscan API 和 The Graph 的主要区别?
答案:
Etherscan 是中心化 REST API,提供预定义的通用数据端点(交易历史、合约 ABI),适合快速取用。The Graph 是去中心化 GraphQL 服务,开发者自定义 Schema 和索引逻辑,灵活性更高,适合构建 DApp 所需的定制化查询。
Q8: 如何在前端高效展示大量链上数据(如 Token 列表)?
答案:
- Multicall 批量查询:一次请求获取多个 Token 的余额和元信息
- The Graph 或增强 API:获取结构化列表(带排序、过滤)
- TanStack Query 缓存:区块号作为失效依据
- 虚拟列表渲染:
react-window只渲染可视区域 - 渐进式加载:先显示基础信息,再异步加载价格等辅助数据