背景:一个 DeFi 看板项目引发的血案
三个月前,我接了一个 DeFi 看板项目,需求是实时展示某个 Uniswap V3 池子的 Swap 事件------用户一交易,前端就要立刻显示新的交易记录。听起来很简单对吧?我当时也这么想。
项目用的是 React + TypeScript,链上交互本来用 ethers.js v5。但我想着新项目嘛,试试 wagmi v2 和 viem 这套组合拳,毕竟文档说它们"更现代、类型安全、支持 tree-shaking"。结果这一步踏出去,我就在坑里待了三天。
问题分析:为什么我的监听总是断?
第一天:ethers.js 的 on 方法,看似简单实则坑多
我最初的思路很简单:用 ethers.js 的 Contract 对象,直接 contract.on("Swap", callback)。代码大概这样:
typescript
// 第一天写的代码,后来发现全是坑
const provider = new ethers.providers.Web3Provider(window.ethereum);
const contract = new ethers.Contract(POOL_ADDRESS, POOL_ABI, provider);
contract.on("Swap", (sender, recipient, amount0, amount1, event) => {
console.log("收到 Swap 事件:", { sender, recipient, amount0, amount1 });
});
跑起来之后,前几分钟一切正常。然后问题就来了:
坑1:断连后不自动重连 用户切了网络(比如从以太坊切到 Polygon),或者钱包断开再重连,监听就悄无声息地挂了。没有任何报错,就是收不到事件了。我当时在浏览器控制台蹲了半小时,才意识到事件已经停了。
坑2:组件卸载后还在监听 React 组件卸载时,我忘了调用 contract.removeAllListeners()。结果页面切换到别的路由再回来,事件回调被绑了两次,每次事件触发两次 console.log。用户要是来回切换几次,页面直接卡死。
坑3:浏览器标签页休眠后恢复,事件堆积 用户把标签页切到后台几分钟再切回来,事件回调会一次性触发所有堆积的事件。我的看板列表瞬间插入几百条记录,UI 直接崩了。
第二天:尝试自己写重连逻辑,越写越复杂
我决定自己封装一个带重连的监听 Hook。写了大概 150 行代码,包括:
- 监听
accountsChanged和chainChanged事件 - 断连后自动重新订阅
- 用
useEffect清理监听器 - 用
useRef存回调函数避免闭包陷阱
结果呢?代码跑起来了,但总有一些边界情况没处理好。比如用户同时切换了账户和网络,两个事件先后触发,我的重连逻辑会重复订阅两次。更崩溃的是,测试发现有时候事件会漏掉------因为重连的时机和事件到达的时间有竞态条件。
第三天:发现 wagmi v2 + viem 的 watchContractEvent 才是正解
就在我准备放弃的时候,我突然想起来 wagmi v2 的文档里有一个 watchContractEvent 方法。之前没仔细看,以为它只是对 ethers.js 的简单封装。结果仔细一读,发现它内置了重连、清理、批量处理等所有我需要的功能。
核心实现:用 wagmi v2 + viem 优雅监听合约事件
第一步:安装依赖并初始化
bash
# 我用的版本
npm install wagmi viem @tanstack/react-query
注意:wagmi v2 需要 @tanstack/react-query 作为依赖,因为它的底层用了 React Query 来管理异步状态。
第二步:创建 wagmi 配置
typescript
// src/wagmi.ts
import { createConfig, http } from 'wagmi'
import { mainnet, polygon } from 'wagmi/chains'
// 这里有个坑:wagmi v2 的 createConfig 不再需要 provider 和 webSocketProvider
// 而是用 transports 统一配置
export const config = createConfig({
chains: [mainnet, polygon],
transports: {
[mainnet.id]: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_KEY'),
[polygon.id]: http('https://polygon-mainnet.g.alchemy.com/v2/YOUR_ALCHEMY_KEY'),
},
})
注意这个细节 :如果你需要实时性更高的监听,可以用 webSocket 替代 http。但 viem 的 webSocket 需要单独配置,而且有些公共 RPC 不支持 WebSocket。我后来选择用 Alchemy 的 WebSocket 端点,连接更稳定。
第三步:封装 useSwapEvents Hook
这是整个方案的核心。我用 watchContractEvent 替代了手动监听,代码量直接减少 70%。
typescript
// src/hooks/useSwapEvents.ts
import { useContractEvent } from 'wagmi'
import { parseAbiItem } from 'viem'
import { POOL_ADDRESS } from '../constants'
// Uniswap V3 Pool 的 Swap 事件 ABI
const swapEventAbi = parseAbiItem(
'event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick)'
)
export function useSwapEvents(poolAddress: `0x${string}`) {
// 这里有个坑:useContractEvent 的回调函数必须是稳定的引用
// 否则会造成无限重订阅
const onSwap = useCallback((logs: any[]) => {
logs.forEach((log) => {
// log.args 是事件参数,类型安全
const { sender, recipient, amount0, amount1 } = log.args
console.log('收到 Swap:', { sender, recipient, amount0, amount1 })
// 这里可以 dispatch 到状态管理或直接更新 UI
})
}, [])
useContractEvent({
address: poolAddress,
abi: [swapEventAbi],
eventName: 'Swap',
listener: onSwap,
// 重要:设置 batch 为 true,让 wagmi 自动聚合事件
// 避免标签页休眠后事件堆积
batch: true,
})
}
关键细节:
useCallback包裹回调函数:如果不这样做,每次渲染都会创建新的函数引用,导致useContractEvent内部重新订阅。batch: true:这个参数解决了标签页休眠后事件堆积的问题。wagmi 会把短时间内的事件聚合成一个数组,一次性传给回调。这样你可以在回调里批量处理,而不是一条一条插入 DOM。
第四步:在组件中使用
typescript
// src/components/SwapList.tsx
import { useSwapEvents } from '../hooks/useSwapEvents'
export function SwapList() {
const [swaps, setSwaps] = useState<SwapEvent[]>([])
// 这里有个坑:poolAddress 必须是 checksummed 地址
// 否则 wagmi 会报 "Invalid address" 错误
const poolAddress = '0x88e6A0c2dDD26FEEb64F039a2c41296FcB3f5640' as `0x${string}`
const onSwap = useCallback((logs: any[]) => {
const newSwaps = logs.map((log) => ({
sender: log.args.sender,
recipient: log.args.recipient,
amount0: formatUnits(log.args.amount0, 18),
amount1: formatUnits(log.args.amount1, 18),
timestamp: Date.now(),
}))
// 只保留最近 100 条记录
setSwaps((prev) => [...newSwaps, ...prev].slice(0, 100))
}, [])
useSwapEvents(poolAddress, onSwap)
return (
<div>
{swaps.length === 0 ? (
<p>等待交易中...</p>
) : (
<ul>
{swaps.map((swap, index) => (
<li key={`${swap.timestamp}-${index}`}>
{swap.sender.slice(0, 6)}... 交易了 {swap.amount0} ETH
</li>
))}
</ul>
)}
</div>
)
}
第五步:处理多链和多账户切换
wagmi 的 useContractEvent 自动处理了链切换和账户切换。当用户切换网络时,wagmi 会:
- 自动取消当前链的订阅
- 重新订阅新链的事件
- 如果新链上没有这个合约,会静默忽略
但我还遇到了一个问题:用户切换账户后,事件监听仍然存在,但回调里的 sender 参数还是旧账户。解决方案是用 useAccount 获取当前账户,在回调里做过滤:
typescript
import { useAccount } from 'wagmi'
export function useMySwaps(poolAddress: `0x${string}`) {
const { address } = useAccount()
const onSwap = useCallback((logs: any[]) => {
// 只处理当前用户相关的交易
const mySwaps = logs.filter(
(log) => log.args.sender === address || log.args.recipient === address
)
// ...更新状态
}, [address])
useContractEvent({
address: poolAddress,
abi: [swapEventAbi],
eventName: 'Swap',
listener: onSwap,
batch: true,
})
}
完整代码:可直接运行的示例
typescript
// App.tsx
import { WagmiProvider, createConfig, http } from 'wagmi'
import { mainnet } from 'wagmi/chains'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { SwapList } from './components/SwapList'
const config = createConfig({
chains: [mainnet],
transports: {
[mainnet.id]: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
},
})
const queryClient = new QueryClient()
export default function App() {
return (
<WagmiProvider config={config}>
<QueryClientProvider client={queryClient}>
<SwapList />
</QueryClientProvider>
</WagmiProvider>
)
}
typescript
// hooks/useSwapEvents.ts
import { useContractEvent } from 'wagmi'
import { parseAbiItem } from 'viem'
import { useCallback } from 'react'
const swapEventAbi = parseAbiItem(
'event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick)'
)
export function useSwapEvents(
poolAddress: `0x${string}`,
onSwap: (logs: any[]) => void
) {
useContractEvent({
address: poolAddress,
abi: [swapEventAbi],
eventName: 'Swap',
listener: onSwap,
batch: true,
})
}
踩坑记录
-
useContractEvent重复订阅 :第一次使用时,我没有用useCallback包裹回调函数。每次组件渲染,回调函数引用都变了,导致 wagmi 以为要重新订阅。结果是每次状态更新都触发一次新的订阅,监听器数量指数级增长。解决方法:用useCallback或useRef保持回调引用稳定。 -
Invalid address错误 :把地址字符串传给useContractEvent时,没有做 checksum 校验。Uniswap V3 的池子地址是小写的,但 wagmi 要求地址必须是 EIP-55 格式。解决方法:用viem的getAddress函数转换地址。 -
事件参数类型错误 :
watchContractEvent返回的log.args类型是unknown[],直接访问log.args.sender会报 TypeScript 错误。解决方法:手动断言类型,或者用parseAbiItem生成的类型。 -
标签页休眠后 UI 卡死 :没有设置
batch: true时,用户切回标签页会一次性触发上百个事件,每个事件都调用setState,导致 React 批量更新处理不过来。解决方法:设置batch: true并限制列表长度。
小结
监听智能合约事件,最省心的方案就是用 wagmi v2 的 useContractEvent,它内置了重连、清理、批量处理等所有基础设施。如果你还在用 ethers.js 手动管理监听器,强烈建议迁移到 wagmi + viem。下一步可以研究 watchContractEvent 的 onError 回调,处理 RPC 请求失败的情况。