背景
上个月,我接了一个NFT项目的铸造页面开发。需求很明确:用户连接钱包后,页面需要实时显示当前钱包地址的铸造数量、合约的总铸造量,并且当用户自己成功铸造后,页面上的这些数字要立刻更新,给用户即时的反馈。
一开始,我觉得这很简单。不就是查数据吗?我在useEffect里设个setInterval,每隔几秒用合约的read方法查一下balanceOf和totalSupply不就行了?于是,第一版代码迅速上线。在本地测试和测试网小流量下,好像也没什么问题。
但问题很快就来了。当模拟大量用户同时访问页面时,前端疯狂地轮询合约,不仅页面变得卡顿,RPC服务的速率限制也频频被触发,导致请求失败,数据更新延迟。更糟糕的是,用户铸造成功后,需要等下一个轮询周期(我设了5秒)才能看到更新,体验非常差。项目经理拿着测试反馈来找我:"这个实时性,能不能像DeFi交易那样,提交完交易确认就立刻变?"
我知道,是时候抛弃轮询,拥抱真正的事件监听了。
问题分析
我的目标是监听两个事件:
- 合约的
Transfer事件(ERC-721标准)。因为铸造本质上是from地址为0x0的Transfer,监听它可以同时捕获到总供应量变化和特定用户余额变化。 - 用户钱包地址的变化,以便在用户切换钱包时,更新监听的目标地址。
最初的思路是直接用ethers.js的contract.on。但在React函数组件里直接使用,我立刻遇到了监听器清理和组件重渲染导致重复监听的问题。然后我尝试用wagmi的useWatchContractEvent hook,它封装得很好,但在处理动态地址(当前连接的钱包地址)和需要同时监听多个过滤器(如特定from或to)时,配置变得有些复杂。我还需要考虑多链切换、Provider稳定性等问题。
排查过程就是不断地在测试网上铸造测试NFT,观察控制台日志,看事件是否被正确捕获,监听器是否重复添加或意外移除。我发现,一个健壮的监听方案需要处理好以下几个关键点:监听器的声明周期必须与React组件生命周期绑定、必须能依赖动态参数(如address)、必须能优雅地处理RPC连接变化和错误重试。
核心实现
第一步:定义合约ABI与地址
首先,我们需要准确定义要监听的事件。我创建了一个单独的constants.ts文件来管理合约信息。这里有一个坑 :为了正确监听事件,ABI里必须包含对应事件的完整定义,不能只用几个function的ABI。
typescript
// constants.ts
export const NFT_CONTRACT_ADDRESS = '0x...'; // 你的合约地址
export const NFT_CONTRACT_ABI = [
// 其他函数定义...
// 关键:必须明确定义Transfer事件
{
"anonymous": false,
"inputs": [
{ "indexed": true, "internalType": "address", "name": "from", "type": "address" },
{ "indexed": true, "internalType": "address", "name": "to", "type": "address" },
{ "indexed": true, "internalType": "uint256", "name": "tokenId", "type": "uint256" }
],
"name": "Transfer",
"type": "event"
}
] as const; // 使用 `as const` 获得字面量类型,对viem/wagmi类型推断有帮助
第二步:使用 wagmi + viem 创建合约客户端
我选择wagmi和viem作为主要工具链,因为它们与React集成度最高,且viem的事件监听机制比较现代。首先配置wagmi。
typescript
// app.tsx 或 main.tsx 根组件
import { createConfig, http, WagmiProvider } from 'wagmi';
import { mainnet, sepolia } from 'wagmi/chains';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const config = createConfig({
chains: [mainnet, sepolia],
transports: {
[mainnet.id]: http(),
[sepolia.id]: http('https://rpc.sepolia.org'), // 测试网RPC
},
});
const queryClient = new QueryClient();
function App() {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
{/* 你的组件 */}
</QueryClientProvider>
</WagmiProvider>
);
}
第三步:实现核心事件监听Hook
这是最核心的部分。我将监听逻辑封装成一个自定义Hook useNFTTransferEvents。这个Hook需要接收一个可选的userAddress参数,来监听与该用户相关的转账。
注意这个细节 :我们监听所有Transfer事件,但在回调函数里根据from和to参数来过滤出我们关心的逻辑(如铸造、转账给用户、用户转出)。这样只需要建立一个监听器,更高效。
typescript
// hooks/useNFTTransferEvents.ts
import { useEffect } from 'react';
import { usePublicClient } from 'wagmi';
import { NFT_CONTRACT_ADDRESS, NFT_CONTRACT_ABI } from '../constants';
interface UseNFTTransferEventsProps {
userAddress?: `0x${string}`; // 当前连接的用户地址
onMint?: (to: string, tokenId: bigint) => void; // 铸造回调
onTransferToUser?: (tokenId: bigint) => void; // NFT转入用户地址回调
onTransferFromUser?: (tokenId: bigint) => void; // NFT从用户地址转出回调
}
export function useNFTTransferEvents({
userAddress,
onMint,
onTransferToUser,
onTransferFromUser,
}: UseNFTTransferEventsProps) {
const publicClient = usePublicClient();
useEffect(() => {
if (!publicClient) return;
// 定义事件处理函数
const handleTransfer = async (log: any) => {
// viem 返回的事件日志需要解码
const { args } = log;
if (!args) return;
const { from, to, tokenId } = args;
// 情况1:铸造 (from 是零地址)
const zeroAddress = `0x${'0'.repeat(40)}` as `0x${string}`;
if (from === zeroAddress && onMint) {
console.log(`NFT 被铸造至: ${to}, TokenID: ${tokenId}`);
onMint(to, tokenId);
}
// 情况2:NFT转入当前用户钱包
if (userAddress && to.toLowerCase() === userAddress.toLowerCase() && onTransferToUser) {
console.log(`NFT 转入用户: ${userAddress}, TokenID: ${tokenId}`);
onTransferToUser(tokenId);
}
// 情况3:NFT从当前用户钱包转出
if (userAddress && from.toLowerCase() === userAddress.toLowerCase() && onTransferFromUser) {
console.log(`NFT 从用户转出: ${userAddress}, TokenID: ${tokenId}`);
onTransferFromUser(tokenId);
}
};
// 创建事件监听
const unwatch = publicClient.watchContractEvent({
address: NFT_CONTRACT_ADDRESS,
abi: NFT_CONTRACT_ABI,
eventName: 'Transfer',
onLogs: (logs) => {
logs.forEach(handleTransfer);
},
onError: (error) => {
console.error('监听合约事件出错:', error);
// 在实际项目中,这里可以加入错误上报和重试逻辑
},
});
// 组件卸载或依赖变化时,清理监听
return () => {
unwatch();
};
}, [publicClient, userAddress, onMint, onTransferToUser, onTransferFromUser]); // 所有依赖项
}
第四步:在组件中集成与状态更新
现在,在显示铸造数量和总量的组件中使用这个Hook。我们同时使用wagmi的useReadContract来初始读取数据,当监听到事件后,手动使查询失效,触发重新获取,从而更新UI。
这里有个坑 :直接更新复杂状态(如对象、数组)时,要确保创建新的引用,以触发React的重新渲染。使用tanstack-query的invalidateQueries可以优雅地解决这个问题。
typescript
// components/NFTMintStats.tsx
import React from 'react';
import { useAccount, useReadContract } from 'wagmi';
import { useQueryClient } from '@tanstack/react-query';
import { NFT_CONTRACT_ADDRESS, NFT_CONTRACT_ABI } from '../constants';
import { useNFTTransferEvents } from '../hooks/useNFTTransferEvents';
export const NFTMintStats: React.FC = () => {
const { address: userAddress } = useAccount();
const queryClient = useQueryClient();
// 1. 读取初始数据
const { data: totalSupply, refetch: refetchTotalSupply } = useReadContract({
address: NFT_CONTRACT_ADDRESS,
abi: NFT_CONTRACT_ABI,
functionName: 'totalSupply',
});
const { data: userBalance, refetch: refetchUserBalance } = useReadContract({
address: NFT_CONTRACT_ADDRESS,
abi: NFT_CONTRACT_ABI,
functionName: 'balanceOf',
args: userAddress ? [userAddress] : undefined,
query: {
enabled: !!userAddress, // 只有用户连接时才查询
},
});
// 2. 设置事件回调:当事件发生时,使相关查询失效,触发自动重查
const handleMintOrTransfer = () => {
// 使totalSupply和当前用户balance的查询失效
queryClient.invalidateQueries({
queryKey: [{ entity: 'readContract', address: NFT_CONTRACT_ADDRESS, functionName: 'totalSupply' }]
});
if (userAddress) {
queryClient.invalidateQueries({
queryKey: [{ entity: 'readContract', address: NFT_CONTRACT_ADDRESS, functionName: 'balanceOf', args: [userAddress] }]
});
}
// 也可以直接调用 refetchTotalSupply() 和 refetchUserBalance(),但invalidateQueries更符合声明式风格
};
// 3. 启动事件监听
useNFTTransferEvents({
userAddress,
onMint: handleMintOrTransfer,
onTransferToUser: handleMintOrTransfer,
onTransferFromUser: handleMintOrTransfer,
});
return (
<div>
<p>合约总铸造量: {totalSupply?.toString() || '0'}</p>
{userAddress && (
<p>你的持有数量: {userBalance?.toString() || '0'}</p>
)}
</div>
);
};
完整代码示例
以下是一个更完整、可直接在支持wagmi的React项目中运行的组件示例,包含了连接钱包的部分。
typescript
// 文件: pages/index.tsx
import { ConnectButton } from '@rainbow-me/rainbowkit';
import { NFTMintStats } from '../components/NFTMintStats';
export default function HomePage() {
return (
<div style={{ padding: '2rem' }}>
<h1>NFT铸造实时看板</h1>
<div style={{ marginBottom: '2rem' }}>
<ConnectButton />
</div>
<NFTMintStats />
{/* 这里可以放置你的铸造按钮组件 */}
</div>
);
}
(constants.ts、hooks/useNFTTransferEvents.ts和components/NFTMintStats.tsx的代码同上文,此处不再重复。)
踩坑记录
- 监听器泄露与重复添加 :最初在
useEffect里直接写contract.on(...),没有返回清理函数,导致组件每次渲染都添加新监听器,内存泄漏且事件处理函数被执行多次。解决 :确保useEffect返回一个清理函数,在其中调用监听器返回的removeListener或unwatch。 - ABI不匹配导致监听失败 :一开始图省事,ABI只写了几个需要的函数,没包含
Transfer事件的定义,导致监听器一直无法触发。控制台也没有明显错误。解决:确保ABI来自完整的合约编译输出,或者至少手动补全需要监听的事件定义。 - RPC Provider的稳定性 :使用Infura或Alchemy的免费套餐时,公共RPC节点有请求频率和并发限制。当监听事件很频繁时,偶尔会出现Provider断开连接的情况。解决 :a) 在
watchContractEvent的onError回调中实现指数退避的重连逻辑;b) 考虑升级到付费套餐或使用更稳定的节点服务;c) 在前端加入简单的"连接状态"提示。 - 对历史事件的处理 :
watchContractEvent默认只监听新区块中的新事件。如果用户希望在页面加载时也显示最近的事件,需要额外用getLogs查询历史日志。解决 :在组件初始化时,用publicClient.getLogs查询过去一段时间(如最近100个区块)的事件,与实时监听的事件合并展示。
小结
这次优化让我彻底明白,Web3前端的"实时"体验必须依赖事件驱动,轮询只是权宜之计。核心收获是:将事件监听逻辑封装成与React生命周期绑定的自定义Hook,并利用状态管理库(如tanstack-query)的缓存失效机制来同步更新UI,是清晰且高效的模式。未来可以继续深挖如何优雅地处理监听错误重试、跨链事件同步,以及如何优化大量事件日志的渲染性能。