从监听失败到实时更新:我在NFT铸造项目中搞定合约事件监听的全过程

14 阅读1分钟

背景

上个月,我接了一个NFT铸造平台的前端开发。项目有个核心需求:用户点击“铸造”按钮后,前端需要实时显示铸造成功的交易,并立刻更新用户的NFT持有数量。这听起来是个典型的“监听智能合约事件”场景。

我一开始觉得这很简单——用 ethers.jscontract.on 不就搞定了吗?但实际开发中,我遇到了各种幺蛾子:页面切换时监听没取消导致内存泄漏、用户切换钱包网络后监听器还在老链上工作、甚至有时候事件根本触发不了。用户反馈说“铸造成功了但页面没反应”,这体验实在太差。我不得不停下来,系统性地解决这个监听问题。

问题分析

我最开始的实现确实很 naive。在React组件里,我直接用了 ethers.jscontract.on('Transfer', callback)

useEffect(() => {
  const contract = new ethers.Contract(address, abi, provider);
  
  const handleTransfer = (from, to, tokenId, event) => {
    console.log('NFT转移了!', tokenId.toString());
    // 更新UI状态...
  };
  
  contract.on('Transfer', handleTransfer);
  
  return () => {
    contract.off('Transfer', handleTransfer);
  };
}, []);

这个方案有三个明显问题:

  1. 网络切换问题:当用户从以太坊主网切换到Polygon时,contract 实例还是基于旧网络的provider,监听自然失效
  2. 组件生命周期问题:虽然我写了清理函数,但有时候组件卸载和重新挂载的速度太快,off 可能没执行到位
  3. 状态同步问题:监听回调里更新React状态时,如果组件已经卸载,会报“内存泄漏”警告

更麻烦的是,我们的DApp支持多链(以太坊、Polygon、Arbitrum),用户随时可能切换网络。我需要一个能自动处理网络切换、能优雅清理、并且与React状态管理无缝集成的方案。

核心实现

放弃 ethers.js,拥抱 wagmi + viem

经过一番调研和试错,我决定用 wagmi + viem 这套现代Web3开发组合。wagmi 提供了完善的React Hooks,而 viem 是类型安全、模块化的以太坊库。最重要的是,wagmiuseWatchContractEvent Hook 看起来就是为这个场景设计的。

但这里有个坑:wagmi 的文档虽然不错,但关于事件监听的部分例子不多,特别是处理实时UI更新和错误处理的实战案例很少。我得自己摸索。

实现基础监听

首先,我配置了 wagmi 的客户端,支持多链:

// wagmi.config.ts
import { createConfig, http } from 'wagmi';
import { mainnet, polygon, arbitrum } from 'wagmi/chains';
import { injected } from 'wagmi/connectors';

export const config = createConfig({
  chains: [mainnet, polygon, arbitrum],
  connectors: [injected()],
  transports: {
    [mainnet.id]: http(),
    [polygon.id]: http(),
    [arbitrum.id]: http(),
  },
});

然后在React组件中,我这样使用 useWatchContractEvent

import { useWatchContractEvent } from 'wagmi';

function NFTMintComponent() {
  const { address } = useAccount();
  
  useWatchContractEvent({
    address: '0x742d35Cc6634C0532925a3b844Bc9e...', // NFT合约地址
    abi: nftContractAbi,
    eventName: 'Transfer',
    args: { to: address }, // 只监听转给当前用户的事件
    onLogs: (logs) => {
      console.log('监听到新的Transfer事件:', logs);
      // 这里更新UI状态
    },
  });
  
  return (
    // UI组件
  );
}

这个基础版本已经比最初的 ethers.js 方案好多了:wagmi 会自动处理网络切换,当用户切换链时,监听会自动重新建立到新链上。

处理事件去重和状态更新

但很快我发现新问题:同一个交易的事件有时会被触发多次。这是因为区块链节点可能推送重复的事件,或者组件重新渲染导致监听重新建立。

我需要去重逻辑。每个事件都有唯一的 transactionHashlogIndex,可以用它们组合成唯一ID:

import { useCallback, useRef } from 'react';
import { useWatchContractEvent, Log } from 'wagmi';

function useUniqueContractEvents(options: {
  address: `0x${string}`;
  abi: any;
  eventName: string;
  onUniqueLogs: (logs: Log[]) => void;
}) {
  const processedIds = useRef<Set<string>>(new Set());
  
  const handleLogs = useCallback((logs: Log[]) => {
    const uniqueLogs = logs.filter(log => {
      const id = `${log.transactionHash}-${log.logIndex}`;
      if (processedIds.current.has(id)) {
        return false;
      }
      processedIds.current.add(id);
      return true;
    });
    
    if (uniqueLogs.length > 0) {
      options.onUniqueLogs(uniqueLogs);
    }
  }, [options.onUniqueLogs]);
  
  useWatchContractEvent({
    address: options.address,
    abi: options.abi,
    eventName: options.eventName,
    onLogs: handleLogs,
  });
}

然后在组件中使用这个自定义Hook:

function NFTMintComponent() {
  const [mintedTokens, setMintedTokens] = useState<number[]>([]);
  
  useUniqueContractEvents({
    address: nftContractAddress,
    abi: nftContractAbi,
    eventName: 'Transfer',
    onUniqueLogs: (logs) => {
      // 从事件数据中提取tokenId
      const newTokenIds = logs.map(log => 
        Number(log.args.tokenId)
      );
      
      // 批量更新状态,减少重新渲染
      setMintedTokens(prev => [...prev, ...newTokenIds]);
      
      // 显示成功提示
      showNotification(`成功铸造 ${newTokenIds.length} 个NFT!`);
    },
  });
  
  return (
    <div>
      已铸造的NFT: {mintedTokens.join(', ')}
    </div>
  );
}

处理组件卸载和错误边界

还有一个重要问题:如果监听过程中RPC节点连接失败怎么办?或者组件卸载时如何确保监听完全清理?

wagmiuseWatchContractEvent 在组件卸载时会自动清理,但错误处理需要我们自己加:

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

function useRobustContractEvent(options: {
  address: `0x${string}`;
  abi: any;
  eventName: string;
  onLogs: (logs: Log[]) => void;
  onError?: (error: Error) => void;
}) {
  const publicClient = usePublicClient();
  
  // 先获取历史事件,避免遗漏
  useEffect(() => {
    const fetchPastEvents = async () => {
      try {
        const logs = await publicClient.getLogs({
          address: options.address,
          event: parseAbiItem(`event ${options.eventName}(address indexed from, address indexed to, uint256 indexed tokenId)`),
          fromBlock: 'latest', // 实际项目中可能需要更大的范围
        });
        
        if (logs.length > 0) {
          options.onLogs(logs);
        }
      } catch (error) {
        console.error('获取历史事件失败:', error);
        options.onError?.(error as Error);
      }
    };
    
    fetchPastEvents();
  }, [options.address, options.eventName, publicClient]);
  
  // 实时监听新事件
  useWatchContractEvent({
    address: options.address,
    abi: options.abi,
    eventName: options.eventName,
    onLogs: options.onLogs,
    onError: options.onError,
  });
}

这个方案结合了历史事件查询和实时监听,确保不会遗漏任何事件。即使实时监听暂时断开,也能通过轮询历史事件来弥补。

完整代码

下面是一个完整的、可直接运行的NFT铸造页面组件:

// NFTMintPage.tsx
import React, { useState, useCallback, useRef } from 'react';
import { useWatchContractEvent, useAccount, usePublicClient, useWriteContract } from 'wagmi';
import { parseAbiItem, Log } from 'viem';
import { showNotification } from './notification';

// NFT合约ABI片段
const nftContractAbi = [
  {
    name: 'Transfer',
    type: 'event',
    inputs: [
      { name: 'from', type: 'address', indexed: true },
      { name: 'to', type: 'address', indexed: true },
      { name: 'tokenId', type: 'uint256', indexed: true },
    ],
  },
  {
    name: 'mint',
    type: 'function',
    stateMutability: 'payable',
    inputs: [],
    outputs: [],
  },
] as const;

const NFT_CONTRACT_ADDRESS = '0x742d35Cc6634C0532925a3b844Bc9e...';

function useUniqueContractEvents(options: {
  address: `0x${string}`;
  abi: any;
  eventName: string;
  onUniqueLogs: (logs: Log[]) => void;
  onError?: (error: Error) => void;
}) {
  const processedIds = useRef<Set<string>>(new Set());
  const publicClient = usePublicClient();
  
  // 获取历史事件
  React.useEffect(() => {
    const fetchPastEvents = async () => {
      try {
        const logs = await publicClient.getLogs({
          address: options.address,
          event: parseAbiItem(`event ${options.eventName}(address indexed from, address indexed to, uint256 indexed tokenId)`),
          fromBlock: 'latest',
          toBlock: 'latest',
        });
        
        const uniqueLogs = logs.filter(log => {
          const id = `${log.transactionHash}-${log.logIndex}`;
          return !processedIds.current.has(id);
        });
        
        if (uniqueLogs.length > 0) {
          uniqueLogs.forEach(log => {
            processedIds.current.add(`${log.transactionHash}-${log.logIndex}`);
          });
          options.onUniqueLogs(uniqueLogs);
        }
      } catch (error) {
        console.error('获取历史事件失败:', error);
        options.onError?.(error as Error);
      }
    };
    
    fetchPastEvents();
  }, [options.address, options.eventName, publicClient]);
  
  // 实时监听
  const handleLogs = useCallback((logs: Log[]) => {
    const uniqueLogs = logs.filter(log => {
      const id = `${log.transactionHash}-${log.logIndex}`;
      if (processedIds.current.has(id)) {
        return false;
      }
      processedIds.current.add(id);
      return true;
    });
    
    if (uniqueLogs.length > 0) {
      options.onUniqueLogs(uniqueLogs);
    }
  }, [options.onUniqueLogs]);
  
  useWatchContractEvent({
    address: options.address,
    abi: options.abi,
    eventName: options.eventName,
    onLogs: handleLogs,
    onError: options.onError,
  });
}

export function NFTMintPage() {
  const { address } = useAccount();
  const [mintedTokens, setMintedTokens] = useState<number[]>([]);
  const [isMinting, setIsMinting] = useState(false);
  
  const { writeContractAsync } = useWriteContract();
  
  // 监听Transfer事件
  useUniqueContractEvents({
    address: NFT_CONTRACT_ADDRESS,
    abi: nftContractAbi,
    eventName: 'Transfer',
    onUniqueLogs: (logs) => {
      // 只处理转给当前用户的事件
      const relevantLogs = logs.filter(log => 
        log.args.to?.toLowerCase() === address?.toLowerCase()
      );
      
      if (relevantLogs.length > 0) {
        const newTokenIds = relevantLogs.map(log => 
          Number(log.args.tokenId)
        );
        
        setMintedTokens(prev => {
          const combined = [...prev, ...newTokenIds];
          // 去重排序
          return [...new Set(combined)].sort((a, b) => a - b);
        });
        
        showNotification(`🎉 成功收到 ${newTokenIds.length} 个NFT!`);
      }
    },
    onError: (error) => {
      console.error('事件监听出错:', error);
      showNotification('事件监听连接不稳定,请刷新页面', 'warning');
    },
  });
  
  // 铸造函数
  const handleMint = async () => {
    if (!address) {
      showNotification('请先连接钱包', 'error');
      return;
    }
    
    try {
      setIsMinting(true);
      
      await writeContractAsync({
        address: NFT_CONTRACT_ADDRESS,
        abi: nftContractAbi,
        functionName: 'mint',
        value: 0.01n * 10n ** 18n, // 假设铸造价格是0.01 ETH
      });
      
      showNotification('交易已提交,请等待确认...', 'info');
    } catch (error: any) {
      console.error('铸造失败:', error);
      showNotification(`铸造失败: ${error.shortMessage || error.message}`, 'error');
    } finally {
      setIsMinting(false);
    }
  };
  
  return (
    <div className="nft-mint-page">
      <h1>NFT铸造平台</h1>
      
      <div className="mint-section">
        <button 
          onClick={handleMint}
          disabled={isMinting || !address}
        >
          {isMinting ? '铸造中...' : '铸造NFT (0.01 ETH)'}
        </button>
        
        {!address && (
          <p className="hint">请先连接钱包</p>
        )}
      </div>
      
      <div className="tokens-section">
        <h2>你的NFT ({mintedTokens.length}个)</h2>
        
        {mintedTokens.length === 0 ? (
          <p>还没有NFT,点击上方按钮铸造</p>
        ) : (
          <div className="token-list">
            {mintedTokens.map(tokenId => (
              <div key={tokenId} className="token-card">
                NFT #{tokenId}
              </div>
            ))}
          </div>
        )}
      </div>
    </div>
  );
}

踩坑记录

在实际开发中,我遇到了几个具体的坑,这里记录下来:

  1. 事件重复触发:最开始没有做去重,发现同一个铸造交易会触发2-3次事件更新。原因是节点推送可能重复,且组件重渲染会重新建立监听。解决方案就是用 transactionHash + logIndex 做唯一标识去重。

  2. 网络切换后监听不更新:虽然 wagmi 理论上应该自动处理,但我发现切换到某些测试网时,监听器还在旧链上。后来发现是因为我硬编码了RPC URL,没有用 wagmiusePublicClient。改用 usePublicClient() 后,网络切换就正常了。

  3. TypeScript类型错误viem 对类型要求很严格,事件参数的访问方式从 log.args[2] 变成了 log.args.tokenId。一开始我按老习惯写,类型检查报错。需要仔细看ABI定义,用正确的属性名访问。

  4. 内存泄漏警告:在监听回调中直接更新状态,如果组件卸载得快,会报“Can't perform a React state update on an unmounted component”。我加了 useRef 来跟踪组件挂载状态,但后来发现 wagmi 的 Hook 已经处理了这个问题,主要是我自己的 setState 调用时机不对。最终方案是把状态更新包装在条件判断里。

小结

经过这次折腾,我最大的收获是:现代Web3前端开发中,用 wagmi + viem 这套组合能省去很多底层细节的麻烦。事件监听这种看似简单的功能,实际上要考虑网络切换、错误处理、性能优化等多个方面。现在这套方案已经在生产环境稳定运行,用户反馈“铸造后立刻能看到NFT”的体验很好。

如果想进一步优化,可以考虑加上事件监听的状态指示器(比如显示“正在监听事件...”),或者实现离线事件队列,等网络恢复后一并处理。不过对于大多数DApp来说,现在的方案已经足够可靠了。