wagmi v2 监听合约事件踩坑记:从 `useContractEvent` 到 `watchContractEvent`,我重构了三版才搞定实时数据流

背景

上个月,我在做一个基于 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),但事件监听不会自动跟随。我当时的做法是监听 accountchainId 的变化,重新建立监听:

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 v2viem@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 的依赖数组中包含 addresschainId,并使用 useCallback 包裹 onLogs 回调。

小结

从 wagmi v1 到 v2 的迁移过程中,事件监听是最让我头疼的部分。核心收获是:监听合约事件时,一定要处理好生命周期(组件卸载清理)、多链切换(依赖监听)、事件签名(参数名不能省略)三个关键点 。如果你也在用 wagmi v2,建议优先使用 watchContractEvent,配合 React Query 做状态管理,比手动轮询更优雅。

进一步可以研究:如何监听大量合约事件(比如 Uniswap 的流动性池),以及如何使用 viemcreateEventFilter 实现更细粒度的过滤。

相关推荐
天若有情6732 小时前
纯HTML+Tailwind单页作品集网站——零框架静态前端完整源码
前端·html
WebGirl2 小时前
H5唤起app前端实现方案
前端
heimeiyingwang2 小时前
【架构实战】SQL注入与XSS防御:常见Web漏洞的系统性修复
前端·sql·架构
xingcheng2 小时前
Vue 2 + JavaScript编码规范skill
javascript
Larcher2 小时前
从零实现一个可运行的 RAG - LangChain、内存向量库与检索增强生成
前端·javascript
mONESY2 小时前
Vue3流式调用大模型接口完整实践
javascript
饼饼饼3 小时前
React 组件间传参好痛苦?试试这一套"优雅组合拳":Context 与 Refs 实战
前端
qq_419854053 小时前
空间网格管理器
前端·javascript·html