背景
上个月我接了一个NFT铸造平台的前端开发,需要实时显示用户的铸造状态。合约那边每成功铸造一个NFT就会发出一个Transfer事件(从零地址到用户地址),我的任务就是在前端页面上实时更新“已铸造数量”和用户的“铸造成功”提示。
最开始我觉得这很简单——不就是监听事件嘛!但真正做起来才发现,从“能监听”到“监听得好”中间隔着好几个大坑。我最初用setInterval轮询查询,结果页面卡得不行;后来换事件监听,又遇到了内存泄漏、状态不同步、多链兼容等问题。这篇文章就是我完整解决这个问题的实战记录。
问题分析
我最开始的思路特别直接:既然要实时显示,那我就每隔几秒查一下总供应量不就行了?于是写了个简单的轮询:
useEffect(() => {
const interval = setInterval(async () => {
const totalSupply = await contract.totalSupply();
setSupply(totalSupply);
}, 3000);
return () => clearInterval(interval);
}, []);
第一个问题马上出现了:页面越来越卡。打开Chrome的性能面板一看,内存使用量直线上升。原因是每次轮询都会创建新的Promise,旧的又没有及时清理。而且用户量稍微一多,对RPC节点的请求压力巨大,有时候还会触发rate limit。
这时候我才意识到:智能合约事件本来就是为这种场景设计的,我应该用事件监听(subscribe)而不是轮询(polling)。但具体怎么实现?用ethers.js的contract.on?还是用wagmi的useWatchContractEvent?不同方案有什么坑?我开始系统地研究。
核心实现
方案一:直接用ethers.js的contract.on()
首先我尝试了最直接的ethers.js方案,因为项目里本来就在用ethers.js连接合约:
import { ethers } from 'ethers';
import { useEffect, useState } from 'react';
const useContractEvents = (contractAddress: string, abi: any) => {
const [transfers, setTransfers] = useState<any[]>([]);
useEffect(() => {
const provider = new ethers.providers.WebSocketProvider(
process.env.NEXT_PUBLIC_WSS_RPC_URL!
);
const contract = new ethers.Contract(contractAddress, abi, provider);
// 监听Transfer事件
const filter = contract.filters.Transfer();
contract.on(filter, (from, to, tokenId, event) => {
console.log(`NFT #${tokenId} 从 ${from} 转移到 ${to}`);
// 更新状态
setTransfers(prev => [...prev, {
from,
to,
tokenId: tokenId.toString(),
txHash: event.transactionHash
}]);
});
// 清理函数
return () => {
contract.removeAllListeners();
provider.destroy();
};
}, [contractAddress, abi]);
return transfers;
};
这里有个关键细节:我用了WebSocketProvider而不是JsonRpcProvider。因为HTTP协议不支持服务端推送,必须用WebSocket才能实现真正的订阅。如果用了HTTP,ethers.js会在底层用轮询模拟事件监听,效果和我的初始方案差不多。
第一个坑出现了:测试时发现,页面切走再切回来,事件监听失效了。原因是React组件卸载时,虽然执行了removeAllListeners(),但重新挂载时创建了新的合约实例,新旧实例之间没有状态同步。用户切回页面时看不到刚才错过的事件。
方案二:结合wagmi的状态管理
项目本来也用了wagmi,所以我尝试用wagmi的hook来管理事件状态。wagmi v1有useContractEvent,但文档说v2的useWatchContractEvent更推荐:
import { useWatchContractEvent, useBlockNumber } from 'wagmi';
import { useEffect, useState } from 'react';
import nftAbi from './abis/nft.json';
const useNFTMintEvents = () => {
const [mintEvents, setMintEvents] = useState<any[]>([]);
const { data: blockNumber } = useBlockNumber({ watch: true });
useWatchContractEvent({
address: '0x742d35Cc6634C0532925a3b844Bc9e...',
abi: nftAbi,
eventName: 'Transfer',
args: {
from: '0x0000000000000000000000000000000000000000' // 只监听从零地址的转移(即铸造)
},
onLogs: (logs) => {
logs.forEach(log => {
const [from, to, tokenId] = log.args;
if (from === '0x0000000000000000000000000000000000000000') {
setMintEvents(prev => [...prev, {
to,
tokenId: tokenId.toString(),
blockNumber: log.blockNumber,
txHash: log.transactionHash
}]);
}
});
},
});
// 根据区块号清理旧事件,防止内存无限增长
useEffect(() => {
if (blockNumber && blockNumber > 10000) {
const cutoffBlock = blockNumber - 10000;
setMintEvents(prev =>
prev.filter(event => event.blockNumber > cutoffBlock)
);
}
}, [blockNumber]);
return mintEvents;
};
这个方案解决了两个问题:
- wagmi帮我们管理了WebSocket连接和重连逻辑,不用自己处理
- 通过监听区块号,可以定期清理太旧的事件,防止内存泄漏
但又发现了新问题:在测试网(比如Sepolia)上,useWatchContractEvent有时候收不到事件。排查后发现,某些公共RPC节点的WebSocket服务不太稳定。需要准备备用RPC节点。
方案三:多RPC回退机制
为了解决RPC节点不稳定的问题,我实现了一个带重试和回退的监听器:
import { ethers } from 'ethers';
import { useEffect, useRef, useState } from 'react';
const useRobustEventListeners = (contractAddress: string, abi: any) => {
const [events, setEvents] = useState<any[]>([]);
const [currentProviderIndex, setCurrentProviderIndex] = useState(0);
const contractRef = useRef<ethers.Contract | null>(null);
const wssUrls = [
process.env.NEXT_PUBLIC_WSS_RPC_1,
process.env.NEXT_PUBLIC_WSS_RPC_2,
process.env.NEXT_PUBLIC_WSS_RPC_3
].filter(Boolean) as string[];
const setupListener = (providerUrl: string, index: number) => {
try {
// 清理旧的监听器
if (contractRef.current) {
contractRef.current.removeAllListeners();
}
const provider = new ethers.providers.WebSocketProvider(providerUrl);
// 设置超时和错误处理
provider._websocket.onerror = (error) => {
console.error(`Provider ${index} WebSocket错误:`, error);
// 切换到下一个Provider
const nextIndex = (index + 1) % wssUrls.length;
if (nextIndex !== currentProviderIndex) {
setCurrentProviderIndex(nextIndex);
}
};
provider._websocket.onclose = () => {
console.log(`Provider ${index} WebSocket关闭,尝试重连...`);
setTimeout(() => {
const nextIndex = (index + 1) % wssUrls.length;
setCurrentProviderIndex(nextIndex);
}, 2000);
};
const contract = new ethers.Contract(contractAddress, abi, provider);
contractRef.current = contract;
contract.on('Transfer', (from, to, tokenId, event) => {
// 只处理铸造事件
if (from === ethers.constants.AddressZero) {
setEvents(prev => [...prev, {
to,
tokenId: tokenId.toString(),
txHash: event.transactionHash,
timestamp: Date.now()
}]);
}
});
console.log(`使用Provider ${index} 监听事件`);
} catch (error) {
console.error(`设置Provider ${index}失败:`, error);
// 尝试下一个
const nextIndex = (index + 1) % wssUrls.length;
setTimeout(() => {
setCurrentProviderIndex(nextIndex);
}, 1000);
}
};
useEffect(() => {
if (wssUrls.length === 0) {
console.error('没有可用的WebSocket RPC URL');
return;
}
setupListener(wssUrls[currentProviderIndex], currentProviderIndex);
// 清理函数
return () => {
if (contractRef.current) {
contractRef.current.removeAllListeners();
contractRef.current = null;
}
};
}, [currentProviderIndex, contractAddress, abi]);
return events;
};
这个方案的关键改进:
- 多个WebSocket RPC备用,一个挂了自动切下一个
- 监听WebSocket的错误和关闭事件,自动重连
- 用
useRef保存合约实例,避免重复创建
完整代码
下面是我最终在生产环境中使用的完整方案,结合了wagmi的状态管理和ethers.js的稳健事件监听:
import { useEffect, useRef, useState, useCallback } from 'react';
import { ethers } from 'ethers';
import { useNetwork, useBlockNumber } from 'wagmi';
import { debounce } from 'lodash';
// NFT合约ABI片段
const NFT_ABI = [
"event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
];
interface MintEvent {
to: string;
tokenId: string;
txHash: string;
blockNumber: number;
timestamp: number;
}
interface UseNFTMintTrackerProps {
contractAddress: string;
onNewMint?: (event: MintEvent) => void;
}
export const useNFTMintTracker = ({
contractAddress,
onNewMint
}: UseNFTMintTrackerProps) => {
const [mintEvents, setMintEvents] = useState<MintEvent[]>([]);
const [isConnected, setIsConnected] = useState(false);
const [currentProviderUrl, setCurrentProviderUrl] = useState<string>('');
const { chain } = useNetwork();
const { data: blockNumber } = useBlockNumber({ watch: true });
const contractRef = useRef<ethers.Contract | null>(null);
const providerRef = useRef<ethers.providers.WebSocketProvider | null>(null);
// 根据链ID获取可用的WebSocket RPC URLs
const getWssUrls = useCallback(() => {
const chainId = chain?.id;
// 这里可以从配置文件中读取,或者使用服务动态获取
const urls: Record<number, string[]> = {
1: [ // Ethereum Mainnet
process.env.NEXT_PUBLIC_ETH_WSS_1!,
process.env.NEXT_PUBLIC_ETH_WSS_2!,
],
11155111: [ // Sepolia
process.env.NEXT_PUBLIC_SEPOLIA_WSS_1!,
process.env.NEXT_PUBLIC_SEPOLIA_WSS_2!,
],
137: [ // Polygon
process.env.NEXT_PUBLIC_POLYGON_WSS_1!,
]
};
return urls[chainId || 1]?.filter(url => url && url.startsWith('wss://')) || [];
}, [chain?.id]);
// 初始化事件监听
const setupEventListeners = useCallback((providerUrl: string) => {
// 清理现有的监听器
if (contractRef.current) {
contractRef.current.removeAllListeners();
}
if (providerRef.current) {
providerRef.current.destroy();
}
try {
const provider = new ethers.providers.WebSocketProvider(providerUrl);
providerRef.current = provider;
const contract = new ethers.Contract(contractAddress, NFT_ABI, provider);
contractRef.current = contract;
// 监听Transfer事件
contract.on('Transfer', async (from: string, to: string, tokenId: ethers.BigNumber, event: ethers.Event) => {
// 只处理从零地址的转移(铸造)
if (from === ethers.constants.AddressZero) {
const block = await event.getBlock();
const mintEvent: MintEvent = {
to,
tokenId: tokenId.toString(),
txHash: event.transactionHash,
blockNumber: event.blockNumber,
timestamp: block.timestamp * 1000 // 转为毫秒
};
// 更新状态
setMintEvents(prev => {
// 去重,避免同一事件被多次处理
if (prev.some(e => e.txHash === mintEvent.txHash && e.tokenId === mintEvent.tokenId)) {
return prev;
}
return [mintEvent, ...prev.slice(0, 49)]; // 只保留最新的50个
});
// 回调函数
onNewMint?.(mintEvent);
}
});
// 监听WebSocket连接状态
provider._websocket.onopen = () => {
console.log('WebSocket连接成功');
setIsConnected(true);
setCurrentProviderUrl(providerUrl);
};
provider._websocket.onerror = (error) => {
console.error('WebSocket错误:', error);
setIsConnected(false);
};
provider._websocket.onclose = () => {
console.log('WebSocket连接关闭');
setIsConnected(false);
// 延迟重连
setTimeout(() => {
const urls = getWssUrls();
if (urls.length > 0) {
const currentIndex = urls.indexOf(providerUrl);
const nextUrl = urls[(currentIndex + 1) % urls.length];
setupEventListeners(nextUrl);
}
}, 2000);
};
} catch (error) {
console.error('初始化事件监听失败:', error);
setIsConnected(false);
}
}, [contractAddress, getWssUrls, onNewMint]);
// 初始化或链切换时重新设置监听器
useEffect(() => {
const urls = getWssUrls();
if (urls.length > 0 && contractAddress) {
setupEventListeners(urls[0]);
} else {
console.warn('没有可用的WebSocket RPC或合约地址');
}
// 清理函数
return () => {
if (contractRef.current) {
contractRef.current.removeAllListeners();
}
if (providerRef.current) {
providerRef.current.destroy();
}
};
}, [contractAddress, chain?.id, setupEventListeners, getWssUrls]);
// 定期清理旧事件(防抖处理)
useEffect(() => {
const cleanupOldEvents = debounce(() => {
if (mintEvents.length > 100) {
setMintEvents(prev => prev.slice(0, 100));
}
}, 5000);
cleanupOldEvents();
return () => {
cleanupOldEvents.cancel();
};
}, [mintEvents.length]);
// 获取过去一段时间内的铸造数量
const getMintCount = useCallback((timeRange: number = 5 * 60 * 1000) => {
const now = Date.now();
return mintEvents.filter(event =>
now - event.timestamp < timeRange
).length;
}, [mintEvents]);
return {
mintEvents,
isConnected,
currentProviderUrl,
totalMints: mintEvents.length,
getMintCount,
// 手动重连
reconnect: () => {
const urls = getWssUrls();
if (urls.length > 0 && currentProviderUrl) {
setupEventListeners(currentProviderUrl);
}
}
};
};
// 使用示例
/*
const MintTrackerComponent = () => {
const { mintEvents, isConnected, totalMints } = useNFTMintTracker({
contractAddress: '0x...',
onNewMint: (event) => {
toast.success(`新的NFT铸造成功!ID: ${event.tokenId}`);
}
});
return (
<div>
<div>连接状态: {isConnected ? '已连接' : '连接中...'}</div>
<div>总铸造数: {totalMints}</div>
<div>
{mintEvents.slice(0, 10).map(event => (
<div key={`${event.txHash}-${event.tokenId}`}>
NFT #{event.tokenId} 铸造给 {event.to.slice(0, 6)}...{event.to.slice(-4)}
</div>
))}
</div>
</div>
);
};
*/
踩坑记录
1. 内存泄漏:事件监听器没有正确清理
现象:页面切换几次后,内存占用飙升,控制台看到重复的事件被处理。
原因:每次组件重新渲染都创建了新的事件监听器,旧的没有移除。
解决:在useEffect的清理函数中调用contract.removeAllListeners(),并用useRef保存合约实例。
2. WebSocket连接意外断开
现象:监听一段时间后收不到新事件,但控制台没有报错。
原因:公共RPC节点的WebSocket连接有时会超时断开。
解决:监听WebSocket的onclose事件,实现自动重连机制,并准备多个备用RPC节点。
3. 同一事件被多次处理
现象:同一个铸造事件在页面上显示了多次。
原因:某些RPC节点可能会重复推送相同的事件,或者组件重新渲染导致重复监听。
解决:在更新状态前检查事件是否已存在(通过txHash + tokenId去重)。
4. 切换链时事件监听混乱
现象:从Ethereum切换到Polygon后,还在监听旧链的事件。
原因:链切换后没有及时清理旧链的监听器。
解决:在useEffect的依赖数组中加入chain?.id,链变化时重新初始化监听器。
小结
经过这一轮折腾,我最大的收获是:Web3前端的事件监听不能只考虑“能不能收到”,更要考虑“收得稳不稳”。生产环境中的网络波动、RPC节点不稳定、用户频繁切换链等场景,都需要在代码层面做好防御。现在我这个事件监听方案已经稳定运行了3周,每天处理几千个铸造事件,再没出过问题。下一步我打算研究一下The Graph,对于更复杂的事件查询需求,可能用索引服务会更合适。