被合约事件搞到失眠?我踩了三天坑,终于写出一份监听智能合约事件的实战指南

背景:一个 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 行代码,包括:

  • 监听 accountsChangedchainChanged 事件
  • 断连后自动重新订阅
  • 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,
  })
}

关键细节

  1. useCallback 包裹回调函数:如果不这样做,每次渲染都会创建新的函数引用,导致 useContractEvent 内部重新订阅。
  2. 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 会:

  1. 自动取消当前链的订阅
  2. 重新订阅新链的事件
  3. 如果新链上没有这个合约,会静默忽略

但我还遇到了一个问题:用户切换账户后,事件监听仍然存在,但回调里的 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,
  })
}

踩坑记录

  1. useContractEvent 重复订阅 :第一次使用时,我没有用 useCallback 包裹回调函数。每次组件渲染,回调函数引用都变了,导致 wagmi 以为要重新订阅。结果是每次状态更新都触发一次新的订阅,监听器数量指数级增长。解决方法:用 useCallbackuseRef 保持回调引用稳定。

  2. Invalid address 错误 :把地址字符串传给 useContractEvent 时,没有做 checksum 校验。Uniswap V3 的池子地址是小写的,但 wagmi 要求地址必须是 EIP-55 格式。解决方法:用 viemgetAddress 函数转换地址。

  3. 事件参数类型错误watchContractEvent 返回的 log.args 类型是 unknown[],直接访问 log.args.sender 会报 TypeScript 错误。解决方法:手动断言类型,或者用 parseAbiItem 生成的类型。

  4. 标签页休眠后 UI 卡死 :没有设置 batch: true 时,用户切回标签页会一次性触发上百个事件,每个事件都调用 setState,导致 React 批量更新处理不过来。解决方法:设置 batch: true 并限制列表长度。

小结

监听智能合约事件,最省心的方案就是用 wagmi v2 的 useContractEvent,它内置了重连、清理、批量处理等所有基础设施。如果你还在用 ethers.js 手动管理监听器,强烈建议迁移到 wagmi + viem。下一步可以研究 watchContractEventonError 回调,处理 RPC 请求失败的情况。

相关推荐
用户059540174461 小时前
把 AI 记忆验证从手工 Log 换成 Pytest+Mem0,上下文丢失 bug 减少 90%
前端·css
在逃花果山的小松1 小时前
容器化部署实战:从Dockerfile到Kubernetes上云
javascript
艾利克斯冰1 小时前
TypeScript 静态类型入门教程:可选静态类型与类型推导详
前端·javascript·typescript
GuWenyue1 小时前
告别命名混乱!5步掌握BEM规范,写出易维护的前端页面
前端·javascript·面试
小林ixn1 小时前
BEM 命名规范与 CSS 重置:打造优雅的按钮页面实战
前端·css
雨季mo浅忆1 小时前
记录利用Cursor快速实现首页数据大屏
前端·ai编程
像我这样帅的人丶你还1 小时前
🚀🚀🚀2026年还不会Nginx?
前端·nginx
用户059540174461 小时前
把对话记忆从内存搬到 Redis,长期记忆准确率从 63% 提升到 98%
前端·css