从轮询到监听:我在NFT铸造项目中优化智能合约事件订阅的实战记录

0 阅读1分钟

背景

上个月我接手了一个NFT铸造平台的前端开发,核心功能是让用户连接钱包后,点击按钮铸造NFT,并实时看到铸造结果。合约那边同事已经写好了,标准的ERC721,每次成功铸造都会触发一个 Transfer 事件(从零地址到用户地址),失败或暂停时会触发自定义的 MintPaused 事件。

一开始,为了快速上线原型,我用了最“笨”但直接的方法:用户点击铸造后,前端每隔2秒就调用一次合约的 balanceOf 方法,检查用户NFT余额是否增加。同时,为了监听合约是否暂停,还设置了一个定时器轮询合约的 paused 状态变量。

这个方案在演示时勉强能用,但问题很快暴露出来。当有几十个用户同时在线铸造时,前端疯狂的轮询请求让我们的RPC节点(当时用的Infura免费层)频频报出速率限制错误,页面卡顿严重。更糟糕的是,用户需要等待轮询周期才能看到结果,体验很差。我意识到,必须把轮询改成真正的事件监听,让链上事件主动通知前端,这才是Web3前端该有的样子。

问题分析

我的第一反应是去查 ethers.js 文档,找到了 contract.on(event, callback) 这个方法。看起来很简单,于是在一个 useEffect 里写了 myContract.on('Transfer', handleTransfer)。本地测试网(Hardhat)上跑得挺顺,事件一来,回调函数就能触发。

但一部署到Goerli测试网,问题就来了。监听是建立了,但有时能收到事件,有时收不到,控制台也没有报错。我最初怀疑是网络延迟或RPC节点的问题,换了好几个公共RPC节点,情况依旧。然后我检查了回调函数,加了大量 console.log,发现事件对象有时是 undefined

通过更细致的排查,我把问题聚焦在两点上:

  1. 事件过滤器范围:我一开始监听的是所有 Transfer 事件。但ERC721合约的 Transfer 事件在转账、授权等很多操作时都会触发。我的前端页面其实只关心“铸造”这一特定场景,即 from 地址是 0x000...000Transfer 事件。监听所有事件带来了大量噪音,也可能干扰了逻辑。
  2. Provider的稳定性:我直接用了 ethers.providers.Web3Provider(window.ethereum) 作为provider。但用户可能切换钱包账户、切换网络,甚至锁屏。这些操作可能导致底层的WebSocket连接(如果provider支持)中断,而简单的 contract.on 可能没有自动重连机制。

所以,我需要一个精准健壮的事件监听方案。

核心实现

1. 构建精准的事件过滤器

首先,我决定不再监听宽泛的 Transfer 事件,而是创建一个过滤器(Filter),只捕获与当前用户铸造相关的特定事件。这里用到了 ethers 中的 filters 工具。

import { ethers } from 'ethers';

// 假设合约ABI中已经包含了事件定义
const contractABI = [...]; // 你的合约ABI
const contractAddress = '0x...';
const provider = new ethers.providers.Web3Provider(window.ethereum);
const contract = new ethers.Contract(contractAddress, contractABI, provider);

// 获取当前用户地址
const signer = provider.getSigner();
const userAddress = await signer.getAddress();

// 创建过滤器:只监听从零地址转移到当前用户地址的Transfer事件
// 这就是铸造事件的特征
const mintEventFilter = contract.filters.Transfer(
  ethers.constants.AddressZero, // from 为零地址
  userAddress                   // to 为当前用户
);

// 也可以监听自定义的暂停事件
const pauseEventFilter = contract.filters.MintPaused();

这里有个坑ethers.constants.AddressZero0x000...000 的可靠引用。自己手写字符串 ‘0x000...000’ 容易出错,而且 ethers 内部可能对零地址有特殊处理,用常量更安全。

2. 使用 provider.on 进行更底层的监听

我放弃了 contract.on,转而使用 provider.on(filter, callback)。文档上说,这种方式更底层,直接让Provider监听特定的日志(Log),理论上更可靠。而且,它返回的监听器ID可以用于后续的取消操作。

const handleMintEvent = (log: ethers.providers.Log) => {
  // 解析日志
  const parsedLog = contract.interface.parseLog(log);
  console.log('铸造成功!Token ID:', parsedLog.args.tokenId);
  console.log('接收者:', parsedLog.args.to);
  // 更新前端状态,比如显示成功提示、刷新NFT列表
  setMintStatus('success');
  setUserNFTs(prev => [...prev, parsedLog.args.tokenId]);
};

// 启动监听
provider.on(mintEventFilter, handleMintEvent);

注意这个细节provider.on 的回调参数是一个原始的日志对象 Log,我们需要用合约的接口(contract.interface)来解析它,才能得到结构化的 args。这和 contract.on 直接回调解析后的事件对象不同。

3. 在React组件中安全地管理监听生命周期

这是最关键的一步,处理不好会导致内存泄漏。监听必须在组件挂载时建立,在组件卸载、用户地址或网络变化时及时清理。我把它封装进一个自定义Hook useContractEvent

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

function useContractEvent(
  contract: ethers.Contract | null,
  eventName: string,
  filterArgs: any[],
  callback: (args: any) => void
) {
  const callbackRef = useRef(callback);
  callbackRef.current = callback; // 保证回调函数最新

  useEffect(() => {
    if (!contract?.provider) return;

    const eventFilter = contract.filters[eventName](...filterArgs);
    const provider = contract.provider;

    const listener = (log: ethers.providers.Log) => {
      const parsedLog = contract.interface.parseLog(log);
      callbackRef.current(parsedLog.args);
    };

    // 开始监听
    provider.on(eventFilter, listener);

    // 清理函数:组件卸载或依赖变化时移除监听
    return () => {
      provider.off(eventFilter, listener);
    };
  }, [contract, eventName, ...filterArgs]); // 依赖项包括过滤条件
}

这个Hook的设计思路

  • useRef 包裹回调:避免因回调函数变化导致 useEffect 频繁重建监听。监听器始终引用同一个稳定的 callbackRef.current
  • 明确的依赖数组:监听器在合约实例、事件名或过滤条件变化时,会销毁旧的,创建新的。这完美应对了用户切换钱包或网络的情况。
  • 可靠的清理useEffect 的返回函数确保监听一定会被移除(provider.off)。

4. 处理Provider连接中断与重连

即使这样,在测试中我发现,如果用户长时间不操作,或者MetaMask因某些原因重置了连接,监听依然会失效。为了增加鲁棒性,我添加了一个“心跳检测”和手动重连的备选方案。

我并没有直接去修改Provider的底层连接,而是采用了一个更务实的策略:在应用顶层监听钱包的 accountsChangedchainChanged 事件。一旦触发,就完全重置整个合约和Provider的实例,从而间接重建所有事件监听。

// 在应用根组件或钱包连接逻辑中
useEffect(() => {
  if (!window.ethereum) return;

  const handleAccountsChanged = () => {
    console.log('账户变更,重置合约连接...');
    // 此处应触发一个全局状态更新,让所有使用合约的组件重新初始化
    resetContractConnection();
  };

  const handleChainChanged = () => {
    console.log('网络变更,重置合约连接...');
    // 同样,重置连接
    resetContractConnection();
    window.location.reload(); // 对于复杂应用,重载页面是最干净的方式
  };

  window.ethereum.on('accountsChanged', handleAccountsChanged);
  window.ethereum.on('chainChanged', handleChainChanged);

  return () => {
    window.ethereum.removeListener('accountsChanged', handleAccountsChanged);
    window.ethereum.removeListener('chainChanged', handleChainChanged);
  };
}, []);

这里的权衡:网络切换后,合约地址、ABI都可能不同,简单的重连监听可能不够。对于这个项目,我选择了在链变更后直接重载页面,虽然体验有折损,但保证了状态的绝对干净。更复杂的应用可以考虑用状态管理来协调。

完整代码示例

下面是一个整合了以上思路的简化版NFT铸造组件:

import React, { useState, useEffect, useCallback } from 'react';
import { ethers } from 'ethers';
import NFT_ABI from './abis/NFT.json';

// 自定义事件监听Hook
function useContractEvent(
  contract: ethers.Contract | null,
  eventName: string,
  filterArgs: any[],
  callback: (args: any) => void
) {
  const callbackRef = React.useRef(callback);
  callbackRef.current = callback;

  useEffect(() => {
    if (!contract?.provider) return;

    const eventFilter = contract.filters[eventName](...filterArgs);
    const provider = contract.provider;

    const listener = (log: ethers.providers.Log) => {
      try {
        const parsedLog = contract.interface.parseLog(log);
        callbackRef.current(parsedLog.args);
      } catch (error) {
        console.error('解析事件日志失败:', error);
      }
    };

    provider.on(eventFilter, listener);
    console.log(`监听事件已建立: ${eventName}`, filterArgs);

    return () => {
      console.log(`清理事件监听: ${eventName}`);
      provider.off(eventFilter, listener);
    };
  }, [contract, eventName, ...filterArgs]);
}

const NFTMinter: React.FC = () => {
  const [provider, setProvider] = useState<ethers.providers.Web3Provider | null>(null);
  const [contract, setContract] = useState<ethers.Contract | null>(null);
  const [userAddress, setUserAddress] = useState<string>('');
  const [mintStatus, setMintStatus] = useState<'idle' | 'pending' | 'success' | 'error'>('idle');
  const [myNFTs, setMyNFTs] = useState<number[]>([]);

  // 初始化Provider和合约
  useEffect(() => {
    if (!window.ethereum) {
      alert('请安装MetaMask!');
      return;
    }

    const init = async () => {
      const prov = new ethers.providers.Web3Provider(window.ethereum);
      await prov.send('eth_requestAccounts', []);
      const signer = prov.getSigner();
      const address = await signer.getAddress();

      const nftContract = new ethers.Contract(
        process.env.REACT_APP_NFT_CONTRACT_ADDRESS!,
        NFT_ABI,
        signer // 用signer连接,以便发送交易
      );

      setProvider(prov);
      setContract(nftContract);
      setUserAddress(address);
    };

    init();
  }, []);

  // 监听铸造成功事件
  useContractEvent(
    contract,
    'Transfer',
    [ethers.constants.AddressZero, userAddress], // 过滤器:从零地址到当前用户
    useCallback((args: { from: string; to: string; tokenId: ethers.BigNumber }) => {
      console.log('🎉 监听到铸造事件,Token ID:', args.tokenId.toString());
      setMintStatus('success');
      setMyNFTs(prev => [...prev, args.tokenId.toNumber()]);
    }, [])
  );

  // 监听合约暂停事件
  useContractEvent(
    contract,
    'MintPaused',
    [],
    useCallback(() => {
      console.log('⚠️ 合约铸造功能已暂停');
      setMintStatus('error');
      alert('铸造功能已暂停,请稍后再试。');
    }, [])
  );

  const handleMint = async () => {
    if (!contract) return;
    try {
      setMintStatus('pending');
      const tx = await contract.mint(); // 假设有一个无参数的mint函数
      await tx.wait(); // 等待交易上链
      // 注意:状态更新将由事件监听器处理,此处无需再轮询查询余额
    } catch (error: any) {
      console.error('铸造失败:', error);
      setMintStatus('error');
      // 处理用户拒绝交易等错误
      if (error.code === 4001) {
        alert('您拒绝了交易。');
      }
    }
  };

  return (
    <div>
      <p>当前地址: {userAddress}</p>
      <p>状态: {mintStatus}</p>
      <button onClick={handleMint} disabled={mintStatus === 'pending'}>
        {mintStatus === 'pending' ? '铸造中...' : '铸造NFT'}
      </button>
      <div>
        <h3>我的NFTs:</h3>
        <ul>
          {myNFTs.map(id => <li key={id}>Token #{id}</li>)}
        </ul>
      </div>
    </div>
  );
};

export default NFTMinter;

踩坑记录

  1. contract.on 在部分公共RPC节点上不触发:这是我遇到的第一个大坑。现象是监听器注册成功,但事件永远不来。解决方法:切换到更稳定的RPC节点(如Alchemy),并使用 provider.on(filter, callback) 这个更底层、兼容性更好的API。后来了解到,有些公共RPC节点对WebSocket支持不完整,而 contract.on 可能依赖于此。

  2. 内存泄漏:组件卸载后事件监听仍在:在React开发模式下,严格模式会导致组件挂载/卸载两次,如果清理函数没写对,监听器会翻倍。解决方法:确保 useEffect 的清理函数 (return () => {...}) 一定被调用,并且内部使用 provider.off 传入与 on完全相同的过滤器对象和回调引用。这也是我为什么在自定义Hook里用 useRef 来稳定回调。

  3. 事件参数解析错误:在使用 provider.on 时,直接访问 log.argsundefined解决方法:必须用 contract.interface.parseLog(log) 来解析原始日志。需要确保传入的 contract 对象包含正确的ABI。

  4. 用户切换账户后,旧地址的过滤器仍在监听:这会导致事件混乱。解决方法:将过滤条件(如用户地址)作为Hook的依赖项。当 userAddress 变化时,useEffect 会清理旧监听器,并用新地址创建新监听器。这是React Hooks模式带来的天然优势。

小结

这次优化让我彻底理解了Web3前端事件监听的核心:精准的过滤器、健壮的生命周期管理和对Provider连接状态的兜底处理。替换轮询后,前端请求量下降了95%,用户体验也从“等待刷新”变成了“实时响应”。下一步,我可以探索如何将这套逻辑与状态管理库(如Zustand、Redux)结合,在大型应用中更优雅地管理全局的合约事件状态。