用 wagmi v2 + viem 监听合约事件踩坑实录:从轮询到实时推送,我终于搞懂了
摘要
做 DeFi 质押项目时,需要实时监听用户操作事件。我一开始用轮询,延迟高还浪费资源。换成 wagmi v2 的 useWatchContractEvent hook,结果遇到事件重复触发、断网后监听失效、多链切换时事件错乱等问题。这篇文章记录了我如何一步步排查、修复,最终实现稳定可靠的事件监听。
背景
上个月接了一个 DeFi 质押项目,用户可以把 ETH 质押进合约赚取收益。前端需要实时展示用户的操作记录------比如谁质押了、谁提取了、质押了多少。产品要求延迟不能超过 3 秒,而且不能因为用户切换钱包链或者网络波动就丢失事件。
我一开始想当然地用了轮询方案:每 5 秒调用一次合约的 getUserEvents 方法,然后和本地缓存对比。结果上线测试时发现,用户质押后前端要等 5 秒才能看到记录,而且如果用户连续操作两次,轮询可能只抓到一次。产品经理直接说:"这体验太差了,用户以为交易没成功,会重复操作。"
我当时就想,必须换成事件监听。wagmi v2 刚出了 useWatchContractEvent hook,看起来就是干这个的。但没想到,这一换就踩了三天坑。
问题分析
最初的思路
我的合约里定义了 Staked 和 Withdrawn 两个事件:
solidity
event Staked(address indexed user, uint256 amount, uint256 timestamp);
event Withdrawn(address indexed user, uint256 amount, uint256 timestamp);
前端逻辑很简单:监听这两个事件,当有新事件时,更新本地状态。
于是我直接用了 wagmi 的 useWatchContractEvent:
tsx
useWatchContractEvent({
address: contractAddress,
abi: stakingAbi,
eventName: 'Staked',
onLogs(logs) {
// 更新状态
},
})
结果一跑起来,问题就来了。
第一个坑:事件重复触发
测试时发现,同一个 Staked 事件会被触发两次。我一开始以为是合约问题,后来打开浏览器控制台看 onLogs 的参数,发现两次 logs 数组里包含的是同一个交易哈希。这说明事件被监听到了两次。
排查了半天,发现是因为我的组件在 React 严格模式下被渲染了两次,导致 useWatchContractEvent 注册了两个监听器。wagmi v2 的 hook 在严格模式下确实会执行两次 effect,但它的内部应该有去重逻辑才对。
后来我仔细看了 wagmi 文档,发现 useWatchContractEvent 会在组件卸载时自动清理监听器。但问题在于,React 严格模式下组件会先挂载再卸载再挂载,如果第一次挂载时注册的监听器在卸载时没有正确清理,第二次挂载就会多一个重复的。
第二个坑:断网后监听失效
更严重的是,当用户网络波动(比如断网几秒后恢复),监听就彻底停了。没有任何事件被触发,直到用户刷新页面。
我一开始以为是 RPC 节点的问题,但换了好几个节点都一样。后来用 ethers.js 的 WebSocketProvider 手动测试,发现 WebSocket 连接断开后,viem 的 watchContractEvent 不会自动重连。
核心实现
1. 用 useEffect 手动管理监听器生命周期
第一个坑的解决方案很简单:确保 useWatchContractEvent 只在组件挂载时执行一次,并且卸载时清理干净。
但 wagmi v2 的 hook 本身已经做了清理,问题出在 React 严格模式的双重渲染。我查了 wagmi 的 GitHub issue,发现官方推荐的做法是:不要在组件内部直接使用 useWatchContractEvent,而是把它放到一个自定义 hook 里,用 ref 控制执行次数。
tsx
import { useWatchContractEvent } from 'wagmi'
import { useRef } from 'react'
export function useStakingEvents() {
const isMounted = useRef(false)
// 这个 effect 只执行一次
useEffect(() => {
if (isMounted.current) return
isMounted.current = true
// 注册监听器
const unwatch = watchContractEvent({
address: contractAddress,
abi: stakingAbi,
eventName: 'Staked',
onLogs: (logs) => {
// 处理事件
},
})
return () => {
unwatch?.()
isMounted.current = false
}
}, [])
}
但这样写又回到了老路------用 watchContractEvent 的底层 API。不过好处是我们可以完全控制生命周期。
2. 处理断连重连
第二个坑更棘手。我研究了 viem 的 watchContractEvent 源码,发现它底层用的是 createEventFilter + poll 或者 subscribe。如果是 WebSocket 连接,它会订阅事件;如果是 HTTP 连接,它会轮询。
问题在于,当 WebSocket 断开时,viem 不会自动重新订阅。解决方案是:监听连接状态,断连后重新创建监听器。
我写了一个 useReconnectingWatch hook:
tsx
import { usePublicClient } from 'wagmi'
import { useEffect, useRef, useCallback } from 'react'
export function useReconnectingWatch({
address,
abi,
eventName,
onLogs,
reconnectDelay = 3000,
}) {
const publicClient = usePublicClient()
const unwatchRef = useRef<(() => void) | null>(null)
const isActiveRef = useRef(true)
const startWatching = useCallback(() => {
if (!publicClient || !isActiveRef.current) return
// 清理旧监听器
unwatchRef.current?.()
// 创建新监听器
unwatchRef.current = publicClient.watchContractEvent({
address,
abi,
eventName,
onLogs: (logs) => {
// 事件处理
onLogs(logs)
},
// 设置轮询间隔作为备选
pollingInterval: 4000,
})
}, [publicClient, address, abi, eventName, onLogs])
// 监听网络变化
useEffect(() => {
const handleOnline = () => {
console.log('网络恢复,重新监听')
startWatching()
}
window.addEventListener('online', handleOnline)
return () => window.removeEventListener('online', handleOnline)
}, [startWatching])
// 初始启动
useEffect(() => {
startWatching()
return () => {
isActiveRef.current = false
unwatchRef.current?.()
}
}, [startWatching])
}
这个 hook 的关键点:
- 用
isActiveRef防止组件卸载后还重连 - 监听
window.online事件,网络恢复时自动重建监听器 - 设置
pollingInterval作为备选,这样即使 WebSocket 断开,轮询也能兜底
3. 多链切换时的处理
第三个问题是多链。用户可能在以太坊主网和 Sepolia 测试网之间切换。切换时,旧的监听器应该销毁,新的监听器应该使用新链的 publicClient。
wagmi 的 usePublicClient 会自动跟随链切换,所以只要把 chainId 作为依赖,就能实现自动重新监听:
tsx
export function useChainAwareWatch({ eventName, onLogs }) {
const { chain } = useAccount()
const publicClient = usePublicClient({ chainId: chain?.id })
useEffect(() => {
if (!chain?.id || !publicClient) return
const unwatch = publicClient.watchContractEvent({
address: getContractAddress(chain.id), // 根据链获取合约地址
abi: stakingAbi,
eventName,
onLogs,
})
return () => unwatch?.()
}, [chain?.id, publicClient, eventName, onLogs])
}
注意这里用 chain?.id 作为 key,而不是 chain 对象。因为 chain 对象每次渲染都可能变化,但 chain.id 只在切换链时变化。
4. 事件去重
解决了生命周期问题,事件重复的问题依然存在。我分析后发现,重复的原因有两个:
- 同一个交易被多个 RPC 节点推送了两次
- 监听器重建时,会收到之前已经处理过的事件
解决方案是维护一个已处理交易哈希的 Set:
tsx
const processedTxHashes = useRef(new Set<string>())
const handleLogs = useCallback((logs: Log[]) => {
logs.forEach(log => {
const txHash = log.transactionHash
if (processedTxHashes.current.has(txHash)) {
return // 跳过已处理的事件
}
processedTxHashes.current.add(txHash)
// 处理新事件
updateEvents(log)
})
}, [])
注意这个 Set 不能无限增长,我设置了最大 1000 条,超过时删除最旧的。
完整代码
下面是一个完整的 React 组件示例,实现了稳定可靠的合约事件监听:
tsx
// hooks/useContractEvents.ts
import { usePublicClient, useAccount } from 'wagmi'
import { useEffect, useRef, useCallback, useState } from 'react'
import { type Log, type Address } from 'viem'
import { stakingAbi } from '../abis/staking'
interface EventRecord {
type: 'Staked' | 'Withdrawn'
user: Address
amount: bigint
timestamp: bigint
txHash: string
}
function getContractAddress(chainId: number): Address {
// 根据链 ID 返回对应的合约地址
const addresses: Record<number, Address> = {
1: '0x...', // 主网
11155111: '0x...', // Sepolia
}
return addresses[chainId] || '0x...'
}
export function useContractEvents() {
const { chain } = useAccount()
const publicClient = usePublicClient({ chainId: chain?.id })
const [events, setEvents] = useState<EventRecord[]>([])
const unwatchRef = useRef<(() => void) | null>(null)
const processedTxHashes = useRef(new Set<string>())
const maxProcessedTx = 1000
const addEvent = useCallback((log: Log) => {
const txHash = log.transactionHash
if (processedTxHashes.current.has(txHash)) return
processedTxHashes.current.add(txHash)
// 限制 Set 大小
if (processedTxHashes.current.size > maxProcessedTx) {
const iterator = processedTxHashes.current.values()
const first = iterator.next().value
if (first) processedTxHashes.current.delete(first)
}
// 解析事件参数
const args = log.args as any
const newEvent: EventRecord = {
type: log.eventName as 'Staked' | 'Withdrawn',
user: args.user,
amount: args.amount,
timestamp: args.timestamp,
txHash: txHash,
}
setEvents(prev => [newEvent, ...prev])
}, [])
const startWatching = useCallback(() => {
if (!publicClient || !chain?.id) return
// 清理旧监听器
if (unwatchRef.current) {
unwatchRef.current()
unwatchRef.current = null
}
const contractAddress = getContractAddress(chain.id)
// 监听 Staked 事件
const unwatchStaked = publicClient.watchContractEvent({
address: contractAddress,
abi: stakingAbi,
eventName: 'Staked',
onLogs: (logs) => logs.forEach(addEvent),
pollingInterval: 4000, // 备选轮询
})
// 监听 Withdrawn 事件
const unwatchWithdrawn = publicClient.watchContractEvent({
address: contractAddress,
abi: stakingAbi,
eventName: 'Withdrawn',
onLogs: (logs) => logs.forEach(addEvent),
pollingInterval: 4000,
})
// 合并清理函数
unwatchRef.current = () => {
unwatchStaked()
unwatchWithdrawn()
}
}, [publicClient, chain?.id, addEvent])
// 网络恢复时重连
useEffect(() => {
const handleOnline = () => {
console.log('[Events] 网络恢复,重新监听')
startWatching()
}
window.addEventListener('online', handleOnline)
return () => window.removeEventListener('online', handleOnline)
}, [startWatching])
// 链变化或组件挂载时启动监听
useEffect(() => {
startWatching()
return () => {
if (unwatchRef.current) {
unwatchRef.current()
unwatchRef.current = null
}
}
}, [startWatching])
return { events }
}
使用这个 hook 的组件:
tsx
// components/EventList.tsx
import { useContractEvents } from '../hooks/useContractEvents'
import { formatEther } from 'viem'
export function EventList() {
const { events } = useContractEvents()
if (events.length === 0) {
return <div>暂无事件记录</div>
}
return (
<div className="space-y-2">
{events.map((event, index) => (
<div key={event.txHash + index} className="p-3 bg-gray-100 rounded">
<span className="font-bold">
{event.type === 'Staked' ? '质押' : '提取'}
</span>
<span> 用户: {event.user.slice(0, 6)}...{event.user.slice(-4)}</span>
<span> 数量: {formatEther(event.amount)} ETH</span>
<span className="text-gray-500 text-sm ml-2">
{new Date(Number(event.timestamp) * 1000).toLocaleString()}
</span>
</div>
))}
</div>
)
}
踩坑记录
-
useWatchContractEvent在 React 严格模式下重复执行报错表现:同一个事件触发两次
onLogs。原因:严格模式下组件挂载两次,hook 内部 effect 执行两次。
解决:改用底层
publicClient.watchContractEvent手动管理生命周期,或用 ref 控制执行次数。 -
WebSocket 断连后监听永久失效
报错表现:网络断开再恢复后,不再收到任何事件。
原因:viem 的
watchContractEvent在 WebSocket 断开后不会自动重新订阅。解决:监听
window.online事件,断连后手动重建监听器,并设置pollingInterval作为备选。 -
切换链后收到旧链的事件
报错表现:从 Sepolia 切回主网后,还收到 Sepolia 的事件。
原因:旧监听器未清理,且新监听器使用了旧链的 publicClient。
解决:将
chain?.id作为useEffect依赖,链变化时先清理旧监听器再创建新监听器。 -
事件去重不彻底
报错表现:同一个交易的事件被处理两次。
原因:RPC 节点可能推送重复的日志,或者监听器重建时拉取了历史事件。
解决:用
Set存储已处理的交易哈希,每次处理前检查去重。
小结
合约事件监听看似简单,但涉及生命周期、网络状态、多链切换等多个边界情况。核心收获是:不要完全依赖框架 hook 的自动管理,要自己掌控监听器的创建和销毁,同时做好去重和断连恢复 。如果想深入,可以研究 viem 的 watchContractEvent 源码,了解它底层是如何实现轮询和订阅的切换的。