背景
上个月我接了一个NFT铸造平台的前端开发,项目要求是用户连接钱包后,页面需要实时显示两个关键数据:当前钱包地址的铸造数量(userMintedCount)和该NFT项目的总铸造量(totalSupply)。合约已经由团队的其他成员部署好了,事件也定义得很清楚:Minted(address indexed minter, uint256 tokenId)。
一开始我觉得这很简单——不就是监听事件嘛。但真正做起来才发现,从“能工作”到“稳定、实时、高性能”之间,隔着好几个大坑。我最开始用了最笨的轮询方式,每5秒调用一次合约的totalSupply()和查询用户的余额,结果就是页面卡顿、数据更新不及时,用户体验很差。我意识到必须用真正的事件监听,但该选哪种方案?怎么处理多链?断开监听怎么办?这一路踩的坑,今天我就完整记录下来。
问题分析
我的第一反应是直接用ethers.js的contract.on方法。写了个简单的测试:
const contract = new ethers.Contract(address, abi, provider);
contract.on('Minted', (minter, tokenId) => {
console.log(`用户 ${minter} 铸造了 token #${tokenId}`);
// 更新状态...
});
在本地测试网上跑得挺好。但一上主网测试就出问题了:当用户切换钱包账户时,之前账户的监听器没有正确清理,导致事件重复触发。更麻烦的是,我们的DApp需要支持多链(Ethereum、Polygon、Arbitrum),不同链的Provider和合约实例管理起来很混乱。
然后我尝试了wagmi的useContractEvent(这是wagmi v1的写法),发现它确实帮我处理了React生命周期内的监听器清理,但它在用户切换链时表现不稳定,有时会漏掉事件。而且项目用的是wagmi v2,API已经有了变化。
经过一番排查,我确定了几个必须解决的核心问题:
- 监听器管理:如何确保组件卸载、用户切换账户或链时,旧的监听器被正确清理
- 多链支持:用户可能在Ethereum上铸造,也可能在Polygon上,监听需要适配当前活跃链
- 性能优化:避免不必要的重复监听和状态更新
- 错误处理:RPC节点连接失败、网络切换时的降级方案
核心实现
第一步:选择正确的wagmi v2 Hook
在wagmi v2中,监听合约事件的主要Hook是useWatchContractEvent。与v1的useContractEvent不同,它更专注于“监听”这一单一职责,并且返回的是undefined(监听行为是副作用),这让它在组合使用时更清晰。
我首先实现了最基本的监听:
import { useWatchContractEvent } from 'wagmi';
function MintTracker() {
const { chain } = useAccount();
useWatchContractEvent({
address: NFT_CONTRACT_ADDRESS[chain?.id || 1], // 根据当前链选择合约地址
abi: NFT_ABI,
eventName: 'Minted',
onLogs(logs) {
logs.forEach((log) => {
const [minter, tokenId] = log.args;
console.log('监听到铸造事件:', minter, tokenId);
// 这里更新状态
});
},
});
return <div>监听中...</div>;
}
这里有个坑:useWatchContractEvent默认只在组件挂载时开始监听。但如果合约地址是动态的(比如根据链ID变化),当链切换后,监听的目标合约地址不会自动更新!这意味着用户切换到Polygon后,还在监听Ethereum上的旧合约。
第二步:处理动态合约地址和链切换
为了解决链切换问题,我需要确保监听器在链变化时重新建立。wagmi的useWatchContractEvent本身不会自动处理这个,但我们可以利用它的enabled参数和依赖数组:
import { useAccount, useWatchContractEvent } from 'wagmi';
function MintTracker() {
const { chain, address: userAddress } = useAccount();
const [totalSupply, setTotalSupply] = useState(0);
const [userMinted, setUserMinted] = useState(0);
// 获取当前链对应的合约地址
const contractAddress = chain?.id ? NFT_CONTRACT_ADDRESS[chain.id] : undefined;
// 监听Minted事件 - 只有合约地址存在时才启用监听
useWatchContractEvent({
address: contractAddress,
abi: NFT_ABI,
eventName: 'Minted',
enabled: !!contractAddress, // 关键:地址不存在时不建立监听
onLogs(logs) {
// 处理事件日志
handleMintLogs(logs);
},
});
const handleMintLogs = useCallback((logs: any[]) => {
logs.forEach((log) => {
const [minter, tokenId] = log.args;
// 更新总供应量(每次铸造+1)
setTotalSupply(prev => prev + 1);
// 如果铸造者是当前用户,更新用户铸造数量
if (minter.toLowerCase() === userAddress?.toLowerCase()) {
setUserMinted(prev => prev + 1);
}
});
}, [userAddress]);
return (
<div>
<p>总铸造量: {totalSupply}</p>
<p>你的铸造数量: {userMinted}</p>
</div>
);
}
注意这个细节:我用了enabled: !!contractAddress来确保只有合约地址有效时才建立监听。这样当用户断开钱包连接或切换到不支持的链时,监听会自动停止。
第三步:处理历史事件和初始状态
事件监听只能捕获未来的事件,但页面加载时我们需要显示当前的状态。所以还需要在组件加载时获取初始值,并在每次事件触发时更新。
我创建了一个自定义Hook来封装这个逻辑:
import { useContractRead, useWatchContractEvent } from 'wagmi';
import { useEffect } from 'react';
export function useNFTMintTracker() {
const { chain, address: userAddress } = useAccount();
const contractAddress = chain?.id ? NFT_CONTRACT_ADDRESS[chain.id] : undefined;
// 1. 获取初始的总供应量
const {
data: totalSupplyData,
refetch: refetchTotalSupply
} = useContractRead({
address: contractAddress,
abi: NFT_ABI,
functionName: 'totalSupply',
enabled: !!contractAddress,
});
// 2. 获取用户的初始铸造数量(需要合约有相应的view函数)
const {
data: userBalanceData,
refetch: refetchUserBalance
} = useContractRead({
address: contractAddress,
abi: NFT_ABI,
functionName: 'balanceOf',
args: userAddress ? [userAddress] : undefined,
enabled: !!contractAddress && !!userAddress,
});
// 3. 监听Minted事件
useWatchContractEvent({
address: contractAddress,
abi: NFT_ABI,
eventName: 'Minted',
enabled: !!contractAddress,
onLogs(logs) {
// 每次有铸造事件时,重新获取最新数据
refetchTotalSupply();
if (userAddress) {
refetchUserBalance();
}
},
});
// 4. 当链或用户地址变化时,重新获取数据
useEffect(() => {
if (contractAddress) {
refetchTotalSupply();
if (userAddress) {
refetchUserBalance();
}
}
}, [chain?.id, userAddress, contractAddress]);
return {
totalSupply: totalSupplyData ? Number(totalSupplyData) : 0,
userMinted: userBalanceData ? Number(userBalanceData) : 0,
isLoading: !totalSupplyData && !userBalanceData
};
}
这里有个重要的设计决策:我选择在监听到事件时重新调用refetch函数,而不是直接在前端计算+1。为什么?因为可能有多个用户同时铸造,直接+1可能导致数据不一致。重新从链上查询虽然多了一次调用,但保证了数据的准确性。
第四步:添加Provider级别的监听(高级需求)
上面的方案在大多数情况下够用了。但在我们的项目中,还有一个需求:无论用户当前在哪个页面,只要发生了铸造,页面右上角的一个全局徽章数字都需要更新。
这意味着我需要一个“全局”的事件监听,而不是组件级别的。我最终选择了在wagmi的Provider层面设置监听:
// 在_app.tsx或类似的根组件中
import { createConfig, WagmiProvider } from 'wagmi';
import { http } from 'wagmi';
import { mainnet, polygon } from 'wagmi/chains';
// 创建自定义的wagmi配置
const config = createConfig({
chains: [mainnet, polygon],
transports: {
[mainnet.id]: http(),
[polygon.id]: http(),
},
// 这里可以添加事件监听
});
// 然后在React组件外部设置全局监听器
let unsubscribe: (() => void) | undefined;
// 一个工具函数来管理全局监听
export function setupGlobalMintListener(config: any) {
// 清理之前的监听器
if (unsubscribe) {
unsubscribe();
}
// 为每条链都设置监听
config.chains.forEach((chain: any) => {
const contractAddress = NFT_CONTRACT_ADDRESS[chain.id];
if (contractAddress) {
const publicClient = config.getPublicClient({ chainId: chain.id });
// 监听事件
unsubscribe = publicClient.watchContractEvent({
address: contractAddress,
abi: NFT_ABI,
eventName: 'Minted',
onLogs: (logs) => {
// 这里可以更新全局状态,比如使用zustand或Redux
console.log('全局监听到铸造事件:', logs);
},
});
}
});
}
注意:这种全局监听要谨慎使用,因为它不在React生命周期内,需要手动管理清理。我把它用在确实需要跨组件共享状态的场景。
完整代码
下面是一个完整的、可直接运行的组件示例:
import React, { useCallback } from 'react';
import { useAccount, useContractRead, useWatchContractEvent } from 'wagmi';
// NFT合约ABI片段
const NFT_ABI = [
{
"anonymous": false,
"inputs": [
{ "indexed": true, "name": "minter", "type": "address" },
{ "indexed": false, "name": "tokenId", "type": "uint256" }
],
"name": "Minted",
"type": "event"
},
{
"inputs": [],
"name": "totalSupply",
"outputs": [{ "name": "", "type": "uint256" }],
"stateMutability": "view",
"type": "function"
},
{
"inputs": [{ "name": "owner", "type": "address" }],
"name": "balanceOf",
"outputs": [{ "name": "", "type": "uint256" }],
"stateMutability": "view",
"type": "function"
}
] as const;
// 各链的合约地址
const NFT_CONTRACT_ADDRESS: Record<number, `0x${string}`> = {
1: '0x...', // Ethereum主网
137: '0x...', // Polygon
42161: '0x...', // Arbitrum
};
interface MintTrackerProps {
showUserStats?: boolean;
}
export default function MintTracker({ showUserStats = true }: MintTrackerProps) {
const { chain, address: userAddress } = useAccount();
// 获取当前链的合约地址
const contractAddress = chain?.id ? NFT_CONTRACT_ADDRESS[chain.id] : undefined;
// 读取总供应量
const {
data: totalSupplyData,
refetch: refetchTotalSupply,
isLoading: isLoadingTotalSupply
} = useContractRead({
address: contractAddress,
abi: NFT_ABI,
functionName: 'totalSupply',
enabled: !!contractAddress,
});
// 读取用户余额
const {
data: userBalanceData,
refetch: refetchUserBalance,
isLoading: isLoadingUserBalance
} = useContractRead({
address: contractAddress,
abi: NFT_ABI,
functionName: 'balanceOf',
args: userAddress ? [userAddress] : undefined,
enabled: !!contractAddress && !!userAddress && showUserStats,
});
// 处理铸造事件的回调
const handleMintLogs = useCallback((logs: any[]) => {
console.log('监听到铸造事件,数量:', logs.length);
// 重新获取最新数据
refetchTotalSupply();
if (userAddress && showUserStats) {
refetchUserBalance();
}
// 可以在这里添加通知或动画效果
if (logs.length > 0) {
const latestLog = logs[logs.length - 1];
const [minter, tokenId] = latestLog.args;
console.log(`最新铸造: ${minter} 铸造了 #${tokenId}`);
}
}, [refetchTotalSupply, refetchUserBalance, userAddress, showUserStats]);
// 监听Minted事件
useWatchContractEvent({
address: contractAddress,
abi: NFT_ABI,
eventName: 'Minted',
enabled: !!contractAddress,
onLogs: handleMintLogs,
});
// 加载状态
if (isLoadingTotalSupply) {
return <div>加载数据中...</div>;
}
// 不支持的网络
if (!contractAddress) {
return <div>请切换到支持的网络(Ethereum、Polygon或Arbitrum)</div>;
}
return (
<div className="mint-tracker">
<div className="stats">
<div className="stat">
<h3>总铸造量</h3>
<p className="value">{totalSupplyData?.toString() || '0'}</p>
</div>
{showUserStats && userAddress && (
<div className="stat">
<h3>你的铸造数量</h3>
<p className="value">{userBalanceData?.toString() || '0'}</p>
</div>
)}
</div>
<div className="status">
<span className="indicator active"></span>
<span>实时监听已启用</span>
</div>
</div>
);
}
踩坑记录
-
监听器泄露导致重复触发
- 现象:用户切换账户后,同一个事件触发了两次更新
- 原因:
useWatchContractEvent的依赖项变化时,旧的监听器没有立即清理,新旧监听器短暂共存 - 解决:确保
enabled参数正确设置,当不应监听时立即禁用。另外,使用useCallback包装回调函数,避免每次渲染创建新函数
-
链切换后监听不更新
- 现象:从Ethereum切换到Polygon,事件监听还在旧的链上
- 原因:
useWatchContractEvent的地址参数变化时,不会自动重新建立监听 - 解决:通过
enabled参数和contractAddress依赖控制,地址变化时先禁用再重新启用监听
-
RPC节点限制导致监听中断
- 现象:生产环境偶尔收不到事件,但本地测试正常
- 原因:使用的公共RPC节点有速率限制或websocket连接数限制
- 解决:配置自己的节点或使用更可靠的节点服务。添加重连逻辑和错误监控
-
大量事件导致性能问题
- 现象:在公售期间,每秒几十个铸造事件,页面变得卡顿
- 原因:每个事件都触发状态更新和UI重渲染
- 解决:添加防抖逻辑,批量处理事件。或者使用更轻量的状态管理,避免不必要的组件重渲染
小结
经过这一轮折腾,我最大的收获是:Web3前端的事件监听不是简单的“监听就行”,需要考虑React生命周期、链切换、性能、错误处理等方方面面。wagmi v2的useWatchContractEvent是个好工具,但它不是魔法,需要正确理解它的行为。对于更复杂的场景,可能还需要结合viem的底层API或自定义Provider监听。下次我准备深入研究一下如何优化大量事件的处理性能,这又是一个值得记录的坑。