从轮询到订阅:我在 React 项目中实现实时监听 ERC-20 转账事件的完整踩坑记录

背景

上个月,我接了一个 DeFi 仪表板项目的开发需求。这个仪表板需要实时展示用户钱包地址下的各种 ERC-20 代币余额,并且当用户进行转账、交易等操作时,余额要能够立即更新,而不是让用户手动刷新页面。

项目用的是 React + TypeScript 技术栈,Web3 部分我们选择了目前比较主流的 wagmi(v2 版本)和 viem 组合。一开始,我图省事,直接在每个代币卡片组件里用 setInterval 定时轮询用户的余额,大概每 10 秒查询一次。上线测试时问题就来了:当用户添加的代币比较多(比如超过 10 个),页面上同时有十几个定时器在跑,浏览器性能明显下降,而且网络请求暴增。更糟糕的是,用户完成一笔转账后,可能要等好几秒甚至错过一个轮询周期,余额才会更新,体验很差。

产品经理拿着测试反馈来找我:"这个延迟用户接受不了,咱们能不能像 UniSwap 那样,转账成功页面上的数字'唰'一下就变了?" 我知道,是时候抛弃轮询,上真正的"事件监听"了。

问题分析

我的第一反应是:"监听合约事件,那不就是用 ethers.jscontract.on 吗?" 我之前在小项目里用过,看起来挺简单的。于是我在项目里试了一下:

typescript 复制代码
import { ethers } from 'ethers';
import { tokenABI } from './abi';

const provider = new ethers.JsonRpcProvider('https://mainnet.infura.io/v3/your-key');
const contract = new ethers.Contract(tokenAddress, tokenABI, provider);

// 监听 Transfer 事件
contract.on('Transfer', (from, to, value, event) => {
  console.log(`${from} 向 ${to} 转账 ${ethers.formatUnits(value, 18)} 个代币`);
});

代码跑起来了,控制台也能看到事件日志。但我很快发现了几个严重问题:

  1. 连接管理混乱:我的应用支持用户切换钱包(MetaMask、WalletConnect 等),也支持切换网络(Ethereum、Polygon、Arbitrum)。用上面这种方式,我需要手动管理不同 provider 和 contract 实例的创建与销毁,很容易出现内存泄漏或者监听错链的情况。
  2. 类型安全缺失ethers.Contract 对 TypeScript 的支持不够友好,事件参数的类型推断基本靠猜,容易写错。
  3. 与 React 生命周期脱节 :我需要把监听逻辑放在 useEffect 里,并在组件卸载时手动移除监听器(contract.off)。如果忘了移除,或者移除的时机不对,监听器就会一直存在,导致奇怪的问题。

就在我头疼的时候,我重新审视了我们项目已经在用的 wagmi 和 viem。我意识到,它们作为一套更现代的 Web3 开发工具,应该对事件监听有更好的封装和支持。我决定放弃直接操作 ethers.js 的底层 API,转而研究如何用 wagmi + viem 优雅地解决这个问题。

核心实现

第一步:理解 wagmi v2 的事件监听范式

wagmi v2 的核心是 "钩子(hooks)""配置(config)" 。对于读取链上数据,它有 useReadContract;对于写入,有 useWriteContract。但对于事件监听,它并没有一个叫 useWatchContractEvent 的钩子(注:wagmi v1 有,但 v2 的设计哲学变了)。

在 wagmi v2 中,事件监听被整合到了更底层的 "观察(watch)" 能力中。具体来说,是通过 useWatchContractEvents 这个钩子来实现的。它的设计思路是:你定义好你要监听哪个合约的哪个事件(或所有事件),以及过滤条件,当链上产生匹配的新事件时,你的回调函数就会被触发。

这里有个关键点:这个钩子本身并不返回数据,它只负责设置和清理监听器。你需要提供一个回调函数来处理接收到的事件。这其实更符合 React 的响应式思维------数据变化触发副作用,而不是主动去获取。

第二步:定义合约 ABI 与配置

要监听事件,首先得让 wagmi 知道合约的接口。我选择在项目中集中管理 ABI。我为常用的 ERC-20 标准创建了一个 TypeScript 文件:

typescript 复制代码
// src/abis/erc20.ts
export const erc20Abi = [
  // ... 其他函数和事件
  {
    type: 'event',
    name: 'Transfer',
    inputs: [
      { name: 'from', type: 'address', indexed: true },
      { name: 'to', type: 'address', indexed: true },
      { name: 'value', type: 'uint256', indexed: false },
    ],
  },
  {
    type: 'event',
    name: 'Approval',
    inputs: [
      { name: 'owner', type: 'address', indexed: true },
      { name: 'spender', type: 'address', indexed: true },
      { name: 'value', type: 'uint256', indexed: false },
    ],
  },
] as const; // 注意这里的 `as const`,对类型推断至关重要

注意这个细节as const 断言将数组字面量变为只读元组,这样 viem 才能精确推断出每个输入/输出的类型,后面用的时候会有完美的 TypeScript 提示。

然后,在 wagmi 的配置文件(通常是 src/wagmi.ts)中,确保你的客户端配置正确,特别是公共客户端(publicClient)的 RPC URL 要稳定,因为事件监听依赖于它建立的 WebSocket 连接(如果 RPC 提供商支持的话)或长轮询。

第三步:实现核心监听钩子

接下来,我在一个显示特定代币余额的组件中实现监听。假设这个组件已经通过 props 拿到了 tokenAddressuserAddress

typescript 复制代码
// src/components/TokenBalanceCard.tsx
import React, { useCallback } from 'react';
import { useWatchContractEvents, usePublicClient } from 'wagmi';
import { erc20Abi } from '@/abis/erc20';
import { formatUnits } from 'viem';

interface TokenBalanceCardProps {
  tokenAddress: `0x${string}`;
  userAddress: `0x${string}`;
}

export const TokenBalanceCard: React.FC<TokenBalanceCardProps> = ({
  tokenAddress,
  userAddress,
}) => {
  const publicClient = usePublicClient();

  // 定义事件处理回调
  const handleTransferEvent = useCallback(
    (logs: any[]) => { // 暂时用 any,后面会优化类型
      console.log('检测到 Transfer 事件:', logs);
      logs.forEach((log) => {
        const { args } = log;
        // 判断事件是否与当前用户相关
        if (
          args?.from?.toLowerCase() === userAddress.toLowerCase() ||
          args?.to?.toLowerCase() === userAddress.toLowerCase()
        ) {
          // 触发余额重新获取的逻辑,例如设置一个状态让 useReadContract 重新查询
          // 或者直接在这里根据事件参数计算新的余额(如果前端有缓存的话)
          console.log(`用户 ${userAddress} 余额可能已变更,需更新UI`);
          // 在实际项目中,这里通常会触发一个状态更新或重新获取
          setNeedsRefresh(true); // 假设有一个状态
        }
      });
    },
    [userAddress] // 依赖项
  );

  // 设置事件监听
  useWatchContractEvents({
    address: tokenAddress,
    abi: erc20Abi,
    eventName: 'Transfer', // 监听特定事件
    args: {
      // 可选:过滤条件,只监听与用户地址相关的事件,大幅减少不必要回调
      // 注意:由于 `from` 和 `to` 是 indexed 参数,可以这样过滤
      // 但这里逻辑是"或",wagmi/viem 的 args 过滤似乎是"与",所以可能需要分开监听或不过滤
      // 我这里先注释掉,后面在踩坑部分会讲这个问题
      // from: userAddress,
      // to: userAddress,
    },
    onLogs: handleTransferEvent,
    poll: true, // 如果 RPC 不支持 WebSocket,则启用轮询作为降级方案
    // pollInterval: 4_000, // 轮询间隔,默认是 4 秒
  });

  // ... 组件其他部分,例如用 useReadContract 显示余额
  return <div>{/* ... */}</div>;
};

这里有个坑 :我一开始以为 args 过滤可以像上面注释里那样写,实现"来自用户或发给用户"的过滤。但实际上,args 对象里的多个条件是"与(AND)"关系。也就是说 { from: userAddress, to: userAddress } 只会匹配 fromto 都是 userAddress 的事件(这通常是销毁代币的操作)。所以,如果你想监听所有与用户相关的事件,要么不设置 args 过滤(在回调函数里做判断),要么需要更复杂的配置。我选择了在回调函数里判断,更灵活。

第四步:优化类型与性能

上面的 handleTransferEvent 参数用了 any[],这失去了 TypeScript 的优势。我们可以利用 viem 提供的类型工具来获得完美类型提示。

typescript 复制代码
import { GetContractEventsParameters } from 'viem';
import { erc20Abi } from '@/abis/erc20';

// 从 ABI 和事件名推断出日志类型
type TransferLog = GetContractEventsParameters<
  typeof erc20Abi,
  'Transfer'
>['logs'];

const handleTransferEvent = useCallback(
  (logs: TransferLog) => { // 现在 logs 有类型了!
    logs.forEach((log) => {
      // 现在 log.args 的类型是 { from: `0x${string}`; to: `0x${string}`; value: bigint; }
      const { args } = log;
      if (!args) return;
      const { from, to, value } = args;
      // ... 后续逻辑,from, to, value 都有明确类型
      const formattedValue = formatUnits(value, 18); // 假设代币精度是 18
      console.log(`转账详情: ${from} -> ${to}, 金额: ${formattedValue}`);
    });
  },
  [userAddress]
);

性能方面,useWatchContractEvents 钩子会自动处理监听器的生命周期。当组件卸载,或者 addressabieventNameargs 等依赖项发生变化时,旧的监听器会被清理,新的监听器会建立。这比我手动管理 contract.oncontract.off 要可靠得多。

第五步:处理多链与钱包切换

这是 wagmi 方案最省心的地方。我的应用通过 WagmiProvider 包裹,并配置了多个链。useWatchContractEvents 内部会使用 usePublicClient() 获取当前活跃链的公共客户端。当用户通过 RainbowKit(我们集成的钱包连接器)切换网络时,wagmi 的状态会更新,当前链的客户端也会变。useWatchContractEvents 钩子检测到 chainId 变化后,会自动销毁旧链的监听器,并在新链上建立新的监听器。整个过程对开发者是透明的,我完全不用写 if (chainId === 1) { ... } else if ... 这样的代码。

完整代码示例

下面是一个简化但可运行的组件示例,展示了如何监听事件并触发余额更新:

typescript 复制代码
// src/components/TokenBalanceWithListener.tsx
import React, { useState, useCallback } from 'react';
import { useWatchContractEvents, useReadContract, useAccount } from 'wagmi';
import { erc20Abi } from '@/abis/erc20';
import { formatUnits } from 'viem';

interface Props {
  tokenAddress: `0x${string}`;
}

export const TokenBalanceWithListener: React.FC<Props> = ({ tokenAddress }) => {
  const { address: userAddress } = useAccount();
  const [nonce, setNonce] = useState(0); // 用于强制重新获取余额

  // 1. 读取用户当前余额
  const { data: balance, refetch } = useReadContract({
    address: tokenAddress,
    abi: erc20Abi,
    functionName: 'balanceOf',
    args: userAddress ? [userAddress] : undefined,
    query: {
      enabled: !!userAddress,
    },
  });

  // 2. 监听 Transfer 事件
  useWatchContractEvents({
    address: tokenAddress,
    abi: erc20Abi,
    eventName: 'Transfer',
    poll: true, // 确保即使没有 WebSocket 也能工作
    onLogs: useCallback(
      (logs) => {
        if (!userAddress) return;
        logs.forEach((log) => {
          const { args } = log;
          if (!args) return;
          const { from, to } = args;
          // 如果事件涉及当前用户
          if (
            from.toLowerCase() === userAddress.toLowerCase() ||
            to.toLowerCase() === userAddress.toLowerCase()
          ) {
            console.log('检测到与用户相关的事件,更新余额');
            // 方法一:触发重新获取
            refetch();
            // 方法二:通过改变 nonce 使 useReadContract 的 query key 变化,也会重新获取
            // setNonce((prev) => prev + 1);
          }
        });
      },
      [userAddress, refetch] // 依赖项
    ),
  });

  if (!userAddress) return <div>请连接钱包</div>;
  if (balance === undefined) return <div>加载中...</div>;

  return (
    <div className="token-balance-card">
      <h3>代币余额</h3>
      <p>地址: {tokenAddress.slice(0, 6)}...{tokenAddress.slice(-4)}</p>
      <p>
        余额: {formatUnits(balance, 18)} {/* 再次提醒,这里精度应根据实际代币调整 */}
      </p>
      <p>监听非ce: {nonce}</p>
      <button onClick={() => refetch()}>手动刷新</button>
    </div>
  );
};

踩坑记录

  1. args 过滤逻辑误解 :如前所述,我一开始以为 args: { from: userAddress, to: userAddress } 是"或"的关系,结果只监听到了 self-transfer解决方法 :仔细阅读 viem 文档,发现过滤是精确匹配且为"与"。对于"或"逻辑,要么不过滤在回调里判断,要么用更高级的 topics 参数手动构造过滤条件(比较麻烦)。

  2. RPC 提供商不支持 WebSocket :我在测试网上用的一个免费 RPC 端点只提供 HTTP,不支持 WebSocket。导致监听器根本收不到事件。解决方法 :在 useWatchContractEvents 的配置中显式设置 poll: true。这样 wagmi 会使用轮询(默认 4 秒一次)来检查新区块中的事件,作为降级方案。虽然实时性稍差,但保证了功能的可用性。

  3. 监听所有事件导致回调过多 :有一次我忘了写 eventName: 'Transfer',结果监听了合约的所有事件。当这个代币合约活跃时,每个区块都会产生大量 Approval 等其他事件,回调函数被疯狂调用,页面卡死。解决方法 :务必指定要监听的特定 eventName。如果确实需要监听多个事件,可以写多个 useWatchContractEvents 钩子。

  4. 类型错误:as const 缺失 :在定义 ABI 时,一开始没加 as const,导致在 useWatchContractEventsabi 属性的类型报错,提示"类型过于宽泛"。解决方法 :在 ABI 数组声明末尾加上 as const,将其锁定为字面量类型,这样 TypeScript 和 viem 才能进行精确的类型推断。

小结

这次从轮询升级到事件监听的实践让我深刻体会到,利用好 wagmi 和 viem 这样的现代工具,能让我们更专注于业务逻辑,而不是底层连接和生命周期管理的细枝末节。核心收获是:在 React 生态中,将链上事件视为一种数据源的变化,通过声明式的钩子去响应它,是最贴合框架思维、也最不容易出错的方式。 后续可以继续深挖如何优化大量事件监听时的性能,或者如何将事件数据流与状态管理库(如 Zustand、Redux)更优雅地集成。

相关推荐
Mapmost2 小时前
别乱调了!Mapmost 渲染第一步:选对HDRI,让你直接赢在起跑线
前端
技术爬爬虾2 小时前
OpenCode详细攻略,开源版Claude Code,免费模型与神级插件
前端·后端
Bernard02152 小时前
我试了下最近很火的 Hermes Agent:真正值得看的,不是会调工具,而是会把经验沉淀成 Skill
前端·后端