从轮询到订阅:我在NFT铸造项目中优化合约事件监听的完整踩坑记录

0 阅读1分钟

背景

上个月我接了一个NFT铸造平台的前端开发,需要实时显示用户的铸造状态。合约那边每成功铸造一个NFT就会发出一个Transfer事件(从零地址到用户地址),我的任务就是在前端页面上实时更新“已铸造数量”和用户的“铸造成功”提示。

最开始我觉得这很简单——不就是监听事件嘛!但真正做起来才发现,从“能监听”到“监听得好”中间隔着好几个大坑。我最初用setInterval轮询查询,结果页面卡得不行;后来换事件监听,又遇到了内存泄漏、状态不同步、多链兼容等问题。这篇文章就是我完整解决这个问题的实战记录。

问题分析

我最开始的思路特别直接:既然要实时显示,那我就每隔几秒查一下总供应量不就行了?于是写了个简单的轮询:

useEffect(() => {
  const interval = setInterval(async () => {
    const totalSupply = await contract.totalSupply();
    setSupply(totalSupply);
  }, 3000);
  
  return () => clearInterval(interval);
}, []);

第一个问题马上出现了:页面越来越卡。打开Chrome的性能面板一看,内存使用量直线上升。原因是每次轮询都会创建新的Promise,旧的又没有及时清理。而且用户量稍微一多,对RPC节点的请求压力巨大,有时候还会触发rate limit。

这时候我才意识到:智能合约事件本来就是为这种场景设计的,我应该用事件监听(subscribe)而不是轮询(polling)。但具体怎么实现?用ethers.js的contract.on?还是用wagmi的useWatchContractEvent?不同方案有什么坑?我开始系统地研究。

核心实现

方案一:直接用ethers.js的contract.on()

首先我尝试了最直接的ethers.js方案,因为项目里本来就在用ethers.js连接合约:

import { ethers } from 'ethers';
import { useEffect, useState } from 'react';

const useContractEvents = (contractAddress: string, abi: any) => {
  const [transfers, setTransfers] = useState<any[]>([]);
  
  useEffect(() => {
    const provider = new ethers.providers.WebSocketProvider(
      process.env.NEXT_PUBLIC_WSS_RPC_URL!
    );
    
    const contract = new ethers.Contract(contractAddress, abi, provider);
    
    // 监听Transfer事件
    const filter = contract.filters.Transfer();
    
    contract.on(filter, (from, to, tokenId, event) => {
      console.log(`NFT #${tokenId}${from} 转移到 ${to}`);
      
      // 更新状态
      setTransfers(prev => [...prev, {
        from,
        to,
        tokenId: tokenId.toString(),
        txHash: event.transactionHash
      }]);
    });
    
    // 清理函数
    return () => {
      contract.removeAllListeners();
      provider.destroy();
    };
  }, [contractAddress, abi]);
  
  return transfers;
};

这里有个关键细节:我用了WebSocketProvider而不是JsonRpcProvider。因为HTTP协议不支持服务端推送,必须用WebSocket才能实现真正的订阅。如果用了HTTP,ethers.js会在底层用轮询模拟事件监听,效果和我的初始方案差不多。

第一个坑出现了:测试时发现,页面切走再切回来,事件监听失效了。原因是React组件卸载时,虽然执行了removeAllListeners(),但重新挂载时创建了新的合约实例,新旧实例之间没有状态同步。用户切回页面时看不到刚才错过的事件。

方案二:结合wagmi的状态管理

项目本来也用了wagmi,所以我尝试用wagmi的hook来管理事件状态。wagmi v1有useContractEvent,但文档说v2的useWatchContractEvent更推荐:

import { useWatchContractEvent, useBlockNumber } from 'wagmi';
import { useEffect, useState } from 'react';
import nftAbi from './abis/nft.json';

const useNFTMintEvents = () => {
  const [mintEvents, setMintEvents] = useState<any[]>([]);
  const { data: blockNumber } = useBlockNumber({ watch: true });
  
  useWatchContractEvent({
    address: '0x742d35Cc6634C0532925a3b844Bc9e...',
    abi: nftAbi,
    eventName: 'Transfer',
    args: {
      from: '0x0000000000000000000000000000000000000000' // 只监听从零地址的转移(即铸造)
    },
    onLogs: (logs) => {
      logs.forEach(log => {
        const [from, to, tokenId] = log.args;
        if (from === '0x0000000000000000000000000000000000000000') {
          setMintEvents(prev => [...prev, {
            to,
            tokenId: tokenId.toString(),
            blockNumber: log.blockNumber,
            txHash: log.transactionHash
          }]);
        }
      });
    },
  });
  
  // 根据区块号清理旧事件,防止内存无限增长
  useEffect(() => {
    if (blockNumber && blockNumber > 10000) {
      const cutoffBlock = blockNumber - 10000;
      setMintEvents(prev => 
        prev.filter(event => event.blockNumber > cutoffBlock)
      );
    }
  }, [blockNumber]);
  
  return mintEvents;
};

这个方案解决了两个问题

  1. wagmi帮我们管理了WebSocket连接和重连逻辑,不用自己处理
  2. 通过监听区块号,可以定期清理太旧的事件,防止内存泄漏

但又发现了新问题:在测试网(比如Sepolia)上,useWatchContractEvent有时候收不到事件。排查后发现,某些公共RPC节点的WebSocket服务不太稳定。需要准备备用RPC节点。

方案三:多RPC回退机制

为了解决RPC节点不稳定的问题,我实现了一个带重试和回退的监听器:

import { ethers } from 'ethers';
import { useEffect, useRef, useState } from 'react';

const useRobustEventListeners = (contractAddress: string, abi: any) => {
  const [events, setEvents] = useState<any[]>([]);
  const [currentProviderIndex, setCurrentProviderIndex] = useState(0);
  const contractRef = useRef<ethers.Contract | null>(null);
  
  const wssUrls = [
    process.env.NEXT_PUBLIC_WSS_RPC_1,
    process.env.NEXT_PUBLIC_WSS_RPC_2,
    process.env.NEXT_PUBLIC_WSS_RPC_3
  ].filter(Boolean) as string[];
  
  const setupListener = (providerUrl: string, index: number) => {
    try {
      // 清理旧的监听器
      if (contractRef.current) {
        contractRef.current.removeAllListeners();
      }
      
      const provider = new ethers.providers.WebSocketProvider(providerUrl);
      
      // 设置超时和错误处理
      provider._websocket.onerror = (error) => {
        console.error(`Provider ${index} WebSocket错误:`, error);
        // 切换到下一个Provider
        const nextIndex = (index + 1) % wssUrls.length;
        if (nextIndex !== currentProviderIndex) {
          setCurrentProviderIndex(nextIndex);
        }
      };
      
      provider._websocket.onclose = () => {
        console.log(`Provider ${index} WebSocket关闭,尝试重连...`);
        setTimeout(() => {
          const nextIndex = (index + 1) % wssUrls.length;
          setCurrentProviderIndex(nextIndex);
        }, 2000);
      };
      
      const contract = new ethers.Contract(contractAddress, abi, provider);
      contractRef.current = contract;
      
      contract.on('Transfer', (from, to, tokenId, event) => {
        // 只处理铸造事件
        if (from === ethers.constants.AddressZero) {
          setEvents(prev => [...prev, {
            to,
            tokenId: tokenId.toString(),
            txHash: event.transactionHash,
            timestamp: Date.now()
          }]);
        }
      });
      
      console.log(`使用Provider ${index} 监听事件`);
      
    } catch (error) {
      console.error(`设置Provider ${index}失败:`, error);
      // 尝试下一个
      const nextIndex = (index + 1) % wssUrls.length;
      setTimeout(() => {
        setCurrentProviderIndex(nextIndex);
      }, 1000);
    }
  };
  
  useEffect(() => {
    if (wssUrls.length === 0) {
      console.error('没有可用的WebSocket RPC URL');
      return;
    }
    
    setupListener(wssUrls[currentProviderIndex], currentProviderIndex);
    
    // 清理函数
    return () => {
      if (contractRef.current) {
        contractRef.current.removeAllListeners();
        contractRef.current = null;
      }
    };
  }, [currentProviderIndex, contractAddress, abi]);
  
  return events;
};

这个方案的关键改进

  1. 多个WebSocket RPC备用,一个挂了自动切下一个
  2. 监听WebSocket的错误和关闭事件,自动重连
  3. useRef保存合约实例,避免重复创建

完整代码

下面是我最终在生产环境中使用的完整方案,结合了wagmi的状态管理和ethers.js的稳健事件监听:

import { useEffect, useRef, useState, useCallback } from 'react';
import { ethers } from 'ethers';
import { useNetwork, useBlockNumber } from 'wagmi';
import { debounce } from 'lodash';

// NFT合约ABI片段
const NFT_ABI = [
  "event Transfer(address indexed from, address indexed to, uint256 indexed tokenId)"
];

interface MintEvent {
  to: string;
  tokenId: string;
  txHash: string;
  blockNumber: number;
  timestamp: number;
}

interface UseNFTMintTrackerProps {
  contractAddress: string;
  onNewMint?: (event: MintEvent) => void;
}

export const useNFTMintTracker = ({
  contractAddress,
  onNewMint
}: UseNFTMintTrackerProps) => {
  const [mintEvents, setMintEvents] = useState<MintEvent[]>([]);
  const [isConnected, setIsConnected] = useState(false);
  const [currentProviderUrl, setCurrentProviderUrl] = useState<string>('');
  const { chain } = useNetwork();
  const { data: blockNumber } = useBlockNumber({ watch: true });
  
  const contractRef = useRef<ethers.Contract | null>(null);
  const providerRef = useRef<ethers.providers.WebSocketProvider | null>(null);
  
  // 根据链ID获取可用的WebSocket RPC URLs
  const getWssUrls = useCallback(() => {
    const chainId = chain?.id;
    
    // 这里可以从配置文件中读取,或者使用服务动态获取
    const urls: Record<number, string[]> = {
      1: [ // Ethereum Mainnet
        process.env.NEXT_PUBLIC_ETH_WSS_1!,
        process.env.NEXT_PUBLIC_ETH_WSS_2!,
      ],
      11155111: [ // Sepolia
        process.env.NEXT_PUBLIC_SEPOLIA_WSS_1!,
        process.env.NEXT_PUBLIC_SEPOLIA_WSS_2!,
      ],
      137: [ // Polygon
        process.env.NEXT_PUBLIC_POLYGON_WSS_1!,
      ]
    };
    
    return urls[chainId || 1]?.filter(url => url && url.startsWith('wss://')) || [];
  }, [chain?.id]);
  
  // 初始化事件监听
  const setupEventListeners = useCallback((providerUrl: string) => {
    // 清理现有的监听器
    if (contractRef.current) {
      contractRef.current.removeAllListeners();
    }
    if (providerRef.current) {
      providerRef.current.destroy();
    }
    
    try {
      const provider = new ethers.providers.WebSocketProvider(providerUrl);
      providerRef.current = provider;
      
      const contract = new ethers.Contract(contractAddress, NFT_ABI, provider);
      contractRef.current = contract;
      
      // 监听Transfer事件
      contract.on('Transfer', async (from: string, to: string, tokenId: ethers.BigNumber, event: ethers.Event) => {
        // 只处理从零地址的转移(铸造)
        if (from === ethers.constants.AddressZero) {
          const block = await event.getBlock();
          
          const mintEvent: MintEvent = {
            to,
            tokenId: tokenId.toString(),
            txHash: event.transactionHash,
            blockNumber: event.blockNumber,
            timestamp: block.timestamp * 1000 // 转为毫秒
          };
          
          // 更新状态
          setMintEvents(prev => {
            // 去重,避免同一事件被多次处理
            if (prev.some(e => e.txHash === mintEvent.txHash && e.tokenId === mintEvent.tokenId)) {
              return prev;
            }
            return [mintEvent, ...prev.slice(0, 49)]; // 只保留最新的50个
          });
          
          // 回调函数
          onNewMint?.(mintEvent);
        }
      });
      
      // 监听WebSocket连接状态
      provider._websocket.onopen = () => {
        console.log('WebSocket连接成功');
        setIsConnected(true);
        setCurrentProviderUrl(providerUrl);
      };
      
      provider._websocket.onerror = (error) => {
        console.error('WebSocket错误:', error);
        setIsConnected(false);
      };
      
      provider._websocket.onclose = () => {
        console.log('WebSocket连接关闭');
        setIsConnected(false);
        // 延迟重连
        setTimeout(() => {
          const urls = getWssUrls();
          if (urls.length > 0) {
            const currentIndex = urls.indexOf(providerUrl);
            const nextUrl = urls[(currentIndex + 1) % urls.length];
            setupEventListeners(nextUrl);
          }
        }, 2000);
      };
      
    } catch (error) {
      console.error('初始化事件监听失败:', error);
      setIsConnected(false);
    }
  }, [contractAddress, getWssUrls, onNewMint]);
  
  // 初始化或链切换时重新设置监听器
  useEffect(() => {
    const urls = getWssUrls();
    if (urls.length > 0 && contractAddress) {
      setupEventListeners(urls[0]);
    } else {
      console.warn('没有可用的WebSocket RPC或合约地址');
    }
    
    // 清理函数
    return () => {
      if (contractRef.current) {
        contractRef.current.removeAllListeners();
      }
      if (providerRef.current) {
        providerRef.current.destroy();
      }
    };
  }, [contractAddress, chain?.id, setupEventListeners, getWssUrls]);
  
  // 定期清理旧事件(防抖处理)
  useEffect(() => {
    const cleanupOldEvents = debounce(() => {
      if (mintEvents.length > 100) {
        setMintEvents(prev => prev.slice(0, 100));
      }
    }, 5000);
    
    cleanupOldEvents();
    
    return () => {
      cleanupOldEvents.cancel();
    };
  }, [mintEvents.length]);
  
  // 获取过去一段时间内的铸造数量
  const getMintCount = useCallback((timeRange: number = 5 * 60 * 1000) => {
    const now = Date.now();
    return mintEvents.filter(event => 
      now - event.timestamp < timeRange
    ).length;
  }, [mintEvents]);
  
  return {
    mintEvents,
    isConnected,
    currentProviderUrl,
    totalMints: mintEvents.length,
    getMintCount,
    // 手动重连
    reconnect: () => {
      const urls = getWssUrls();
      if (urls.length > 0 && currentProviderUrl) {
        setupEventListeners(currentProviderUrl);
      }
    }
  };
};

// 使用示例
/*
const MintTrackerComponent = () => {
  const { mintEvents, isConnected, totalMints } = useNFTMintTracker({
    contractAddress: '0x...',
    onNewMint: (event) => {
      toast.success(`新的NFT铸造成功!ID: ${event.tokenId}`);
    }
  });
  
  return (
    <div>
      <div>连接状态: {isConnected ? '已连接' : '连接中...'}</div>
      <div>总铸造数: {totalMints}</div>
      <div>
        {mintEvents.slice(0, 10).map(event => (
          <div key={`${event.txHash}-${event.tokenId}`}>
            NFT #{event.tokenId} 铸造给 {event.to.slice(0, 6)}...{event.to.slice(-4)}
          </div>
        ))}
      </div>
    </div>
  );
};
*/

踩坑记录

1. 内存泄漏:事件监听器没有正确清理

现象:页面切换几次后,内存占用飙升,控制台看到重复的事件被处理。 原因:每次组件重新渲染都创建了新的事件监听器,旧的没有移除。 解决:在useEffect的清理函数中调用contract.removeAllListeners(),并用useRef保存合约实例。

2. WebSocket连接意外断开

现象:监听一段时间后收不到新事件,但控制台没有报错。 原因:公共RPC节点的WebSocket连接有时会超时断开。 解决:监听WebSocket的onclose事件,实现自动重连机制,并准备多个备用RPC节点。

3. 同一事件被多次处理

现象:同一个铸造事件在页面上显示了多次。 原因:某些RPC节点可能会重复推送相同的事件,或者组件重新渲染导致重复监听。 解决:在更新状态前检查事件是否已存在(通过txHash + tokenId去重)。

4. 切换链时事件监听混乱

现象:从Ethereum切换到Polygon后,还在监听旧链的事件。 原因:链切换后没有及时清理旧链的监听器。 解决:在useEffect的依赖数组中加入chain?.id,链变化时重新初始化监听器。

小结

经过这一轮折腾,我最大的收获是:Web3前端的事件监听不能只考虑“能不能收到”,更要考虑“收得稳不稳”。生产环境中的网络波动、RPC节点不稳定、用户频繁切换链等场景,都需要在代码层面做好防御。现在我这个事件监听方案已经稳定运行了3周,每天处理几千个铸造事件,再没出过问题。下一步我打算研究一下The Graph,对于更复杂的事件查询需求,可能用索引服务会更合适。