用 wagmi v2 + viem 监听链上事件,我踩了三天坑终于搞懂了实时日志与历史补全

用 wagmi v2 + viem 监听链上事件,我踩了三天坑终于搞懂了实时日志与历史补全

摘要

做 DeFi 看板时,我需要实时监听 Uniswap V3 的 Swap 事件并展示交易列表。一开始用 wagmi 的 useWatchContractEvent 发现历史数据丢失、链切换后监听失效。折腾三天后,我搞懂了 viem 的 watchContractEvent + getLogs 组合方案,解决了实时监听、历史补全和内存泄漏三个核心问题。本文记录完整踩坑过程。

背景

上个月我在做一个 DeFi 看板项目,类似 DexScreener 的简化版。核心功能是:用户连接钱包后,能看到指定交易对(比如 ETH/USDC)的实时 swap 记录,包括价格、交易量、发送方等数据。

我选的技术栈是 React + wagmi v2 + viem。为什么没用 ethers.js?因为 wagmi v2 全面拥抱 viem,而且 React hooks 用起来确实爽。但问题来了:wagmi 的 useWatchContractEvent 只监听新产生的事件,不会返回历史数据。用户第一次打开页面时,列表是空的,要等新交易发生才显示。这显然不行。

更坑的是,用户切换链(比如从 Ethereum 切到 Polygon)后,之前的监听还在,新链的监听没启动,导致数据混乱。我当时就想:这玩意儿到底该怎么用?

问题分析

最初的思路:只用 useWatchContractEvent

最开始我天真地以为 wagmi 的 hook 能搞定一切:

tsx 复制代码
const { data: events } = useWatchContractEvent({
  address: poolAddress,
  abi: uniswapV3PoolABI,
  eventName: 'Swap',
  chainId: 1,
})

结果问题来了:

  1. 事件丢失events 只返回新事件,页面刷新后历史数据全没了。
  2. 链切换失效 :用户切到 Polygon 后,hook 不会自动重新订阅,需要手动 reset
  3. 内存泄漏 :快速切换页面时,监听没有及时清理,控制台报 MaxListenersExceededWarning

排查过程

我打开 wagmi 源码一看,发现 useWatchContractEvent 底层调用的是 viem 的 watchContractEvent,它本质是一个 WebSocket 订阅。而 WebSocket 订阅的特点就是:只推送新事件,不返回历史日志

要显示历史数据,必须先用 getLogsgetContractEvents 拉取一次,然后再开启实时监听。而且链切换时,必须手动销毁旧监听并创建新监听。

核心实现

第一步:用 viem 的 getLogs 拉取历史事件

首先,我们需要获取合约的历史事件。viem 提供了 getLogs 方法,可以按区块范围或时间范围过滤。

typescript 复制代码
import { createPublicClient, http, parseAbiItem } from 'viem'
import { mainnet } from 'viem/chains'

// 创建公共客户端
const publicClient = createPublicClient({
  chain: mainnet,
  transport: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY'),
})

// 获取最近 1000 个区块的 Swap 事件
async function fetchHistoricalEvents(poolAddress: `0x${string}`) {
  const latestBlock = await publicClient.getBlockNumber()
  const fromBlock = latestBlock - BigInt(1000) // 最近 1000 个区块

  const logs = await publicClient.getLogs({
    address: poolAddress,
    event: parseAbiItem('event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick)'),
    fromBlock,
    toBlock: latestBlock,
  })

  return logs
}

这里有个坑getLogs 返回的 logs 是按区块顺序排列的,但事件内部的数据结构是 args 对象。要拿到 senderamount0 等实际参数,需要这样解析:

typescript 复制代码
const parsedEvents = logs.map(log => ({
  sender: log.args.sender,
  recipient: log.args.recipient,
  amount0: log.args.amount0,
  amount1: log.args.amount1,
  // ... 其他字段
}))

另外,fromBlock 如果设置得太远(比如 10000 个区块),RPC 节点可能会报错。我一开始设了 100000,结果 Alchemy 直接返回 query returned more than 10000 results。解决方案是分批次查询:

typescript 复制代码
async function fetchEventsInBatches(poolAddress: `0x${string}`, totalBlocks: bigint = BigInt(10000)) {
  const latestBlock = await publicClient.getBlockNumber()
  const batchSize = BigInt(2000) // 每批 2000 个区块
  let currentBlock = latestBlock - totalBlocks
  const allLogs = []

  while (currentBlock < latestBlock) {
    const toBlock = currentBlock + batchSize > latestBlock ? latestBlock : currentBlock + batchSize
    const logs = await publicClient.getLogs({
      address: poolAddress,
      event: parseAbiItem('event Swap(...)'),
      fromBlock: currentBlock,
      toBlock,
    })
    allLogs.push(...logs)
    currentBlock = toBlock + BigInt(1)
  }

  return allLogs
}

第二步:用 watchContractEvent 实现实时监听

拉完历史数据后,需要开启 WebSocket 实时监听。viem 的 watchContractEvent 返回一个 unwatch 函数,用于清理监听。

typescript 复制代码
import { createPublicClient, webSocket } from 'viem'

// 注意:这里用 WebSocket 传输方式
const wsClient = createPublicClient({
  chain: mainnet,
  transport: webSocket('wss://eth-mainnet.g.alchemy.com/v2/YOUR_API_KEY'),
})

let unwatch: (() => void) | null = null

function startWatching(poolAddress: `0x${string}`, onEvent: (log: any) => void) {
  // 先清理旧监听
  if (unwatch) {
    unwatch()
    unwatch = null
  }

  unwatch = wsClient.watchContractEvent({
    address: poolAddress,
    event: parseAbiItem('event Swap(...)'),
    onLogs: (logs) => {
      logs.forEach(log => onEvent(log))
    },
  })
}

这里有个坑watchContractEventonLogs 回调接收的是一个数组,不是单个事件。我第一次写成了 onLogs: (log) => {...},结果一直拿不到数据。后来看了文档才发现是 logs(复数)。

另外,WebSocket 连接有超时机制。如果长时间没有事件,连接可能会自动断开。我遇到过一次:半夜没有交易,第二天早上发现监听没了。解决方案是加心跳检测:

typescript 复制代码
let heartbeatInterval: NodeJS.Timeout | null = null

function startWatchingWithHeartbeat(poolAddress: `0x${string}`, onEvent: (log: any) => void) {
  // ... 创建监听

  // 每 30 秒发一次 ping
  heartbeatInterval = setInterval(() => {
    wsClient.transport.request({ method: 'net_version' }).catch(() => {
      console.warn('WebSocket 连接可能断开,尝试重连')
      startWatchingWithHeartbeat(poolAddress, onEvent)
    })
  }, 30000)
}

function stopWatching() {
  if (heartbeatInterval) {
    clearInterval(heartbeatInterval)
    heartbeatInterval = null
  }
  if (unwatch) {
    unwatch()
    unwatch = null
  }
}

第三步:封装成 React Hook,处理链切换和组件卸载

现在把上面两步封装成一个自定义 hook,让 React 组件能优雅地使用。

typescript 复制代码
import { useEffect, useRef, useState, useCallback } from 'react'
import { createPublicClient, http, webSocket, parseAbiItem } from 'viem'
import { mainnet, polygon } from 'viem/chains'
import { useAccount, useChainId } from 'wagmi'

// 链配置映射
const chainConfig = {
  [mainnet.id]: { rpc: 'https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY', ws: 'wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY' },
  [polygon.id]: { rpc: 'https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY', ws: 'wss://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY' },
}

export function usePoolEvents(poolAddress: `0x${string}`) {
  const { address } = useAccount()
  const chainId = useChainId()
  const [events, setEvents] = useState<any[]>([])
  const unwatchRef = useRef<(() => void) | null>(null)
  const heartbeatRef = useRef<NodeJS.Timeout | null>(null)

  // 拉取历史数据
  const fetchHistory = useCallback(async () => {
    const config = chainConfig[chainId]
    if (!config) return

    const client = createPublicClient({
      chain: chainId === mainnet.id ? mainnet : polygon,
      transport: http(config.rpc),
    })

    const latestBlock = await client.getBlockNumber()
    const logs = await client.getLogs({
      address: poolAddress,
      event: parseAbiItem('event Swap(address indexed sender, address indexed recipient, int256 amount0, int256 amount1, uint160 sqrtPriceX96, uint128 liquidity, int24 tick)'),
      fromBlock: latestBlock - BigInt(2000),
      toBlock: latestBlock,
    })

    setEvents(logs.map(log => ({
      ...log.args,
      transactionHash: log.transactionHash,
      blockNumber: log.blockNumber,
    })))
  }, [chainId, poolAddress])

  // 开启实时监听
  const startWatching = useCallback(() => {
    const config = chainConfig[chainId]
    if (!config) return

    // 清理旧监听
    if (unwatchRef.current) {
      unwatchRef.current()
      unwatchRef.current = null
    }

    const wsClient = createPublicClient({
      chain: chainId === mainnet.id ? mainnet : polygon,
      transport: webSocket(config.ws),
    })

    unwatchRef.current = wsClient.watchContractEvent({
      address: poolAddress,
      event: parseAbiItem('event Swap(...)'),
      onLogs: (logs) => {
        setEvents(prev => {
          const newEvents = logs.map(log => ({
            ...log.args,
            transactionHash: log.transactionHash,
            blockNumber: log.blockNumber,
          }))
          // 去重:防止同一个交易被重复添加
          const existingHashes = new Set(prev.map(e => e.transactionHash))
          const uniqueNew = newEvents.filter(e => !existingHashes.has(e.transactionHash))
          return [...uniqueNew, ...prev].slice(0, 100) // 最多保留 100 条
        })
      },
    })

    // 心跳
    heartbeatRef.current = setInterval(() => {
      wsClient.transport.request({ method: 'net_version' }).catch(() => {
        console.warn('WS 断开,尝试重连')
        startWatching()
      })
    }, 30000)
  }, [chainId, poolAddress])

  // 组件挂载时拉取历史 + 监听
  useEffect(() => {
    if (!poolAddress || !chainConfig[chainId]) return

    fetchHistory()
    startWatching()

    return () => {
      // 清理
      if (unwatchRef.current) {
        unwatchRef.current()
        unwatchRef.current = null
      }
      if (heartbeatRef.current) {
        clearInterval(heartbeatRef.current)
        heartbeatRef.current = null
      }
    }
  }, [poolAddress, chainId, fetchHistory, startWatching])

  return { events }
}

这里有个坑 :在 onLogs 回调中更新状态时,我用的是 setEvents(prev => ...) 函数式更新,而不是直接 setEvents([...newEvents, ...events])。因为在高频事件场景下(比如 Uniswap 的 Swap 事件),闭包中的 events 可能是旧值,导致数据丢失。

第四步:解决内存泄漏和重复监听

在 React 18 的严格模式下,useEffect 会执行两次。如果 hook 不处理好清理逻辑,会导致两个监听同时存在。

我在 useEffect 的返回函数中已经做了清理,但还有一个细节:watchContractEventunwatch 函数在组件卸载后调用可能会报错 。解决方案是加一个 mounted 标记:

typescript 复制代码
useEffect(() => {
  let mounted = true

  // ... 创建监听

  unwatchRef.current = wsClient.watchContractEvent({
    // ...
    onLogs: (logs) => {
      if (!mounted) return // 组件已卸载,不更新状态
      // ...
    },
  })

  return () => {
    mounted = false
    // ... 清理
  }
}, [...])

完整代码

下面是一个可直接运行的 React 组件示例,实现了历史事件拉取 + 实时监听 + 链切换 + 内存清理:

tsx 复制代码
// SwapMonitor.tsx
import { useEffect, useRef, useState } from 'react'
import { createPublicClient, http, webSocket, parseAbiItem } from 'viem'
import { mainnet, polygon } from 'viem/chains'
import { useChainId, useAccount } from 'wagmi'

const CHAIN_CONFIG = {
  1: { rpc: 'https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY', ws: 'wss://eth-mainnet.g.alchemy.com/v2/YOUR_KEY' },
  137: { rpc: 'https://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY', ws: 'wss://polygon-mainnet.g.alchemy.com/v2/YOUR_KEY' },
}

// Uniswap V3 的 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 SwapMonitor({ poolAddress }: { poolAddress: `0x${string}` }) {
  const chainId = useChainId()
  const { address } = useAccount()
  const [events, setEvents] = useState<any[]>([])
  const [loading, setLoading] = useState(true)
  const unwatchRef = useRef<() => void>()
  const heartbeatRef = useRef<NodeJS.Timeout>()

  useEffect(() => {
    let mounted = true
    const config = CHAIN_CONFIG[chainId]
    if (!config) return

    const httpClient = createPublicClient({
      chain: chainId === 1 ? mainnet : polygon,
      transport: http(config.rpc),
    })

    const wsClient = createPublicClient({
      chain: chainId === 1 ? mainnet : polygon,
      transport: webSocket(config.ws),
    })

    // 1. 拉取历史事件
    async function fetchHistory() {
      try {
        const latestBlock = await httpClient.getBlockNumber()
        const logs = await httpClient.getLogs({
          address: poolAddress,
          event: swapEventAbi,
          fromBlock: latestBlock - BigInt(2000),
          toBlock: latestBlock,
        })

        if (mounted) {
          setEvents(logs.map(log => ({
            sender: log.args.sender,
            recipient: log.args.recipient,
            amount0: log.args.amount0,
            amount1: log.args.amount1,
            txHash: log.transactionHash,
            block: Number(log.blockNumber),
          })))
          setLoading(false)
        }
      } catch (err) {
        console.error('历史事件拉取失败:', err)
        setLoading(false)
      }
    }

    // 2. 开启实时监听
    function startWatching() {
      // 清理旧监听
      if (unwatchRef.current) {
        unwatchRef.current()
        unwatchRef.current = undefined
      }

      unwatchRef.current = wsClient.watchContractEvent({
        address: poolAddress,
        event: swapEventAbi,
        onLogs: (logs) => {
          if (!mounted) return
          setEvents(prev => {
            const newEvents = logs.map(log => ({
              sender: log.args.sender,
              recipient: log.args.recipient,
              amount0: log.args.amount0,
              amount1: log.args.amount1,
              txHash: log.transactionHash,
              block: Number(log.blockNumber),
            }))
            // 去重
            const existingTxs = new Set(prev.map(e => e.txHash))
            const unique = newEvents.filter(e => !existingTxs.has(e.txHash))
            return [...unique, ...prev].slice(0, 100)
          })
        },
      })

      // 心跳
      heartbeatRef.current = setInterval(() => {
        wsClient.transport.request({ method: 'net_version' }).catch(() => {
          console.warn('WS 断开,尝试重连')
          startWatching()
        })
      }, 30000)
    }

    fetchHistory()
    startWatching()

    return () => {
      mounted = false
      if (unwatchRef.current) {
        unwatchRef.current()
        unwatchRef.current = undefined
      }
      if (heartbeatRef.current) {
        clearInterval(heartbeatRef.current)
        heartbeatRef.current = undefined
      }
    }
  }, [chainId, poolAddress])

  if (loading) return <div>正在加载交易记录...</div>

  return (
    <div>
      <h3>实时 Swap 事件 ({chainId === 1 ? 'Ethereum' : 'Polygon'})</h3>
      <div style={{ maxHeight: 400, overflow: 'auto' }}>
        {events.map((event, i) => (
          <div key={`${event.txHash}-${i}`} style={{ padding: 8, borderBottom: '1px solid #eee' }}>
            <div>发送方: {event.sender?.slice(0, 6)}...{event.sender?.slice(-4)}</div>
            <div>接收方: {event.recipient?.slice(0, 6)}...{event.recipient?.slice(-4)}</div>
            <div>数量0: {event.amount0?.toString()}</div>
            <div>数量1: {event.amount1?.toString()}</div>
            <div>区块: {event.block}</div>
          </div>
        ))}
      </div>
    </div>
  )
}

踩坑记录

  1. getLogs 返回空数组

    我一开始没指定 fromBlocktoBlock,结果返回空。viem 的 getLogs 默认只查最近一个区块的事件,必须显式指定区块范围。

  2. watchContractEventonLogs 参数是数组

    文档写的是 onLogs: (logs: Log[]) => void,我当成单个事件处理,导致数据处理逻辑全错。后来用 console.log 打印才发现的。

  3. WebSocket 断开后不自动重连

    Alchemy 的 WebSocket 会在 5 分钟无活动后断开。我加了心跳检测,每 30 秒发一次 net_version 请求。但注意:如果心跳请求失败,不要立即重连,而是等几秒再试,否则可能无限重连。

  4. React 18 严格模式导致监听重复

    开发环境下,useEffect 会执行两次。如果清理函数没写对,会有两个监听同时存在。解决方案:用 unwatchRef 保存清理函数,在每次创建新监听前先清理旧的。

小结

监听智能合约事件的核心思路就两步:先用 getLogs 拉取历史数据,再用 watchContractEvent 开启实时监听。关键是处理好链切换、内存泄漏和重连逻辑。如果想进一步优化,可以考虑用 useSyncExternalStore 把事件状态同步到 React 外部,或者用 IndexedDB 做本地缓存。

相关推荐
只一2 小时前
😭从回调地狱到 async/await:一文打通 Ajax 与 JS 异步编程
javascript
weedsfly2 小时前
语法糖褪去之后——Babel 转译产物中的 JavaScript 本貌
前端·javascript
JustHappy2 小时前
「软件设计思想杂谈🤔」“切图仔”也能懂编译原理?框架源码也许没那么难。聊聊 Vue 的编译(上)
前端·javascript·vue.js
晓得迷路了3 小时前
栗子前端技术周刊第 134 期 - React Router v8、TypeScript 7 RC、React Native 0.86...
前端·javascript·react.js
代码煮茶18 小时前
React 组件封装方法论 —— 以 Todo App 为例
javascript·react.js
任沫19 小时前
Agent之Function Call
javascript·人工智能·go
默_笙20 小时前
🛬 我让 AI 帮我写了一个打飞机游戏,结果 Canvas 把我整不会了
前端·javascript
梯度不陡20 小时前
AI 到底能不能从零写软件?ProgramBench 和 RepoZero 给出了两种答案
前端·javascript·面试
胡萝卜术1 天前
滑动窗口最大值:从暴力到单调队列,层层优化全解析
前端·javascript·面试