阅读视图

发现新文章,点击刷新页面。

用wagmi v2 + viem重构DeFi前端:从连接钱包到读取合约数据的完整踩坑实录

背景

上个月,我接手了一个DeFi收益聚合器项目的前端重构任务。这个项目最初是用ethers.js 5.xweb3-react构建的,代码已经运行了两年多。随着项目发展,老架构的问题逐渐暴露:钱包连接逻辑分散在各个组件、多链支持维护困难、类型定义几乎为零。

团队决定迁移到更现代的wagmi v2 + viem技术栈。wagmi的Hooks式API看起来简洁优雅,viem的类型安全也很有吸引力。我本以为这是个“升级依赖”的简单任务,但实际动手才发现,从老模式切换到新范式,中间有太多细节需要重新理解。最大的挑战不是写新代码,而是让新老逻辑在数据流和状态管理上保持一致。

问题分析

我最初的计划很直接:安装wagmiviem@tanstack/react-query(wagmi v2的依赖),然后逐步替换组件中的useWeb3Reactethers调用。

第一个拦路虎很快就出现了:钱包连接状态频繁丢失。

在旧版中,用户连接钱包后,accountchainId等信息通过React Context全局可用。但在新版本中,我按照官方示例配置了WagmiProvider后,发现useAccount()返回的address时不时会变成undefined,即使MetaMask明明还连接着。

我排查的方向:

  1. 检查Provider配置:确认了config对象正确传递给了WagmiProvider
  2. 检查连接器顺序:按照文档把injected连接器放在第一位
  3. 检查React Query配置:确认了缓存时间设置

后来通过仔细阅读wagmi的源码和issue,才发现问题核心:wagmi v2默认的行为更“谨慎”了。它不会永久保持连接状态,而是需要应用层明确处理连接持久化。同时,@tanstack/react-query的缓存行为也会影响状态同步。

另一个头疼的问题是多链切换。旧版中我们手动处理链切换逻辑,但wagmi提供了useSwitchChain这样的高级Hook。当我尝试切换到Polygon链时,控制台没有报错,但交易始终在以太坊主网发送。这里涉及到viem的Transport配置和wagmi的chain配置对齐问题。

核心实现

1. 正确配置Wagmi Provider与连接持久化

经过调试,我找到了wagmi v2连接状态不稳定的主要原因:缺少状态持久化和正确的存储配置。下面是最终的配置方案:

// src/providers/WagmiProvider.tsx
import { createConfig, http, WagmiProvider as WagmiProviderCore } from 'wagmi'
import { mainnet, polygon, arbitrum } from 'wagmi/chains'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { injected } from 'wagmi/connectors'
import { ReactNode } from 'react'

// 创建QueryClient实例,这是wagmi v2的强制依赖
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      // 这里有个坑:缓存时间不能太短,否则频繁重连
      gcTime: 1000 * 60 * 60 * 24, // 24小时
      staleTime: 1000 * 60 * 5, // 5分钟
      retry: 1
    }
  }
})

// 配置支持的链
const supportedChains = [mainnet, polygon, arbitrum]

// 创建wagmi配置
const config = createConfig({
  chains: supportedChains,
  transports: {
    // 这里必须为每个链配置transport,否则会报错
    [mainnet.id]: http(),
    [polygon.id]: http('https://polygon-rpc.com'),
    [arbitrum.id]: http('https://arb1.arbitrum.io/rpc'),
  },
  connectors: [
    injected(),
    // 可以添加其他连接器如walletConnect
  ],
  // 关键配置:启用状态存储
  ssr: false, // 如果不是SSR应用,设为false
})

export function WagmiProvider({ children }: { children: ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <WagmiProviderCore config={config}>
        {children}
      </WagmiProviderCore>
    </QueryClientProvider>
  )
}

关键点

  • transports配置必须为每个链提供RPC端点,否则跨链操作会失败
  • gcTime(原cacheTime)设置足够长,避免频繁重连
  • 通过ssr: false明确禁用SSR,避免hydration问题

2. 实现稳健的钱包连接与状态管理

连接钱包的UI组件需要处理更多边缘情况。我创建了一个WalletConnector组件:

// src/components/WalletConnector.tsx
import { useAccount, useConnect, useDisconnect, useChainId } from 'wagmi'
import { useEffect, useState } from 'react'

export function WalletConnector() {
  const { address, isConnected, isConnecting } = useAccount()
  const { connect, connectors, error: connectError } = useConnect()
  const { disconnect } = useDisconnect()
  const chainId = useChainId()
  
  const [mounted, setMounted] = useState(false)
  
  // 解决hydration不匹配问题
  useEffect(() => {
    setMounted(true)
  }, [])
  
  if (!mounted) {
    return <div>Loading...</div>
  }
  
  if (!isConnected) {
    return (
      <div>
        <h3>Connect Wallet</h3>
        {connectors.map((connector) => (
          <button
            key={connector.uid}
            onClick={() => connect({ connector })}
            disabled={isConnecting}
          >
            {connector.name}
            {isConnecting && ' Connecting...'}
          </button>
        ))}
        {connectError && (
          <div style={{ color: 'red' }}>
            Error: {connectError.message}
          </div>
        )}
      </div>
    )
  }
  
  return (
    <div>
      <p>Connected: {address?.slice(0, 6)}...{address?.slice(-4)}</p>
      <p>Chain ID: {chainId}</p>
      <button onClick={() => disconnect()}>
        Disconnect
      </button>
    </div>
  )
}

注意这个细节mounted状态是为了解决Next.js等SSR框架下的hydration警告。wagmi的状态在服务端和客户端可能不一致。

3. 多链切换与网络状态监听

DeFi应用经常需要跨链操作。我实现了一个链切换组件,并添加了网络状态监听:

// src/components/ChainSwitcher.tsx
import { useSwitchChain, useAccount } from 'wagmi'
import { mainnet, polygon, arbitrum } from 'wagmi/chains'

const chainConfigs = {
  [mainnet.id]: { name: 'Ethereum', color: '#627EEA' },
  [polygon.id]: { name: 'Polygon', color: '#8247E5' },
  [arbitrum.id]: { name: 'Arbitrum', color: '#28A0F0' },
}

export function ChainSwitcher() {
  const { chainId } = useAccount()
  const { switchChain, isPending, error } = useSwitchChain()
  
  // 监听网络切换
  useEffect(() => {
    if (typeof window !== 'undefined' && window.ethereum) {
      const handleChainChanged = (newChainId: string) => {
        // MetaMask会重新加载页面,但其他钱包可能不会
        console.log('Chain changed to:', newChainId)
      }
      
      window.ethereum.on('chainChanged', handleChainChanged)
      
      return () => {
        window.ethereum.removeListener('chainChanged', handleChainChanged)
      }
    }
  }, [])
  
  return (
    <div>
      <p>Current chain: {chainId ? chainConfigs[chainId]?.name : 'Unknown'}</p>
      <div style={{ display: 'flex', gap: '8px' }}>
        {Object.keys(chainConfigs).map((id) => (
          <button
            key={id}
            onClick={() => switchChain({ chainId: Number(id) })}
            disabled={isPending || chainId === Number(id)}
            style={{
              backgroundColor: chainConfigs[Number(id)].color,
              color: 'white'
            }}
          >
            {chainConfigs[Number(id)].name}
            {isPending && ' Switching...'}
          </button>
        ))}
      </div>
      {error && (
        <div style={{ color: 'red', marginTop: '8px' }}>
          Switch failed: {error.message}
        </div>
      )}
    </div>
  )
}

这里有个坑switchChain可能因为钱包未添加目标链而失败。在生产环境中,需要添加useAddChain Hook来动态添加链配置。

4. 读取合约数据:从ethers.js到viem的迁移

这是最核心的部分。旧代码中读取ERC20余额是这样的:

// 旧代码 - ethers.js方式
const contract = new ethers.Contract(tokenAddress, ERC20_ABI, provider)
const balance = await contract.balanceOf(account)
const decimals = await contract.decimals()
const formattedBalance = ethers.utils.formatUnits(balance, decimals)

迁移到viem后,需要改用useReadContract Hook:

// src/hooks/useTokenBalance.ts
import { useReadContract, useAccount } from 'wagmi'
import { erc20Abi } from 'viem'

export function useTokenBalance(tokenAddress: `0x${string}`) {
  const { address, chainId } = useAccount()
  
  // 读取余额
  const { 
    data: balance, 
    isLoading, 
    error, 
    refetch 
  } = useReadContract({
    abi: erc20Abi,
    address: tokenAddress,
    functionName: 'balanceOf',
    args: address ? [address] : undefined,
    chainId, // 关键:指定链ID,确保读取正确的链上数据
    query: {
      enabled: !!address, // 只有连接钱包时才查询
      // 这里有个重要细节:refetchInterval
      refetchInterval: 10000, // 每10秒自动刷新
    }
  })
  
  // 读取代币小数位
  const { data: decimals } = useReadContract({
    abi: erc20Abi,
    address: tokenAddress,
    functionName: 'decimals',
    chainId,
    query: {
      enabled: !!address,
    }
  })
  
  // 格式化余额
  const formattedBalance = React.useMemo(() => {
    if (!balance || !decimals) return '0'
    // viem的格式化方式
    const divisor = 10n ** BigInt(decimals)
    const integerPart = balance / divisor
    const fractionalPart = balance % divisor
    return `${integerPart}.${fractionalPart.toString().padStart(decimals, '0')}`
  }, [balance, decimals])
  
  return {
    balance,
    formattedBalance,
    isLoading,
    error,
    refetch
  }
}

关键变化

  1. useReadContract自动处理缓存、重试和错误状态
  2. 必须指定chainId,否则可能读取到错误链的数据
  3. enabled选项控制查询时机,避免不必要的RPC调用
  4. viem使用bigint而不是ethers.BigNumber

5. 发送交易:处理用户确认和状态反馈

发送交易是DeFi应用的核心交互。我创建了一个发送ERC20转账的Hook:

// src/hooks/useTransferToken.ts
import { useWriteContract, useWaitForTransactionReceipt } from 'wagmi'
import { erc20Abi } from 'viem'
import { useState } from 'react'

export function useTransferToken() {
  const [isDialogOpen, setIsDialogOpen] = useState(false)
  
  const {
    writeContract,
    data: hash,
    error: writeError,
    isPending: isWriting,
    reset: resetWrite
  } = useWriteContract()
  
  // 等待交易确认
  const {
    isLoading: isConfirming,
    isSuccess: isConfirmed,
    error: confirmError
  } = useWaitForTransactionReceipt({
    hash,
    // 这里可以配置确认数
    confirmations: 1,
  })
  
  const transfer = async (
    tokenAddress: `0x${string}`,
    to: `0x${string}`,
    amount: bigint
  ) => {
    try {
      setIsDialogOpen(true)
      
      writeContract({
        abi: erc20Abi,
        address: tokenAddress,
        functionName: 'transfer',
        args: [to, amount],
      })
    } catch (error) {
      console.error('Transfer failed:', error)
      setIsDialogOpen(false)
    }
  }
  
  // 交易完成后重置状态
  React.useEffect(() => {
    if (isConfirmed || confirmError) {
      const timer = setTimeout(() => {
        setIsDialogOpen(false)
        resetWrite()
      }, 3000)
      
      return () => clearTimeout(timer)
    }
  }, [isConfirmed, confirmError, resetWrite])
  
  return {
    transfer,
    hash,
    isDialogOpen,
    isWriting,
    isConfirming,
    isConfirmed,
    error: writeError || confirmError
  }
}

用户体验优化:这个Hook管理了完整的交易生命周期——从用户点击、钱包确认、链上等待到最终状态反馈。useWaitForTransactionReceipt会自动轮询交易收据,无需手动实现。

完整代码示例

下面是一个整合了上述所有功能的简化版DeFi前端组件:

// src/App.tsx
import { WagmiProvider } from './providers/WagmiProvider'
import { WalletConnector } from './components/WalletConnector'
import { ChainSwitcher } from './components/ChainSwitcher'
import { useTokenBalance } from './hooks/useTokenBalance'
import { useTransferToken } from './hooks/useTransferToken'

// 示例代币地址(USDT on Ethereum)
const USDT_ADDRESS = '0xdAC17F958D2ee523a2206206994597C13D831ec7'

function DeFiApp() {
  const { address } = useAccount()
  const { formattedBalance, isLoading: isLoadingBalance } = 
    useTokenBalance(USDT_ADDRESS)
  const {
    transfer,
    isWriting,
    isConfirming,
    isConfirmed,
    error: transferError
  } = useTransferToken()
  
  const handleTransfer = () => {
    if (!address) return
    
    // 转账0.1 USDT(USDT有6位小数)
    const amount = 100000n // 0.1 USDT = 100000 wei
    const recipient = '0x742d35Cc6634C0532925a3b844Bc9e90F90a1497' // 示例地址
    
    transfer(USDT_ADDRESS, recipient, amount)
  }
  
  return (
    <div style={{ padding: '20px', maxWidth: '800px', margin: '0 auto' }}>
      <h1>DeFi Dashboard</h1>
      
      <WalletConnector />
      
      {address && (
        <>
          <ChainSwitcher />
          
          <div style={{ marginTop: '20px', padding: '15px', border: '1px solid #ccc' }}>
            <h3>USDT Balance</h3>
            {isLoadingBalance ? (
              <p>Loading balance...</p>
            ) : (
              <p>{formattedBalance} USDT</p>
            )}
            
            <button 
              onClick={handleTransfer}
              disabled={isWriting || isConfirming}
              style={{ marginTop: '10px' }}
            >
              {isWriting ? 'Confirm in Wallet...' : 
               isConfirming ? 'Waiting for confirmation...' : 
               'Transfer 0.1 USDT'}
            </button>
            
            {isConfirmed && (
              <p style={{ color: 'green' }}>Transfer successful!</p>
            )}
            
            {transferError && (
              <p style={{ color: 'red' }}>
                Transfer failed: {transferError.message}
              </p>
            )}
          </div>
        </>
      )}
    </div>
  )
}

// 应用入口
function App() {
  return (
    <WagmiProvider>
      <DeFiApp />
    </WagmiProvider>
  )
}

export default App

踩坑记录

在实际迁移过程中,我遇到了以下几个典型问题:

  1. "Invalid BigNumber value"错误

    • 现象:从ethers.js迁移时,传入useWriteContractargs包含ethers的BigNumber对象
    • 原因:viem只接受原生的JavaScript bigint类型
    • 解决:将所有ethers.BigNumber转换为bigintBigInt(balance.toString())
  2. 跨链读取返回错误数据

    • 现象:在Polygon链上却读到了以太坊主网的余额
    • 原因useReadContract没有指定chainId,使用了默认链
    • 解决:在所有合约读取Hook中显式传递当前chainId
  3. 钱包连接在页面刷新后丢失

    • 现象:用户刷新页面后需要重新连接钱包
    • 原因:wagmi默认配置没有启用连接持久化
    • 解决:正确配置QueryClient的缓存时间,并考虑使用'wagmi/connectors'中的createStorage进行localStorage持久化
  4. TypeScript类型错误:0x${string}

    • 现象:传递普通字符串地址时TypeScript报错
    • 原因:viem要求地址是0x开头的严格格式
    • 解决:使用类型断言或验证函数:address as 0x${string},或使用viem的isAddress工具函数

小结

这次从ethers.js + web3-react迁移到wagmi v2 + viem,最大的收获是理解了现代Web3前端的状态管理范式。wagmi将React Query的缓存策略与区块链状态同步结合,虽然初期配置复杂,但一旦理顺,代码会比老方案更简洁健壮。下一步可以探索wagmi的更多高级特性,如合约事件监听、批量查询优化,以及如何与状态管理库(如Zustand)深度集成。

Next.js 14 + wagmi v2 构建 NFT 市场:从列表渲染到实时更新的完整链路

背景

上个月,我接手了一个新的 Web3 项目:为一个基于 Base 链的 NFT 系列开发一个轻量级的交易市场前端。核心需求很简单:展示该系列的所有 NFT,显示每个 NFT 的当前挂单价格,并且当用户购买或取消挂单时,页面上的信息要能实时更新,无需手动刷新。

技术栈选型很明确:Next.js 14(App Router)、TypeScript、Tailwind CSS,以及 Web3 交互的核心——wagmi v2 和 viem。我心想,这不过是把链上数据读出来,监听几个事件,再渲染成列表,用 useAccountuseReadContract 不就搞定了吗?结果,从第一行代码开始,坑就一个接一个地来了。

问题分析

我的初始思路非常直接:在页面组件里,用 wagmi 的 useReadContract 读取 NFT 合约的 totalSupply,然后循环获取每个 Token 的元数据和挂单信息,最后用 useEffect 监听 ListingUpdatedTransfer 事件来触发数据重拉。

但一上手就发现了几个致命问题:

  1. 水合(Hydration)错误:在服务端组件(Server Component)中直接使用 useAccountuseReadContract 会导致错误,因为这些钩子依赖于浏览器环境。
  2. 性能灾难:如果我有 1000 个 NFT,难道要发起 1000+ 次 RPC 调用吗?页面加载会慢到无法接受。
  3. 实时更新失效:简单地用 useEffect 监听事件,在用户切换钱包或断开连接时,监听器会混乱,导致更新不及时或重复更新。
  4. 状态同步难题:交易(购买、挂单)提交后,如何优雅地等待链上确认,并立即更新 UI,而不是等用户手动刷新?

最初的方案完全走不通。我意识到,必须把服务端初始渲染、客户端状态管理、批量数据获取和事件驱动更新这几个环节拆解开,设计一个更清晰的架构。

核心实现

1. 架构分层:服务端获取初始数据

首先,我放弃了在页面组件里直接调用 Web3 钩子获取所有数据的想法。对于 NFT 列表这种相对静态的初始数据,应该在服务端获取。我创建了一个服务端函数,使用 Viem 的公共客户端(Public Client)来读取链上数据。

关键点:在 App Router 中,我们可以在 Server Component 或 Server Action 里直接与区块链交互,无需钱包连接。这完美解决了初始渲染的问题。

// app/api/nfts/route.ts
import { createPublicClient, http } from 'viem';
import { base } from 'viem/chains';

// 初始化一个不需要钱包的公共客户端
const publicClient = createPublicClient({
  chain: base,
  transport: http(process.env.NEXT_PUBLIC_RPC_URL),
});

// NFT 合约 ABI 片段
const NFT_ABI = [
  {
    name: 'totalSupply',
    type: 'function',
    stateMutability: 'view',
    inputs: [],
    outputs: [{ type: 'uint256' }],
  },
  {
    name: 'tokenURI',
    type: 'function',
    stateMutability: 'view',
    inputs: [{ name: 'tokenId', type: 'uint256' }],
    outputs: [{ type: 'string' }],
  },
] as const;

// 市场合约 ABI 片段
const MARKET_ABI = [
  {
    name: 'listings',
    type: 'function',
    stateMutability: 'view',
    inputs: [{ name: 'tokenId', type: 'uint256' }],
    outputs: [
      { name: 'seller', type: 'address' },
      { name: 'price', type: 'uint256' },
      { name: 'isActive', type: 'bool' },
    ],
  },
] as const;

export async function GET() {
  try {
    const totalSupply = await publicClient.readContract({
      address: process.env.NEXT_PUBLIC_NFT_CONTRACT as `0x${string}`,
      abi: NFT_ABI,
      functionName: 'totalSupply',
    });

    const nftDataPromises = [];
    // 注意:这里用 Number 转换只适用于总量不大的情况,真实项目需考虑 BigInt
    for (let i = 0; i < Number(totalSupply); i++) {
      const promise = Promise.all([
        // 获取元数据 URI
        publicClient.readContract({
          address: process.env.NEXT_PUBLIC_NFT_CONTRACT as `0x${string}`,
          abi: NFT_ABI,
          functionName: 'tokenURI',
          args: [BigInt(i)],
        }),
        // 获取挂单信息
        publicClient.readContract({
          address: process.env.NEXT_PUBLIC_MARKET_CONTRACT as `0x${string}`,
          abi: MARKET_ABI,
          functionName: 'listings',
          args: [BigInt(i)],
        }),
      ]).then(([tokenURI, listing]) => ({
        tokenId: i,
        tokenURI,
        listing,
      }));
      nftDataPromises.push(promise);
    }

    const nfts = await Promise.all(nftDataPromises);
    return Response.json({ nfts });
  } catch (error) {
    console.error('Failed to fetch NFTs:', error);
    return Response.json({ error: 'Fetch failed' }, { status: 500 });
  }
}

这里有个坑:直接循环调用 RPC 在 NFT 数量多时确实慢。在生产环境中,你应该考虑让合约本身返回批量数据,或者使用 The Graph 这类索引服务。我这里为了演示核心流程,先采用简单循环。

2. 客户端状态与实时更新

服务端提供了初始数据,但购买、挂单等交互后的实时更新必须在客户端处理。我创建了一个客户端组件 NftList,它接收服务端的初始数据,并负责管理动态状态。

实时更新的核心是 监听链上事件。wagmi v2 提供了 useWatchContractEvent 钩子,但直接用在列表组件里会导致每个 NFT 卡片都创建一个监听器,性能极差。我的方案是:在父级组件只监听市场合约的全局事件。

// components/nft-list.tsx
'use client';

import { useEffect, useState } from 'react';
import { useWatchContractEvent } from 'wagmi';
import { NftCard } from './nft-card';

// 市场合约 ABI 事件片段
const MARKET_EVENT_ABI = [
  {
    type: 'event',
    name: 'ListingUpdated',
    inputs: [
      { indexed: true, name: 'tokenId', type: 'uint256' },
      { indexed: false, name: 'seller', type: 'address' },
      { indexed: false, name: 'price', type: 'uint256' },
      { indexed: false, name: 'isActive', type: 'bool' },
    ],
  },
] as const;

interface NftListProps {
  initialNfts: Array<{
    tokenId: number;
    tokenURI: string;
    listing: [string, bigint, boolean];
  }>;
}

export function NftList({ initialNfts }: NftListProps) {
  // 使用服务端数据初始化状态
  const [nfts, setNfts] = useState(initialNfts);

  // 关键:监听全局的 ListingUpdated 事件
  useWatchContractEvent({
    address: process.env.NEXT_PUBLIC_MARKET_CONTRACT as `0x${string}`,
    abi: MARKET_EVENT_ABI,
    eventName: 'ListingUpdated',
    onLogs(logs) {
      console.log('ListingUpdated logs:', logs);
      // 当事件触发时,更新对应 NFT 的挂单信息
      logs.forEach((log) => {
        const { tokenId, price, isActive } = log.args;
        if (tokenId !== undefined) {
          setNfts((prev) =>
            prev.map((nft) =>
              nft.tokenId === Number(tokenId)
                ? {
                    ...nft,
                    listing: [log.args.seller || '0x', price || 0n, isActive || false],
                  }
                : nft
            )
          );
        }
      });
    },
  });

  // 一个手动刷新函数,用于在交易确认后主动触发(作为兜底)
  const refreshData = async () => {
    const res = await fetch('/api/nfts');
    const data = await res.json();
    if (data.nfts) setNfts(data.nfts);
  };

  return (
    <div>
      <button onClick={refreshData} className="mb-4 p-2 bg-gray-200 rounded">
        手动刷新数据
      </button>
      <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
        {nfts.map((nft) => (
          <NftCard key={nft.tokenId} nft={nft} onActionSuccess={refreshData} />
        ))}
      </div>
    </div>
  );
}

注意这个细节useWatchContractEvent 的回调函数中,log.args 的类型可能是 undefined,必须做防御性判断,否则 TypeScript 会报错,运行时也可能崩溃。

3. 交易交互与乐观更新

用户点击“购买”时,如果等到交易上链确认(可能十几秒)才更新 UI,体验会很差。我采用了 乐观更新(Optimistic Update) 的策略:先立即更新本地状态,假设交易会成功;如果交易失败,再回滚状态。

// components/nft-card.tsx
'use client';

import { useState } from 'react';
import { useAccount, useWriteContract, useWaitForTransactionReceipt } from 'wagmi';
import { parseEther } from 'viem';

interface NftCardProps {
  nft: {
    tokenId: number;
    tokenURI: string;
    listing: [string, bigint, boolean];
  };
  onActionSuccess?: () => void;
}

export function NftCard({ nft, onActionSuccess }: NftCardProps) {
  const { address } = useAccount();
  const [isUpdating, setIsUpdating] = useState(false);
  const { data: hash, writeContract, error } = useWriteContract();
  const { isLoading: isConfirming } = useWaitForTransactionReceipt({ hash });

  const [seller, price, isActive] = nft.listing;

  const handleBuy = async () => {
    if (!address || !isActive) return;

    setIsUpdating(true); // 开始乐观更新
    // 这里可以立即调用父组件传递的回调,或者用状态管理更新本地列表
    // 为了简化,我们假设父组件会通过事件监听更新,这里只处理自身加载状态

    try {
      writeContract({
        address: process.env.NEXT_PUBLIC_MARKET_CONTRACT as `0x${string}`,
        abi: [
          {
            name: 'buyToken',
            type: 'function',
            stateMutability: 'payable',
            inputs: [{ name: 'tokenId', type: 'uint256' }],
            outputs: [],
          },
        ] as const,
        functionName: 'buyToken',
        args: [BigInt(nft.tokenId)],
        value: price,
      });
    } catch (err) {
      console.error('Buy failed:', err);
      setIsUpdating(false); // 回滚乐观更新
    }
  };

  // 交易确认后的处理
  useEffect(() => {
    if (hash && !isConfirming) {
      console.log('Transaction confirmed!');
      setIsUpdating(false);
      onActionSuccess?.(); // 通知父组件刷新数据
    }
  }, [hash, isConfirming, onActionSuccess]);

  return (
    <div className="border p-4 rounded-lg shadow">
      <img src={`https://ipfs.io/ipfs/${nft.tokenURI.split('://')[1]}`} alt={`NFT ${nft.tokenId}`} className="w-full h-48 object-cover rounded" />
      <div className="mt-2">
        <p className="font-bold">Token ID: {nft.tokenId}</p>
        <p>Price: {price ? parseFloat(parseEther(price.toString()).toString()).toFixed(4)} ETH</p>
        <p>Status: {isActive ? 'For Sale' : 'Not Listed'}</p>
      </div>
      {isActive && address !== seller && (
        <button
          onClick={handleBuy}
          disabled={isUpdating || isConfirming}
          className={`mt-2 w-full py-2 rounded ${isUpdating || isConfirming ? 'bg-gray-400' : 'bg-blue-500 hover:bg-blue-600 text-white'}`}
        >
          {isUpdating || isConfirming ? 'Processing...' : 'Buy Now'}
        </button>
      )}
      {error && <p className="text-red-500 text-sm mt-1">Error: {error.message}</p>}
    </div>
  );
}

这里有个大坑:乐观更新时,你更新的状态必须与链上最终状态一致。比如购买后,NFT 的卖家会变,挂单状态会变为 false。如果只是简单地把 isActive 设为 false,但卖家地址没变,就会与链上数据不一致。最稳妥的方式是,在交易发送后,立即用事件监听来更新,或者等确认后触发一次数据重拉。

4. 页面集成与配置

最后,将服务端数据获取和客户端组件在页面中组装起来。页面是服务端组件,它获取数据并传递给客户端组件。

// app/page.tsx
import { NftList } from '@/components/nft-list';

async function getInitialNfts() {
  // 在构建时或请求时从 API 路由获取数据
  const res = await fetch(`${process.env.NEXT_PUBLIC_BASE_URL}/api/nfts`, {
    // 根据需求配置缓存
    // next: { revalidate: 60 }, // ISR: 每60秒重新验证
    cache: 'no-store', // 每次请求都获取最新数据
  });
  if (!res.ok) {
    throw new Error('Failed to fetch NFTs');
  }
  return res.json();
}

export default async function HomePage() {
  const data = await getInitialNfts();

  return (
    <main className="container mx-auto p-4">
      <h1 className="text-3xl font-bold mb-6">NFT Marketplace</h1>
      {/* 将服务端数据作为 prop 传递给客户端组件 */}
      <NftList initialNfts={data.nfts || []} />
    </main>
  );
}

同时,需要在项目根目录配置 wagmi 的 Provider。注意 Next.js 14 App Router 中,Provider 必须是客户端组件。

// app/providers.tsx
'use client';

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { WagmiProvider, createConfig, http } from 'wagmi';
import { base } from 'wagmi/chains';
import { injected } from 'wagmi/connectors';

const queryClient = new QueryClient();

const config = createConfig({
  chains: [base],
  connectors: [injected()],
  transports: {
    [base.id]: http(process.env.NEXT_PUBLIC_RPC_URL),
  },
});

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <WagmiProvider config={config}>
      <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
    </WagmiProvider>
  );
}
// app/layout.tsx
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from './providers';

const inter = Inter({ subsets: ['latin'] });

export const metadata: Metadata = {
  title: 'NFT Marketplace',
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <Providers>{children}</Providers>
      </body>
    </html>
  );
}

完整代码结构

项目的主要文件结构如下:

my-nft-marketplace/
├── app/
│   ├── api/
│   │   └── nfts/
│   │       └── route.ts          # 服务端 API,获取初始 NFT 数据
│   ├── layout.tsx                # 根布局,包含 Providers
│   ├── page.tsx                  # 主页(服务端组件)
│   └── providers.tsx             # Wagmi & React Query Provider
├── components/
│   ├── nft-list.tsx              # NFT 列表客户端组件(核心状态与事件监听)
│   └── nft-card.tsx              # 单个 NFT 卡片组件(交易交互)
├── .env.local                    # 环境变量(合约地址、RPC URL)
└── package.json

踩坑记录

  1. NEXT_PUBLIC_ 变量在服务端为 undefined:我一开始把合约地址放在 .env.local 但没加 NEXT_PUBLIC_ 前缀,导致在服务端 API 路由中读取不到。解决:确保所有需要在浏览器和服务端共享的变量都以 NEXT_PUBLIC_ 开头。
  2. useWatchContractEvent 监听不到事件:我一开始把监听器放在 NftCard 组件里,结果组件卸载时监听器也被移除了,而且重复创建。解决:将全局事件监听提升到父组件(NftList),并确保合约地址和 ABI 正确。
  3. BigInt 序列化错误:从服务端 API 返回的数据中包含 bigint 类型的价格,直接 JSON.stringify 会报错。解决:在服务端将 bigint 转换为字符串,或者在客户端使用 Viem 的 parseEther 等工具处理。我在 API 路由中返回了原始数据,在组件内处理转换。
  4. 交易确认后状态不同步:用户购买成功后,列表里该 NFT 的 isActive 状态变了,但卖家地址还是旧的。解决:这是因为我的乐观更新逻辑不完整。最终我选择依赖 useWatchContractEvent 的事件监听作为主要更新源,手动刷新作为兜底,保证了数据与链上严格同步。

小结

这套方案的核心收获是 “服务端初始化,客户端维护动态状态,事件驱动更新”。它既保证了首屏加载速度,又实现了流畅的实时交互。对于更复杂的场景,比如分页、筛选、多链支持,还可以在此基础上扩展,例如引入状态管理库来集中管理 NFT 数据,或者用 The Graph 替代批量 RPC 调用。Web3 前端开发就是这样,每一个需求都在逼你更深入地理解数据流和链上链下的同步逻辑。

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

背景

上个月我接了一个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监听。下次我准备深入研究一下如何优化大量事件的处理性能,这又是一个值得记录的坑。

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

背景

上个月,我接了一个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来说,现在的方案已经足够可靠了。

Web3前端开发:使用ethers.js监听智能合约事件

Web3前端开发:使用ethers.js监听智能合约事件

前言

在Web3开发中,实时获取区块链上的状态变化是构建交互式DApp的关键。传统的前端轮询方式不仅效率低下,还会消耗大量API调用。本文将分享我在一个NFT铸造项目中,如何从轮询优化为事件监听,实现实时更新的完整踩坑记录。

为什么需要事件监听?

在以太坊生态中,智能合约通过事件(Events)向外广播状态变化。与轮询相比,事件监听具有以下优势:

  1. 实时性:事件触发后立即通知前端
  2. 高效性:减少不必要的RPC调用
  3. 可靠性:不会错过任何状态变化
  4. 节省成本:减少API调用次数

基础实现:轮询方式

// 传统轮询方式 - 不推荐
async function pollNFTBalance(userAddress, contract) {
  setInterval(async () => {
    const balance = await contract.balanceOf(userAddress);
    updateUI(balance);
  }, 5000); // 每5秒查询一次
}

这种方式的问题很明显:延迟高、API调用频繁、用户体验差。

优化方案:ethers.js事件监听

1. 基础事件监听

import { ethers } from 'ethers';

// 连接合约
const provider = new ethers.JsonRpcProvider('https://mainnet.infura.io/v3/YOUR_KEY');
const contract = new ethers.Contract(
  CONTRACT_ADDRESS,
  NFT_ABI,
  provider
);

// 监听Transfer事件
contract.on('Transfer', (from, to, tokenId, event) => {
  console.log(`NFT #${tokenId}${from} 转移到 ${to}`);
  updateNFTList(tokenId, to);
});

2. 过滤特定地址的事件

// 只监听与用户相关的事件
const filter = contract.filters.Transfer(null, userAddress);
contract.on(filter, (from, to, tokenId, event) => {
  console.log(`用户收到 NFT #${tokenId}`);
  addToUserCollection(tokenId);
});

3. 处理历史事件

// 获取过去24小时的事件
async function getPastEvents() {
  const blockNumber = await provider.getBlockNumber();
  const fromBlock = blockNumber - 5760; // 大约24小时前的区块
  
  const events = await contract.queryFilter('Transfer', fromBlock, blockNumber);
  events.forEach(event => {
    console.log(`历史事件: ${event.args.tokenId}`);
  });
}

实战踩坑记录

坑1:事件重复触发

问题:同一个事件被监听到多次 原因:重新连接合约时没有移除旧监听器 解决方案

let eventListeners = [];

function setupEventListeners() {
  // 先移除所有旧监听器
  eventListeners.forEach(listener => contract.off(listener));
  eventListeners = [];
  
  // 添加新监听器
  const transferListener = (from, to, tokenId) => {
    console.log(`Transfer: ${tokenId}`);
  };
  contract.on('Transfer', transferListener);
  eventListeners.push(['Transfer', transferListener]);
}

坑2:内存泄漏

问题:页面切换后监听器未清理,导致内存占用持续增长 解决方案

// 组件卸载时清理
useEffect(() => {
  const transferListener = (from, to, tokenId) => {
    // 处理事件
  };
  
  contract.on('Transfer', transferListener);
  
  return () => {
    contract.off('Transfer', transferListener);
  };
}, []);

坑3:网络切换处理

问题:用户切换网络(如从主网切换到测试网)后事件监听失效 解决方案

// 监听网络变化
provider.on('network', (newNetwork, oldNetwork) => {
  if (oldNetwork) {
    // 网络变化,重新连接合约
    setupEventListeners();
  }
});

高级技巧

1. 批量处理事件

// 使用防抖避免频繁UI更新
let eventQueue = [];
let processing = false;

contract.on('Transfer', async (from, to, tokenId) => {
  eventQueue.push({ from, to, tokenId });
  
  if (!processing) {
    processing = true;
    setTimeout(processEvents, 1000); // 1秒后批量处理
  }
});

async function processEvents() {
  if (eventQueue.length === 0) {
    processing = false;
    return;
  }
  
  const batch = [...eventQueue];
  eventQueue = [];
  
  // 批量更新UI
  await updateUIBatch(batch);
  
  processing = false;
}

2. 错误处理与重连

function setupEventListenersWithRetry() {
  try {
    contract.on('Transfer', handleTransfer);
    
    // 监听错误
    contract.on('error', (error) => {
      console.error('事件监听错误:', error);
      setTimeout(setupEventListenersWithRetry, 5000); // 5秒后重试
    });
  } catch (error) {
    console.error('设置监听器失败:', error);
    setTimeout(setupEventListenersWithRetry, 5000);
  }
}

性能优化建议

  1. 按需监听:只监听用户相关的事件
  2. 使用过滤器:减少不必要的事件处理
  3. 批量更新:避免频繁的UI重绘
  4. 清理机制:及时移除不需要的监听器
  5. 错误边界:添加适当的错误处理和重试机制

完整示例代码

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

function useNFTEventListeners(contract, userAddress) {
  const listenersRef = useRef([]);
  
  useEffect(() => {
    if (!contract || !userAddress) return;
    
    const setupListeners = () => {
      // 清理旧监听器
      listenersRef.current.forEach(([event, listener]) => {
        contract.off(event, listener);
      });
      listenersRef.current = [];
      
      // 监听用户收到的NFT
      const receivedFilter = contract.filters.Transfer(null, userAddress);
      const receivedListener = (from, to, tokenId) => {
        console.log(`收到 NFT #${tokenId}`);
        addToCollection(tokenId);
      };
      contract.on(receivedFilter, receivedListener);
      listenersRef.current.push([receivedFilter, receivedListener]);
      
      // 监听用户发送的NFT
      const sentFilter = contract.filters.Transfer(userAddress, null);
      const sentListener = (from, to, tokenId) => {
        console.log(`发送 NFT #${tokenId}`);
        removeFromCollection(tokenId);
      };
      contract.on(sentFilter, sentListener);
      listenersRef.current.push([sentFilter, sentListener]);
    };
    
    setupListeners();
    
    return () => {
      listenersRef.current.forEach(([event, listener]) => {
        contract.off(event, listener);
      });
    };
  }, [contract, userAddress]);
}

总结

从轮询到事件监听,不仅仅是技术方案的改变,更是对Web3开发理念的深入理解。通过合理使用ethers.js的事件监听功能,我们可以:

  1. 构建更实时的用户体验
  2. 显著降低API调用成本
  3. 提高应用的整体性能
  4. 减少服务器压力

希望本文的踩坑经验能帮助你在Web3开发中少走弯路。记住,好的事件监听策略是构建优秀DApp的基石。

下一步

  1. 尝试使用ethers.js的contract.once()方法处理一次性事件
  2. 探索使用The Graph等索引服务替代复杂的事件监听
  3. 考虑使用WebSocket提供商(如Alchemy)获得更好的实时性

Happy building! 🚀

从“连接失败”到丝滑登录:我用 ethers.js 连接 MetaMask 的完整踩坑实录

背景

上个月,我接手了一个新的 DeFi 项目前端开发。第一个核心功能就是用户钱包登录。团队技术栈是 React + TypeScript,并且决定使用 ethers.js 这个老牌库来处理区块链交互,而不是较新的 viem。理由是我们的合约交互模式相对复杂,团队对 ethers 的 API 更熟悉,而且项目需要尽快上线。

“连接 MetaMask 嘛,不就是 window.ethereum.request({ method: 'eth_requestAccounts' }) 一下?” 我一开始也是这么想的,觉得这应该是最快完成的任务之一。然而,当我真正开始动手,试图构建一个在生产环境下稳定、用户体验良好的登录流程时,才发现里面门道不少,坑是一个接一个。

问题分析

我最开始的思路非常简单粗暴:在用户点击“连接钱包”按钮时,直接尝试获取 window.ethereum 对象,然后调用 request 方法。代码大概长这样:

const connectWallet = async () => {
  if (window.ethereum) {
    const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
    setAccount(accounts[0]);
  } else {
    alert('请安装 MetaMask!');
  }
};

很快,问题就来了:

  1. 类型错误:在 TypeScript 中,window 对象默认没有 ethereum 属性,直接使用会报错。
  2. Provider 注入时机:MetaMask 的 Provider 并不是页面一加载就立刻注入到 window.ethereum 的。如果用户安装了 MetaMask 但页面加载太快,脚本可能检测不到,误判为用户未安装。
  3. 事件监听缺失:用户切换账户、切换网络时,前端页面没有任何反应,状态不同步。
  4. 连接状态持久化:页面刷新后,登录状态丢失,用户需要重新点击连接。

这显然离“可用”还差得远。我意识到,我需要一个更系统的方法,来处理 Provider 的检测、账户和网络的监听、以及状态的持久化。我的目标升级为:实现一个类似 useWeb3Reactwagmi 提供的、封装良好的自定义 Hook。

核心实现

第一步:安全地获取 Provider 并检测钱包安装

首先,要解决 TypeScript 的类型问题和 Provider 的异步注入问题。我决定在 window 上扩展 ethereum 的类型定义。

这里有个坑:MetaMask 的 Provider 类型在不断演进。直接使用 any 类型会失去类型安全,最好从 @metamask/providersethers 库中引入正确的类型。

我选择在项目根目录创建一个 types/global.d.ts 文件进行类型声明:

// types/global.d.ts
import { MetaMaskInpageProvider } from '@metamask/providers';

declare global {
  interface Window {
    ethereum?: MetaMaskInpageProvider;
  }
}

然后,我创建了一个自定义 Hook useEthereum 来安全地处理和访问 Provider。关键点在于,不能只在组件挂载时检查一次 window.ethereum,因为 MetaMask 可能稍后才注入。一个更健壮的做法是监听 ethereum#initialized 事件(尽管这个事件并非所有版本都稳定),或者设置一个短暂的延迟重试机制。但在实践中,我发现对于大多数情况,在 useEffect 中检查并结合一个“安装 MetaMask”的引导按钮就足够了。

第二步:连接钱包并获取账户信息

连接钱包的核心是 eth_requestAccounts 方法,它会触发 MetaMask 的授权弹窗。但仅仅获取账户地址还不够,我们通常还需要获取当前链的 ID(网络)。

我封装了一个 connect 函数:

import { BrowserProvider } from 'ethers';

const connect = async (): Promise<{ account: string; chainId: bigint }> => {
  if (!window.ethereum) {
    throw new Error('MetaMask 未安装');
  }

  // 1. 请求账户访问权限
  const accounts = await window.ethereum.request({
    method: 'eth_requestAccounts',
  });

  if (!accounts || accounts.length === 0) {
    throw new Error('用户拒绝了连接请求或未选择账户');
  }
  const account = accounts[0];

  // 2. 获取当前网络链ID
  const chainIdHex = await window.ethereum.request({
    method: 'eth_chainId',
  });
  const chainId = BigInt(chainIdHex);

  return { account, chainId };
};

注意这个细节eth_chainId 返回的是十六进制字符串(如 “0x1”),而 ethers.js v6 在很多地方使用 bigint 类型来表示链 ID,所以这里进行了转换。

第三步:监听账户和网络变化

这是让应用状态与钱包状态保持同步的关键。MetaMask 的 Provider 提供了 accountsChangedchainChanged 事件。

import { useEffect } from 'react';

const useWalletEvents = (provider: any, setAccount: (acc: string) => void, setChainId: (id: bigint) => void) => {
  useEffect(() => {
    if (!provider) return;

    const handleAccountsChanged = (accounts: string[]) => {
      console.log('账户变更:', accounts);
      // 如果用户切换了账户,accounts[0] 是新账户
      // 如果用户在 MetaMask 中锁定了钱包或断开连接,accounts 会是空数组
      if (accounts.length === 0) {
        // 处理用户断开连接的情况
        setAccount('');
      } else {
        setAccount(accounts[0]);
      }
    };

    const handleChainChanged = (chainIdHex: string) => {
      console.log('网络变更:', chainIdHex);
      // **重要!** 当网络变更时,MetaMask 建议页面重载
      // 但为了更好体验,我们可以只更新状态,并提示用户或重置相关合约实例
      // window.location.reload(); // 简单粗暴的方法
      const newChainId = BigInt(chainIdHex);
      setChainId(newChainId);
      // 通常这里还需要根据新的 chainId 更新 RPC Provider 和合约实例
    };

    provider.on('accountsChanged', handleAccountsChanged);
    provider.on('chainChanged', handleChainChanged);

    // 组件卸载时清理监听器
    return () => {
      provider.removeListener('accountsChanged', handleAccountsChanged);
      provider.removeListener('chainChanged', handleChainChanged);
    };
  }, [provider, setAccount, setChainId]);
};

这里有个大坑chainChanged 事件发生时,MetaMask 的官方文档建议直接 window.location.reload()。这是因为早期很多 dApp 的状态(特别是合约实例)严重依赖当前网络,不重载容易出错。但在现代前端架构中,我们可以通过更新状态、重新初始化 Provider 和合约来避免整页刷新,提供更流畅的体验。不过,这要求你的状态管理足够健壮。

第四步:状态持久化与初始化检查

用户刷新页面后,我们如何知道他之前已经连接过钱包?MetaMask 不会自动重新弹出授权窗口,但我们可以尝试获取已连接的账户。

我们可以使用 eth_accounts 方法,它不会弹出授权框,只会返回当前已授权的账户列表(如果用户已连接)。这非常适合在应用初始化时静默恢复登录状态。

const trySilentConnect = async (): Promise<{ account: string; chainId: bigint } | null> => {
  if (!window.ethereum) return null;

  try {
    const accounts = await window.ethereum.request({ method: 'eth_accounts' });
    if (accounts.length > 0) {
      const chainIdHex = await window.ethereum.request({ method: 'eth_chainId' });
      return {
        account: accounts[0],
        chainId: BigInt(chainIdHex),
      };
    }
  } catch (err) {
    console.error('静默连接失败:', err);
  }
  return null;
};

在应用加载时(例如在 App.tsxuseEffect 或自定义 Hook 的初始化中)调用这个函数,就能实现“刷新页面保持登录状态”。

完整代码

下面是一个整合了以上所有思路的、相对完整的自定义 React Hook useMetaMask 示例:

// hooks/useMetaMask.ts
import { useEffect, useState, useCallback } from 'react';

export const useMetaMask = () => {
  const [account, setAccount] = useState<string>('');
  const [chainId, setChainId] = useState<bigint>(0n);
  const [isConnecting, setIsConnecting] = useState(false);
  const [error, setError] = useState<string>('');

  // 获取 Provider 的辅助函数
  const getProvider = () => {
    if (typeof window !== 'undefined' && window.ethereum) {
      return window.ethereum;
    }
    return null;
  };

  // 静默连接(用于初始化)
  const trySilentConnect = useCallback(async () => {
    const provider = getProvider();
    if (!provider) return;

    try {
      const accounts = await provider.request({ method: 'eth_accounts' });
      if (accounts.length > 0) {
        const chainIdHex = await provider.request({ method: 'eth_chainId' });
        setAccount(accounts[0]);
        setChainId(BigInt(chainIdHex));
        console.log('静默连接成功:', accounts[0]);
      }
    } catch (err) {
      console.error('静默连接失败:', err);
    }
  }, []);

  // 主动连接(用户点击按钮)
  const connect = useCallback(async () => {
    setError('');
    setIsConnecting(true);
    const provider = getProvider();
    if (!provider) {
      setError('请安装 MetaMask 浏览器扩展。');
      setIsConnecting(false);
      return;
    }

    try {
      // 请求账户
      const accounts = await provider.request({
        method: 'eth_requestAccounts',
      });
      if (!accounts || accounts.length === 0) {
        throw new Error('用户拒绝了连接请求。');
      }
      const newAccount = accounts[0];

      // 获取当前网络
      const chainIdHex = await provider.request({ method: 'eth_chainId' });
      const newChainId = BigInt(chainIdHex);

      setAccount(newAccount);
      setChainId(newChainId);
      console.log('连接成功:', newAccount, '网络:', newChainId);
    } catch (err: any) {
      console.error('连接失败:', err);
      setError(err.message || '连接钱包时发生未知错误。');
      // 可选:重置状态
      setAccount('');
      setChainId(0n);
    } finally {
      setIsConnecting(false);
    }
  }, []);

  // 断开连接(本质上是前端清除状态,因为 MetaMask 没有真正的“断开”RPC调用)
  const disconnect = useCallback(() => {
    setAccount('');
    setChainId(0n);
    setError('');
    console.log('已断开钱包连接(前端状态)');
  }, []);

  // 监听账户和网络变化
  useEffect(() => {
    const provider = getProvider();
    if (!provider) return;

    const handleAccountsChanged = (accounts: string[]) => {
      console.log('accountsChanged:', accounts);
      if (accounts.length === 0) {
        // 用户锁定了钱包或切走了所有账户
        disconnect(); // 调用我们自己的断开函数
      } else if (accounts[0] !== account) {
        setAccount(accounts[0]);
      }
    };

    const handleChainChanged = (chainIdHex: string) => {
      console.log('chainChanged:', chainIdHex);
      // 更新链 ID,并可以在这里触发网络变更的副作用(如更新合约实例)
      setChainId(BigInt(chainIdHex));
      // 可以添加一个 toast 提示:“网络已切换至 xxx”
    };

    provider.on('accountsChanged', handleAccountsChanged);
    provider.on('chainChanged', handleChainChanged);

    // 应用启动时尝试静默连接
    trySilentConnect();

    return () => {
      provider.removeListener('accountsChanged', handleAccountsChanged);
      provider.removeListener('chainChanged', handleChainChanged);
    };
  }, [account, disconnect, trySilentConnect]);

  return {
    account,
    chainId,
    isConnecting,
    error,
    connect,
    disconnect,
    isInstalled: !!getProvider(),
  };
};
// components/WalletConnector.tsx
import React from 'react';
import { useMetaMask } from '../hooks/useMetaMask';

const WalletConnector: React.FC = () => {
  const { account, chainId, isConnecting, error, connect, disconnect, isInstalled } = useMetaMask();

  // 将 bigint 链 ID 转换为可读名称
  const getNetworkName = (id: bigint) => {
    const map: Record<string, string> = {
      '0x1': '以太坊主网',
      '0xaa36a7': 'Sepolia测试网',
      '0x89': 'Polygon',
      '0x13881': 'Mumbai测试网',
    };
    return map[id.toString(16)] || `未知网络 (${id.toString()})`;
  };

  if (!isInstalled) {
    return (
      <div>
        <p>未检测到 MetaMask。请安装后刷新页面。</p>
        <a href="https://metamask.io/download/" target="_blank" rel="noreferrer">
          下载 MetaMask
        </a>
      </div>
    );
  }

  return (
    <div>
      {error && <div style={{ color: 'red' }}>错误: {error}</div>}

      {!account ? (
        <button onClick={connect} disabled={isConnecting}>
          {isConnecting ? '连接中...' : '连接 MetaMask'}
        </button>
      ) : (
        <div>
          <p>
            <strong>已连接账户:</strong> {`${account.slice(0, 6)}...${account.slice(-4)}`}
          </p>
          <p>
            <strong>当前网络:</strong> {getNetworkName(chainId)}
          </p>
          <button onClick={disconnect}>断开连接</button>
        </div>
      )}
    </div>
  );
};

export default WalletConnector;

踩坑记录

  1. window.ethereum 类型错误:一开始在 TS 里直接写 window.ethereum 满屏红字。解决方案就是通过声明文件 (global.d.ts) 扩展 Window 接口。记得安装 @metamask/providers 包来获取准确类型。
  2. chainChanged 事件导致无限循环:早期版本中,我在 handleChainChanged 里更新了 chainId 状态,而这个状态又被用在 useEffect 的依赖数组中,导致状态更新 -> 副作用重新执行 -> 重新绑定事件... 形成了一个循环。后来我将事件处理函数用 useCallback 包裹,并确保依赖项正确,才解决了这个问题。
  3. 账户断开状态处理不当:当用户在 MetaMask 中点击“断开与此站点的连接”时,accountsChanged 事件会触发,并传入一个空数组 []。我最开始只是简单地 setAccount(accounts[0] || ''),这会导致 UI 显示空地址但其他状态还保留着。正确的做法是像上面代码一样,触发一个完整的“断开连接”流程,清除所有相关状态。
  4. BigInt 序列化问题:在 React 状态中直接存储 bigint 类型的 chainId 是没问题的,但如果你想把它存到 localStorage 或者通过 API 发送,就会遇到序列化错误(BigInt 不能直接 JSON.stringify)。我后来在需要持久化的地方,都将其转换为字符串 chainId.toString() 或十六进制 '0x' + chainId.toString(16)

小结

通过这一轮折腾,我深刻体会到,即使是一个看似简单的“连接钱包”功能,要做得健壮、用户体验好,也需要考虑 Provider 检测、异步连接、事件监听、状态持久化和错误处理等多个环节。封装成一个自定义 Hook 大大提升了代码的复用性和可维护性。下一步,我可以考虑在这个 Hook 基础上,集成 ethers.js 的 BrowserProvider 来直接提供签名和合约调用能力,或者加入对 WalletConnect 等其他连接方式的支持。

从ethers.js迁移到Viem:我在一个DeFi项目前端重构中踩过的坑

背景

上个月我接手了一个老牌的DeFi收益聚合器项目的前端维护工作。这个项目大概两年前开发的,前端核心库用的是 ethers.js v5,配合着一些自定义的 Provider 封装和事件轮询逻辑。刚开始只是修几个小 bug,但当我需要添加对新链(比如 Arbitrum)的支持时,问题就来了。

老代码里到处都是 new ethers.providers.JsonRpcProvider() 的硬编码,钱包连接逻辑和业务逻辑耦合得很深,添加一个新链得改七八个文件。更头疼的是,项目里有些自定义的 BigNumber 处理逻辑在 ethers.js v6 里已经不兼容了,升级版本风险太大。就在我纠结是硬着头皮重构老代码,还是找个新方案时,团队里另一个在做新项目的同事提到了 Viem,说它类型安全、模块化,而且和 Wagmi 搭配起来开发效率很高。我研究了一下,决定拿一个相对独立的功能模块——用户质押和领取奖励的页面——作为“试验田”,尝试用 Viem 彻底替换掉 ethers.js。

问题分析

我选择的功能模块主要做三件事:

  1. 读取用户在当前链上的质押余额和待领取奖励。
  2. 让用户进行质押(调用合约的 stake 方法)。
  3. 让用户领取奖励(调用合约的 claimRewards 方法)。

ethers.js 的老代码大概是这样的骨架:

import { ethers } from 'ethers';
import stakingABI from './abis/staking.json';

const provider = new ethers.providers.Web3Provider(window.ethereum);
const signer = provider.getSigner();
const stakingContract = new ethers.Contract(STAKING_ADDRESS, stakingABI, signer);

// 读取数据
const userBalance = await stakingContract.balanceOf(userAddress);
const pendingRewards = await stakingContract.earned(userAddress);

// 发送交易
const stakeTx = await stakingContract.stake(amount);
await stakeTx.wait();

思路很直接,但问题也很明显:Provider 和 Signer 的创建与钱包状态绑定死,ABI 管理松散,错误处理简陋。我的迁移目标很明确:用 Viem 的 PublicClientWalletClient 来分离读和写,用 TypeScript 生成强类型的合约接口,并整合进现有的 React 上下文里。

一开始我以为就是简单的 API 替换,但真正动手才发现,从“面向对象”的 ethers.js 思维切换到“函数式”的 Viem 思维,以及处理两者在数据类型(尤其是 BigNumberbigint)上的差异,才是真正的挑战。

核心实现

第一步:搭建 Viem 客户端与替换读取逻辑

首先,我安装了必要的包:viem@wagmi/core(为了复用项目已有的 Wagmi 配置)。我的策略是,先不碰钱包连接和交易发送,只把数据读取的部分换掉。

在 ethers.js 里,一个 Provider 既负责读也负责写(通过 Signer)。Viem 则明确分成了 PublicClient(读)和 WalletClient(写)。我创建了一个公共的读取客户端:

// src/lib/viemClient.ts
import { createPublicClient, http, PublicClient } from 'viem';
import { mainnet, arbitrum } from 'viem/chains'; // 从老配置里拿到链信息

// 根据当前链ID创建对应的客户端
export function getPublicClient(chainId: number): PublicClient {
  const chain = [mainnet, arbitrum].find(c => c.id === chainId) || mainnet;
  return createPublicClient({
    chain,
    transport: http(), // 这里先用公开RPC,后面可以替换成项目自己的节点
  });
}

接下来是重头戏:合约调用。我不想再手动管理 ABI JSON 文件了。Viem 鼓励使用 @wagmi/cliabitype 来生成类型。我用了更直接的方式,利用 Viem 的 createContractFunctionArgs 思路,手动为我的质押合约定义了一个类型化的“读”对象。这里有个:Viem 的合约函数返回的数值类型默认是 bigint,而我的前端界面渲染逻辑到处都在用 ethers.utils.formatUnits 来处理 BigNumber。我必须统一处理这个转换。

// src/contracts/stakingContract.ts
import { getPublicClient } from '@/lib/viemClient';
import stakingABI from './abis/staking.json' assert { type: 'json' }; // 暂时沿用老ABI

export const STAKING_ADDRESS = '0x...'; // 合约地址

// 封装一个类型安全的读取函数
export async function getUserStakingInfo(userAddress: `0x${string}`, chainId: number) {
  const publicClient = getPublicClient(chainId);

  // 注意:这里返回的是 bigint
  const [balance, rewards] = await Promise.all([
    publicClient.readContract({
      address: STAKING_ADDRESS,
      abi: stakingABI,
      functionName: 'balanceOf',
      args: [userAddress],
    }) as Promise<bigint>,
    publicClient.readContract({
      address: STAKING_ADDRESS,
      abi: stakingABI,
      functionName: 'earned',
      args: [userAddress],
    }) as Promise<bigint>,
  ]);

  // 统一转换:bigint -> 格式化的字符串(这里假设代币精度为18)
  const formatBigInt = (value: bigint) => Number(value) / 10**18; // 简单处理,生产环境建议用库
  return {
    balance: formatBigInt(balance),
    pendingRewards: formatBigInt(rewards),
  };
}

在 React 组件里,我就可以把老的 ethers 调用替换成:

// 老代码
// const balance = await stakingContract.balanceOf(address);

// 新代码
const { balance, pendingRewards } = await getUserStakingInfo(address, chainId);

第一步很顺利,界面数据显示正常。这给了我很大信心。

第二步:处理钱包连接与交易发送

这是最核心也最容易出错的部分。在 ethers.js 里,我们从 window.ethereum 创建 Provider,然后 getSigner()。Viem 的 WalletClient 概念类似,但创建方式更多样。我选择与项目已有的 Wagmi 连接器集成,通过 Wagmi 的 useAccountuseWalletClient 钩子来获取。

这里有个关键细节:Viem 的 writeContract 方法返回的是交易哈希(0x${string}),而不是一个像 ethers.js 那样的交易对象(包含 wait 方法)。你需要用 PublicClientwaitForTransactionReceipt 来等待交易确认。

// src/hooks/useStakingAction.ts
import { useAccount, useWalletClient } from 'wagmi';
import { getPublicClient } from '@/lib/viemClient';
import { STAKING_ADDRESS, stakingABI } from '@/contracts/stakingContract';

export function useStakingAction() {
  const { address, chainId } = useAccount();
  const { data: walletClient } = useWalletClient();

  const stake = async (amount: bigint) => {
    if (!walletClient || !address) throw new Error('钱包未连接');

    try {
      // 1. 发送交易,获取哈希
      const hash = await walletClient.writeContract({
        address: STAKING_ADDRESS,
        abi: stakingABI,
        functionName: 'stake',
        args: [amount],
        account: address,
      });
      console.log('交易哈希:', hash);

      // 2. 等待交易确认
      const publicClient = getPublicClient(chainId!);
      const receipt = await publicClient.waitForTransactionReceipt({ hash });
      console.log('交易确认,区块号:', receipt.blockNumber);
      return receipt;
    } catch (error) {
      console.error('质押失败:', error);
      // 这里可以细化错误处理,比如用户拒绝、gas不足等
      throw error;
    }
  };

  const claimRewards = async () => {
    // 逻辑类似,调用 `claimRewards` 函数
    if (!walletClient || !address) throw new Error('钱包未连接');
    const hash = await walletClient.writeContract({
      address: STAKING_ADDRESS,
      abi: stakingABI,
      functionName: 'claimRewards',
      account: address,
    });
    const publicClient = getPublicClient(chainId!);
    return await publicClient.waitForTransactionReceipt({ hash });
  };

  return { stake, claimRewards };
}

在组件中使用就非常清晰了:

const StakingButton: React.FC = () => {
  const [amount, setAmount] = useState('');
  const { stake } = useStakingAction();
  const handleStake = async () => {
    const amountInWei = BigInt(parseFloat(amount) * 10**18); // 转换精度
    await stake(amountInWei);
    // ... 成功后刷新数据
  };
  return <button onClick={handleStake}>质押</button>;
};

第三步:集成与错误边界处理

替换了核心逻辑后,我需要把新的 Viem 客户端集成到项目的上下文中,并处理好可能出现的错误。我创建了一个 ViemProvider 上下文,用来在不同的组件中共享 PublicClient 和合约方法。

另外,我遇到了一个非常实际的坑:合约事件监听。老代码用 ethers.Contracton 方法监听事件来更新 UI。Viem 提供了 watchContractEvent,但它的用法是函数式的,返回一个取消监听的函数,并且需要自己管理生命周期。

// 在组件或Hook中监听质押事件
useEffect(() => {
  if (!address || !chainId) return;

  const publicClient = getPublicClient(chainId);
  const unwatch = publicClient.watchContractEvent({
    address: STAKING_ADDRESS,
    abi: stakingABI,
    eventName: 'Staked',
    args: { user: address }, // 只监听当前用户的事件
    onLogs: (logs) => {
      console.log('新的质押事件:', logs);
      // 触发UI数据更新
      refetchUserInfo();
    },
    onError: (error) => {
      console.error('监听事件出错:', error);
    }
  });

  // 组件卸载时取消监听
  return () => unwatch();
}, [address, chainId]);

完整代码示例

以下是一个简化但可运行的 React 组件,展示了如何使用我们上面封装的逻辑:

// src/components/StakingPanel.tsx
import React, { useState, useEffect } from 'react';
import { useAccount } from 'wagmi';
import { getUserStakingInfo } from '@/contracts/stakingContract';
import { useStakingAction } from '@/hooks/useStakingAction';

const StakingPanel: React.FC = () => {
  const { address, chainId } = useAccount();
  const { stake, claimRewards } = useStakingAction();
  const [userInfo, setUserInfo] = useState({ balance: 0, pendingRewards: 0 });
  const [stakeAmount, setStakeAmount] = useState('');
  const [isLoading, setIsLoading] = useState(false);

  // 加载用户数据
  const loadUserInfo = async () => {
    if (!address || !chainId) return;
    setIsLoading(true);
    try {
      const info = await getUserStakingInfo(address, chainId);
      setUserInfo(info);
    } catch (error) {
      console.error('加载数据失败:', error);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    loadUserInfo();
  }, [address, chainId]);

  const handleStake = async () => {
    if (!stakeAmount) return;
    setIsLoading(true);
    try {
      const amountInWei = BigInt(Math.floor(parseFloat(stakeAmount) * 10**18));
      await stake(amountInWei);
      setStakeAmount('');
      await loadUserInfo(); // 刷新数据
      alert('质押成功!');
    } catch (error: any) {
      alert(`质押失败: ${error?.shortMessage || error.message}`);
    } finally {
      setIsLoading(false);
    }
  };

  const handleClaim = async () => {
    setIsLoading(true);
    try {
      await claimRewards();
      await loadUserInfo();
      alert('领取成功!');
    } catch (error: any) {
      alert(`领取失败: ${error?.shortMessage || error.message}`);
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <div>
      <h2>我的质押</h2>
      {isLoading && <p>加载中...</p>}
      <p>质押余额: {userInfo.balance}</p>
      <p>待领取奖励: {userInfo.pendingRewards}</p>

      <div>
        <input
          type="number"
          value={stakeAmount}
          onChange={(e) => setStakeAmount(e.target.value)}
          placeholder="输入质押数量"
          disabled={isLoading}
        />
        <button onClick={handleStake} disabled={isLoading}>
          质押
        </button>
      </div>

      <button onClick={handleClaim} disabled={isLoading || userInfo.pendingRewards <= 0}>
        领取奖励
      </button>
    </div>
  );
};

export default StakingPanel;

踩坑记录

  1. bigint 序列化错误(JSON.stringify):这是第一个拦路虎。当我将从 Viem 合约调用中获取的 bigint 类型的状态直接放入 React 状态或传递给 JSON.stringify 时,控制台会报错“Do not know how to serialize a BigInt”。解决方法:在数据层(如 getUserStakingInfo 函数中)就将其转换为 numberstring。对于大数,可以使用 viem 自带的 formatUnits 函数或转换为字符串 value.toString()

  2. 钱包客户端(WalletClient)获取为 undefined:在 useStakingAction 钩子中,useWalletClient() 返回的 data 可能为 undefined,尤其是在钱包连接初始状态或切换链时。解决方法:增加严格的空值检查,并在 UI 上给出明确的禁用状态或提示。确保 Wagmi 配置正确,连接器支持当前链。

  3. 事件监听内存泄漏:最初我在组件中直接调用 watchContractEvent 而没有在 useEffect 中返回清理函数,导致组件卸载后监听依然存在,控制台会有警告,并可能引发状态更新错误。解决方法:严格遵守 useEffect 的生命周期,将 watchContractEvent 返回的 unwatch 函数在清理阶段调用。

  4. 交易模拟错误信息不直观walletClient.writeContract 失败时,抛出的错误对象有时很深,直接 error.message 可能是一串复杂的 RPC 错误。解决方法:利用 Viem 错误工具类,如 parseContractError(在较新版本中)或 decodeErrorResult 来解析。在实践中,我发现 error.shortMessageerror.details 通常包含了可读性更强的信息,可以优先展示给用户。

小结

这次迁移就像给老房子换了一套更现代化的水电管道,过程有点折腾,但完成后维护性和扩展性肉眼可见地提升了。Viem 的函数式、类型安全设计,强迫我写出更清晰、解耦的代码。最大的收获不是学会了一个新库的 API,而是理解了如何用“客户端分离”和“类型优先”的思想来构建更健壮的 Web3 前端。下一步,我打算用 @wagmi/cli 来自动生成所有合约的完整类型化接口,彻底告别手写 ABI 导入的日子。

❌