从轮询到实时:我在NFT铸造项目中用wagmi监听合约事件的完整踩坑记录

14 阅读1分钟

背景

上个月我接了一个NFT铸造平台的前端开发,项目要求是用户连接钱包后,页面需要实时显示两个关键数据:当前钱包地址的铸造数量(userMintedCount)和该NFT项目的总铸造量(totalSupply)。合约已经由团队的其他成员部署好了,事件也定义得很清楚:Minted(address indexed minter, uint256 tokenId)

一开始我觉得这很简单——不就是监听事件嘛。但真正做起来才发现,从“能工作”到“稳定、实时、高性能”之间,隔着好几个大坑。我最开始用了最笨的轮询方式,每5秒调用一次合约的totalSupply()和查询用户的余额,结果就是页面卡顿、数据更新不及时,用户体验很差。我意识到必须用真正的事件监听,但该选哪种方案?怎么处理多链?断开监听怎么办?这一路踩的坑,今天我就完整记录下来。

问题分析

我的第一反应是直接用ethers.jscontract.on方法。写了个简单的测试:

const contract = new ethers.Contract(address, abi, provider);
contract.on('Minted', (minter, tokenId) => {
  console.log(`用户 ${minter} 铸造了 token #${tokenId}`);
  // 更新状态...
});

在本地测试网上跑得挺好。但一上主网测试就出问题了:当用户切换钱包账户时,之前账户的监听器没有正确清理,导致事件重复触发。更麻烦的是,我们的DApp需要支持多链(Ethereum、Polygon、Arbitrum),不同链的Provider和合约实例管理起来很混乱。

然后我尝试了wagmiuseContractEvent(这是wagmi v1的写法),发现它确实帮我处理了React生命周期内的监听器清理,但它在用户切换链时表现不稳定,有时会漏掉事件。而且项目用的是wagmi v2,API已经有了变化。

经过一番排查,我确定了几个必须解决的核心问题:

  1. 监听器管理:如何确保组件卸载、用户切换账户或链时,旧的监听器被正确清理
  2. 多链支持:用户可能在Ethereum上铸造,也可能在Polygon上,监听需要适配当前活跃链
  3. 性能优化:避免不必要的重复监听和状态更新
  4. 错误处理:RPC节点连接失败、网络切换时的降级方案

核心实现

第一步:选择正确的wagmi v2 Hook

在wagmi v2中,监听合约事件的主要Hook是useWatchContractEvent。与v1的useContractEvent不同,它更专注于“监听”这一单一职责,并且返回的是undefined(监听行为是副作用),这让它在组合使用时更清晰。

我首先实现了最基本的监听:

import { useWatchContractEvent } from 'wagmi';

function MintTracker() {
  const { chain } = useAccount();
  
  useWatchContractEvent({
    address: NFT_CONTRACT_ADDRESS[chain?.id || 1], // 根据当前链选择合约地址
    abi: NFT_ABI,
    eventName: 'Minted',
    onLogs(logs) {
      logs.forEach((log) => {
        const [minter, tokenId] = log.args;
        console.log('监听到铸造事件:', minter, tokenId);
        // 这里更新状态
      });
    },
  });
  
  return <div>监听中...</div>;
}

这里有个坑useWatchContractEvent默认只在组件挂载时开始监听。但如果合约地址是动态的(比如根据链ID变化),当链切换后,监听的目标合约地址不会自动更新!这意味着用户切换到Polygon后,还在监听Ethereum上的旧合约。

第二步:处理动态合约地址和链切换

为了解决链切换问题,我需要确保监听器在链变化时重新建立。wagmi的useWatchContractEvent本身不会自动处理这个,但我们可以利用它的enabled参数和依赖数组:

import { useAccount, useWatchContractEvent } from 'wagmi';

function MintTracker() {
  const { chain, address: userAddress } = useAccount();
  const [totalSupply, setTotalSupply] = useState(0);
  const [userMinted, setUserMinted] = useState(0);
  
  // 获取当前链对应的合约地址
  const contractAddress = chain?.id ? NFT_CONTRACT_ADDRESS[chain.id] : undefined;
  
  // 监听Minted事件 - 只有合约地址存在时才启用监听
  useWatchContractEvent({
    address: contractAddress,
    abi: NFT_ABI,
    eventName: 'Minted',
    enabled: !!contractAddress, // 关键:地址不存在时不建立监听
    onLogs(logs) {
      // 处理事件日志
      handleMintLogs(logs);
    },
  });
  
  const handleMintLogs = useCallback((logs: any[]) => {
    logs.forEach((log) => {
      const [minter, tokenId] = log.args;
      
      // 更新总供应量(每次铸造+1)
      setTotalSupply(prev => prev + 1);
      
      // 如果铸造者是当前用户,更新用户铸造数量
      if (minter.toLowerCase() === userAddress?.toLowerCase()) {
        setUserMinted(prev => prev + 1);
      }
    });
  }, [userAddress]);
  
  return (
    <div>
      <p>总铸造量: {totalSupply}</p>
      <p>你的铸造数量: {userMinted}</p>
    </div>
  );
}

注意这个细节:我用了enabled: !!contractAddress来确保只有合约地址有效时才建立监听。这样当用户断开钱包连接或切换到不支持的链时,监听会自动停止。

第三步:处理历史事件和初始状态

事件监听只能捕获未来的事件,但页面加载时我们需要显示当前的状态。所以还需要在组件加载时获取初始值,并在每次事件触发时更新。

我创建了一个自定义Hook来封装这个逻辑:

import { useContractRead, useWatchContractEvent } from 'wagmi';
import { useEffect } from 'react';

export function useNFTMintTracker() {
  const { chain, address: userAddress } = useAccount();
  const contractAddress = chain?.id ? NFT_CONTRACT_ADDRESS[chain.id] : undefined;
  
  // 1. 获取初始的总供应量
  const { 
    data: totalSupplyData, 
    refetch: refetchTotalSupply 
  } = useContractRead({
    address: contractAddress,
    abi: NFT_ABI,
    functionName: 'totalSupply',
    enabled: !!contractAddress,
  });
  
  // 2. 获取用户的初始铸造数量(需要合约有相应的view函数)
  const { 
    data: userBalanceData,
    refetch: refetchUserBalance 
  } = useContractRead({
    address: contractAddress,
    abi: NFT_ABI,
    functionName: 'balanceOf',
    args: userAddress ? [userAddress] : undefined,
    enabled: !!contractAddress && !!userAddress,
  });
  
  // 3. 监听Minted事件
  useWatchContractEvent({
    address: contractAddress,
    abi: NFT_ABI,
    eventName: 'Minted',
    enabled: !!contractAddress,
    onLogs(logs) {
      // 每次有铸造事件时,重新获取最新数据
      refetchTotalSupply();
      if (userAddress) {
        refetchUserBalance();
      }
    },
  });
  
  // 4. 当链或用户地址变化时,重新获取数据
  useEffect(() => {
    if (contractAddress) {
      refetchTotalSupply();
      if (userAddress) {
        refetchUserBalance();
      }
    }
  }, [chain?.id, userAddress, contractAddress]);
  
  return {
    totalSupply: totalSupplyData ? Number(totalSupplyData) : 0,
    userMinted: userBalanceData ? Number(userBalanceData) : 0,
    isLoading: !totalSupplyData && !userBalanceData
  };
}

这里有个重要的设计决策:我选择在监听到事件时重新调用refetch函数,而不是直接在前端计算+1。为什么?因为可能有多个用户同时铸造,直接+1可能导致数据不一致。重新从链上查询虽然多了一次调用,但保证了数据的准确性。

第四步:添加Provider级别的监听(高级需求)

上面的方案在大多数情况下够用了。但在我们的项目中,还有一个需求:无论用户当前在哪个页面,只要发生了铸造,页面右上角的一个全局徽章数字都需要更新。

这意味着我需要一个“全局”的事件监听,而不是组件级别的。我最终选择了在wagmi的Provider层面设置监听:

// 在_app.tsx或类似的根组件中
import { createConfig, WagmiProvider } from 'wagmi';
import { http } from 'wagmi';
import { mainnet, polygon } from 'wagmi/chains';

// 创建自定义的wagmi配置
const config = createConfig({
  chains: [mainnet, polygon],
  transports: {
    [mainnet.id]: http(),
    [polygon.id]: http(),
  },
  // 这里可以添加事件监听
});

// 然后在React组件外部设置全局监听器
let unsubscribe: (() => void) | undefined;

// 一个工具函数来管理全局监听
export function setupGlobalMintListener(config: any) {
  // 清理之前的监听器
  if (unsubscribe) {
    unsubscribe();
  }
  
  // 为每条链都设置监听
  config.chains.forEach((chain: any) => {
    const contractAddress = NFT_CONTRACT_ADDRESS[chain.id];
    if (contractAddress) {
      const publicClient = config.getPublicClient({ chainId: chain.id });
      
      // 监听事件
      unsubscribe = publicClient.watchContractEvent({
        address: contractAddress,
        abi: NFT_ABI,
        eventName: 'Minted',
        onLogs: (logs) => {
          // 这里可以更新全局状态,比如使用zustand或Redux
          console.log('全局监听到铸造事件:', logs);
        },
      });
    }
  });
}

注意:这种全局监听要谨慎使用,因为它不在React生命周期内,需要手动管理清理。我把它用在确实需要跨组件共享状态的场景。

完整代码

下面是一个完整的、可直接运行的组件示例:

import React, { useCallback } from 'react';
import { useAccount, useContractRead, useWatchContractEvent } from 'wagmi';

// NFT合约ABI片段
const NFT_ABI = [
  {
    "anonymous": false,
    "inputs": [
      { "indexed": true, "name": "minter", "type": "address" },
      { "indexed": false, "name": "tokenId", "type": "uint256" }
    ],
    "name": "Minted",
    "type": "event"
  },
  {
    "inputs": [],
    "name": "totalSupply",
    "outputs": [{ "name": "", "type": "uint256" }],
    "stateMutability": "view",
    "type": "function"
  },
  {
    "inputs": [{ "name": "owner", "type": "address" }],
    "name": "balanceOf",
    "outputs": [{ "name": "", "type": "uint256" }],
    "stateMutability": "view",
    "type": "function"
  }
] as const;

// 各链的合约地址
const NFT_CONTRACT_ADDRESS: Record<number, `0x${string}`> = {
  1: '0x...', // Ethereum主网
  137: '0x...', // Polygon
  42161: '0x...', // Arbitrum
};

interface MintTrackerProps {
  showUserStats?: boolean;
}

export default function MintTracker({ showUserStats = true }: MintTrackerProps) {
  const { chain, address: userAddress } = useAccount();
  
  // 获取当前链的合约地址
  const contractAddress = chain?.id ? NFT_CONTRACT_ADDRESS[chain.id] : undefined;
  
  // 读取总供应量
  const { 
    data: totalSupplyData, 
    refetch: refetchTotalSupply,
    isLoading: isLoadingTotalSupply 
  } = useContractRead({
    address: contractAddress,
    abi: NFT_ABI,
    functionName: 'totalSupply',
    enabled: !!contractAddress,
  });
  
  // 读取用户余额
  const { 
    data: userBalanceData,
    refetch: refetchUserBalance,
    isLoading: isLoadingUserBalance 
  } = useContractRead({
    address: contractAddress,
    abi: NFT_ABI,
    functionName: 'balanceOf',
    args: userAddress ? [userAddress] : undefined,
    enabled: !!contractAddress && !!userAddress && showUserStats,
  });
  
  // 处理铸造事件的回调
  const handleMintLogs = useCallback((logs: any[]) => {
    console.log('监听到铸造事件,数量:', logs.length);
    
    // 重新获取最新数据
    refetchTotalSupply();
    if (userAddress && showUserStats) {
      refetchUserBalance();
    }
    
    // 可以在这里添加通知或动画效果
    if (logs.length > 0) {
      const latestLog = logs[logs.length - 1];
      const [minter, tokenId] = latestLog.args;
      console.log(`最新铸造: ${minter} 铸造了 #${tokenId}`);
    }
  }, [refetchTotalSupply, refetchUserBalance, userAddress, showUserStats]);
  
  // 监听Minted事件
  useWatchContractEvent({
    address: contractAddress,
    abi: NFT_ABI,
    eventName: 'Minted',
    enabled: !!contractAddress,
    onLogs: handleMintLogs,
  });
  
  // 加载状态
  if (isLoadingTotalSupply) {
    return <div>加载数据中...</div>;
  }
  
  // 不支持的网络
  if (!contractAddress) {
    return <div>请切换到支持的网络(Ethereum、Polygon或Arbitrum)</div>;
  }
  
  return (
    <div className="mint-tracker">
      <div className="stats">
        <div className="stat">
          <h3>总铸造量</h3>
          <p className="value">{totalSupplyData?.toString() || '0'}</p>
        </div>
        
        {showUserStats && userAddress && (
          <div className="stat">
            <h3>你的铸造数量</h3>
            <p className="value">{userBalanceData?.toString() || '0'}</p>
          </div>
        )}
      </div>
      
      <div className="status">
        <span className="indicator active"></span>
        <span>实时监听已启用</span>
      </div>
    </div>
  );
}

踩坑记录

  1. 监听器泄露导致重复触发

    • 现象:用户切换账户后,同一个事件触发了两次更新
    • 原因useWatchContractEvent的依赖项变化时,旧的监听器没有立即清理,新旧监听器短暂共存
    • 解决:确保enabled参数正确设置,当不应监听时立即禁用。另外,使用useCallback包装回调函数,避免每次渲染创建新函数
  2. 链切换后监听不更新

    • 现象:从Ethereum切换到Polygon,事件监听还在旧的链上
    • 原因useWatchContractEvent的地址参数变化时,不会自动重新建立监听
    • 解决:通过enabled参数和contractAddress依赖控制,地址变化时先禁用再重新启用监听
  3. RPC节点限制导致监听中断

    • 现象:生产环境偶尔收不到事件,但本地测试正常
    • 原因:使用的公共RPC节点有速率限制或websocket连接数限制
    • 解决:配置自己的节点或使用更可靠的节点服务。添加重连逻辑和错误监控
  4. 大量事件导致性能问题

    • 现象:在公售期间,每秒几十个铸造事件,页面变得卡顿
    • 原因:每个事件都触发状态更新和UI重渲染
    • 解决:添加防抖逻辑,批量处理事件。或者使用更轻量的状态管理,避免不必要的组件重渲染

小结

经过这一轮折腾,我最大的收获是:Web3前端的事件监听不是简单的“监听就行”,需要考虑React生命周期、链切换、性能、错误处理等方方面面。wagmi v2的useWatchContractEvent是个好工具,但它不是魔法,需要正确理解它的行为。对于更复杂的场景,可能还需要结合viem的底层API或自定义Provider监听。下次我准备深入研究一下如何优化大量事件的处理性能,这又是一个值得记录的坑。