背景
上个月,我在做一个基于 ERC-721 的 NFT 铸造 DApp。需求很简单:用户点击"铸造"按钮后,前端需要实时监听合约的 Transfer 事件,一旦交易被确认,立即更新 NFT 列表和铸造状态。
当时我手头有一个现成的 wagmi v1 项目,useContractEvent 钩子用得还算顺手。但问题来了------我接手时这个项目的 wagmi 版本是 v2,API 已经大改。useContractEvent 被移除了,取而代之的是 watchContractEvent。更麻烦的是,项目里还混用了 ethers.js 和 viem 的合约实例,导致事件监听总是收不到数据。
这篇文章就是我当时解决问题过程的完整记录。如果你也在用 wagmi v2 或 viem 做事件监听,希望能帮你少走弯路。
问题分析
最初的思路:照搬 v1 的写法
我最初的想法很简单:把 wagmi v1 的 useContractEvent 换成 v2 的 watchContractEvent,改改参数应该就行了。
typescript
// 这是我最初写的代码(会报错)
import { useContractEvent } from 'wagmi' // v1 的写法,v2 里已经没了
useContractEvent({
address: contractAddress,
abi: nftABI,
eventName: 'Transfer',
listener: (event) => {
console.log('收到事件:', event)
setNfts(prev => [...prev, event.args.tokenId])
}
})
运行后控制台直接报错:useContractEvent is not a function。我当时还以为是导入路径错了,查了半天文档才发现 wagmi v2 已经彻底移除了这个钩子。
第一次尝试:watchContractEvent
翻看 wagmi v2 文档,发现新的 API 叫 watchContractEvent,用法类似但有些不同:
typescript
import { watchContractEvent } from 'wagmi/actions'
const unwatch = watchContractEvent({
address: contractAddress,
abi: nftABI,
eventName: 'Transfer',
onLogs: (logs) => {
console.log('收到事件:', logs)
}
})
这次没报错,但问题来了------事件收不到。我试了十几次铸造操作,控制台始终静悄悄。后来排查发现,watchContractEvent 在 wagmi v2 中需要配合 config 参数使用,而且默认只监听当前连接的链。如果合约部署在其他链上,或者用户切换了网络,监听就会失效。
核心问题:事件签名不匹配
更隐蔽的坑在事件签名上。我的合约用 Solidity 写的,事件定义如下:
solidity
event Transfer(address indexed from, address indexed to, uint256 indexed tokenId);
但在前端用 viem 的 parseAbiItem 解析时,我写成了:
typescript
const transferEvent = parseAbiItem('event Transfer(address indexed, address indexed, uint256)')
注意这里参数名省略了,但 viem 要求事件签名必须包含参数名,否则解析会失败。当时我排查了整整两个小时,最后在 viem 的 GitHub issue 里找到了答案。
核心实现
1. 用 wagmi v2 的 watchContractEvent 实现基础监听
解决了上面的坑后,我写出了第一个可运行的版本:
typescript
// hooks/useTransferListener.ts
import { watchContractEvent } from '@wagmi/core'
import { config } from '../wagmi' // 从 wagmi 配置中导出 config
import { nftABI } from '../abi/nftABI'
import { parseAbiItem } from 'viem'
export function useTransferListener(contractAddress: `0x${string}`) {
useEffect(() => {
// 注意:事件签名必须包含参数名
const unwatch = watchContractEvent(config, {
address: contractAddress,
abi: nftABI,
eventName: 'Transfer',
// 这里有个坑:onLogs 接收的是 Log 数组,不是单个事件
onLogs: (logs) => {
logs.forEach(log => {
// log.args 是 viem 解析后的参数对象
console.log('Transfer 事件:', {
from: log.args.from,
to: log.args.to,
tokenId: log.args.tokenId
})
})
}
})
// 组件卸载时清理监听
return () => {
unwatch?.()
}
}, [contractAddress])
}
这里有个坑 :watchContractEvent 返回的 unwatch 函数必须在组件卸载时调用,否则会造成内存泄漏。我一开始忘了加清理函数,结果页面越来越卡,最后浏览器直接崩溃了。
2. 处理多链场景和网络切换
项目里用户可能会在不同链之间切换(比如从 Ethereum 主网切换到 Polygon),但事件监听不会自动跟随。我当时的做法是监听 account 或 chainId 的变化,重新建立监听:
typescript
// hooks/useMultiChainTransferListener.ts
import { useAccount, useChainId } from 'wagmi'
import { watchContractEvent } from '@wagmi/core'
import { config } from '../wagmi'
export function useMultiChainTransferListener(contractAddresses: Record<number, `0x${string}`>) {
const { address } = useAccount()
const chainId = useChainId()
useEffect(() => {
const contractAddress = contractAddresses[chainId]
if (!contractAddress || !address) return
console.log(`切换到链 ${chainId},监听合约 ${contractAddress}`)
const unwatch = watchContractEvent(config, {
address: contractAddress,
abi: nftABI,
eventName: 'Transfer',
onLogs: (logs) => {
// 只有当前用户相关的事件才更新 UI
const userLogs = logs.filter(
log => log.args.to?.toLowerCase() === address.toLowerCase()
)
if (userLogs.length > 0) {
// 更新 NFT 列表
updateNFTList(userLogs)
}
}
})
return () => {
console.log('清理监听')
unwatch?.()
}
}, [chainId, address, contractAddresses])
}
这里有个细节 :useChainId 返回的是当前连接的链 ID,当用户切换网络时,这个值会变化,从而触发 useEffect 重新执行。但注意,watchContractEvent 内部会自动处理链切换吗?答案是否定的------它只会在你传入的 chainId 参数变化时重新订阅。所以我们必须手动监听 chainId 的变化。
3. 用 viem 公共客户端实现自定义轮询
wagmi v2 的 watchContractEvent 默认使用 WebSocket 或轮询,取决于你的配置。但有一次我发现事件监听突然停了,排查后发现是 WebSocket 连接断了但没自动重连。后来我改用 viem 的公共客户端手动实现轮询,更可控:
typescript
// hooks/usePollingEventListener.ts
import { createPublicClient, http, parseAbiItem } from 'viem'
import { mainnet } from 'viem/chains'
export function usePollingEventListener(
contractAddress: `0x${string}`,
fromBlock: bigint = 0n
) {
const [events, setEvents] = useState<Log[]>([])
useEffect(() => {
const client = createPublicClient({
chain: mainnet,
transport: http() // 使用 HTTP 轮询
})
const pollInterval = setInterval(async () => {
try {
const logs = await client.getLogs({
address: contractAddress,
event: parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)'),
fromBlock: fromBlock,
toBlock: 'latest'
})
if (logs.length > 0) {
setEvents(prev => [...prev, ...logs])
// 更新 fromBlock 避免重复获取
fromBlock = logs[logs.length - 1].blockNumber + 1n
}
} catch (error) {
console.error('轮询事件失败:', error)
}
}, 5000) // 每 5 秒轮询一次
return () => {
clearInterval(pollInterval)
}
}, [contractAddress])
return events
}
这里有个坑 :fromBlock 如果设置成 0n,每次轮询都会从头开始获取所有 Transfer 事件,数据量会非常大。正确的做法是记录上次处理的最新区块号,下次轮询时从那个区块开始。我一开始没注意这个问题,结果页面加载了十几秒才显示出来。
4. 结合 React Query 实现状态管理
事件监听只是获取数据的一部分,更重要的是如何管理这些数据。我用 React Query 来缓存事件数据,避免重复请求:
typescript
// hooks/useTransferEvents.ts
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { createPublicClient, http, parseAbiItem } from 'viem'
import { mainnet } from 'viem/chains'
export function useTransferEvents(
contractAddress: `0x${string}`,
fromBlock: bigint = 0n
) {
const queryClient = useQueryClient()
const { data, isLoading, error } = useQuery({
queryKey: ['transferEvents', contractAddress, fromBlock.toString()],
queryFn: async () => {
const client = createPublicClient({
chain: mainnet,
transport: http()
})
const logs = await client.getLogs({
address: contractAddress,
event: parseAbiItem('event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)'),
fromBlock: fromBlock,
toBlock: 'latest'
})
return logs
},
refetchInterval: 5000 // 每 5 秒自动重新获取
})
// 手动触发刷新
const refresh = () => {
queryClient.invalidateQueries({
queryKey: ['transferEvents', contractAddress]
})
}
return { events: data, isLoading, error, refresh }
}
这里有个细节 :React Query 的 refetchInterval 和 viem 的轮询是两回事。前者是让 React Query 重新执行查询函数,后者是直接调用合约节点。如果两者都开,会导致重复请求。我最终选择了只用 React Query 的轮询,配合 queryKey 来管理缓存。
完整代码
以下是一个完整的 React 组件,集成了上述所有方案,可直接运行(需要安装 wagmi v2、viem、@tanstack/react-query):
typescript
// components/NFTMinter.tsx
import { useState, useEffect } from 'react'
import { useAccount, useChainId, useWriteContract } from 'wagmi'
import { watchContractEvent } from '@wagmi/core'
import { config } from '../wagmi'
import { nftABI } from '../abi/nftABI'
import { parseAbiItem } from 'viem'
// 合约地址映射(不同链)
const CONTRACT_ADDRESSES: Record<number, `0x${string}`> = {
1: '0x...Ethereum主网合约地址',
137: '0x...Polygon合约地址'
}
export function NFTMinter() {
const { address } = useAccount()
const chainId = useChainId()
const { writeContract } = useWriteContract()
const [mintedTokenIds, setMintedTokenIds] = useState<number[]>([])
const [isListening, setIsListening] = useState(false)
// 铸造 NFT
const handleMint = async () => {
try {
await writeContract({
address: CONTRACT_ADDRESSES[chainId],
abi: nftABI,
functionName: 'mint',
args: [address]
})
console.log('铸造交易已提交')
} catch (error) {
console.error('铸造失败:', error)
}
}
// 监听 Transfer 事件
useEffect(() => {
const contractAddress = CONTRACT_ADDRESSES[chainId]
if (!contractAddress || !address) return
console.log(`开始监听链 ${chainId} 的合约 ${contractAddress}`)
const unwatch = watchContractEvent(config, {
address: contractAddress,
abi: nftABI,
eventName: 'Transfer',
onLogs: (logs) => {
logs.forEach(log => {
// 只处理当前用户收到 NFT 的事件
if (log.args.to?.toLowerCase() === address.toLowerCase()) {
const tokenId = Number(log.args.tokenId)
console.log(`用户 ${address} 收到了 NFT #${tokenId}`)
setMintedTokenIds(prev => [...prev, tokenId])
}
})
},
// 可选:指定从哪个区块开始监听
fromBlock: chainId === 1 ? 19000000n : 50000000n
})
setIsListening(true)
return () => {
console.log('清理事件监听')
unwatch?.()
setIsListening(false)
}
}, [chainId, address])
return (
<div>
<h1>NFT 铸造 DApp</h1>
<p>当前链 ID: {chainId}</p>
<p>监听状态: {isListening ? '已开启' : '未开启'}</p>
<button onClick={handleMint} disabled={!address}>
铸造 NFT
</button>
<h2>已铸造的 NFT</h2>
<ul>
{mintedTokenIds.map(id => (
<li key={id}>Token ID: {id}</li>
))}
</ul>
</div>
)
}
踩坑记录
坑 1:watchContractEvent 不返回 unwatch 函数?
现象 :在 wagmi v2 的早期版本中,watchContractEvent 返回的是 void,不是函数。 解决 :升级到 @wagmi/core@2.x 最新版。如果无法升级,可以用 useEffect 的清理函数手动调用 config.unwatch()。
坑 2:事件参数类型不匹配
现象 :log.args.tokenId 的类型是 bigint,但 UI 需要 number。直接 Number(log.args.tokenId) 可能溢出。 解决 :使用 Number() 转换前先检查 tokenId 是否在安全范围内(小于 Number.MAX_SAFE_INTEGER)。对于大数值,保持 bigint 类型。
坑 3:WebSocket 连接断开后不自动重连
现象 :使用 wagmi 默认的 WebSocket 传输时,网络波动后监听停止,但没有任何报错。 解决 :改用 HTTP 轮询(如 transport: http()),或者手动实现 WebSocket 重连逻辑。我最终选择了 HTTP 轮询,因为更稳定。
坑 4:事件监听导致组件无限重渲染
现象 :onLogs 回调里直接调用了 setState,但 useEffect 的依赖数组没有正确设置,导致组件不断重渲染。 解决 :在 useEffect 的依赖数组中包含 address 和 chainId,并使用 useCallback 包裹 onLogs 回调。
小结
从 wagmi v1 到 v2 的迁移过程中,事件监听是最让我头疼的部分。核心收获是:监听合约事件时,一定要处理好生命周期(组件卸载清理)、多链切换(依赖监听)、事件签名(参数名不能省略)三个关键点 。如果你也在用 wagmi v2,建议优先使用 watchContractEvent,配合 React Query 做状态管理,比手动轮询更优雅。
进一步可以研究:如何监听大量合约事件(比如 Uniswap 的流动性池),以及如何使用 viem 的 createEventFilter 实现更细粒度的过滤。