用 wagmi v2 + viem 监听合约事件时踩的坑,我花了两天才把"遗漏事件"修好

背景

去年下半年,我参与了一个跨链借贷协议的前端开发。这个协议允许用户在不同链之间抵押资产并借出稳定币,前端需要实时监听用户抵押品价格的变化,一旦触发清算线就要立刻更新 UI,给用户弹窗提醒。

一开始我图省事,直接用 setInterval 每 10 秒调用一次合约的 getUserCollateral 方法,虽然能用,但体验很差------用户明明已经补仓了,UI 还要等好几秒才更新。而且频繁 RPC 调用还容易被节点限流。

后来我决定改成事件驱动:监听合约的 CollateralUpdated 事件,只要有新的抵押操作或清算操作,前端就立刻更新。听起来很简单对吧?我当时也是这么想的,结果一动手才发现全是坑。

问题分析:为什么轮询不行,事件监听也有坑?

最初的思路:用 ethers.js 的 contract.on

我第一个想到的是用 ethers.js 的 contract.on("CollateralUpdated", callback)。在 React 组件里用 useEffect 初始化监听:

typescript 复制代码
useEffect(() => {
  const contract = new ethers.Contract(address, abi, provider);
  const handleEvent = (user, amount, event) => {
    console.log("收到事件:", user, amount);
    // 更新 UI
  };
  contract.on("CollateralUpdated", handleEvent);
  return () => {
    contract.off("CollateralUpdated", handleEvent);
  };
}, []);

看起来没问题,但跑起来发现:事件经常收不到。特别是用户快速操作(比如连续两次抵押),第二次事件大概率会丢失。更诡异的是,在开发环境用本地节点(Anvil)时一切正常,一上 Goerli 测试网就出问题。

排查了两天,我发现了几个问题:

  1. ethers.js v5 的 contract.on 底层用的是 WebSocket 连接,但很多公共 RPC 节点(比如 Infura 的免费层)对 WebSocket 连接数有限制,而且连接不稳定会自动断开,ethers.js 不会自动重连。
  2. React 组件卸载时,如果 cleanup 不彻底,事件监听会残留,导致多次触发或内存泄漏。
  3. 事件回调里直接调用 setState,在 React 18 的严格模式下会触发两次渲染,导致状态混乱。

转向 wagmi v2 + viem

当时正好 wagmi 发布了 v2 版本,底层从 ethers.js 换成了 viem。viem 的 watchContractEvent 支持自动重连,而且 wagmi 的 useWatchContractEvent hook 是 React 原生的,能自动处理组件生命周期。

我心想:这下总该稳了吧?结果又踩了第二个大坑。

核心实现:一步一步搭建可靠的事件监听

1. 用 wagmi 的 useWatchContractEvent 替代原生监听

先安装依赖:

bash 复制代码
npm install wagmi viem @tanstack/react-query

然后配置 wagmi 客户端。这里注意,publicClient 必须指定一个稳定的 RPC 节点,我推荐用 Alchemy 或 QuickNode 的专用节点,不要用公共节点:

typescript 复制代码
// wagmi.config.ts
import { createConfig, http } from 'wagmi';
import { mainnet, goerli } from 'wagmi/chains';

export const config = createConfig({
  chains: [mainnet, goerli],
  transports: {
    [mainnet.id]: http('https://eth-mainnet.g.alchemy.com/v2/YOUR_KEY'),
    [goerli.id]: http('https://eth-goerli.g.alchemy.com/v2/YOUR_KEY'),
  },
});

接着在组件里使用 useWatchContractEvent

typescript 复制代码
import { useWatchContractEvent } from 'wagmi';

function CollateralMonitor({ userAddress }: { userAddress: string }) {
  const { data: events, isError, isLoading } = useWatchContractEvent({
    address: '0x你的合约地址',
    abi: [...你的ABI],
    eventName: 'CollateralUpdated',
    args: { user: userAddress }, // 只监听当前用户的事件
    onLogs(logs) {
      console.log('收到事件:', logs);
      // 这里不要直接 setState,后面会讲
    },
  });
  // ...
}

这里有个坑useWatchContractEventargs 参数必须和合约事件定义完全一致。如果事件定义是 event CollateralUpdated(address indexed user, uint256 amount),那 args 必须是 { user: userAddress },不能写成 { userAddress }。我因为少写了 user: 字段名,浪费了半天。

2. 处理事件数据,更新 UI 状态

事件回调里不能直接调用 setState,因为 wagmi 的事件处理是在 React 的 effect 之外执行的,直接 setState 会导致 React 报 warning。正确的做法是用 useEffect 监听事件列表的变化:

typescript 复制代码
import { useState, useEffect, useCallback } from 'react';
import { useWatchContractEvent } from 'wagmi';

interface CollateralEvent {
  user: string;
  amount: bigint;
  timestamp: bigint;
}

function CollateralMonitor({ userAddress }: { userAddress: string }) {
  const [collateralHistory, setCollateralHistory] = useState<CollateralEvent[]>([]);

  // 用 useCallback 包装事件处理函数,避免重复创建
  const handleEvent = useCallback((logs: any[]) => {
    const newEvents = logs.map(log => ({
      user: log.args.user,
      amount: log.args.amount,
      timestamp: log.args.timestamp,
    }));
    // 用函数式更新,避免闭包陷阱
    setCollateralHistory(prev => [...prev, ...newEvents]);
  }, []);

  useWatchContractEvent({
    address: '0x你的合约地址',
    abi: [...你的ABI],
    eventName: 'CollateralUpdated',
    args: { user: userAddress },
    onLogs: handleEvent,
  });

  // 用 useEffect 处理 UI 更新逻辑
  useEffect(() => {
    if (collateralHistory.length === 0) return;
    const latest = collateralHistory[collateralHistory.length - 1];
    // 检查是否触发清算
    if (latest.amount < 100n) {
      alert('抵押品不足,请及时补仓!');
    }
  }, [collateralHistory]);

  return (
    <div>
      <h3>最近事件数: {collateralHistory.length}</h3>
      {/* 渲染事件列表 */}
    </div>
  );
}

注意这个细节handleEvent 必须用 useCallback 包裹,否则每次渲染都会创建新函数,导致 useWatchContractEvent 重新订阅,浪费资源。

3. 处理多链场景:切换链时重新监听

我们的协议支持以太坊和 Polygon 两条链,用户切换链时,监听必须跟着切换。wagmi v2 的 useWatchContractEvent 会自动感知链的变化,但有个前提:你的 config 里必须配置了所有支持的链,并且 address 参数在不同链上要对应不同的合约地址。

typescript 复制代码
import { useChainId } from 'wagmi';

function CollateralMonitor({ userAddress }: { userAddress: string }) {
  const chainId = useChainId();

  // 根据链 ID 获取合约地址
  const contractAddress = chainId === 1
    ? '0x以太坊主网合约地址'
    : '0xPolygon合约地址';

  useWatchContractEvent({
    address: contractAddress as `0x${string}`,
    abi: [...你的ABI],
    eventName: 'CollateralUpdated',
    args: { user: userAddress },
    onLogs(logs) {
      // ...
    },
  });

  // ...
}

这里有个大坑 :当 chainId 变化时,useWatchContractEvent 会先取消旧链的监听,再开始新链的监听。但如果在取消和开始之间有短暂的时间窗口,用户的操作可能会被遗漏。解决方法是在切换链时,先手动拉取一次历史事件,再开始监听:

typescript 复制代码
import { usePublicClient } from 'wagmi';

function CollateralMonitor({ userAddress }: { userAddress: string }) {
  const chainId = useChainId();
  const publicClient = usePublicClient();
  const [isSyncing, setIsSyncing] = useState(true);

  // 链切换时,先拉取历史事件
  useEffect(() => {
    async function syncHistory() {
      setIsSyncing(true);
      const events = await publicClient.getContractEvents({
        address: contractAddress,
        abi: [...你的ABI],
        eventName: 'CollateralUpdated',
        args: { user: userAddress },
        fromBlock: 'earliest',
        toBlock: 'latest',
      });
      setCollateralHistory(events.map(...));
      setIsSyncing(false);
    }
    syncHistory();
  }, [chainId]);

  // 然后开始监听新事件
  useWatchContractEvent({
    address: contractAddress,
    abi: [...你的ABI],
    eventName: 'CollateralUpdated',
    args: { user: userAddress },
    onLogs(logs) {
      setCollateralHistory(prev => [...prev, ...logs]);
    },
    // 在历史同步完成前不开始监听
    enabled: !isSyncing,
  });
}

完整代码:一个可运行的事件监听组件

下面是一个完整的 React 组件,包含历史事件同步、实时监听、UI 更新和错误处理:

typescript 复制代码
// CollateralMonitor.tsx
import { useState, useEffect, useCallback } from 'react';
import { useWatchContractEvent, usePublicClient, useChainId } from 'wagmi';
import { parseAbiItem } from 'viem';

const COLLATERAL_ABI = [
  'event CollateralUpdated(address indexed user, uint256 amount, uint256 timestamp)',
];

interface CollateralEvent {
  user: string;
  amount: bigint;
  timestamp: bigint;
  txHash: string;
}

export function CollateralMonitor({ userAddress }: { userAddress: string }) {
  const chainId = useChainId();
  const publicClient = usePublicClient();
  const [events, setEvents] = useState<CollateralEvent[]>([]);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  // 根据链 ID 获取合约地址
  const contractAddress = chainId === 1
    ? '0x...' // 主网地址
    : '0x...'; // 测试网地址

  // 链切换时同步历史事件
  useEffect(() => {
    async function syncHistory() {
      setIsLoading(true);
      setError(null);
      try {
        const logs = await publicClient.getLogs({
          address: contractAddress as `0x${string}`,
          event: parseAbiItem(COLLATERAL_ABI[0]),
          args: { user: userAddress as `0x${string}` },
          fromBlock: 0n,
          toBlock: 'latest',
        });
        const parsedEvents = logs.map(log => ({
          user: log.args.user,
          amount: log.args.amount,
          timestamp: log.args.timestamp,
          txHash: log.transactionHash,
        }));
        setEvents(parsedEvents);
      } catch (err) {
        setError('同步历史事件失败: ' + (err as Error).message);
      } finally {
        setIsLoading(false);
      }
    }
    syncHistory();
  }, [chainId, userAddress]);

  // 实时监听新事件
  const handleNewEvent = useCallback((logs: any[]) => {
    const newEvents = logs.map(log => ({
      user: log.args.user,
      amount: log.args.amount,
      timestamp: log.args.timestamp,
      txHash: log.transactionHash,
    }));
    setEvents(prev => [...prev, ...newEvents]);
  }, []);

  useWatchContractEvent({
    address: contractAddress as `0x${string}`,
    abi: COLLATERAL_ABI,
    eventName: 'CollateralUpdated',
    args: { user: userAddress as `0x${string}` },
    onLogs: handleNewEvent,
    enabled: !isLoading, // 历史同步完成后再开始监听
  });

  if (isLoading) return <div>正在同步历史数据...</div>;
  if (error) return <div style={{ color: 'red' }}>{error}</div>;

  return (
    <div>
      <h3>抵押品变动记录(共 {events.length} 条)</h3>
      <ul>
        {events.slice(-10).reverse().map((event, idx) => (
          <li key={event.txHash + idx}>
            <span>金额: {event.amount.toString()}</span>
            <span>时间: {new Date(Number(event.timestamp) * 1000).toLocaleString()}</span>
            <a href={`https://etherscan.io/tx/${event.txHash}`} target="_blank">查看交易</a>
          </li>
        ))}
      </ul>
    </div>
  );
}

踩坑记录

  1. 事件丢失 :最开始用 ethers.js 的 contract.on,在 Goerli 测试网上事件经常丢失。后来发现是 WebSocket 连接不稳定,而且 ethers.js 不会自动重连。换成 wagmi v2 + viem 后,底层用的是 watchContractEvent,支持自动重连和失败重试,问题解决。

  2. React 状态更新错误 :在 onLogs 回调里直接调用 setState,控制台报 "Cannot update a component while rendering a different component"。原因是 wagmi 的事件回调在 React 的 effect 之外执行。解决方案:用 useCallback 包装回调,并在回调里使用函数式更新。

  3. 链切换时的事件遗漏 :用户从以太坊切换到 Polygon 时,中间有几十秒的事件没有被监听到。原因是 useWatchContractEvent 在链切换时会取消旧监听并创建新监听,但中间有窗口期。解决方法:在切换链时先手动调用 getLogs 拉取历史事件,等同步完成后再开始监听。

  4. BigInt 序列化问题 :事件返回的 amountbigint 类型,直接渲染会报错。需要调用 .toString()Number() 转换。注意 Number() 可能丢失精度,建议用 toString() 或使用 viemformatUnits 工具函数。

小结

事件监听的核心坑不在于 API 怎么用,而在于如何保证不丢事件 。我的最终方案是:wagmi v2 + viem 做实时监听 + 链切换时手动同步历史 + 用 useCallback 和函数式更新处理 React 状态。如果项目需要更高级的功能(比如多链聚合、历史事件分页),可以继续研究 viemgetLogscreateContractEventFilter

相关推荐
小花酱酱2 小时前
QQ群里只有你一个人?邪门歪道破局之路——AstrBot
javascript
bonechips2 小时前
JS 数组指南:从内存原理到二维矩阵
前端·javascript
mONESY2 小时前
前端零基础精讲:Canvas3D、CSS3D、文档流、定位全方位复盘
javascript
竹林81818 小时前
Web3表单签名验证:我用 wagmi 和 ethers 给 DApp 加了一个“免密登录”,踩坑记录全在这了
javascript
用户69903048487518 小时前
try catch使用场景 处理同步代码错误兼容用的
javascript·uni-app
雪碧聊技术18 小时前
Tree.js是什么?一文讲透
开发语言·javascript·ecmascript
VidDown18 小时前
VidDown 工具站:免费、本地优先的开发者工具箱
javascript·编辑器·音视频·视频编解码·视频
触底反弹19 小时前
🚀 手把手用 HTML5 Canvas 从零打造飞机大战游戏,代码全开源!
前端·javascript·canvas