用wagmi v2 + viem重构DeFi前端:从连接钱包到读取合约数据的完整踩坑实录
背景
上个月,我接手了一个DeFi收益聚合器项目的前端重构任务。这个项目最初是用ethers.js 5.x和web3-react构建的,代码已经运行了两年多。随着项目发展,老架构的问题逐渐暴露:钱包连接逻辑分散在各个组件、多链支持维护困难、类型定义几乎为零。
团队决定迁移到更现代的wagmi v2 + viem技术栈。wagmi的Hooks式API看起来简洁优雅,viem的类型安全也很有吸引力。我本以为这是个“升级依赖”的简单任务,但实际动手才发现,从老模式切换到新范式,中间有太多细节需要重新理解。最大的挑战不是写新代码,而是让新老逻辑在数据流和状态管理上保持一致。
问题分析
我最初的计划很直接:安装wagmi、viem、@tanstack/react-query(wagmi v2的依赖),然后逐步替换组件中的useWeb3React和ethers调用。
第一个拦路虎很快就出现了:钱包连接状态频繁丢失。
在旧版中,用户连接钱包后,account和chainId等信息通过React Context全局可用。但在新版本中,我按照官方示例配置了WagmiProvider后,发现useAccount()返回的address时不时会变成undefined,即使MetaMask明明还连接着。
我排查的方向:
-
检查Provider配置:确认了
config对象正确传递给了WagmiProvider -
检查连接器顺序:按照文档把
injected连接器放在第一位 - 检查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
}
}
关键变化:
-
useReadContract自动处理缓存、重试和错误状态 - 必须指定
chainId,否则可能读取到错误链的数据 -
enabled选项控制查询时机,避免不必要的RPC调用 - 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
踩坑记录
在实际迁移过程中,我遇到了以下几个典型问题:
-
"Invalid BigNumber value"错误
-
现象:从ethers.js迁移时,传入
useWriteContract的args包含ethers的BigNumber对象 -
原因:viem只接受原生的JavaScript
bigint类型 -
解决:将所有
ethers.BigNumber转换为bigint:BigInt(balance.toString())
-
现象:从ethers.js迁移时,传入
-
跨链读取返回错误数据
- 现象:在Polygon链上却读到了以太坊主网的余额
-
原因:
useReadContract没有指定chainId,使用了默认链 -
解决:在所有合约读取Hook中显式传递当前
chainId
-
钱包连接在页面刷新后丢失
- 现象:用户刷新页面后需要重新连接钱包
- 原因:wagmi默认配置没有启用连接持久化
-
解决:正确配置
QueryClient的缓存时间,并考虑使用'wagmi/connectors'中的createStorage进行localStorage持久化
-
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)深度集成。