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

5 阅读1分钟

背景

上个月,我接手了一个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)深度集成。