用 wagmi v2 + viem 监听合约事件踩坑实录:从轮询到实时推送,我终于搞懂了

用 wagmi v2 + viem 监听合约事件踩坑实录:从轮询到实时推送,我终于搞懂了

摘要

做 DeFi 质押项目时,需要实时监听用户操作事件。我一开始用轮询,延迟高还浪费资源。换成 wagmi v2 的 useWatchContractEvent hook,结果遇到事件重复触发、断网后监听失效、多链切换时事件错乱等问题。这篇文章记录了我如何一步步排查、修复,最终实现稳定可靠的事件监听。

背景

上个月接了一个 DeFi 质押项目,用户可以把 ETH 质押进合约赚取收益。前端需要实时展示用户的操作记录------比如谁质押了、谁提取了、质押了多少。产品要求延迟不能超过 3 秒,而且不能因为用户切换钱包链或者网络波动就丢失事件。

我一开始想当然地用了轮询方案:每 5 秒调用一次合约的 getUserEvents 方法,然后和本地缓存对比。结果上线测试时发现,用户质押后前端要等 5 秒才能看到记录,而且如果用户连续操作两次,轮询可能只抓到一次。产品经理直接说:"这体验太差了,用户以为交易没成功,会重复操作。"

我当时就想,必须换成事件监听。wagmi v2 刚出了 useWatchContractEvent hook,看起来就是干这个的。但没想到,这一换就踩了三天坑。

问题分析

最初的思路

我的合约里定义了 StakedWithdrawn 两个事件:

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. 事件去重

解决了生命周期问题,事件重复的问题依然存在。我分析后发现,重复的原因有两个:

  1. 同一个交易被多个 RPC 节点推送了两次
  2. 监听器重建时,会收到之前已经处理过的事件

解决方案是维护一个已处理交易哈希的 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>
  )
}

踩坑记录

  1. useWatchContractEvent 在 React 严格模式下重复执行

    报错表现:同一个事件触发两次 onLogs

    原因:严格模式下组件挂载两次,hook 内部 effect 执行两次。

    解决:改用底层 publicClient.watchContractEvent 手动管理生命周期,或用 ref 控制执行次数。

  2. WebSocket 断连后监听永久失效

    报错表现:网络断开再恢复后,不再收到任何事件。

    原因:viem 的 watchContractEvent 在 WebSocket 断开后不会自动重新订阅。

    解决:监听 window.online 事件,断连后手动重建监听器,并设置 pollingInterval 作为备选。

  3. 切换链后收到旧链的事件

    报错表现:从 Sepolia 切回主网后,还收到 Sepolia 的事件。

    原因:旧监听器未清理,且新监听器使用了旧链的 publicClient。

    解决:将 chain?.id 作为 useEffect 依赖,链变化时先清理旧监听器再创建新监听器。

  4. 事件去重不彻底

    报错表现:同一个交易的事件被处理两次。

    原因:RPC 节点可能推送重复的日志,或者监听器重建时拉取了历史事件。

    解决:用 Set 存储已处理的交易哈希,每次处理前检查去重。

小结

合约事件监听看似简单,但涉及生命周期、网络状态、多链切换等多个边界情况。核心收获是:不要完全依赖框架 hook 的自动管理,要自己掌控监听器的创建和销毁,同时做好去重和断连恢复 。如果想深入,可以研究 viem 的 watchContractEvent 源码,了解它底层是如何实现轮询和订阅的切换的。

相关推荐
ai_coder_ai1 小时前
如何在自动化脚本中实现定时操作?
java·前端·javascript
如烟花的信页1 小时前
易盾滑块逆向分析
javascript·爬虫·python·js逆向
努力早日退休1 小时前
一个 9999px 引发的跨平台血案:小程序离屏隐藏元素的滚动兼容性问题
前端·javascript
Darling噜啦啦2 小时前
正则表达式实战指南:从手机号验证到模板引擎,5 个案例彻底搞懂 RegExp
javascript·面试
sugar__salt2 小时前
JS正则表达式与字符串高阶实战精讲
开发语言·javascript·正则表达式
HjhIron2 小时前
从手机号校验到模板引擎:正则表达式的实战之旅
javascript
Hello馒头儿2 小时前
vue3+uniapp经典hook方式实现一个更多加载的列表组件
前端·javascript·vue.js
用户938515635072 小时前
前端必会:从 Fetch 到 DeepSeek,一篇搞懂 HTTP 请求的方方面面
javascript·架构
半个烧饼不加肉2 小时前
JS 底层探究--执行上下文
开发语言·前端·javascript