背景
上个月,我接了一个NFT铸造平台的前端开发。项目有个核心需求:用户点击“铸造”按钮后,前端需要实时显示铸造成功的交易,并立刻更新用户的NFT持有数量。这听起来是个典型的“监听智能合约事件”场景。
我一开始觉得这很简单——用 ethers.js 的 contract.on 不就搞定了吗?但实际开发中,我遇到了各种幺蛾子:页面切换时监听没取消导致内存泄漏、用户切换钱包网络后监听器还在老链上工作、甚至有时候事件根本触发不了。用户反馈说“铸造成功了但页面没反应”,这体验实在太差。我不得不停下来,系统性地解决这个监听问题。
问题分析
我最开始的实现确实很 naive。在React组件里,我直接用了 ethers.js 的 contract.on('Transfer', callback):
useEffect(() => {
const contract = new ethers.Contract(address, abi, provider);
const handleTransfer = (from, to, tokenId, event) => {
console.log('NFT转移了!', tokenId.toString());
// 更新UI状态...
};
contract.on('Transfer', handleTransfer);
return () => {
contract.off('Transfer', handleTransfer);
};
}, []);
这个方案有三个明显问题:
- 网络切换问题:当用户从以太坊主网切换到Polygon时,
contract实例还是基于旧网络的provider,监听自然失效 - 组件生命周期问题:虽然我写了清理函数,但有时候组件卸载和重新挂载的速度太快,
off可能没执行到位 - 状态同步问题:监听回调里更新React状态时,如果组件已经卸载,会报“内存泄漏”警告
更麻烦的是,我们的DApp支持多链(以太坊、Polygon、Arbitrum),用户随时可能切换网络。我需要一个能自动处理网络切换、能优雅清理、并且与React状态管理无缝集成的方案。
核心实现
放弃 ethers.js,拥抱 wagmi + viem
经过一番调研和试错,我决定用 wagmi + viem 这套现代Web3开发组合。wagmi 提供了完善的React Hooks,而 viem 是类型安全、模块化的以太坊库。最重要的是,wagmi 的 useWatchContractEvent Hook 看起来就是为这个场景设计的。
但这里有个坑:wagmi 的文档虽然不错,但关于事件监听的部分例子不多,特别是处理实时UI更新和错误处理的实战案例很少。我得自己摸索。
实现基础监听
首先,我配置了 wagmi 的客户端,支持多链:
// wagmi.config.ts
import { createConfig, http } from 'wagmi';
import { mainnet, polygon, arbitrum } from 'wagmi/chains';
import { injected } from 'wagmi/connectors';
export const config = createConfig({
chains: [mainnet, polygon, arbitrum],
connectors: [injected()],
transports: {
[mainnet.id]: http(),
[polygon.id]: http(),
[arbitrum.id]: http(),
},
});
然后在React组件中,我这样使用 useWatchContractEvent:
import { useWatchContractEvent } from 'wagmi';
function NFTMintComponent() {
const { address } = useAccount();
useWatchContractEvent({
address: '0x742d35Cc6634C0532925a3b844Bc9e...', // NFT合约地址
abi: nftContractAbi,
eventName: 'Transfer',
args: { to: address }, // 只监听转给当前用户的事件
onLogs: (logs) => {
console.log('监听到新的Transfer事件:', logs);
// 这里更新UI状态
},
});
return (
// UI组件
);
}
这个基础版本已经比最初的 ethers.js 方案好多了:wagmi 会自动处理网络切换,当用户切换链时,监听会自动重新建立到新链上。
处理事件去重和状态更新
但很快我发现新问题:同一个交易的事件有时会被触发多次。这是因为区块链节点可能推送重复的事件,或者组件重新渲染导致监听重新建立。
我需要去重逻辑。每个事件都有唯一的 transactionHash 和 logIndex,可以用它们组合成唯一ID:
import { useCallback, useRef } from 'react';
import { useWatchContractEvent, Log } from 'wagmi';
function useUniqueContractEvents(options: {
address: `0x${string}`;
abi: any;
eventName: string;
onUniqueLogs: (logs: Log[]) => void;
}) {
const processedIds = useRef<Set<string>>(new Set());
const handleLogs = useCallback((logs: Log[]) => {
const uniqueLogs = logs.filter(log => {
const id = `${log.transactionHash}-${log.logIndex}`;
if (processedIds.current.has(id)) {
return false;
}
processedIds.current.add(id);
return true;
});
if (uniqueLogs.length > 0) {
options.onUniqueLogs(uniqueLogs);
}
}, [options.onUniqueLogs]);
useWatchContractEvent({
address: options.address,
abi: options.abi,
eventName: options.eventName,
onLogs: handleLogs,
});
}
然后在组件中使用这个自定义Hook:
function NFTMintComponent() {
const [mintedTokens, setMintedTokens] = useState<number[]>([]);
useUniqueContractEvents({
address: nftContractAddress,
abi: nftContractAbi,
eventName: 'Transfer',
onUniqueLogs: (logs) => {
// 从事件数据中提取tokenId
const newTokenIds = logs.map(log =>
Number(log.args.tokenId)
);
// 批量更新状态,减少重新渲染
setMintedTokens(prev => [...prev, ...newTokenIds]);
// 显示成功提示
showNotification(`成功铸造 ${newTokenIds.length} 个NFT!`);
},
});
return (
<div>
已铸造的NFT: {mintedTokens.join(', ')}
</div>
);
}
处理组件卸载和错误边界
还有一个重要问题:如果监听过程中RPC节点连接失败怎么办?或者组件卸载时如何确保监听完全清理?
wagmi 的 useWatchContractEvent 在组件卸载时会自动清理,但错误处理需要我们自己加:
import { useWatchContractEvent, usePublicClient } from 'wagmi';
import { useEffect } from 'react';
function useRobustContractEvent(options: {
address: `0x${string}`;
abi: any;
eventName: string;
onLogs: (logs: Log[]) => void;
onError?: (error: Error) => void;
}) {
const publicClient = usePublicClient();
// 先获取历史事件,避免遗漏
useEffect(() => {
const fetchPastEvents = async () => {
try {
const logs = await publicClient.getLogs({
address: options.address,
event: parseAbiItem(`event ${options.eventName}(address indexed from, address indexed to, uint256 indexed tokenId)`),
fromBlock: 'latest', // 实际项目中可能需要更大的范围
});
if (logs.length > 0) {
options.onLogs(logs);
}
} catch (error) {
console.error('获取历史事件失败:', error);
options.onError?.(error as Error);
}
};
fetchPastEvents();
}, [options.address, options.eventName, publicClient]);
// 实时监听新事件
useWatchContractEvent({
address: options.address,
abi: options.abi,
eventName: options.eventName,
onLogs: options.onLogs,
onError: options.onError,
});
}
这个方案结合了历史事件查询和实时监听,确保不会遗漏任何事件。即使实时监听暂时断开,也能通过轮询历史事件来弥补。
完整代码
下面是一个完整的、可直接运行的NFT铸造页面组件:
// NFTMintPage.tsx
import React, { useState, useCallback, useRef } from 'react';
import { useWatchContractEvent, useAccount, usePublicClient, useWriteContract } from 'wagmi';
import { parseAbiItem, Log } from 'viem';
import { showNotification } from './notification';
// NFT合约ABI片段
const nftContractAbi = [
{
name: 'Transfer',
type: 'event',
inputs: [
{ name: 'from', type: 'address', indexed: true },
{ name: 'to', type: 'address', indexed: true },
{ name: 'tokenId', type: 'uint256', indexed: true },
],
},
{
name: 'mint',
type: 'function',
stateMutability: 'payable',
inputs: [],
outputs: [],
},
] as const;
const NFT_CONTRACT_ADDRESS = '0x742d35Cc6634C0532925a3b844Bc9e...';
function useUniqueContractEvents(options: {
address: `0x${string}`;
abi: any;
eventName: string;
onUniqueLogs: (logs: Log[]) => void;
onError?: (error: Error) => void;
}) {
const processedIds = useRef<Set<string>>(new Set());
const publicClient = usePublicClient();
// 获取历史事件
React.useEffect(() => {
const fetchPastEvents = async () => {
try {
const logs = await publicClient.getLogs({
address: options.address,
event: parseAbiItem(`event ${options.eventName}(address indexed from, address indexed to, uint256 indexed tokenId)`),
fromBlock: 'latest',
toBlock: 'latest',
});
const uniqueLogs = logs.filter(log => {
const id = `${log.transactionHash}-${log.logIndex}`;
return !processedIds.current.has(id);
});
if (uniqueLogs.length > 0) {
uniqueLogs.forEach(log => {
processedIds.current.add(`${log.transactionHash}-${log.logIndex}`);
});
options.onUniqueLogs(uniqueLogs);
}
} catch (error) {
console.error('获取历史事件失败:', error);
options.onError?.(error as Error);
}
};
fetchPastEvents();
}, [options.address, options.eventName, publicClient]);
// 实时监听
const handleLogs = useCallback((logs: Log[]) => {
const uniqueLogs = logs.filter(log => {
const id = `${log.transactionHash}-${log.logIndex}`;
if (processedIds.current.has(id)) {
return false;
}
processedIds.current.add(id);
return true;
});
if (uniqueLogs.length > 0) {
options.onUniqueLogs(uniqueLogs);
}
}, [options.onUniqueLogs]);
useWatchContractEvent({
address: options.address,
abi: options.abi,
eventName: options.eventName,
onLogs: handleLogs,
onError: options.onError,
});
}
export function NFTMintPage() {
const { address } = useAccount();
const [mintedTokens, setMintedTokens] = useState<number[]>([]);
const [isMinting, setIsMinting] = useState(false);
const { writeContractAsync } = useWriteContract();
// 监听Transfer事件
useUniqueContractEvents({
address: NFT_CONTRACT_ADDRESS,
abi: nftContractAbi,
eventName: 'Transfer',
onUniqueLogs: (logs) => {
// 只处理转给当前用户的事件
const relevantLogs = logs.filter(log =>
log.args.to?.toLowerCase() === address?.toLowerCase()
);
if (relevantLogs.length > 0) {
const newTokenIds = relevantLogs.map(log =>
Number(log.args.tokenId)
);
setMintedTokens(prev => {
const combined = [...prev, ...newTokenIds];
// 去重排序
return [...new Set(combined)].sort((a, b) => a - b);
});
showNotification(`🎉 成功收到 ${newTokenIds.length} 个NFT!`);
}
},
onError: (error) => {
console.error('事件监听出错:', error);
showNotification('事件监听连接不稳定,请刷新页面', 'warning');
},
});
// 铸造函数
const handleMint = async () => {
if (!address) {
showNotification('请先连接钱包', 'error');
return;
}
try {
setIsMinting(true);
await writeContractAsync({
address: NFT_CONTRACT_ADDRESS,
abi: nftContractAbi,
functionName: 'mint',
value: 0.01n * 10n ** 18n, // 假设铸造价格是0.01 ETH
});
showNotification('交易已提交,请等待确认...', 'info');
} catch (error: any) {
console.error('铸造失败:', error);
showNotification(`铸造失败: ${error.shortMessage || error.message}`, 'error');
} finally {
setIsMinting(false);
}
};
return (
<div className="nft-mint-page">
<h1>NFT铸造平台</h1>
<div className="mint-section">
<button
onClick={handleMint}
disabled={isMinting || !address}
>
{isMinting ? '铸造中...' : '铸造NFT (0.01 ETH)'}
</button>
{!address && (
<p className="hint">请先连接钱包</p>
)}
</div>
<div className="tokens-section">
<h2>你的NFT ({mintedTokens.length}个)</h2>
{mintedTokens.length === 0 ? (
<p>还没有NFT,点击上方按钮铸造</p>
) : (
<div className="token-list">
{mintedTokens.map(tokenId => (
<div key={tokenId} className="token-card">
NFT #{tokenId}
</div>
))}
</div>
)}
</div>
</div>
);
}
踩坑记录
在实际开发中,我遇到了几个具体的坑,这里记录下来:
-
事件重复触发:最开始没有做去重,发现同一个铸造交易会触发2-3次事件更新。原因是节点推送可能重复,且组件重渲染会重新建立监听。解决方案就是用
transactionHash + logIndex做唯一标识去重。 -
网络切换后监听不更新:虽然
wagmi理论上应该自动处理,但我发现切换到某些测试网时,监听器还在旧链上。后来发现是因为我硬编码了RPC URL,没有用wagmi的usePublicClient。改用usePublicClient()后,网络切换就正常了。 -
TypeScript类型错误:
viem对类型要求很严格,事件参数的访问方式从log.args[2]变成了log.args.tokenId。一开始我按老习惯写,类型检查报错。需要仔细看ABI定义,用正确的属性名访问。 -
内存泄漏警告:在监听回调中直接更新状态,如果组件卸载得快,会报“Can't perform a React state update on an unmounted component”。我加了
useRef来跟踪组件挂载状态,但后来发现wagmi的 Hook 已经处理了这个问题,主要是我自己的setState调用时机不对。最终方案是把状态更新包装在条件判断里。
小结
经过这次折腾,我最大的收获是:现代Web3前端开发中,用 wagmi + viem 这套组合能省去很多底层细节的麻烦。事件监听这种看似简单的功能,实际上要考虑网络切换、错误处理、性能优化等多个方面。现在这套方案已经在生产环境稳定运行,用户反馈“铸造后立刻能看到NFT”的体验很好。
如果想进一步优化,可以考虑加上事件监听的状态指示器(比如显示“正在监听事件...”),或者实现离线事件队列,等网络恢复后一并处理。不过对于大多数DApp来说,现在的方案已经足够可靠了。